diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..2d6d258f47 --- /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/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml deleted file mode 100644 index 910adefc79..0000000000 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: 🐞 Bug report -description: Report a bug or an issue. -title: 'bug: ' -labels: ['Bug report'] -body: - - type: markdown - attributes: - value: | - # ReVanced Patches bug report - - Please check for existing bug reports - [here](https://github.com/ReVanced/revanced-patches/labels/Bug%20report) - before creating a new one. - - - type: textarea - attributes: - label: Bug description - description: | - - Describe your bug in detail - - Add steps to reproduce the bug if possible (Step 1. ... Step 2. ...) - - Add images and videos if possible - - List used patches if applicable - validations: - required: true - - type: textarea - attributes: - label: Error logs - description: Exceptions can be captured by running `logcat | grep AndroidRuntime` in a shell. - render: shell - - type: textarea - attributes: - label: Solution - description: If applicable, add a possible solution to the bug. - - type: textarea - attributes: - label: Additional context - description: Add additional context here. - - type: checkboxes - id: acknowledgements - attributes: - label: Acknowledgements - description: Your issue will be closed if you don't follow the checklist below. - options: - - label: This request is not a duplicate of an existing issue. - required: true - - label: I have chosen an appropriate title. - required: true - - label: All requested information has been provided properly. - required: true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..f623d8a579 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,110 @@ +name: 🐞 Bug report +description: Report a bug or an issue. +title: 'bug: ' +labels: ['Bug report'] +body: + - type: markdown + attributes: + value: | +

+ + + + +
+ + + + + +     + + + + + +     + + + + + +     + + + + + +     + + + + + +     + + + + + +     + + + + + + +
+
+ Continuing the legacy of Vanced +

+ + # ReVanced Patches bug report + + Before creating a new bug report, please keep the following in mind: + + - **Do not submit a duplicate bug report**: Search for existing bug reports [here](https://github.com/ReVanced/revanced-patches/issues?q=label%3A%22Bug+report%22). + - **Review the contribution guidelines**: Make sure your bug report adheres to it. You can find the guidelines [here](https://github.com/ReVanced/revanced-patches/blob/main/CONTRIBUTING.md). + - **Do not use the issue page for support**: If you need help or have questions, check out other platforms on [revanced.app](https://revanced.app). + - type: textarea + attributes: + label: Bug description + description: | + - Describe your bug in detail + - Add steps to reproduce the bug if possible (Step 1. ... Step 2. ...) + - Add images and videos if possible + - List used patches if applicable + validations: + required: true + - type: textarea + attributes: + label: Error logs + description: Exceptions can be captured by running `logcat | grep AndroidRuntime` in a shell. + render: shell + - type: textarea + attributes: + label: Solution + description: If applicable, add a possible solution to the bug. + - type: textarea + attributes: + label: Additional context + description: Add additional context here. + - type: checkboxes + id: acknowledgements + attributes: + label: Acknowledgements + description: Your bug report will be closed if you don't follow the checklist below. + options: + - label: I have checked all open and closed bug reports and this is not a duplicate. + required: true + - label: I have chosen an appropriate title. + required: true + - label: All requested information has been provided properly. + required: true diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml deleted file mode 100644 index 63f7ad2da8..0000000000 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: ⭐ Feature request -description: Create a detailed request for a new feature. -title: 'feat: ' -labels: ['Feature request'] -body: - - type: markdown - attributes: - value: | - # ReVanced Patches feature request - - Please check for existing feature requests - [here](https://github.com/ReVanced/revanced-patches/labels/Feature%20request) - before creating a new one. - - type: textarea - attributes: - label: Feature description - description: | - - Describe your feature in detail - - Add images, videos, links, examples, references, etc. if possible - - Add the target application name in case you request a new patch - - type: textarea - attributes: - label: Motivation - description: | - A strong motivation is necessary for a feature request to be considered. - - - Why should this feature be implemented? - - What is the explicit use case? - - What are the benefits? - - What makes this feature important? - validations: - required: true - - type: checkboxes - id: acknowledgements - attributes: - label: Acknowledgements - description: Your issue will be closed if you don't follow the checklist below. - options: - - label: This request is not a duplicate of an existing issue. - required: true - - label: I have chosen an appropriate title. - required: true - - label: All requested information has been provided properly. - required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000..f49436ec6b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,106 @@ +name: ⭐ Feature request +description: Create a detailed request for a new feature. +title: 'feat: ' +labels: ['Feature request'] +body: + - type: markdown + attributes: + value: | +

+ + + + +
+ + + + + +     + + + + + +     + + + + + +     + + + + + +     + + + + + +     + + + + + +     + + + + + + +
+
+ Continuing the legacy of Vanced +

+ + # ReVanced Patches feature request + + Before creating a new feature request, please keep the following in mind: + + - **Do not submit a duplicate feature request**: Search for existing feature requests [here](https://github.com/ReVanced/revanced-patches/issues?q=label%3A%22Feature+request%22). + - **Review the contribution guidelines**: Make sure your feature request adheres to it. You can find the guidelines [here](https://github.com/ReVanced/revanced-patches/blob/main/CONTRIBUTING.md). + - **Do not use the issue page for support**: If you need help or have questions, check out other platforms on [revanced.app](https://revanced.app). + - type: textarea + attributes: + label: Feature description + description: | + - Describe your feature in detail + - Add images, videos, links, examples, references, etc. if possible + - Add the target application name in case you request a new patch + - type: textarea + attributes: + label: Motivation + description: | + A strong motivation is necessary for a feature request to be considered. + + - Why should this feature be implemented? + - What is the explicit use case? + - What are the benefits? + - What makes this feature important? + validations: + required: true + - type: checkboxes + id: acknowledgements + attributes: + label: Acknowledgements + description: Your feature request will be closed if you don't follow the checklist below. + options: + - label: I have checked all open and closed feature requests and this is not a duplicate + required: true + - label: I have chosen an appropriate title. + required: true + - label: All requested information has been provided properly. + required: true diff --git a/.github/config.yml b/.github/config.yml index 09ed019c1c..075f56b535 100644 --- a/.github/config.yml +++ b/.github/config.yml @@ -1,2 +1,2 @@ firstPRMergeComment: > - Thank you for contributing to ReVanced. Join us on [Discord](https://revanced.app/discord) if you want to receive a contributor role. + Thank you for contributing to ReVanced. Join us on [Discord](https://revanced.app/discord) to receive a role for your contribution. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..93e7caf35d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: github-actions + labels: [] + directory: / + target-branch: dev + schedule: + interval: monthly + + - package-ecosystem: npm + labels: [] + directory: / + target-branch: dev + schedule: + interval: monthly + + - package-ecosystem: gradle + labels: [] + directory: / + target-branch: dev + schedule: + interval: monthly diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml new file mode 100644 index 0000000000..193a26af05 --- /dev/null +++ b/.github/workflows/build_pull_request.yml @@ -0,0 +1,31 @@ +name: Build pull request + +on: + workflow_dispatch: + pull_request: + branches: + - dev + +jobs: + release: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Cache Gradle + uses: burrunan/gradle-cache-action@v1 + + - name: Build + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./gradlew build --no-daemon diff --git a/.github/workflows/open_pull_request.yml b/.github/workflows/open_pull_request.yml new file mode 100644 index 0000000000..721ab088d9 --- /dev/null +++ b/.github/workflows/open_pull_request.yml @@ -0,0 +1,28 @@ +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/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..6081405851 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +name: Release + +on: + workflow_dispatch: + push: + branches: + - main + - dev + +jobs: + release: + name: Release + permissions: + contents: write + packages: write + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Make sure the release step uses its own credentials: + # https://github.com/cycjimmy/semantic-release-action#private-packages + persist-credentials: false + fetch-depth: 0 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Cache Gradle + uses: burrunan/gradle-cache-action@v1 + + - name: Build + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # To update `README.md` and `patches.json`, the command `./gradlew generatePatchesFiles clean` should be used instead of the command `./gradlew build clean` + run: ./gradlew generatePatchesFiles clean + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "lts/*" + cache: 'npm' + + - name: Install dependencies + run: npm install + + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.GPG_PASSPHRASE }} + fingerprint: ${{ vars.GPG_FINGERPRINT }} + + - name: Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npm exec semantic-release diff --git a/.github/workflows/update-gradle-wrapper.yml b/.github/workflows/update-gradle-wrapper.yml new file mode 100644 index 0000000000..8136ad5f31 --- /dev/null +++ b/.github/workflows/update-gradle-wrapper.yml @@ -0,0 +1,18 @@ +name: Update Gradle wrapper + +on: + schedule: + - cron: "0 0 1 * *" + workflow_dispatch: + +jobs: + update: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Update Gradle Wrapper + uses: gradle-update/update-gradle-wrapper-action@v1 + with: + target-branch: dev diff --git a/.gitignore b/.gitignore index 74dd4e7289..62f6eb424f 100644 --- a/.gitignore +++ b/.gitignore @@ -122,7 +122,8 @@ gradle-app.setting # Dependency directories node_modules/ -# gradle properties, due to Github token +# Gradle properties, due to Github token ./gradle.properties -.DS_Store +# One package is called the same as the Gradle build folder +!**/src/**/build/ \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index e086a70c4e..bbdaad2de1 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,8 +1,7 @@ - - + \ No newline at end of file diff --git a/.releaserc b/.releaserc index 6193511b8e..0abaf5291b 100644 --- a/.releaserc +++ b/.releaserc @@ -24,8 +24,9 @@ "README.md", "CHANGELOG.md", "gradle.properties", - "patches.json" - ] + "patches.json", + ], + "message": "chore: Release v${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" } ], [ @@ -33,11 +34,11 @@ { "assets": [ { - "path": "build/libs/revanced-patches*" + "path": "patches/build/libs/patches-!(*sources*|*javadoc*).rvp?(.asc)" }, { "path": "patches.json" - } + }, ], successComment: false } diff --git a/README-template.md b/README-template.md index a242ee62fe..bd5e74d799 100644 --- a/README-template.md +++ b/README-template.md @@ -26,7 +26,6 @@ Example: } ], "use":true, - "requiresIntegrations":false, "options": [] }, { @@ -39,7 +38,6 @@ Example: } ], "use":true, - "requiresIntegrations":false, "options": [] }, { @@ -52,7 +50,6 @@ Example: } ], "use":true, - "requiresIntegrations":true, "options": [] } ] diff --git a/README.md b/README.md index 1bc4ed5921..660a65635d 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ See the [documentation](https://github.com/inotia00/revanced-documentation#readm | `Change share sheet` | Add option to change from in-app share sheet to system share sheet. | 18.29.38 ~ 19.16.39 | | `Change start page` | Adds an option to set which page the app opens in instead of the homepage. | 18.29.38 ~ 19.16.39 | | `Custom Shorts action buttons` | Changes, at compile time, the icon of the action buttons of the Shorts player. | 18.29.38 ~ 19.16.39 | -| `Custom branding icon for YouTube` | Changes the YouTube app icon to the icon specified in options.json. | 18.29.38 ~ 19.16.39 | -| `Custom branding name for YouTube` | Renames the YouTube app to the name specified in options.json. | 18.29.38 ~ 19.16.39 | -| `Custom double tap length` | Adds Double-tap to seek values that are specified in options.json. | 18.29.38 ~ 19.16.39 | +| `Custom branding icon for YouTube` | Changes the YouTube app icon to the icon specified in patch options. | 18.29.38 ~ 19.16.39 | +| `Custom branding name for YouTube` | Renames the YouTube app to the name specified in patch options. | 18.29.38 ~ 19.16.39 | +| `Custom double tap length` | Adds Double-tap to seek values that are specified in patch options. | 18.29.38 ~ 19.16.39 | | `Custom header for YouTube` | Applies a custom header in the top left corner within the app. | 18.29.38 ~ 19.16.39 | | `Description components` | Adds options to hide and disable description components. | 18.29.38 ~ 19.16.39 | | `Disable QUIC protocol` | Adds an option to disable CronetEngine's QUIC protocol. | 18.29.38 ~ 19.16.39 | @@ -67,7 +67,7 @@ See the [documentation](https://github.com/inotia00/revanced-documentation#readm | `Spoof app version` | Adds options to spoof the YouTube client version. This can be used to restore old UI elements and features. | 18.29.38 ~ 19.16.39 | | `Spoof streaming data` | Adds options to spoof the streaming data to allow video playback. | 18.29.38 ~ 19.16.39 | | `Swipe controls` | Adds options for controlling volume and brightness with swiping, and whether to enter fullscreen when swiping down below the player. | 18.29.38 ~ 19.16.39 | -| `Theme` | Changes the app's theme to the values specified in options.json. | 18.29.38 ~ 19.16.39 | +| `Theme` | Changes the app's theme to the values specified in patch options. | 18.29.38 ~ 19.16.39 | | `Toolbar components` | Adds options to hide or change components located on the toolbar, such as toolbar buttons, search bar, and header. | 18.29.38 ~ 19.16.39 | | `Translations for YouTube` | Add translations or remove string resources. | 18.29.38 ~ 19.16.39 | | `Video playback` | Adds options to customize settings related to video playback, such as default video quality and playback speed. | 18.29.38 ~ 19.16.39 | @@ -86,8 +86,8 @@ See the [documentation](https://github.com/inotia00/revanced-documentation#readm | `Certificate spoof` | Enables YouTube Music to work with Android Auto by spoofing the YouTube Music certificate. | 6.20.51 ~ 7.16.53 | | `Change share sheet` | Add option to change from in-app share sheet to system share sheet. | 6.20.51 ~ 7.16.53 | | `Change start page` | Adds an option to set which page the app opens in instead of the homepage. | 6.20.51 ~ 7.16.53 | -| `Custom branding icon for YouTube Music` | Changes the YouTube Music app icon to the icon specified in options.json. | 6.20.51 ~ 7.16.53 | -| `Custom branding name for YouTube Music` | Renames the YouTube Music app to the name specified in options.json. | 6.20.51 ~ 7.16.53 | +| `Custom branding icon for YouTube Music` | Changes the YouTube Music app icon to the icon specified in patch options. | 6.20.51 ~ 7.16.53 | +| `Custom branding name for YouTube Music` | Renames the YouTube Music app to the name specified in patch options. | 6.20.51 ~ 7.16.53 | | `Custom header for YouTube Music` | Applies a custom header in the top left corner within the app. | 6.20.51 ~ 7.16.53 | | `Disable Cairo splash animation` | Adds an option to disable Cairo splash animation. | 7.06.54 ~ 7.16.53 | | `Disable auto captions` | Adds an option to disable captions from being automatically enabled. | 6.20.51 ~ 7.16.53 | @@ -124,8 +124,8 @@ See the [documentation](https://github.com/inotia00/revanced-documentation#readm | 💊 Patch | 📜 Description | 🏹 Target Version | |:--------:|:--------------:|:-----------------:| -| `Change package name` | Changes the package name for Reddit to the name specified in options.json. | 2023.12.0 ~ 2024.17.0 | -| `Custom branding name for Reddit` | Renames the Reddit app to the name specified in options.json. | 2023.12.0 ~ 2024.17.0 | +| `Change package name` | Changes the package name for Reddit to the name specified in patch options. | 2023.12.0 ~ 2024.17.0 | +| `Custom branding name for Reddit` | Renames the Reddit app to the name specified in patch options. | 2023.12.0 ~ 2024.17.0 | | `Disable screenshot popup` | Adds an option to disable the popup that appears when taking a screenshot. | 2023.12.0 ~ 2024.17.0 | | `Hide Recently Visited shelf` | Adds an option to hide the Recently Visited shelf in the sidebar. | 2023.12.0 ~ 2024.17.0 | | `Hide ads` | Adds options to hide ads. | 2023.12.0 ~ 2024.17.0 | @@ -166,7 +166,6 @@ Example: } ], "use":true, - "requiresIntegrations":false, "options": [] }, { @@ -185,7 +184,6 @@ Example: } ], "use":true, - "requiresIntegrations":false, "options": [] }, { @@ -201,7 +199,6 @@ Example: } ], "use":true, - "requiresIntegrations":true, "options": [] } ] diff --git a/build.gradle.kts b/build.gradle.kts deleted file mode 100644 index c2151294c7..0000000000 --- a/build.gradle.kts +++ /dev/null @@ -1,149 +0,0 @@ -import org.gradle.kotlin.dsl.support.listFilesOrdered -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - alias(libs.plugins.kotlin) - `maven-publish` - signing -} - -group = "app.revanced" - -repositories { - mavenCentral() - mavenLocal() - google() - maven { - url = uri("https://maven.pkg.github.com/revanced/multidexlib2") - credentials { - username = project.findProperty("gpr.user") as String? ?: System.getenv("GITHUB_ACTOR") - password = project.findProperty("gpr.key") as String? ?: System.getenv("GITHUB_TOKEN") - } - } -} - -dependencies { - implementation(libs.revanced.patcher) - implementation(libs.smali) - // Used in JsonGenerator. - implementation(libs.gson) -} - -kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } -} - -java { - targetCompatibility = JavaVersion.VERSION_11 -} - -tasks { - withType(Jar::class) { - exclude("app/revanced/generator") - - manifest { - attributes["Name"] = "ReVanced Patches" - attributes["Description"] = "Patches for ReVanced." - attributes["Version"] = version - attributes["Timestamp"] = System.currentTimeMillis().toString() - attributes["Source"] = "git@github.com:revanced/revanced-patches.git" - attributes["Author"] = "ReVanced" - attributes["Contact"] = "contact@revanced.app" - attributes["Origin"] = "https://revanced.app" - attributes["License"] = "GNU General Public License v3.0" - } - } - - register("buildDexJar") { - description = "Build and add a DEX to the JAR file" - group = "build" - - dependsOn(build) - - doLast { - val d8 = File(System.getenv("ANDROID_HOME")).resolve("build-tools") - .listFilesOrdered().last().resolve("d8").absolutePath - - val patchesJar = configurations.archives.get().allArtifacts.files.files.first().absolutePath - val workingDirectory = layout.buildDirectory.dir("libs").get().asFile - - exec { - workingDir = workingDirectory - commandLine = listOf(d8, "--release", patchesJar) - } - - exec { - workingDir = workingDirectory - commandLine = listOf("zip", "-u", patchesJar, "classes.dex") - } - } - } - - register("generatePatchesFiles") { - description = "Generate patches files" - - dependsOn(build) - - classpath = sourceSets["main"].runtimeClasspath - mainClass.set("app.revanced.generator.MainKt") - } - - // Needed by gradle-semantic-release-plugin. - // Tracking: https://github.com/KengoTODA/gradle-semantic-release-plugin/issues/435 - publish { - dependsOn("buildDexJar") - dependsOn("generatePatchesFiles") - } -} - -publishing { - repositories { - maven { - name = "GitHubPackages" - url = uri("https://maven.pkg.github.com/inotia00/revanced-patches") - credentials { - username = System.getenv("GITHUB_ACTOR") - password = System.getenv("GITHUB_TOKEN") - } - } - } - - publications { - create("revanced-patches-publication") { - from(components["java"]) - - pom { - name = "ReVanced Patches" - description = "Patches for ReVanced." - url = "https://revanced.app" - - licenses { - license { - name = "GNU General Public License v3.0" - url = "https://www.gnu.org/licenses/gpl-3.0.en.html" - } - } - developers { - developer { - id = "ReVanced" - name = "ReVanced" - email = "contact@revanced.app" - } - } - scm { - connection = "scm:git:git://github.com/revanced/revanced-patches.git" - developerConnection = "scm:git:git@github.com:revanced/revanced-patches.git" - url = "https://github.com/revanced/revanced-patches" - } - } - } - } -} - -signing { - useGpgCmd() - - sign(publishing.publications["revanced-patches-publication"]) -} diff --git a/extensions/shared/build.gradle.kts b/extensions/shared/build.gradle.kts new file mode 100644 index 0000000000..90bd2ac9e7 --- /dev/null +++ b/extensions/shared/build.gradle.kts @@ -0,0 +1,30 @@ +extension { + name = "extensions/shared.rve" +} + +android { + namespace = "app.revanced.extension" + compileSdk = 34 + + defaultConfig { + minSdk = 24 + } + + buildTypes { + release { + isMinifyEnabled = true + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + compileOnly(libs.annotation) + compileOnly(libs.preference) + implementation(libs.lang3) + + compileOnly(project(":extensions:shared:stub")) +} diff --git a/extensions/shared/proguard-rules.pro b/extensions/shared/proguard-rules.pro new file mode 100644 index 0000000000..8f804140d6 --- /dev/null +++ b/extensions/shared/proguard-rules.pro @@ -0,0 +1,9 @@ +-dontobfuscate +-dontoptimize +-keepattributes * +-keep class app.revanced.** { + *; +} +-keep class com.google.** { + *; +} diff --git a/extensions/shared/src/main/AndroidManifest.xml b/extensions/shared/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e960b00030 --- /dev/null +++ b/extensions/shared/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/account/AccountPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/account/AccountPatch.java new file mode 100644 index 0000000000..5e5fb6a069 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/account/AccountPatch.java @@ -0,0 +1,66 @@ +package app.revanced.extension.music.patches.account; + +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.shared.utils.Utils.isSDKAbove; + +import android.view.View; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +import app.revanced.extension.music.settings.Settings; + +@SuppressWarnings("unused") +public class AccountPatch { + + private static String[] accountMenuBlockList; + + static { + accountMenuBlockList = Settings.HIDE_ACCOUNT_MENU_FILTER_STRINGS.get().split("\\n"); + // Some settings should not be hidden. + if (isSDKAbove(24)) { + accountMenuBlockList = Arrays.stream(accountMenuBlockList) + .filter(item -> !Objects.equals(item, str("settings"))) + .toArray(String[]::new); + } else { + List tmp = new ArrayList<>(Arrays.asList(accountMenuBlockList)); + tmp.remove(str("settings")); // "Settings" should appear only once in the account menu + accountMenuBlockList = tmp.toArray(new String[0]); + } + } + + public static void hideAccountMenu(CharSequence charSequence, View view) { + if (!Settings.HIDE_ACCOUNT_MENU.get()) + return; + + if (charSequence == null) { + if (Settings.HIDE_ACCOUNT_MENU_EMPTY_COMPONENT.get()) + view.setVisibility(View.GONE); + + return; + } + + for (String filter : accountMenuBlockList) { + if (!filter.isEmpty() && charSequence.toString().equals(filter)) + view.setVisibility(View.GONE); + } + } + + public static boolean hideHandle(boolean original) { + return Settings.HIDE_HANDLE.get() || original; + } + + public static void hideHandle(TextView textView, int visibility) { + final int finalVisibility = Settings.HIDE_HANDLE.get() + ? View.GONE + : visibility; + textView.setVisibility(finalVisibility); + } + + public static int hideTermsContainer() { + return Settings.HIDE_TERMS_CONTAINER.get() ? View.GONE : View.VISIBLE; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/actionbar/ActionBarPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/actionbar/ActionBarPatch.java new file mode 100644 index 0000000000..d973918eed --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/actionbar/ActionBarPatch.java @@ -0,0 +1,89 @@ +package app.revanced.extension.music.patches.actionbar; + +import static app.revanced.extension.shared.utils.Utils.hideViewBy0dpUnderCondition; +import static app.revanced.extension.shared.utils.Utils.hideViewUnderCondition; + +import android.view.View; + +import androidx.annotation.NonNull; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.music.utils.VideoUtils; + +@SuppressWarnings("unused") +public class ActionBarPatch { + + @NonNull + private static String buttonType = ""; + + public static boolean hideActionBarLabel() { + return Settings.HIDE_ACTION_BUTTON_LABEL.get(); + } + + public static boolean hideActionButton() { + for (ActionButton actionButton : ActionButton.values()) + if (actionButton.enabled && actionButton.name.equals(buttonType)) + return true; + + return false; + } + + public static void hideLikeDislikeButton(View view) { + final boolean enabled = Settings.HIDE_ACTION_BUTTON_LIKE_DISLIKE.get(); + hideViewUnderCondition( + enabled, + view + ); + hideViewBy0dpUnderCondition( + enabled, + view + ); + } + + public static void inAppDownloadButtonOnClick(View view) { + if (!Settings.EXTERNAL_DOWNLOADER_ACTION_BUTTON.get()) { + return; + } + + if (buttonType.equals(ActionButton.DOWNLOAD.name)) + view.setOnClickListener(imageView -> VideoUtils.launchExternalDownloader()); + } + + public static void setButtonType(@NonNull Object obj) { + final String buttonType = obj.toString(); + + for (ActionButton actionButton : ActionButton.values()) + if (buttonType.contains(actionButton.identifier)) + setButtonType(actionButton.name); + } + + public static void setButtonType(@NonNull String newButtonType) { + buttonType = newButtonType; + } + + public static void setButtonTypeDownload(int type) { + if (type != 0) + return; + + setButtonType(ActionButton.DOWNLOAD.name); + } + + private enum ActionButton { + ADD_TO_PLAYLIST("ACTION_BUTTON_ADD_TO_PLAYLIST", "69487224", Settings.HIDE_ACTION_BUTTON_ADD_TO_PLAYLIST.get()), + COMMENT_DISABLED("ACTION_BUTTON_COMMENT", "76623563", Settings.HIDE_ACTION_BUTTON_COMMENT.get()), + COMMENT_ENABLED("ACTION_BUTTON_COMMENT", "138681778", Settings.HIDE_ACTION_BUTTON_COMMENT.get()), + DOWNLOAD("ACTION_BUTTON_DOWNLOAD", "73080600", Settings.HIDE_ACTION_BUTTON_DOWNLOAD.get()), + RADIO("ACTION_BUTTON_RADIO", "48687757", Settings.HIDE_ACTION_BUTTON_RADIO.get()), + SHARE("ACTION_BUTTON_SHARE", "90650344", Settings.HIDE_ACTION_BUTTON_SHARE.get()); + + private final String name; + private final String identifier; + private final boolean enabled; + + ActionButton(String name, String identifier, boolean enabled) { + this.name = name; + this.identifier = identifier; + this.enabled = enabled; + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/ads/MusicAdsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/ads/MusicAdsPatch.java new file mode 100644 index 0000000000..3cf53b27ed --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/ads/MusicAdsPatch.java @@ -0,0 +1,15 @@ +package app.revanced.extension.music.patches.ads; + +import app.revanced.extension.music.settings.Settings; + +@SuppressWarnings("unused") +public class MusicAdsPatch { + + public static boolean hideMusicAds() { + return !Settings.HIDE_MUSIC_ADS.get(); + } + + public static boolean hideMusicAds(boolean original) { + return !Settings.HIDE_MUSIC_ADS.get() && original; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/ads/PremiumPromotionPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/ads/PremiumPromotionPatch.java new file mode 100644 index 0000000000..7a863606f0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/ads/PremiumPromotionPatch.java @@ -0,0 +1,40 @@ +package app.revanced.extension.music.patches.ads; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.shared.utils.Logger; + +@SuppressWarnings("unused") +public class PremiumPromotionPatch { + + public static void hidePremiumPromotion(View view) { + if (!Settings.HIDE_PREMIUM_PROMOTION.get()) + return; + + view.getViewTreeObserver().addOnGlobalLayoutListener(() -> { + try { + if (!(view instanceof ViewGroup viewGroup)) { + return; + } + if (!(viewGroup.getChildAt(0) instanceof ViewGroup mealBarLayoutRoot)) { + return; + } + if (!(mealBarLayoutRoot.getChildAt(0) instanceof LinearLayout linearLayout)) { + return; + } + if (!(linearLayout.getChildAt(0) instanceof ImageView imageView)) { + return; + } + if (imageView.getVisibility() == View.VISIBLE) { + view.setVisibility(View.GONE); + } + } catch (Exception ex) { + Logger.printException(() -> "hideGetPremium failure", ex); + } + }); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/ads/PremiumRenewalPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/ads/PremiumRenewalPatch.java new file mode 100644 index 0000000000..f5efd9c564 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/ads/PremiumRenewalPatch.java @@ -0,0 +1,39 @@ +package app.revanced.extension.music.patches.ads; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +@SuppressWarnings("unused") +public class PremiumRenewalPatch { + + public static void hidePremiumRenewal(LinearLayout buttonContainerView) { + if (!Settings.HIDE_PREMIUM_RENEWAL.get()) + return; + + buttonContainerView.getViewTreeObserver().addOnGlobalLayoutListener(() -> { + try { + Utils.runOnMainThreadDelayed(() -> { + if (!(buttonContainerView.getChildAt(0) instanceof ViewGroup closeButtonParentView)) + return; + if (!(closeButtonParentView.getChildAt(0) instanceof TextView closeButtonView)) + return; + if (closeButtonView.getText().toString().equals(str("dialog_got_it_text"))) + Utils.clickView(closeButtonView); + else + Utils.hideViewByLayoutParams((View) buttonContainerView.getParent()); + }, 0 + ); + } catch (Exception ex) { + Logger.printException(() -> "hidePremiumRenewal failure", ex); + } + }); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/AdsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/AdsFilter.java new file mode 100644 index 0000000000..de4c659854 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/AdsFilter.java @@ -0,0 +1,31 @@ +package app.revanced.extension.music.patches.components; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; + +@SuppressWarnings("unused") +public final class AdsFilter extends Filter { + + public AdsFilter() { + final StringFilterGroup alertBannerPromo = new StringFilterGroup( + Settings.HIDE_PROMOTION_ALERT_BANNER, + "alert_banner_promo.eml" + ); + + final StringFilterGroup paidPromotionLabel = new StringFilterGroup( + Settings.HIDE_PAID_PROMOTION_LABEL, + "music_paid_content_overlay.eml" + ); + + addIdentifierCallbacks(alertBannerPromo, paidPromotionLabel); + + final StringFilterGroup statementBanner = new StringFilterGroup( + Settings.HIDE_GENERAL_ADS, + "statement_banner" + ); + + addPathCallbacks(statementBanner); + + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/CustomFilter.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/CustomFilter.java new file mode 100644 index 0000000000..b3c7661335 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/CustomFilter.java @@ -0,0 +1,164 @@ +package app.revanced.extension.music.patches.components; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.shared.utils.ByteTrieSearch; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +/** + * Allows custom filtering using a path and optionally a proto buffer string. + */ +@SuppressWarnings("unused") +public final class CustomFilter extends Filter { + + private static void showInvalidSyntaxToast(@NonNull String expression) { + Utils.showToastLong(str("revanced_custom_filter_toast_invalid_syntax", expression)); + } + + private static class CustomFilterGroup extends StringFilterGroup { + /** + * Optional character for the path that indicates the custom filter path must match the start. + * Must be the first character of the expression. + */ + public static final String SYNTAX_STARTS_WITH = "^"; + + /** + * Optional character that separates the path from a proto buffer string pattern. + */ + public static final String SYNTAX_BUFFER_SYMBOL = "$"; + + /** + * @return the parsed objects + */ + @NonNull + @SuppressWarnings("ConstantConditions") + static Collection parseCustomFilterGroups() { + String rawCustomFilterText = Settings.CUSTOM_FILTER_STRINGS.get(); + if (rawCustomFilterText.isBlank()) { + return Collections.emptyList(); + } + + // Map key is the path including optional special characters (^ and/or $) + Map result = new HashMap<>(); + Pattern pattern = Pattern.compile( + "(" // map key group + + "(\\Q" + SYNTAX_STARTS_WITH + "\\E?)" // optional starts with + + "([^\\Q" + SYNTAX_BUFFER_SYMBOL + "\\E]*)" // path + + "(\\Q" + SYNTAX_BUFFER_SYMBOL + "\\E?)" // optional buffer symbol + + ")" // end map key group + + "(.*)"); // optional buffer string + + for (String expression : rawCustomFilterText.split("\n")) { + if (expression.isBlank()) continue; + + Matcher matcher = pattern.matcher(expression); + if (!matcher.find()) { + showInvalidSyntaxToast(expression); + continue; + } + + final String mapKey = matcher.group(1); + final boolean pathStartsWith = !matcher.group(2).isEmpty(); + final String path = matcher.group(3); + final boolean hasBufferSymbol = !matcher.group(4).isEmpty(); + final String bufferString = matcher.group(5); + + if (path.isBlank() || (hasBufferSymbol && bufferString.isBlank())) { + showInvalidSyntaxToast(expression); + continue; + } + + // Use one group object for all expressions with the same path. + // This ensures the buffer is searched exactly once + // when multiple paths are used with different buffer strings. + CustomFilterGroup group = result.get(mapKey); + if (group == null) { + group = new CustomFilterGroup(pathStartsWith, path); + result.put(mapKey, group); + } + if (hasBufferSymbol) { + group.addBufferString(bufferString); + } + } + + return result.values(); + } + + final boolean startsWith; + ByteTrieSearch bufferSearch; + + CustomFilterGroup(boolean startsWith, @NonNull String path) { + super(Settings.CUSTOM_FILTER, path); + this.startsWith = startsWith; + } + + void addBufferString(@NonNull String bufferString) { + if (bufferSearch == null) { + bufferSearch = new ByteTrieSearch(); + } + bufferSearch.addPattern(bufferString.getBytes()); + } + + @NonNull + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("CustomFilterGroup{"); + builder.append("path="); + if (startsWith) builder.append(SYNTAX_STARTS_WITH); + builder.append(filters[0]); + + if (bufferSearch != null) { + String delimitingCharacter = "❙"; + builder.append(", bufferStrings="); + builder.append(delimitingCharacter); + for (byte[] bufferString : bufferSearch.getPatterns()) { + builder.append(new String(bufferString)); + builder.append(delimitingCharacter); + } + } + builder.append("}"); + return builder.toString(); + } + } + + public CustomFilter() { + Collection groups = CustomFilterGroup.parseCustomFilterGroups(); + + if (!groups.isEmpty()) { + CustomFilterGroup[] groupsArray = groups.toArray(new CustomFilterGroup[0]); + Logger.printDebug(() -> "Using Custom filters: " + Arrays.toString(groupsArray)); + addPathCallbacks(groupsArray); + } + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + // All callbacks are custom filter groups. + CustomFilterGroup custom = (CustomFilterGroup) matchedGroup; + if (custom.startsWith && contentIndex != 0) { + return false; + } + if (custom.bufferSearch != null && !custom.bufferSearch.matches(protobufBufferArray)) { + return false; + } + + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/LayoutComponentsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/LayoutComponentsFilter.java new file mode 100644 index 0000000000..6724319691 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/LayoutComponentsFilter.java @@ -0,0 +1,39 @@ +package app.revanced.extension.music.patches.components; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; + +@SuppressWarnings("unused") +public final class LayoutComponentsFilter extends Filter { + + public LayoutComponentsFilter() { + + final StringFilterGroup buttonShelf = new StringFilterGroup( + Settings.HIDE_BUTTON_SHELF, + "entry_point_button_shelf.eml" + ); + + final StringFilterGroup carouselShelf = new StringFilterGroup( + Settings.HIDE_CAROUSEL_SHELF, + "music_grid_item_carousel.eml" + ); + + final StringFilterGroup playlistCardShelf = new StringFilterGroup( + Settings.HIDE_PLAYLIST_CARD_SHELF, + "music_container_card_shelf.eml" + ); + + final StringFilterGroup sampleShelf = new StringFilterGroup( + Settings.HIDE_SAMPLE_SHELF, + "immersive_card_shelf.eml" + ); + + addIdentifierCallbacks( + buttonShelf, + carouselShelf, + playlistCardShelf, + sampleShelf + ); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/PlayerComponentsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/PlayerComponentsFilter.java new file mode 100644 index 0000000000..52056aecc6 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/PlayerComponentsFilter.java @@ -0,0 +1,25 @@ +package app.revanced.extension.music.patches.components; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; + +@SuppressWarnings("unused") +public final class PlayerComponentsFilter extends Filter { + + public PlayerComponentsFilter() { + addIdentifierCallbacks( + new StringFilterGroup( + Settings.HIDE_COMMENT_CHANNEL_GUIDELINES, + "channel_guidelines_entry_banner.eml", + "community_guidelines.eml" + ) + ); + addPathCallbacks( + new StringFilterGroup( + Settings.HIDE_COMMENT_TIMESTAMP_AND_EMOJI_BUTTONS, + "|CellType|ContainerType|ContainerType|ContainerType|ContainerType|ContainerType|" + ) + ); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/PlayerFlyoutMenuFilter.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/PlayerFlyoutMenuFilter.java new file mode 100644 index 0000000000..5f6701466f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/PlayerFlyoutMenuFilter.java @@ -0,0 +1,19 @@ +package app.revanced.extension.music.patches.components; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; + +@SuppressWarnings("unused") +public final class PlayerFlyoutMenuFilter extends Filter { + + public PlayerFlyoutMenuFilter() { + addIdentifierCallbacks( + new StringFilterGroup( + Settings.HIDE_FLYOUT_MENU_3_COLUMN_COMPONENT, + "music_highlight_menu_item_carousel.eml", + "tile_button_carousel.eml" + ) + ); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/ShareSheetMenuFilter.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/ShareSheetMenuFilter.java new file mode 100644 index 0000000000..7c6d168170 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/components/ShareSheetMenuFilter.java @@ -0,0 +1,33 @@ +package app.revanced.extension.music.patches.components; + +import androidx.annotation.Nullable; + +import app.revanced.extension.music.patches.misc.ShareSheetPatch; +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; + +/** + * Abuse LithoFilter for {@link ShareSheetPatch}. + */ +public final class ShareSheetMenuFilter extends Filter { + // Must be volatile or synchronized, as litho filtering runs off main thread and this field is then access from the main thread. + public static volatile boolean isShareSheetMenuVisible; + + public ShareSheetMenuFilter() { + addIdentifierCallbacks( + new StringFilterGroup( + Settings.CHANGE_SHARE_SHEET, + "share_sheet_container.eml" + ) + ); + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + isShareSheetMenuVisible = true; + + return false; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/flyout/FlyoutPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/flyout/FlyoutPatch.java new file mode 100644 index 0000000000..70895b027c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/flyout/FlyoutPatch.java @@ -0,0 +1,175 @@ +package app.revanced.extension.music.patches.flyout; + +import static app.revanced.extension.shared.utils.ResourceUtils.getIdentifier; +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.shared.utils.Utils.clickView; +import static app.revanced.extension.shared.utils.Utils.runOnMainThreadDelayed; + +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.ref.WeakReference; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.music.shared.VideoType; +import app.revanced.extension.music.utils.VideoUtils; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.ResourceUtils.ResourceType; + +@SuppressWarnings("unused") +public class FlyoutPatch { + + public static int enableCompactDialog(int original) { + if (!Settings.ENABLE_COMPACT_DIALOG.get()) + return original; + + return Math.max(original, 600); + } + + public static boolean enableTrimSilence(boolean original) { + if (!Settings.ENABLE_TRIM_SILENCE.get()) + return original; + + return VideoType.getCurrent().isPodCast() || original; + } + + public static boolean enableTrimSilenceSwitch(boolean original) { + if (!Settings.ENABLE_TRIM_SILENCE.get()) + return original; + + return VideoType.getCurrent().isPodCast() && original; + } + + public static boolean hideComponents(@Nullable Enum flyoutMenuEnum) { + if (flyoutMenuEnum == null) + return false; + + final String flyoutMenuName = flyoutMenuEnum.name(); + + Logger.printDebug(() -> "flyoutMenu: " + flyoutMenuName); + + for (FlyoutPanelComponent component : FlyoutPanelComponent.values()) + if (component.name.equals(flyoutMenuName) && component.enabled) + return true; + + return false; + } + + public static void hideLikeDislikeContainer(View view) { + if (!Settings.HIDE_FLYOUT_MENU_LIKE_DISLIKE.get()) + return; + + if (view.getParent() instanceof ViewGroup viewGroup) { + viewGroup.removeView(view); + } + } + + private static volatile boolean lastMenuWasDismissQueue = false; + + private static WeakReference touchOutSideViewRef = new WeakReference<>(null); + + public static void setTouchOutSideView(View touchOutSideView) { + touchOutSideViewRef = new WeakReference<>(touchOutSideView); + } + + public static void replaceComponents(@Nullable Enum flyoutPanelEnum, @NonNull TextView textView, @NonNull ImageView imageView) { + if (flyoutPanelEnum == null) + return; + + final String enumString = flyoutPanelEnum.name(); + final boolean isDismissQue = enumString.equals("DISMISS_QUEUE"); + final boolean isReport = enumString.equals("FLAG"); + + if (isDismissQue) { + replaceDismissQueue(textView, imageView); + } else if (isReport) { + replaceReport(textView, imageView, lastMenuWasDismissQueue); + } + lastMenuWasDismissQueue = isDismissQue; + } + + private static void replaceDismissQueue(@NonNull TextView textView, @NonNull ImageView imageView) { + if (!Settings.REPLACE_FLYOUT_MENU_DISMISS_QUEUE.get()) + return; + + if (!(textView.getParent() instanceof ViewGroup clickAbleArea)) + return; + + runOnMainThreadDelayed(() -> { + textView.setText(str("revanced_replace_flyout_menu_dismiss_queue_watch_on_youtube_label")); + imageView.setImageResource(getIdentifier("yt_outline_youtube_logo_icon_vd_theme_24", ResourceType.DRAWABLE, clickAbleArea.getContext())); + clickAbleArea.setOnClickListener(viewGroup -> VideoUtils.openInYouTube()); + }, 0L + ); + } + + private static final ColorFilter cf = new PorterDuffColorFilter(Color.parseColor("#ffffffff"), PorterDuff.Mode.SRC_ATOP); + + private static void replaceReport(@NonNull TextView textView, @NonNull ImageView imageView, boolean wasDismissQueue) { + if (!Settings.REPLACE_FLYOUT_MENU_REPORT.get()) + return; + + if (Settings.REPLACE_FLYOUT_MENU_REPORT_ONLY_PLAYER.get() && !wasDismissQueue) + return; + + if (!(textView.getParent() instanceof ViewGroup clickAbleArea)) + return; + + runOnMainThreadDelayed(() -> { + textView.setText(str("playback_rate_title")); + imageView.setImageResource(getIdentifier("yt_outline_play_arrow_half_circle_black_24", ResourceType.DRAWABLE, clickAbleArea.getContext())); + imageView.setColorFilter(cf); + clickAbleArea.setOnClickListener(view -> { + clickView(touchOutSideViewRef.get()); + VideoUtils.showPlaybackSpeedFlyoutMenu(); + }); + }, 0L + ); + } + + private enum FlyoutPanelComponent { + SAVE_EPISODE_FOR_LATER("BOOKMARK_BORDER", Settings.HIDE_FLYOUT_MENU_SAVE_EPISODE_FOR_LATER.get()), + SHUFFLE_PLAY("SHUFFLE", Settings.HIDE_FLYOUT_MENU_SHUFFLE_PLAY.get()), + RADIO("MIX", Settings.HIDE_FLYOUT_MENU_START_RADIO.get()), + SUBSCRIBE("SUBSCRIBE", Settings.HIDE_FLYOUT_MENU_SUBSCRIBE.get()), + EDIT_PLAYLIST("EDIT", Settings.HIDE_FLYOUT_MENU_EDIT_PLAYLIST.get()), + DELETE_PLAYLIST("DELETE", Settings.HIDE_FLYOUT_MENU_DELETE_PLAYLIST.get()), + PLAY_NEXT("QUEUE_PLAY_NEXT", Settings.HIDE_FLYOUT_MENU_PLAY_NEXT.get()), + ADD_TO_QUEUE("QUEUE_MUSIC", Settings.HIDE_FLYOUT_MENU_ADD_TO_QUEUE.get()), + SAVE_TO_LIBRARY("LIBRARY_ADD", Settings.HIDE_FLYOUT_MENU_SAVE_TO_LIBRARY.get()), + REMOVE_FROM_LIBRARY("LIBRARY_REMOVE", Settings.HIDE_FLYOUT_MENU_REMOVE_FROM_LIBRARY.get()), + REMOVE_FROM_PLAYLIST("REMOVE_FROM_PLAYLIST", Settings.HIDE_FLYOUT_MENU_REMOVE_FROM_PLAYLIST.get()), + DOWNLOAD("OFFLINE_DOWNLOAD", Settings.HIDE_FLYOUT_MENU_DOWNLOAD.get()), + SAVE_TO_PLAYLIST("ADD_TO_PLAYLIST", Settings.HIDE_FLYOUT_MENU_SAVE_TO_PLAYLIST.get()), + GO_TO_EPISODE("INFO", Settings.HIDE_FLYOUT_MENU_GO_TO_EPISODE.get()), + GO_TO_PODCAST("BROADCAST", Settings.HIDE_FLYOUT_MENU_GO_TO_PODCAST.get()), + GO_TO_ALBUM("ALBUM", Settings.HIDE_FLYOUT_MENU_GO_TO_ALBUM.get()), + GO_TO_ARTIST("ARTIST", Settings.HIDE_FLYOUT_MENU_GO_TO_ARTIST.get()), + VIEW_SONG_CREDIT("PEOPLE_GROUP", Settings.HIDE_FLYOUT_MENU_VIEW_SONG_CREDIT.get()), + SHARE("SHARE", Settings.HIDE_FLYOUT_MENU_SHARE.get()), + DISMISS_QUEUE("DISMISS_QUEUE", Settings.HIDE_FLYOUT_MENU_DISMISS_QUEUE.get()), + HELP("HELP_OUTLINE", Settings.HIDE_FLYOUT_MENU_HELP.get()), + REPORT("FLAG", Settings.HIDE_FLYOUT_MENU_REPORT.get()), + QUALITY("SETTINGS_MATERIAL", Settings.HIDE_FLYOUT_MENU_QUALITY.get()), + CAPTIONS("CAPTIONS", Settings.HIDE_FLYOUT_MENU_CAPTIONS.get()), + STATS_FOR_NERDS("PLANNER_REVIEW", Settings.HIDE_FLYOUT_MENU_STATS_FOR_NERDS.get()), + SLEEP_TIMER("MOON_Z", Settings.HIDE_FLYOUT_MENU_SLEEP_TIMER.get()); + + private final boolean enabled; + private final String name; + + FlyoutPanelComponent(String name, boolean enabled) { + this.enabled = enabled; + this.name = name; + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/general/GeneralPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/general/GeneralPatch.java new file mode 100644 index 0000000000..72d3ba3f30 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/general/GeneralPatch.java @@ -0,0 +1,181 @@ +package app.revanced.extension.music.patches.general; + +import static app.revanced.extension.music.utils.ExtendedUtils.isSpoofingToLessThan; +import static app.revanced.extension.shared.utils.Utils.hideViewBy0dpUnderCondition; + +import android.app.AlertDialog; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.ImageView; + +import app.revanced.extension.music.settings.Settings; + +/** + * @noinspection ALL + */ +@SuppressWarnings("unused") +public class GeneralPatch { + + // region [Change start page] patch + + public static String changeStartPage(final String browseId) { + if (!browseId.equals("FEmusic_home")) + return browseId; + + return Settings.CHANGE_START_PAGE.get(); + } + + // endregion + + // region [Disable dislike redirection] patch + + public static boolean disableDislikeRedirection() { + return Settings.DISABLE_DISLIKE_REDIRECTION.get(); + } + + // endregion + + // region [Enable landscape mode] patch + + public static boolean enableLandScapeMode(boolean original) { + return Settings.ENABLE_LANDSCAPE_MODE.get() || original; + } + + // endregion + + // region [Hide layout components] patch + + public static int hideCastButton(int original) { + return Settings.HIDE_CAST_BUTTON.get() ? View.GONE : original; + } + + public static void hideCastButton(View view) { + hideViewBy0dpUnderCondition(Settings.HIDE_CAST_BUTTON.get(), view); + } + + public static void hideCategoryBar(View view) { + hideViewBy0dpUnderCondition(Settings.HIDE_CATEGORY_BAR.get(), view); + } + + public static boolean hideFloatingButton() { + return Settings.HIDE_FLOATING_BUTTON.get(); + } + + public static boolean hideTapToUpdateButton() { + return Settings.HIDE_TAP_TO_UPDATE_BUTTON.get(); + } + + public static boolean hideHistoryButton(boolean original) { + return !Settings.HIDE_HISTORY_BUTTON.get() && original; + } + + public static void hideNotificationButton(View view) { + if (view.getParent() instanceof ViewGroup viewGroup) { + hideViewBy0dpUnderCondition(Settings.HIDE_NOTIFICATION_BUTTON, viewGroup); + } + } + + public static boolean hideSoundSearchButton(boolean original) { + if (!Settings.SETTINGS_INITIALIZED.get()) { + return original; + } + return !Settings.HIDE_SOUND_SEARCH_BUTTON.get(); + } + + public static void hideVoiceSearchButton(ImageView view, int visibility) { + final int finalVisibility = Settings.HIDE_VOICE_SEARCH_BUTTON.get() + ? View.GONE + : visibility; + + view.setVisibility(finalVisibility); + } + + public static void hideTasteBuilder(View view) { + view.setVisibility(View.GONE); + } + + + // endregion + + // region [Hide overlay filter] patch + + public static void disableDimBehind(Window window) { + if (window != null) { + // Disable AlertDialog's background dim. + window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + } + } + + // endregion + + // region [Remove viewer discretion dialog] patch + + /** + * Injection point. + *

+ * The {@link AlertDialog#getButton(int)} method must be used after {@link AlertDialog#show()} is called. + * Otherwise {@link AlertDialog#getButton(int)} method will always return null. + * https://stackoverflow.com/a/4604145 + *

+ * That's why {@link AlertDialog#show()} is absolutely necessary. + * Instead, use two tricks to hide Alertdialog. + *

+ * 1. Change the size of AlertDialog to 0. + * 2. Disable AlertDialog's background dim. + *

+ * This way, AlertDialog will be completely hidden, + * and {@link AlertDialog#getButton(int)} method can be used without issue. + */ + public static void confirmDialog(final AlertDialog dialog) { + if (!Settings.REMOVE_VIEWER_DISCRETION_DIALOG.get()) { + return; + } + + // This method is called after AlertDialog#show(), + // So we need to hide the AlertDialog before pressing the possitive button. + final Window window = dialog.getWindow(); + final Button button = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + if (window != null && button != null) { + WindowManager.LayoutParams params = window.getAttributes(); + params.height = 0; + params.width = 0; + + // Change the size of AlertDialog to 0. + window.setAttributes(params); + + // Disable AlertDialog's background dim. + window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + + button.callOnClick(); + } + } + + // endregion + + // region [Restore old style library shelf] patch + + public static String restoreOldStyleLibraryShelf(final String browseId) { + final boolean oldStyleLibraryShelfEnabled = + Settings.RESTORE_OLD_STYLE_LIBRARY_SHELF.get() || isSpoofingToLessThan("5.38.00"); + return oldStyleLibraryShelfEnabled && browseId.equals("FEmusic_library_landing") + ? "FEmusic_liked" + : browseId; + } + + // endregion + + // region [Spoof app version] patch + + public static String getVersionOverride(String version) { + if (!Settings.SPOOF_APP_VERSION.get()) + return version; + + return Settings.SPOOF_APP_VERSION_TARGET.get(); + } + + // endregion + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/general/SettingsMenuPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/general/SettingsMenuPatch.java new file mode 100644 index 0000000000..27359dcc9c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/general/SettingsMenuPatch.java @@ -0,0 +1,41 @@ +package app.revanced.extension.music.patches.general; + +import androidx.preference.PreferenceScreen; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.shared.patches.BaseSettingsMenuPatch; + +@SuppressWarnings("unused") +public final class SettingsMenuPatch extends BaseSettingsMenuPatch { + + public static void hideSettingsMenu(PreferenceScreen mPreferenceScreen) { + if (mPreferenceScreen == null) return; + for (SettingsMenuComponent component : SettingsMenuComponent.values()) + if (component.enabled) + removePreference(mPreferenceScreen, component.key); + } + + public static boolean hideParentToolsMenu(boolean original) { + return !Settings.HIDE_SETTINGS_MENU_PARENT_TOOLS.get() && original; + } + + private enum SettingsMenuComponent { + GENERAL("settings_header_general", Settings.HIDE_SETTINGS_MENU_GENERAL.get()), + PLAYBACK("settings_header_playback", Settings.HIDE_SETTINGS_MENU_PLAYBACK.get()), + DATA_SAVING("settings_header_data_saving", Settings.HIDE_SETTINGS_MENU_DATA_SAVING.get()), + DOWNLOADS_AND_STORAGE("settings_header_downloads_and_storage", Settings.HIDE_SETTINGS_MENU_DOWNLOADS_AND_STORAGE.get()), + NOTIFICATIONS("settings_header_notifications", Settings.HIDE_SETTINGS_MENU_NOTIFICATIONS.get()), + PRIVACY_AND_LOCATION("settings_header_privacy_and_location", Settings.HIDE_SETTINGS_MENU_PRIVACY_AND_LOCATION.get()), + RECOMMENDATIONS("settings_header_recommendations", Settings.HIDE_SETTINGS_MENU_RECOMMENDATIONS.get()), + PAID_MEMBERSHIPS("settings_header_paid_memberships", Settings.HIDE_SETTINGS_MENU_PAID_MEMBERSHIPS.get()), + ABOUT("settings_header_about_youtube_music", Settings.HIDE_SETTINGS_MENU_ABOUT.get()); + + private final String key; + private final boolean enabled; + + SettingsMenuComponent(String key, boolean enabled) { + this.key = key; + this.enabled = enabled; + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/CairoSplashAnimationPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/CairoSplashAnimationPatch.java new file mode 100644 index 0000000000..c1c9c5094d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/CairoSplashAnimationPatch.java @@ -0,0 +1,11 @@ +package app.revanced.extension.music.patches.misc; + +import app.revanced.extension.music.settings.Settings; + +@SuppressWarnings("unused") +public class CairoSplashAnimationPatch { + + public static boolean disableCairoSplashAnimation(boolean original) { + return !Settings.DISABLE_CAIRO_SPLASH_ANIMATION.get() && original; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/OpusCodecPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/OpusCodecPatch.java new file mode 100644 index 0000000000..5dec961fad --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/OpusCodecPatch.java @@ -0,0 +1,11 @@ +package app.revanced.extension.music.patches.misc; + +import app.revanced.extension.music.settings.Settings; + +@SuppressWarnings("unused") +public class OpusCodecPatch { + + public static boolean enableOpusCodec() { + return Settings.ENABLE_OPUS_CODEC.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/ShareSheetPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/ShareSheetPatch.java new file mode 100644 index 0000000000..afcaaa77e8 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/ShareSheetPatch.java @@ -0,0 +1,41 @@ +package app.revanced.extension.music.patches.misc; + +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.view.ViewGroup; + +import app.revanced.extension.music.patches.components.ShareSheetMenuFilter; +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +@SuppressWarnings("unused") +public class ShareSheetPatch { + /** + * Injection point. + */ + public static void onShareSheetMenuCreate(final RecyclerView recyclerView) { + if (!Settings.CHANGE_SHARE_SHEET.get()) + return; + + recyclerView.getViewTreeObserver().addOnDrawListener(() -> { + try { + if (!ShareSheetMenuFilter.isShareSheetMenuVisible) + return; + if (!(recyclerView.getChildAt(0) instanceof ViewGroup shareContainer)) { + return; + } + if (!(shareContainer.getChildAt(shareContainer.getChildCount() - 1) instanceof ViewGroup shareWithOtherAppsView)) { + return; + } + ShareSheetMenuFilter.isShareSheetMenuVisible = false; + + recyclerView.setVisibility(View.GONE); + Utils.clickView(shareWithOtherAppsView); + } catch (Exception ex) { + Logger.printException(() -> "onShareSheetMenuCreate failure", ex); + } + }); + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/navigation/NavigationPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/navigation/NavigationPatch.java new file mode 100644 index 0000000000..bf99b8fe6c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/navigation/NavigationPatch.java @@ -0,0 +1,56 @@ +package app.revanced.extension.music.patches.navigation; + +import static app.revanced.extension.shared.utils.Utils.hideViewUnderCondition; + +import android.graphics.Color; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.shared.utils.ResourceUtils; + +@SuppressWarnings("unused") +public class NavigationPatch { + private static final int colorGrey12 = + ResourceUtils.getColor("revanced_color_grey_12"); + public static Enum lastPivotTab; + + public static int enableBlackNavigationBar() { + return Settings.ENABLE_BLACK_NAVIGATION_BAR.get() + ? Color.BLACK + : colorGrey12; + } + + public static void hideNavigationLabel(TextView textview) { + hideViewUnderCondition(Settings.HIDE_NAVIGATION_LABEL.get(), textview); + } + + public static void hideNavigationButton(@NonNull View view) { + if (Settings.HIDE_NAVIGATION_BAR.get() && view.getParent() != null) { + hideViewUnderCondition(true, (View) view.getParent()); + return; + } + + for (NavigationButton button : NavigationButton.values()) + if (lastPivotTab.name().equals(button.name)) + hideViewUnderCondition(button.enabled, view); + } + + private enum NavigationButton { + HOME("TAB_HOME", Settings.HIDE_NAVIGATION_HOME_BUTTON.get()), + SAMPLES("TAB_SAMPLES", Settings.HIDE_NAVIGATION_SAMPLES_BUTTON.get()), + EXPLORE("TAB_EXPLORE", Settings.HIDE_NAVIGATION_EXPLORE_BUTTON.get()), + LIBRARY("LIBRARY_MUSIC", Settings.HIDE_NAVIGATION_LIBRARY_BUTTON.get()), + UPGRADE("TAB_MUSIC_PREMIUM", Settings.HIDE_NAVIGATION_UPGRADE_BUTTON.get()); + + private final boolean enabled; + private final String name; + + NavigationButton(String name, boolean enabled) { + this.enabled = enabled; + this.name = name; + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/player/PlayerPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/player/PlayerPatch.java new file mode 100644 index 0000000000..0e31a45df3 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/player/PlayerPatch.java @@ -0,0 +1,198 @@ +package app.revanced.extension.music.patches.player; + +import static app.revanced.extension.shared.utils.Utils.hideViewByRemovingFromParentUnderCondition; +import static app.revanced.extension.shared.utils.Utils.hideViewUnderCondition; +import static app.revanced.extension.shared.utils.Utils.isSDKAbove; + +import android.annotation.SuppressLint; +import android.graphics.Color; +import android.view.View; + +import java.util.Arrays; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.music.shared.VideoType; +import app.revanced.extension.music.utils.VideoUtils; + +@SuppressWarnings({"unused"}) +public class PlayerPatch { + private static final int MUSIC_VIDEO_GREY_BACKGROUND_COLOR = -12566464; + private static final int MUSIC_VIDEO_ORIGINAL_BACKGROUND_COLOR = -16579837; + + @SuppressLint("StaticFieldLeak") + public static View previousButton; + @SuppressLint("StaticFieldLeak") + public static View nextButton; + + public static boolean disableMiniPlayerGesture() { + return Settings.DISABLE_MINI_PLAYER_GESTURE.get(); + } + + public static boolean disablePlayerGesture() { + return Settings.DISABLE_PLAYER_GESTURE.get(); + } + + public static boolean enableColorMatchPlayer() { + return Settings.ENABLE_COLOR_MATCH_PLAYER.get(); + } + + public static int enableBlackPlayerBackground(int originalColor) { + return Settings.ENABLE_BLACK_PLAYER_BACKGROUND.get() + && originalColor != MUSIC_VIDEO_GREY_BACKGROUND_COLOR + ? Color.BLACK + : originalColor; + } + + public static boolean enableForceMinimizedPlayer(boolean original) { + return Settings.ENABLE_FORCE_MINIMIZED_PLAYER.get() || original; + } + + public static boolean enableMiniPlayerNextButton(boolean original) { + return !Settings.ENABLE_MINI_PLAYER_NEXT_BUTTON.get() && original; + } + + public static View[] getViewArray(View[] oldViewArray) { + if (previousButton != null) { + if (nextButton != null) { + return getViewArray(getViewArray(oldViewArray, previousButton), nextButton); + } else { + return getViewArray(oldViewArray, previousButton); + } + } else { + return oldViewArray; + } + } + + private static View[] getViewArray(View[] oldViewArray, View newView) { + final int oldViewArrayLength = oldViewArray.length; + + View[] newViewArray = Arrays.copyOf(oldViewArray, oldViewArrayLength + 1); + newViewArray[oldViewArrayLength] = newView; + return newViewArray; + } + + public static void setNextButton(View nextButtonView) { + if (nextButtonView == null) + return; + + hideViewUnderCondition( + !Settings.ENABLE_MINI_PLAYER_NEXT_BUTTON.get(), + nextButtonView + ); + + nextButtonView.setOnClickListener(PlayerPatch::setNextButtonOnClickListener); + } + + // rest of the implementation added by patch. + private static void setNextButtonOnClickListener(View view) { + if (Settings.ENABLE_MINI_PLAYER_NEXT_BUTTON.get()) + view.getClass(); + } + + public static void setPreviousButton(View previousButtonView) { + if (previousButtonView == null) + return; + + hideViewUnderCondition( + !Settings.ENABLE_MINI_PLAYER_PREVIOUS_BUTTON.get(), + previousButtonView + ); + + previousButtonView.setOnClickListener(PlayerPatch::setPreviousButtonOnClickListener); + } + + // rest of the implementation added by patch. + private static void setPreviousButtonOnClickListener(View view) { + if (Settings.ENABLE_MINI_PLAYER_PREVIOUS_BUTTON.get()) + view.getClass(); + } + + public static boolean enableSwipeToDismissMiniPlayer() { + return Settings.ENABLE_SWIPE_TO_DISMISS_MINI_PLAYER.get(); + } + + public static boolean enableSwipeToDismissMiniPlayer(boolean original) { + return !Settings.ENABLE_SWIPE_TO_DISMISS_MINI_PLAYER.get() && original; + } + + public static Object enableSwipeToDismissMiniPlayer(Object object) { + return Settings.ENABLE_SWIPE_TO_DISMISS_MINI_PLAYER.get() ? null : object; + } + + public static int enableZenMode(int originalColor) { + if (Settings.ENABLE_ZEN_MODE.get() && originalColor == MUSIC_VIDEO_ORIGINAL_BACKGROUND_COLOR) { + if (Settings.ENABLE_ZEN_MODE_PODCAST.get() || !VideoType.getCurrent().isPodCast()) { + return MUSIC_VIDEO_GREY_BACKGROUND_COLOR; + } + } + return originalColor; + } + + public static void hideAudioVideoSwitchToggle(View view, int originalVisibility) { + if (Settings.HIDE_AUDIO_VIDEO_SWITCH_TOGGLE.get()) { + originalVisibility = View.GONE; + } + view.setVisibility(originalVisibility); + } + + public static void hideDoubleTapOverlayFilter(View view) { + hideViewByRemovingFromParentUnderCondition(Settings.HIDE_DOUBLE_TAP_OVERLAY_FILTER, view); + } + + public static int hideFullscreenShareButton(int original) { + return Settings.HIDE_FULLSCREEN_SHARE_BUTTON.get() ? 0 : original; + } + + public static void setShuffleState(Enum shuffleState) { + if (!Settings.REMEMBER_SHUFFLE_SATE.get()) + return; + Settings.ALWAYS_SHUFFLE.save(shuffleState.ordinal() == 1); + } + + public static void shuffleTracks() { + if (!Settings.ALWAYS_SHUFFLE.get()) + return; + VideoUtils.shuffleTracks(); + } + + public static boolean rememberRepeatState(boolean original) { + return Settings.REMEMBER_REPEAT_SATE.get() || original; + } + + public static boolean rememberShuffleState() { + return Settings.REMEMBER_SHUFFLE_SATE.get(); + } + + public static boolean restoreOldCommentsPopUpPanels() { + return restoreOldCommentsPopUpPanels(true); + } + + public static boolean restoreOldCommentsPopUpPanels(boolean original) { + if (!Settings.SETTINGS_INITIALIZED.get()) { + return original; + } + return !Settings.RESTORE_OLD_COMMENTS_POPUP_PANELS.get() && original; + } + + public static boolean restoreOldPlayerBackground(boolean original) { + if (!Settings.SETTINGS_INITIALIZED.get()) { + return original; + } + if (!isSDKAbove(23)) { + // Disable this patch on Android 5.0 / 5.1 to fix a black play button. + // Android 5.x have a different design for play button, + // and if the new background is applied forcibly, the play button turns black. + // 6.20.51 uses the old background from the beginning, so there is no impact. + return original; + } + return !Settings.RESTORE_OLD_PLAYER_BACKGROUND.get(); + } + + public static boolean restoreOldPlayerLayout(boolean original) { + if (!Settings.SETTINGS_INITIALIZED.get()) { + return original; + } + return !Settings.RESTORE_OLD_PLAYER_LAYOUT.get(); + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/DrawableColorPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/DrawableColorPatch.java new file mode 100644 index 0000000000..d728074580 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/DrawableColorPatch.java @@ -0,0 +1,22 @@ +package app.revanced.extension.music.patches.utils; + +@SuppressWarnings("unused") +public class DrawableColorPatch { + private static final int[] DARK_VALUES = { + -14606047 // comments box background + }; + + public static int getColor(int originalValue) { + if (anyEquals(originalValue, DARK_VALUES)) + return -16777215; + + return originalValue; + } + + private static boolean anyEquals(int value, int... of) { + for (int v : of) if (value == v) return true; + return false; + } +} + + diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/InitializationPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/InitializationPatch.java new file mode 100644 index 0000000000..4524a10d02 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/InitializationPatch.java @@ -0,0 +1,34 @@ +package app.revanced.extension.music.patches.utils; + +import static app.revanced.extension.music.utils.RestartUtils.showRestartDialog; + +import android.app.Activity; + +import androidx.annotation.NonNull; + +import app.revanced.extension.music.utils.ExtendedUtils; +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.shared.utils.Utils; + +@SuppressWarnings("unused") +public class InitializationPatch { + + /** + * The new layout is not loaded normally when the app is first installed. + * (Also reproduced on unPatched YouTube Music) + *

+ * To fix this, show the reboot dialog when the app is installed for the first time. + */ + public static void onCreate(@NonNull Activity mActivity) { + if (BaseSettings.SETTINGS_INITIALIZED.get()) + return; + + showRestartDialog(mActivity, "revanced_extended_restart_first_run", 3000); + Utils.runOnMainThreadDelayed(() -> BaseSettings.SETTINGS_INITIALIZED.save(true), 3000); + } + + public static void setDeviceInformation(@NonNull Activity mActivity) { + ExtendedUtils.setApplicationLabel(); + ExtendedUtils.setVersionName(); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/PatchStatus.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/PatchStatus.java new file mode 100644 index 0000000000..08abcf94a0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/PatchStatus.java @@ -0,0 +1,12 @@ +package app.revanced.extension.music.patches.utils; + +@SuppressWarnings("unused") +public class PatchStatus { + public static boolean SpoofAppVersionDefaultBoolean() { + return false; + } + + public static String SpoofAppVersionDefaultString() { + return "6.11.52"; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/PlayerTypeHookPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/PlayerTypeHookPatch.java new file mode 100644 index 0000000000..1efe2d1a88 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/PlayerTypeHookPatch.java @@ -0,0 +1,19 @@ +package app.revanced.extension.music.patches.utils; + +import androidx.annotation.Nullable; + +import app.revanced.extension.music.shared.PlayerType; + +@SuppressWarnings("unused") +public class PlayerTypeHookPatch { + /** + * Injection point. + */ + public static void setPlayerType(@Nullable Enum musicPlayerType) { + if (musicPlayerType == null) + return; + + PlayerType.setFromString(musicPlayerType.name()); + } +} + diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/ReturnYouTubeDislikePatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/ReturnYouTubeDislikePatch.java new file mode 100644 index 0000000000..b864a9b3fd --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/ReturnYouTubeDislikePatch.java @@ -0,0 +1,112 @@ +package app.revanced.extension.music.patches.utils; + +import static app.revanced.extension.shared.returnyoutubedislike.ReturnYouTubeDislike.Vote; + +import android.text.Spanned; + +import androidx.annotation.Nullable; + +import app.revanced.extension.music.returnyoutubedislike.ReturnYouTubeDislike; +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.shared.returnyoutubedislike.requests.ReturnYouTubeDislikeApi; +import app.revanced.extension.shared.utils.Logger; + +/** + * Handles all interaction of UI patch components. + *

+ * Does not handle creating dislike spans or anything to do with {@link ReturnYouTubeDislikeApi}. + */ +@SuppressWarnings("unused") +public class ReturnYouTubeDislikePatch { + /** + * RYD data for the current video on screen. + */ + @Nullable + private static volatile ReturnYouTubeDislike currentVideoData; + + public static void onRYDStatusChange(boolean rydEnabled) { + ReturnYouTubeDislikeApi.resetRateLimits(); + // Must remove all values to protect against using stale data + // if the user enables RYD while a video is on screen. + clearData(); + } + + private static void clearData() { + currentVideoData = null; + } + + /** + * Injection point + *

+ * Called when a Shorts dislike Spannable is created + */ + public static Spanned onSpannedCreated(Spanned original) { + try { + if (original == null) { + return null; + } + ReturnYouTubeDislike videoData = currentVideoData; + if (videoData == null) { + return original; // User enabled RYD while a video was on screen. + } + return videoData.getDislikesSpan(original); + } catch (Exception ex) { + Logger.printException(() -> "onSpannedCreated failure", ex); + } + return original; + } + + /** + * Injection point. + */ + public static void newVideoLoaded(@Nullable String videoId) { + try { + if (!Settings.RYD_ENABLED.get()) { + return; + } + if (videoId == null || videoId.isEmpty()) { + return; + } + if (videoIdIsSame(currentVideoData, videoId)) { + return; + } + currentVideoData = ReturnYouTubeDislike.getFetchForVideoId(videoId); + } catch (Exception ex) { + Logger.printException(() -> "newVideoLoaded failure", ex); + } + } + + private static boolean videoIdIsSame(@Nullable ReturnYouTubeDislike fetch, @Nullable String videoId) { + return (fetch == null && videoId == null) + || (fetch != null && fetch.getVideoId().equals(videoId)); + } + + /** + * Injection point. + *

+ * Called when the user likes or dislikes. + */ + public static void sendVote(int vote) { + try { + if (!Settings.RYD_ENABLED.get()) { + return; + } + ReturnYouTubeDislike videoData = currentVideoData; + if (videoData == null) { + Logger.printDebug(() -> "Cannot send vote, as current video data is null"); + return; // User enabled RYD while a regular video was minimized. + } + + for (Vote v : Vote.values()) { + if (v.value == vote) { + videoData.sendVote(v); + + return; + } + } + Logger.printException(() -> "Unknown vote type: " + vote); + } catch (Exception ex) { + Logger.printException(() -> "sendVote failure", ex); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/VideoTypeHookPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/VideoTypeHookPatch.java new file mode 100644 index 0000000000..c6c3e90c90 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/VideoTypeHookPatch.java @@ -0,0 +1,19 @@ +package app.revanced.extension.music.patches.utils; + +import androidx.annotation.Nullable; + +import app.revanced.extension.music.shared.VideoType; + +@SuppressWarnings("unused") +public class VideoTypeHookPatch { + /** + * Injection point. + */ + public static void setVideoType(@Nullable Enum musicVideoType) { + if (musicVideoType == null) + return; + + VideoType.setFromString(musicVideoType.name()); + } +} + diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/CustomPlaybackSpeedPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/CustomPlaybackSpeedPatch.java new file mode 100644 index 0000000000..4e5fc0d331 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/CustomPlaybackSpeedPatch.java @@ -0,0 +1,94 @@ +package app.revanced.extension.music.patches.video; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import androidx.annotation.NonNull; + +import java.util.Arrays; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +@SuppressWarnings("unused") +public class CustomPlaybackSpeedPatch { + /** + * Maximum playback speed, exclusive value. Custom speeds must be less than this value. + */ + private static final float MAXIMUM_PLAYBACK_SPEED = 5; + + /** + * Custom playback speeds. + */ + private static float[] customPlaybackSpeeds; + + static { + loadCustomSpeeds(); + } + + /** + * Injection point. + */ + public static float[] getArray(float[] original) { + return userChangedCustomPlaybackSpeed() ? customPlaybackSpeeds : original; + } + + /** + * Injection point. + */ + public static int getLength(int original) { + return userChangedCustomPlaybackSpeed() ? customPlaybackSpeeds.length : original; + } + + /** + * Injection point. + */ + public static int getSize(int original) { + return userChangedCustomPlaybackSpeed() ? 0 : original; + } + + private static void resetCustomSpeeds(@NonNull String toastMessage) { + Utils.showToastLong(toastMessage); + Utils.showToastShort(str("revanced_extended_reset_to_default_toast")); + Settings.CUSTOM_PLAYBACK_SPEEDS.resetToDefault(); + } + + public static void loadCustomSpeeds() { + try { + String[] speedStrings = Settings.CUSTOM_PLAYBACK_SPEEDS.get().split("\\s+"); + Arrays.sort(speedStrings); + if (speedStrings.length == 0) { + throw new IllegalArgumentException(); + } + customPlaybackSpeeds = new float[speedStrings.length]; + for (int i = 0, length = speedStrings.length; i < length; i++) { + final float speed = Float.parseFloat(speedStrings[i]); + if (speed <= 0 || arrayContains(customPlaybackSpeeds, speed)) { + throw new IllegalArgumentException(); + } + if (speed > MAXIMUM_PLAYBACK_SPEED) { + resetCustomSpeeds(str("revanced_custom_playback_speeds_invalid", MAXIMUM_PLAYBACK_SPEED + "")); + loadCustomSpeeds(); + return; + } + customPlaybackSpeeds[i] = speed; + } + } catch (Exception ex) { + Logger.printInfo(() -> "parse error", ex); + resetCustomSpeeds(str("revanced_custom_playback_speeds_parse_exception")); + loadCustomSpeeds(); + } + } + + private static boolean userChangedCustomPlaybackSpeed() { + return !Settings.CUSTOM_PLAYBACK_SPEEDS.isSetToDefault() && customPlaybackSpeeds != null; + } + + private static boolean arrayContains(float[] array, float value) { + for (float arrayValue : array) { + if (arrayValue == value) return true; + } + return false; + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/PlaybackSpeedPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/PlaybackSpeedPatch.java new file mode 100644 index 0000000000..843f0c84e7 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/PlaybackSpeedPatch.java @@ -0,0 +1,32 @@ +package app.revanced.extension.music.patches.video; + +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.shared.utils.Utils.showToastShort; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.shared.utils.Logger; + +@SuppressWarnings("unused") +public class PlaybackSpeedPatch { + + public static float getPlaybackSpeed(final float playbackSpeed) { + try { + return Settings.DEFAULT_PLAYBACK_SPEED.get(); + } catch (Exception ex) { + Logger.printException(() -> "Failed to getPlaybackSpeed", ex); + } + return playbackSpeed; + } + + public static void userSelectedPlaybackSpeed(final float playbackSpeed) { + if (!Settings.REMEMBER_PLAYBACK_SPEED_LAST_SELECTED.get()) + return; + + Settings.DEFAULT_PLAYBACK_SPEED.save(playbackSpeed); + + if (!Settings.REMEMBER_PLAYBACK_SPEED_LAST_SELECTED_TOAST.get()) + return; + + showToastShort(str("revanced_remember_playback_speed_toast", playbackSpeed + "x")); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/VideoQualityPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/VideoQualityPatch.java new file mode 100644 index 0000000000..d9e7f8819a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/VideoQualityPatch.java @@ -0,0 +1,68 @@ +package app.revanced.extension.music.patches.video; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.music.shared.VideoInformation; +import app.revanced.extension.shared.settings.IntegerSetting; +import app.revanced.extension.shared.utils.Utils; + +@SuppressWarnings("unused") +public class VideoQualityPatch { + private static final int DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY = -2; + private static final IntegerSetting mobileQualitySetting = Settings.DEFAULT_VIDEO_QUALITY_MOBILE; + private static final IntegerSetting wifiQualitySetting = Settings.DEFAULT_VIDEO_QUALITY_WIFI; + + /** + * Injection point. + */ + public static void newVideoStarted(final String ignoredVideoId) { + final int preferredQuality = + Utils.getNetworkType() == Utils.NetworkType.MOBILE + ? mobileQualitySetting.get() + : wifiQualitySetting.get(); + + if (preferredQuality == DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY) + return; + + Utils.runOnMainThreadDelayed(() -> + VideoInformation.overrideVideoQuality( + VideoInformation.getAvailableVideoQuality(preferredQuality) + ), + 500 + ); + } + + /** + * Injection point. + */ + public static void userSelectedVideoQuality() { + Utils.runOnMainThreadDelayed(() -> + userSelectedVideoQuality(VideoInformation.getVideoQuality()), + 300 + ); + } + + private static void userSelectedVideoQuality(final int defaultQuality) { + if (!Settings.REMEMBER_VIDEO_QUALITY_LAST_SELECTED.get()) + return; + if (defaultQuality == DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY) + return; + + final Utils.NetworkType networkType = Utils.getNetworkType(); + + switch (networkType) { + case NONE -> { + Utils.showToastShort(str("revanced_remember_video_quality_none")); + return; + } + case MOBILE -> mobileQualitySetting.save(defaultQuality); + default -> wifiQualitySetting.save(defaultQuality); + } + + if (!Settings.REMEMBER_VIDEO_QUALITY_LAST_SELECTED_TOAST.get()) + return; + + Utils.showToastShort(str("revanced_remember_video_quality_" + networkType.getName(), defaultQuality + "p")); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/returnyoutubedislike/ReturnYouTubeDislike.java b/extensions/shared/src/main/java/app/revanced/extension/music/returnyoutubedislike/ReturnYouTubeDislike.java new file mode 100644 index 0000000000..bec27d1b3b --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/returnyoutubedislike/ReturnYouTubeDislike.java @@ -0,0 +1,564 @@ +package app.revanced.extension.music.returnyoutubedislike; + +import static app.revanced.extension.shared.returnyoutubedislike.ReturnYouTubeDislike.Vote; +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.shared.utils.Utils.isSDKAbove; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.OvalShape; +import android.graphics.drawable.shapes.RectShape; +import android.icu.text.CompactDecimalFormat; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ForegroundColorSpan; +import android.text.style.ImageSpan; +import android.util.DisplayMetrics; +import android.util.TypedValue; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.text.NumberFormat; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.shared.returnyoutubedislike.requests.RYDVoteData; +import app.revanced.extension.shared.returnyoutubedislike.requests.ReturnYouTubeDislikeApi; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +/** + * Because Litho creates spans using multiple threads, this entire class supports multithreading as well. + */ +public class ReturnYouTubeDislike { + + /** + * Maximum amount of time to block the UI from updates while waiting for network call to complete. + *

+ * Must be less than 5 seconds, as per: + * + */ + private static final long MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH = 4000; + + /** + * How long to retain successful RYD fetches. + */ + private static final long CACHE_TIMEOUT_SUCCESS_MILLISECONDS = 7 * 60 * 1000; // 7 Minutes + + /** + * How long to retain unsuccessful RYD fetches, + * and also the minimum time before retrying again. + */ + private static final long CACHE_TIMEOUT_FAILURE_MILLISECONDS = 3 * 60 * 1000; // 3 Minutes + + /** + * Unique placeholder character, used to detect if a segmented span already has dislikes added to it. + * Can be any almost any non-visible character. + */ + private static final char MIDDLE_SEPARATOR_CHARACTER = '◎'; // 'bullseye' + + private static final int SEPARATOR_COLOR = 872415231; + + /** + * Cached lookup of all video ids. + */ + @GuardedBy("itself") + private static final Map fetchCache = new HashMap<>(); + + /** + * Used to send votes, one by one, in the same order the user created them. + */ + private static final ExecutorService voteSerialExecutor = Executors.newSingleThreadExecutor(); + + /** + * For formatting dislikes as number. + */ + @GuardedBy("ReturnYouTubeDislike.class") // not thread safe + private static CompactDecimalFormat dislikeCountFormatter; + + /** + * For formatting dislikes as percentage. + */ + @GuardedBy("ReturnYouTubeDislike.class") + private static NumberFormat dislikePercentageFormatter; + + public static final Rect leftSeparatorBounds; + private static final Rect middleSeparatorBounds; + + static { + DisplayMetrics dp = Objects.requireNonNull(Utils.getContext()).getResources().getDisplayMetrics(); + + leftSeparatorBounds = new Rect(0, 0, + (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1.2f, dp), + (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 25, dp)); + final int middleSeparatorSize = + (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3.7f, dp); + middleSeparatorBounds = new Rect(0, 0, middleSeparatorSize, middleSeparatorSize); + + ReturnYouTubeDislikeApi.toastOnConnectionError = Settings.RYD_TOAST_ON_CONNECTION_ERROR.get(); + } + + private final String videoId; + + /** + * Stores the results of the vote api fetch, and used as a barrier to wait until fetch completes. + * Absolutely cannot be holding any lock during calls to {@link Future#get()}. + */ + private final Future future; + + /** + * Time this instance and the fetch future was created. + */ + private final long timeFetched; + + /** + * Optional current vote status of the UI. Used to apply a user vote that was done on a previous video viewing. + */ + @Nullable + @GuardedBy("this") + private Vote userVote; + + /** + * Original dislike span, before modifications. + */ + @Nullable + @GuardedBy("this") + private Spanned originalDislikeSpan; + + /** + * Replacement like/dislike span that includes formatted dislikes. + * Used to prevent recreating the same span multiple times. + */ + @Nullable + @GuardedBy("this") + private SpannableString replacementLikeDislikeSpan; + + @NonNull + private static SpannableString createDislikeSpan(@NonNull Spanned oldSpannable, + @NonNull RYDVoteData voteData) { + CharSequence oldLikes = oldSpannable; + + // YouTube creators can hide the like count on a video, + // and the like count appears as a device language specific string that says 'Like'. + // Check if the string contains any numbers. + if (!Utils.containsNumber(oldLikes)) { + if (Settings.RYD_ESTIMATED_LIKE.get()) { + // Likes are hidden by video creator + // + // RYD does not directly provide like data, but can use an estimated likes + // using the same scale factor RYD applied to the raw dislikes. + // + // example video: https://www.youtube.com/watch?v=UnrU5vxCHxw + // RYD data: https://returnyoutubedislikeapi.com/votes?videoId=UnrU5vxCHxw + Logger.printDebug(() -> "Using estimated likes"); + oldLikes = formatDislikeCount(voteData.getLikeCount()); + } else { + // Change the "Likes" string to show that likes and dislikes are hidden. + String hiddenMessageString = str("revanced_ryd_video_likes_hidden_by_video_owner"); + return newSpanUsingStylingOfAnotherSpan(oldSpannable, hiddenMessageString); + } + } + + SpannableStringBuilder builder = new SpannableStringBuilder("\u2009\u2009"); + final boolean compactLayout = Settings.RYD_COMPACT_LAYOUT.get(); + + if (!compactLayout) { + String leftSeparatorString = "\u200E "; // u200E = left to right character + Spannable leftSeparatorSpan = new SpannableString(leftSeparatorString); + ShapeDrawable shapeDrawable = new ShapeDrawable(new RectShape()); + shapeDrawable.getPaint().setColor(SEPARATOR_COLOR); + shapeDrawable.setBounds(leftSeparatorBounds); + leftSeparatorSpan.setSpan(new VerticallyCenteredImageSpan(shapeDrawable), 1, 2, + Spannable.SPAN_INCLUSIVE_EXCLUSIVE); // drawable cannot overwrite RTL or LTR character + builder.append(leftSeparatorSpan); + } + + // likes + builder.append(newSpanUsingStylingOfAnotherSpan(oldSpannable, oldLikes)); + + // middle separator + String middleSeparatorString = compactLayout + ? "\u200E " + MIDDLE_SEPARATOR_CHARACTER + " " + : "\u200E \u2009" + MIDDLE_SEPARATOR_CHARACTER + "\u2009 "; // u2009 = 'narrow space' character + final int shapeInsertionIndex = middleSeparatorString.length() / 2; + Spannable middleSeparatorSpan = new SpannableString(middleSeparatorString); + ShapeDrawable shapeDrawable = new ShapeDrawable(new OvalShape()); + shapeDrawable.getPaint().setColor(SEPARATOR_COLOR); + shapeDrawable.setBounds(middleSeparatorBounds); + // Use original text width if using Rolling Number, + // to ensure the replacement styled span has the same width as the measured String, + // otherwise layout can be broken (especially on devices with small system font sizes). + middleSeparatorSpan.setSpan( + new VerticallyCenteredImageSpan(shapeDrawable), + shapeInsertionIndex, shapeInsertionIndex + 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + builder.append(middleSeparatorSpan); + + // dislikes + builder.append(newSpannableWithDislikes(oldSpannable, voteData)); + + return new SpannableString(builder); + } + + /** + * @return If the text is likely for a previously created likes/dislikes segmented span. + */ + public static boolean isPreviouslyCreatedSegmentedSpan(@NonNull String text) { + return text.indexOf(MIDDLE_SEPARATOR_CHARACTER) >= 0; + } + + private static boolean spansHaveEqualTextAndColor(@NonNull Spanned one, @NonNull Spanned two) { + // Cannot use equals on the span, because many of the inner styling spans do not implement equals. + // Instead, compare the underlying text and the text color to handle when dark mode is changed. + // Cannot compare the status of device dark mode, as Litho components are updated just before dark mode status changes. + if (!one.toString().equals(two.toString())) { + return false; + } + ForegroundColorSpan[] oneColors = one.getSpans(0, one.length(), ForegroundColorSpan.class); + ForegroundColorSpan[] twoColors = two.getSpans(0, two.length(), ForegroundColorSpan.class); + final int oneLength = oneColors.length; + if (oneLength != twoColors.length) { + return false; + } + for (int i = 0; i < oneLength; i++) { + if (oneColors[i].getForegroundColor() != twoColors[i].getForegroundColor()) { + return false; + } + } + return true; + } + + private static SpannableString newSpannableWithDislikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) { + return newSpanUsingStylingOfAnotherSpan(sourceStyling, + Settings.RYD_DISLIKE_PERCENTAGE.get() + ? formatDislikePercentage(voteData.getDislikePercentage()) + : formatDislikeCount(voteData.getDislikeCount())); + } + + private static SpannableString newSpanUsingStylingOfAnotherSpan(@NonNull Spanned sourceStyle, @NonNull CharSequence newSpanText) { + SpannableString destination = new SpannableString(newSpanText); + Object[] spans = sourceStyle.getSpans(0, sourceStyle.length(), Object.class); + for (Object span : spans) { + destination.setSpan(span, 0, destination.length(), sourceStyle.getSpanFlags(span)); + } + return destination; + } + + private static String formatDislikeCount(long dislikeCount) { + if (isSDKAbove(24)) { + if (dislikeCountFormatter == null) { + // Note: Java number formatters will use the locale specific number characters. + // such as Arabic which formats "1.234" into "۱,۲۳٤" + // But YouTube disregards locale specific number characters + // and instead shows english number characters everywhere. + Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().getLocales().get(0); + Logger.printDebug(() -> "Locale: " + locale); + dislikeCountFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT); + } + return dislikeCountFormatter.format(dislikeCount); + } else { + return String.valueOf(dislikeCount); + } + } + + private static String formatDislikePercentage(float dislikePercentage) { + if (isSDKAbove(24)) { + synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize + if (dislikePercentageFormatter == null) { + Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().getLocales().get(0); + Logger.printDebug(() -> "Locale: " + locale); + dislikePercentageFormatter = NumberFormat.getPercentInstance(locale); + } + if (dislikePercentage >= 0.01) { // at least 1% + dislikePercentageFormatter.setMaximumFractionDigits(0); // show only whole percentage points + } else { + dislikePercentageFormatter.setMaximumFractionDigits(1); // show up to 1 digit precision + } + return dislikePercentageFormatter.format(dislikePercentage); + } + } else { + return String.valueOf((int) (dislikePercentage * 100)); + } + } + + @NonNull + public static ReturnYouTubeDislike getFetchForVideoId(@Nullable String videoId) { + Objects.requireNonNull(videoId); + synchronized (fetchCache) { + // Remove any expired entries. + final long now = System.currentTimeMillis(); + if (isSDKAbove(24)) { + fetchCache.values().removeIf(value -> { + final boolean expired = value.isExpired(now); + if (expired) + Logger.printDebug(() -> "Removing expired fetch: " + value.videoId); + return expired; + }); + } else { + final Iterator> itr = fetchCache.entrySet().iterator(); + while (itr.hasNext()) { + final Map.Entry entry = itr.next(); + if (entry.getValue().isExpired(now)) { + Logger.printDebug(() -> "Removing expired fetch: " + entry.getValue().videoId); + itr.remove(); + } + } + } + + ReturnYouTubeDislike fetch = fetchCache.get(videoId); + if (fetch == null) { + fetch = new ReturnYouTubeDislike(videoId); + fetchCache.put(videoId, fetch); + } + return fetch; + } + } + + /** + * Should be called if the user changes dislikes appearance settings. + */ + public static void clearAllUICaches() { + synchronized (fetchCache) { + for (ReturnYouTubeDislike fetch : fetchCache.values()) { + fetch.clearUICache(); + } + } + } + + private ReturnYouTubeDislike(@NonNull String videoId) { + this.videoId = Objects.requireNonNull(videoId); + this.timeFetched = System.currentTimeMillis(); + this.future = Utils.submitOnBackgroundThread(() -> ReturnYouTubeDislikeApi.fetchVotes(videoId)); + } + + private boolean isExpired(long now) { + final long timeSinceCreation = now - timeFetched; + if (timeSinceCreation < CACHE_TIMEOUT_FAILURE_MILLISECONDS) { + return false; // Not expired, even if the API call failed. + } + if (timeSinceCreation > CACHE_TIMEOUT_SUCCESS_MILLISECONDS) { + return true; // Always expired. + } + // Only expired if the fetch failed (API null response). + return (!fetchCompleted() || getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH) == null); + } + + @Nullable + public RYDVoteData getFetchData(long maxTimeToWait) { + try { + return future.get(maxTimeToWait, TimeUnit.MILLISECONDS); + } catch (TimeoutException ex) { + Logger.printDebug(() -> "Waited but future was not complete after: " + maxTimeToWait + "ms"); + } catch (ExecutionException | InterruptedException ex) { + Logger.printException(() -> "Future failure ", ex); // will never happen + } + return null; + } + + /** + * @return if the RYD fetch call has completed. + */ + public boolean fetchCompleted() { + return future.isDone(); + } + + private synchronized void clearUICache() { + if (replacementLikeDislikeSpan != null) { + Logger.printDebug(() -> "Clearing replacement span for: " + videoId); + } + replacementLikeDislikeSpan = null; + } + + /** + * Must call off main thread, as this will make a network call if user is not yet registered. + * + * @return ReturnYouTubeDislike user ID. If user registration has never happened + * and the network call fails, this returns NULL. + */ + @Nullable + private static String getUserId() { + Utils.verifyOffMainThread(); + + String userId = Settings.RYD_USER_ID.get(); + if (!userId.isEmpty()) { + return userId; + } + + userId = ReturnYouTubeDislikeApi.registerAsNewUser(); + if (userId != null) { + Settings.RYD_USER_ID.save(userId); + } + return userId; + } + + @NonNull + public String getVideoId() { + return videoId; + } + + /** + * @return the replacement span containing dislikes, or the original span if RYD is not available. + */ + @NonNull + public synchronized Spanned getDislikesSpan(@NonNull Spanned original) { + return waitForFetchAndUpdateReplacementSpan(original); + } + + @NonNull + private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original) { + try { + RYDVoteData votingData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH); + if (votingData == null) { + Logger.printDebug(() -> "Cannot add dislike to UI (RYD data not available)"); + return original; + } + + synchronized (this) { + if (originalDislikeSpan != null && replacementLikeDislikeSpan != null) { + if (spansHaveEqualTextAndColor(original, replacementLikeDislikeSpan)) { + Logger.printDebug(() -> "Ignoring previously created dislikes span of data: " + videoId); + return original; + } + if (spansHaveEqualTextAndColor(original, originalDislikeSpan)) { + Logger.printDebug(() -> "Replacing span with previously created dislike span of data: " + videoId); + return replacementLikeDislikeSpan; + } + } + if (isPreviouslyCreatedSegmentedSpan(original.toString())) { + // need to recreate using original, as original has prior outdated dislike values + if (originalDislikeSpan == null) { + // Should never happen. + Logger.printDebug(() -> "Cannot add dislikes - original span is null. videoId: " + videoId); + return original; + } + original = originalDislikeSpan; + } + + // No replacement span exist, create it now. + + if (userVote != null) { + votingData.updateUsingVote(userVote); + } + originalDislikeSpan = original; + replacementLikeDislikeSpan = createDislikeSpan(original, votingData); + Logger.printDebug(() -> "Replaced: '" + originalDislikeSpan + "' with: '" + + replacementLikeDislikeSpan + "'" + " using video: " + videoId); + + return replacementLikeDislikeSpan; + } + } catch (Exception e) { + Logger.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", e); // should never happen + } + return original; + } + + public void sendVote(@NonNull Vote vote) { + Utils.verifyOnMainThread(); + Objects.requireNonNull(vote); + try { + setUserVote(vote); + + voteSerialExecutor.execute(() -> { + try { // Must wrap in try/catch to properly log exceptions. + ReturnYouTubeDislikeApi.sendVote(getUserId(), videoId, vote); + } catch (Exception ex) { + Logger.printException(() -> "Failed to send vote", ex); + } + }); + } catch (Exception ex) { + Logger.printException(() -> "Error trying to send vote", ex); + } + } + + /** + * Sets the current user vote value, and does not send the vote to the RYD API. + *

+ * Only used to set value if thumbs up/down is already selected on video load. + */ + public void setUserVote(@NonNull Vote vote) { + Objects.requireNonNull(vote); + try { + Logger.printDebug(() -> "setUserVote: " + vote); + + synchronized (this) { + userVote = vote; + clearUICache(); + } + + if (future.isDone()) { + // Update the fetched vote data. + RYDVoteData voteData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH); + if (voteData == null) { + // RYD fetch failed. + Logger.printDebug(() -> "Cannot update UI (vote data not available)"); + return; + } + voteData.updateUsingVote(vote); + } // Else, vote will be applied after fetch completes. + + } catch (Exception ex) { + Logger.printException(() -> "setUserVote failure", ex); + } + } +} + +/** + * Vertically centers a Spanned Drawable. + */ +class VerticallyCenteredImageSpan extends ImageSpan { + + public VerticallyCenteredImageSpan(Drawable drawable) { + super(drawable); + } + + @Override + public int getSize(@NonNull Paint paint, @NonNull CharSequence text, + int start, int end, @Nullable Paint.FontMetricsInt fontMetrics) { + Drawable drawable = getDrawable(); + Rect bounds = drawable.getBounds(); + if (fontMetrics != null) { + Paint.FontMetricsInt paintMetrics = paint.getFontMetricsInt(); + final int fontHeight = paintMetrics.descent - paintMetrics.ascent; + final int drawHeight = bounds.bottom - bounds.top; + final int halfDrawHeight = drawHeight / 2; + final int yCenter = paintMetrics.ascent + fontHeight / 2; + + fontMetrics.ascent = yCenter - halfDrawHeight; + fontMetrics.top = fontMetrics.ascent; + fontMetrics.bottom = yCenter + halfDrawHeight; + fontMetrics.descent = fontMetrics.bottom; + } + return bounds.right; + } + + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, + float x, int top, int y, int bottom, @NonNull Paint paint) { + Drawable drawable = getDrawable(); + canvas.save(); + Paint.FontMetricsInt paintMetrics = paint.getFontMetricsInt(); + final int fontHeight = paintMetrics.descent - paintMetrics.ascent; + final int yCenter = y + paintMetrics.descent - fontHeight / 2; + final Rect drawBounds = drawable.getBounds(); + final int translateY = yCenter - (drawBounds.bottom - drawBounds.top) / 2; + canvas.translate(x, translateY); + drawable.draw(canvas); + canvas.restore(); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/settings/ActivityHook.java b/extensions/shared/src/main/java/app/revanced/extension/music/settings/ActivityHook.java new file mode 100644 index 0000000000..628c44ed6c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/settings/ActivityHook.java @@ -0,0 +1,80 @@ +package app.revanced.extension.music.settings; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import java.lang.ref.WeakReference; + +import app.revanced.extension.music.settings.preference.ReVancedPreferenceFragment; +import app.revanced.extension.shared.utils.Logger; + +/** + * @noinspection ALL + */ +public class ActivityHook { + private static WeakReference activityRef = new WeakReference<>(null); + + public static Activity getActivity() { + return activityRef.get(); + } + + /** + * Injection point. + * + * @param object object is usually Activity, but sometimes object cannot be cast to Activity. + * Check whether object can be cast as Activity for a safe hook. + */ + public static void setActivity(@NonNull Object object) { + if (object instanceof Activity mActivity) { + activityRef = new WeakReference<>(mActivity); + } + } + + /** + * Injection point. + * + * @param baseActivity Activity containing intent data. + * It should be finished immediately after obtaining the dataString. + * @return Whether or not dataString is included. + */ + public static boolean initialize(@NonNull Activity baseActivity) { + try { + final Intent baseActivityIntent = baseActivity.getIntent(); + if (baseActivityIntent == null) + return false; + + // If we do not finish the activity immediately, the YT Music logo will remain on the screen. + baseActivity.finish(); + + String dataString = baseActivityIntent.getDataString(); + if (dataString == null || dataString.isEmpty()) + return false; + + // Checks whether dataString contains settings that use Intent. + if (!Settings.includeWithIntent(dataString)) + return false; + + + // Save intent data in settings activity. + Activity mActivity = activityRef.get(); + Intent intent = mActivity.getIntent(); + intent.setData(Uri.parse(dataString)); + mActivity.setIntent(intent); + + // Starts a new PreferenceFragment to handle activities freely. + mActivity.getFragmentManager() + .beginTransaction() + .add(new ReVancedPreferenceFragment(), "") + .commit(); + + return true; + } catch (Exception ex) { + Logger.printException(() -> "initializeSettings failure", ex); + } + return false; + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/settings/Settings.java b/extensions/shared/src/main/java/app/revanced/extension/music/settings/Settings.java new file mode 100644 index 0000000000..9618a2ea0d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/settings/Settings.java @@ -0,0 +1,251 @@ +package app.revanced.extension.music.settings; + +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; +import static app.revanced.extension.music.sponsorblock.objects.CategoryBehaviour.SKIP_AUTOMATICALLY; + +import androidx.annotation.NonNull; + +import app.revanced.extension.music.patches.utils.PatchStatus; +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.shared.settings.FloatSetting; +import app.revanced.extension.shared.settings.IntegerSetting; +import app.revanced.extension.shared.settings.LongSetting; +import app.revanced.extension.shared.settings.StringSetting; +import app.revanced.extension.shared.utils.Utils; + + +@SuppressWarnings("unused") +public class Settings extends BaseSettings { + // PreferenceScreen: Account + public static final BooleanSetting HIDE_ACCOUNT_MENU = new BooleanSetting("revanced_hide_account_menu", FALSE); + public static final StringSetting HIDE_ACCOUNT_MENU_FILTER_STRINGS = new StringSetting("revanced_hide_account_menu_filter_strings", ""); + public static final BooleanSetting HIDE_ACCOUNT_MENU_EMPTY_COMPONENT = new BooleanSetting("revanced_hide_account_menu_empty_component", FALSE); + public static final BooleanSetting HIDE_HANDLE = new BooleanSetting("revanced_hide_handle", TRUE, true); + public static final BooleanSetting HIDE_TERMS_CONTAINER = new BooleanSetting("revanced_hide_terms_container", FALSE); + + + // PreferenceScreen: Action Bar + public static final BooleanSetting HIDE_ACTION_BUTTON_LIKE_DISLIKE = new BooleanSetting("revanced_hide_action_button_like_dislike", FALSE, true); + public static final BooleanSetting HIDE_ACTION_BUTTON_COMMENT = new BooleanSetting("revanced_hide_action_button_comment", FALSE, true); + public static final BooleanSetting HIDE_ACTION_BUTTON_ADD_TO_PLAYLIST = new BooleanSetting("revanced_hide_action_button_add_to_playlist", FALSE, true); + public static final BooleanSetting HIDE_ACTION_BUTTON_DOWNLOAD = new BooleanSetting("revanced_hide_action_button_download", FALSE, true); + public static final BooleanSetting HIDE_ACTION_BUTTON_SHARE = new BooleanSetting("revanced_hide_action_button_share", FALSE, true); + public static final BooleanSetting HIDE_ACTION_BUTTON_RADIO = new BooleanSetting("revanced_hide_action_button_radio", FALSE, true); + public static final BooleanSetting HIDE_ACTION_BUTTON_LABEL = new BooleanSetting("revanced_hide_action_button_label", FALSE, true); + public static final BooleanSetting EXTERNAL_DOWNLOADER_ACTION_BUTTON = new BooleanSetting("revanced_external_downloader_action", FALSE, true); + public static final StringSetting EXTERNAL_DOWNLOADER_PACKAGE_NAME = new StringSetting("revanced_external_downloader_package_name", "com.deniscerri.ytdl"); + + + // PreferenceScreen: Ads + public static final BooleanSetting HIDE_GENERAL_ADS = new BooleanSetting("revanced_hide_general_ads", TRUE, true); + public static final BooleanSetting HIDE_MUSIC_ADS = new BooleanSetting("revanced_hide_music_ads", TRUE, true); + public static final BooleanSetting HIDE_PAID_PROMOTION_LABEL = new BooleanSetting("revanced_hide_paid_promotion_label", TRUE, true); + public static final BooleanSetting HIDE_PREMIUM_PROMOTION = new BooleanSetting("revanced_hide_premium_promotion", TRUE, true); + public static final BooleanSetting HIDE_PREMIUM_RENEWAL = new BooleanSetting("revanced_hide_premium_renewal", TRUE, true); + + + // PreferenceScreen: Flyout menu + public static final BooleanSetting ENABLE_COMPACT_DIALOG = new BooleanSetting("revanced_enable_compact_dialog", TRUE); + public static final BooleanSetting ENABLE_TRIM_SILENCE = new BooleanSetting("revanced_enable_trim_silence", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_LIKE_DISLIKE = new BooleanSetting("revanced_hide_flyout_menu_like_dislike", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_3_COLUMN_COMPONENT = new BooleanSetting("revanced_hide_flyout_menu_3_column_component", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_ADD_TO_QUEUE = new BooleanSetting("revanced_hide_flyout_menu_add_to_queue", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_CAPTIONS = new BooleanSetting("revanced_hide_flyout_menu_captions", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_DELETE_PLAYLIST = new BooleanSetting("revanced_hide_flyout_menu_delete_playlist", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_DISMISS_QUEUE = new BooleanSetting("revanced_hide_flyout_menu_dismiss_queue", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_DOWNLOAD = new BooleanSetting("revanced_hide_flyout_menu_download", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_EDIT_PLAYLIST = new BooleanSetting("revanced_hide_flyout_menu_edit_playlist", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_GO_TO_ALBUM = new BooleanSetting("revanced_hide_flyout_menu_go_to_album", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_GO_TO_ARTIST = new BooleanSetting("revanced_hide_flyout_menu_go_to_artist", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_GO_TO_EPISODE = new BooleanSetting("revanced_hide_flyout_menu_go_to_episode", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_GO_TO_PODCAST = new BooleanSetting("revanced_hide_flyout_menu_go_to_podcast", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_HELP = new BooleanSetting("revanced_hide_flyout_menu_help", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_PLAY_NEXT = new BooleanSetting("revanced_hide_flyout_menu_play_next", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_QUALITY = new BooleanSetting("revanced_hide_flyout_menu_quality", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_REMOVE_FROM_LIBRARY = new BooleanSetting("revanced_hide_flyout_menu_remove_from_library", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_REMOVE_FROM_PLAYLIST = new BooleanSetting("revanced_hide_flyout_menu_remove_from_playlist", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_REPORT = new BooleanSetting("revanced_hide_flyout_menu_report", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_SAVE_EPISODE_FOR_LATER = new BooleanSetting("revanced_hide_flyout_menu_save_episode_for_later", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_SAVE_TO_LIBRARY = new BooleanSetting("revanced_hide_flyout_menu_save_to_library", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_SAVE_TO_PLAYLIST = new BooleanSetting("revanced_hide_flyout_menu_save_to_playlist", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_SHARE = new BooleanSetting("revanced_hide_flyout_menu_share", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_SHUFFLE_PLAY = new BooleanSetting("revanced_hide_flyout_menu_shuffle_play", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_SLEEP_TIMER = new BooleanSetting("revanced_hide_flyout_menu_sleep_timer", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_START_RADIO = new BooleanSetting("revanced_hide_flyout_menu_start_radio", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_STATS_FOR_NERDS = new BooleanSetting("revanced_hide_flyout_menu_stats_for_nerds", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_SUBSCRIBE = new BooleanSetting("revanced_hide_flyout_menu_subscribe", FALSE, true); + public static final BooleanSetting HIDE_FLYOUT_MENU_VIEW_SONG_CREDIT = new BooleanSetting("revanced_hide_flyout_menu_view_song_credit", FALSE, true); + public static final BooleanSetting REPLACE_FLYOUT_MENU_DISMISS_QUEUE = new BooleanSetting("revanced_replace_flyout_menu_dismiss_queue", FALSE, true); + public static final BooleanSetting REPLACE_FLYOUT_MENU_DISMISS_QUEUE_CONTINUE_WATCH = new BooleanSetting("revanced_replace_flyout_menu_dismiss_queue_continue_watch", TRUE); + public static final BooleanSetting REPLACE_FLYOUT_MENU_REPORT = new BooleanSetting("revanced_replace_flyout_menu_report", TRUE, true); + public static final BooleanSetting REPLACE_FLYOUT_MENU_REPORT_ONLY_PLAYER = new BooleanSetting("revanced_replace_flyout_menu_report_only_player", TRUE, true); + + + // PreferenceScreen: General + public static final StringSetting CHANGE_START_PAGE = new StringSetting("revanced_change_start_page", "FEmusic_home", true); + public static final BooleanSetting DISABLE_DISLIKE_REDIRECTION = new BooleanSetting("revanced_disable_dislike_redirection", FALSE); + public static final BooleanSetting ENABLE_LANDSCAPE_MODE = new BooleanSetting("revanced_enable_landscape_mode", FALSE, true); + public static final BooleanSetting CUSTOM_FILTER = new BooleanSetting("revanced_custom_filter", FALSE); + public static final StringSetting CUSTOM_FILTER_STRINGS = new StringSetting("revanced_custom_filter_strings", "", true); + public static final BooleanSetting HIDE_BUTTON_SHELF = new BooleanSetting("revanced_hide_button_shelf", FALSE, true); + public static final BooleanSetting HIDE_CAROUSEL_SHELF = new BooleanSetting("revanced_hide_carousel_shelf", FALSE, true); + public static final BooleanSetting HIDE_PLAYLIST_CARD_SHELF = new BooleanSetting("revanced_hide_playlist_card_shelf", FALSE, true); + public static final BooleanSetting HIDE_SAMPLE_SHELF = new BooleanSetting("revanced_hide_samples_shelf", FALSE, true); + public static final BooleanSetting HIDE_CAST_BUTTON = new BooleanSetting("revanced_hide_cast_button", TRUE); + public static final BooleanSetting HIDE_CATEGORY_BAR = new BooleanSetting("revanced_hide_category_bar", FALSE, true); + public static final BooleanSetting HIDE_FLOATING_BUTTON = new BooleanSetting("revanced_hide_floating_button", FALSE, true); + public static final BooleanSetting HIDE_TAP_TO_UPDATE_BUTTON = new BooleanSetting("revanced_hide_tap_to_update_button", FALSE, true); + public static final BooleanSetting HIDE_HISTORY_BUTTON = new BooleanSetting("revanced_hide_history_button", FALSE); + public static final BooleanSetting HIDE_NOTIFICATION_BUTTON = new BooleanSetting("revanced_hide_notification_button", FALSE, true); + public static final BooleanSetting HIDE_SOUND_SEARCH_BUTTON = new BooleanSetting("revanced_hide_sound_search_button", FALSE, true); + public static final BooleanSetting HIDE_VOICE_SEARCH_BUTTON = new BooleanSetting("revanced_hide_voice_search_button", FALSE, true); + public static final BooleanSetting REMOVE_VIEWER_DISCRETION_DIALOG = new BooleanSetting("revanced_remove_viewer_discretion_dialog", FALSE); + public static final BooleanSetting RESTORE_OLD_STYLE_LIBRARY_SHELF = new BooleanSetting("revanced_restore_old_style_library_shelf", FALSE, true); + public static final BooleanSetting SPOOF_APP_VERSION = new BooleanSetting("revanced_spoof_app_version", + PatchStatus.SpoofAppVersionDefaultBoolean(), true); + public static final StringSetting SPOOF_APP_VERSION_TARGET = new StringSetting("revanced_spoof_app_version_target", + PatchStatus.SpoofAppVersionDefaultString(), true); + + + // PreferenceScreen: Navigation bar + public static final BooleanSetting ENABLE_BLACK_NAVIGATION_BAR = new BooleanSetting("revanced_enable_black_navigation_bar", TRUE); + public static final BooleanSetting HIDE_NAVIGATION_HOME_BUTTON = new BooleanSetting("revanced_hide_navigation_home_button", FALSE, true); + public static final BooleanSetting HIDE_NAVIGATION_SAMPLES_BUTTON = new BooleanSetting("revanced_hide_navigation_samples_button", FALSE, true); + public static final BooleanSetting HIDE_NAVIGATION_EXPLORE_BUTTON = new BooleanSetting("revanced_hide_navigation_explore_button", FALSE, true); + public static final BooleanSetting HIDE_NAVIGATION_LIBRARY_BUTTON = new BooleanSetting("revanced_hide_navigation_library_button", FALSE, true); + public static final BooleanSetting HIDE_NAVIGATION_UPGRADE_BUTTON = new BooleanSetting("revanced_hide_navigation_upgrade_button", TRUE, true); + public static final BooleanSetting HIDE_NAVIGATION_BAR = new BooleanSetting("revanced_hide_navigation_bar", FALSE, true); + public static final BooleanSetting HIDE_NAVIGATION_LABEL = new BooleanSetting("revanced_hide_navigation_label", FALSE, true); + + + // PreferenceScreen: Player + public static final BooleanSetting DISABLE_MINI_PLAYER_GESTURE = new BooleanSetting("revanced_disable_mini_player_gesture", FALSE, true); + public static final BooleanSetting DISABLE_PLAYER_GESTURE = new BooleanSetting("revanced_disable_player_gesture", FALSE, true); + public static final BooleanSetting ENABLE_BLACK_PLAYER_BACKGROUND = new BooleanSetting("revanced_enable_black_player_background", FALSE, true); + public static final BooleanSetting ENABLE_COLOR_MATCH_PLAYER = new BooleanSetting("revanced_enable_color_match_player", TRUE); + public static final BooleanSetting ENABLE_FORCE_MINIMIZED_PLAYER = new BooleanSetting("revanced_enable_force_minimized_player", TRUE); + public static final BooleanSetting ENABLE_MINI_PLAYER_NEXT_BUTTON = new BooleanSetting("revanced_enable_mini_player_next_button", TRUE, true); + public static final BooleanSetting ENABLE_MINI_PLAYER_PREVIOUS_BUTTON = new BooleanSetting("revanced_enable_mini_player_previous_button", TRUE, true); + public static final BooleanSetting ENABLE_SWIPE_TO_DISMISS_MINI_PLAYER = new BooleanSetting("revanced_enable_swipe_to_dismiss_mini_player", TRUE, true); + public static final BooleanSetting ENABLE_ZEN_MODE = new BooleanSetting("revanced_enable_zen_mode", FALSE); + public static final BooleanSetting ENABLE_ZEN_MODE_PODCAST = new BooleanSetting("revanced_enable_zen_mode_podcast", FALSE); + public static final BooleanSetting HIDE_AUDIO_VIDEO_SWITCH_TOGGLE = new BooleanSetting("revanced_hide_audio_video_switch_toggle", FALSE, true); + public static final BooleanSetting HIDE_COMMENT_CHANNEL_GUIDELINES = new BooleanSetting("revanced_hide_comment_channel_guidelines", TRUE); + public static final BooleanSetting HIDE_COMMENT_TIMESTAMP_AND_EMOJI_BUTTONS = new BooleanSetting("revanced_hide_comment_timestamp_and_emoji_buttons", FALSE); + public static final BooleanSetting HIDE_DOUBLE_TAP_OVERLAY_FILTER = new BooleanSetting("revanced_hide_double_tap_overlay_filter", FALSE, true); + public static final BooleanSetting HIDE_FULLSCREEN_SHARE_BUTTON = new BooleanSetting("revanced_hide_fullscreen_share_button", FALSE, true); + public static final BooleanSetting REMEMBER_REPEAT_SATE = new BooleanSetting("revanced_remember_repeat_state", TRUE); + public static final BooleanSetting REMEMBER_SHUFFLE_SATE = new BooleanSetting("revanced_remember_shuffle_state", TRUE); + public static final BooleanSetting ALWAYS_SHUFFLE = new BooleanSetting("revanced_always_shuffle", FALSE); + public static final BooleanSetting RESTORE_OLD_COMMENTS_POPUP_PANELS = new BooleanSetting("revanced_restore_old_comments_popup_panels", FALSE, true); + public static final BooleanSetting RESTORE_OLD_PLAYER_BACKGROUND = new BooleanSetting("revanced_restore_old_player_background", FALSE, true); + public static final BooleanSetting RESTORE_OLD_PLAYER_LAYOUT = new BooleanSetting("revanced_restore_old_player_layout", FALSE, true); + + + // PreferenceScreen: Settings menu + public static final BooleanSetting HIDE_SETTINGS_MENU_PARENT_TOOLS = new BooleanSetting("revanced_hide_settings_menu_parent_tools", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_GENERAL = new BooleanSetting("revanced_hide_settings_menu_general", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_PLAYBACK = new BooleanSetting("revanced_hide_settings_menu_playback", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_DATA_SAVING = new BooleanSetting("revanced_hide_settings_menu_data_saving", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_DOWNLOADS_AND_STORAGE = new BooleanSetting("revanced_hide_settings_menu_downloads_and_storage", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_NOTIFICATIONS = new BooleanSetting("revanced_hide_settings_menu_notification", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_PRIVACY_AND_LOCATION = new BooleanSetting("revanced_hide_settings_menu_privacy_and_location", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_RECOMMENDATIONS = new BooleanSetting("revanced_hide_settings_menu_recommendations", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_PAID_MEMBERSHIPS = new BooleanSetting("revanced_hide_settings_menu_paid_memberships", TRUE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_ABOUT = new BooleanSetting("revanced_hide_settings_menu_about", FALSE, true); + + + // PreferenceScreen: Video + public static final StringSetting CUSTOM_PLAYBACK_SPEEDS = new StringSetting("revanced_custom_playback_speeds", "0.5\n0.8\n1.0\n1.2\n1.5\n1.8\n2.0", true); + public static final BooleanSetting REMEMBER_PLAYBACK_SPEED_LAST_SELECTED = new BooleanSetting("revanced_remember_playback_speed_last_selected", TRUE); + public static final BooleanSetting REMEMBER_PLAYBACK_SPEED_LAST_SELECTED_TOAST = new BooleanSetting("revanced_remember_playback_speed_last_selected_toast", TRUE); + public static final BooleanSetting REMEMBER_VIDEO_QUALITY_LAST_SELECTED = new BooleanSetting("revanced_remember_video_quality_last_selected", TRUE); + public static final BooleanSetting REMEMBER_VIDEO_QUALITY_LAST_SELECTED_TOAST = new BooleanSetting("revanced_remember_video_quality_last_selected_toast", TRUE); + public static final FloatSetting DEFAULT_PLAYBACK_SPEED = new FloatSetting("revanced_default_playback_speed", 1.0f); + public static final IntegerSetting DEFAULT_VIDEO_QUALITY_MOBILE = new IntegerSetting("revanced_default_video_quality_mobile", -2); + public static final IntegerSetting DEFAULT_VIDEO_QUALITY_WIFI = new IntegerSetting("revanced_default_video_quality_wifi", -2); + + + // PreferenceScreen: Miscellaneous + public static final BooleanSetting CHANGE_SHARE_SHEET = new BooleanSetting("revanced_change_share_sheet", FALSE, true); + public static final BooleanSetting DISABLE_CAIRO_SPLASH_ANIMATION = new BooleanSetting("revanced_disable_cairo_splash_animation", FALSE, true); + public static final BooleanSetting ENABLE_OPUS_CODEC = new BooleanSetting("revanced_enable_opus_codec", FALSE, true); + public static final BooleanSetting SETTINGS_IMPORT_EXPORT = new BooleanSetting("revanced_extended_settings_import_export", FALSE, false); + + + // PreferenceScreen: Return YouTube Dislike + public static final BooleanSetting RYD_ENABLED = new BooleanSetting("revanced_ryd_enabled", TRUE); + public static final StringSetting RYD_USER_ID = new StringSetting("revanced_ryd_user_id", "", false, false); + public static final BooleanSetting RYD_DISLIKE_PERCENTAGE = new BooleanSetting("revanced_ryd_dislike_percentage", FALSE); + public static final BooleanSetting RYD_COMPACT_LAYOUT = new BooleanSetting("revanced_ryd_compact_layout", FALSE); + public static final BooleanSetting RYD_ESTIMATED_LIKE = new BooleanSetting("revanced_ryd_estimated_like", FALSE, true); + public static final BooleanSetting RYD_TOAST_ON_CONNECTION_ERROR = new BooleanSetting("revanced_ryd_toast_on_connection_error", FALSE); + + // PreferenceScreen: Return YouTube Username + public static final BooleanSetting RETURN_YOUTUBE_USERNAME_ABOUT = new BooleanSetting("revanced_return_youtube_username_youtube_data_api_v3_about", FALSE, false); + + + // PreferenceScreen: SponsorBlock + public static final BooleanSetting SB_ENABLED = new BooleanSetting("sb_enabled", TRUE); + public static final BooleanSetting SB_TOAST_ON_CONNECTION_ERROR = new BooleanSetting("sb_toast_on_connection_error", FALSE); + public static final BooleanSetting SB_TOAST_ON_SKIP = new BooleanSetting("sb_toast_on_skip", TRUE); + public static final StringSetting SB_API_URL = new StringSetting("sb_api_url", "https://sponsor.ajay.app"); + public static final StringSetting SB_PRIVATE_USER_ID = new StringSetting("sb_private_user_id", ""); + public static final BooleanSetting SB_USER_IS_VIP = new BooleanSetting("sb_user_is_vip", FALSE); + + public static final StringSetting SB_CATEGORY_SPONSOR = new StringSetting("sb_sponsor", SKIP_AUTOMATICALLY.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_SPONSOR_COLOR = new StringSetting("sb_sponsor_color", "#00D400"); + public static final StringSetting SB_CATEGORY_SELF_PROMO = new StringSetting("sb_selfpromo", SKIP_AUTOMATICALLY.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_SELF_PROMO_COLOR = new StringSetting("sb_selfpromo_color", "#FFFF00"); + public static final StringSetting SB_CATEGORY_INTERACTION = new StringSetting("sb_interaction", SKIP_AUTOMATICALLY.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_INTERACTION_COLOR = new StringSetting("sb_interaction_color", "#CC00FF"); + public static final StringSetting SB_CATEGORY_INTRO = new StringSetting("sb_intro", SKIP_AUTOMATICALLY.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_INTRO_COLOR = new StringSetting("sb_intro_color", "#00FFFF"); + public static final StringSetting SB_CATEGORY_OUTRO = new StringSetting("sb_outro", SKIP_AUTOMATICALLY.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_OUTRO_COLOR = new StringSetting("sb_outro_color", "#0202ED"); + public static final StringSetting SB_CATEGORY_PREVIEW = new StringSetting("sb_preview", SKIP_AUTOMATICALLY.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_PREVIEW_COLOR = new StringSetting("sb_preview_color", "#008FD6"); + public static final StringSetting SB_CATEGORY_FILLER = new StringSetting("sb_filler", SKIP_AUTOMATICALLY.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_FILLER_COLOR = new StringSetting("sb_filler_color", "#7300FF"); + public static final StringSetting SB_CATEGORY_MUSIC_OFFTOPIC = new StringSetting("sb_music_offtopic", SKIP_AUTOMATICALLY.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_MUSIC_OFFTOPIC_COLOR = new StringSetting("sb_music_offtopic_color", "#FF9900"); + + // SB settings not exported + public static final LongSetting SB_LAST_VIP_CHECK = new LongSetting("sb_last_vip_check", 0L, false, false); + + public static final String OPEN_DEFAULT_APP_SETTINGS = "revanced_default_app_settings"; + + /** + * If a setting path has this prefix, then remove it. + */ + public static final String OPTIONAL_SPONSOR_BLOCK_SETTINGS_PREFIX = "sb_segments_"; + + /** + * Array of settings using intent + */ + private static final String[] intentSettingArray = new String[]{ + BYPASS_IMAGE_REGION_RESTRICTIONS_DOMAIN.key, + CHANGE_START_PAGE.key, + CUSTOM_FILTER_STRINGS.key, + CUSTOM_PLAYBACK_SPEEDS.key, + EXTERNAL_DOWNLOADER_PACKAGE_NAME.key, + HIDE_ACCOUNT_MENU_FILTER_STRINGS.key, + SB_API_URL.key, + SETTINGS_IMPORT_EXPORT.key, + SPOOF_APP_VERSION_TARGET.key, + RETURN_YOUTUBE_USERNAME_ABOUT.key, + RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT.key, + RETURN_YOUTUBE_USERNAME_YOUTUBE_DATA_API_V3_DEVELOPER_KEY.key, + OPEN_DEFAULT_APP_SETTINGS, + OPTIONAL_SPONSOR_BLOCK_SETTINGS_PREFIX + }; + + /** + * @return whether dataString contains settings that use Intent + */ + public static boolean includeWithIntent(@NonNull String dataString) { + return Utils.containsAny(dataString, intentSettingArray); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ExternalDownloaderPreference.java b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ExternalDownloaderPreference.java new file mode 100644 index 0000000000..0454f14240 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ExternalDownloaderPreference.java @@ -0,0 +1,132 @@ +package app.revanced.extension.music.settings.preference; + +import static app.revanced.extension.music.utils.ExtendedUtils.getDialogBuilder; +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Intent; +import android.net.Uri; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.TypedValue; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TableLayout; +import android.widget.TableRow; + +import java.util.Arrays; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.music.utils.ExtendedUtils; +import app.revanced.extension.shared.settings.StringSetting; +import app.revanced.extension.shared.utils.ResourceUtils; +import app.revanced.extension.shared.utils.Utils; + +/** + * @noinspection all + */ +public class ExternalDownloaderPreference { + + private static final StringSetting settings = Settings.EXTERNAL_DOWNLOADER_PACKAGE_NAME; + private static final String[] mEntries = ResourceUtils.getStringArray("revanced_external_downloader_label"); + private static final String[] mEntryValues = ResourceUtils.getStringArray("revanced_external_downloader_package_name"); + private static final String[] mWebsiteEntries = ResourceUtils.getStringArray("revanced_external_downloader_website"); + private static EditText mEditText; + private static String packageName; + private static int mClickedDialogEntryIndex; + + private static final TextWatcher textWatcher = new TextWatcher() { + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + public void afterTextChanged(Editable s) { + packageName = s.toString(); + mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName); + } + }; + + public static void showDialog(Activity mActivity) { + packageName = settings.get().toString(); + mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName); + + AlertDialog.Builder builder = getDialogBuilder(mActivity); + + TableLayout table = new TableLayout(mActivity); + table.setOrientation(LinearLayout.HORIZONTAL); + table.setPadding(15, 0, 15, 0); + + TableRow row = new TableRow(mActivity); + + mEditText = new EditText(mActivity); + mEditText.setText(packageName); + mEditText.addTextChangedListener(textWatcher); + mEditText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 9); + mEditText.setLayoutParams(new TableRow.LayoutParams(TableRow.LayoutParams.MATCH_PARENT, TableRow.LayoutParams.WRAP_CONTENT, 1f)); + row.addView(mEditText); + + table.addView(row); + builder.setView(table); + + builder.setTitle(str("revanced_external_downloader_dialog_title")); + builder.setSingleChoiceItems(mEntries, mClickedDialogEntryIndex, (dialog, which) -> { + mClickedDialogEntryIndex = which; + mEditText.setText(mEntryValues[which].toString()); + }); + builder.setPositiveButton(android.R.string.ok, (dialog, which) -> { + final String packageName = mEditText.getText().toString().trim(); + settings.save(packageName); + checkPackageIsValid(mActivity, packageName); + dialog.dismiss(); + }); + builder.setNeutralButton(str("revanced_extended_settings_reset"), (dialog, which) -> settings.resetToDefault()); + builder.setNegativeButton(android.R.string.cancel, null); + + builder.show(); + } + + private static boolean checkPackageIsValid(Activity mActivity, String packageName) { + String appName = ""; + String website = ""; + if (mClickedDialogEntryIndex >= 0) { + appName = mEntries[mClickedDialogEntryIndex].toString(); + website = mWebsiteEntries[mClickedDialogEntryIndex].toString(); + return showToastOrOpenWebsites(mActivity, appName, packageName, website); + } else { + return showToastOrOpenWebsites(mActivity, appName, packageName, website); + } + } + + private static boolean showToastOrOpenWebsites(Activity mActivity, String appName, String packageName, String website) { + if (ExtendedUtils.isPackageEnabled(packageName)) + return true; + + if (website.isEmpty()) { + Utils.showToastShort(str("revanced_external_downloader_not_installed_warning", packageName)); + return false; + } + + getDialogBuilder(mActivity) + .setTitle(str("revanced_external_downloader_not_installed_dialog_title")) + .setMessage(str("revanced_external_downloader_not_installed_dialog_message", appName, appName)) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(website)); + mActivity.startActivity(i); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + + return false; + } + + public static boolean checkPackageIsEnabled() { + final Activity mActivity = Utils.getActivity(); + packageName = settings.get().toString(); + mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName); + return checkPackageIsValid(mActivity, packageName); + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ReVancedPreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ReVancedPreferenceFragment.java new file mode 100644 index 0000000000..a819e425bd --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ReVancedPreferenceFragment.java @@ -0,0 +1,351 @@ +package app.revanced.extension.music.settings.preference; + +import static app.revanced.extension.music.settings.Settings.BYPASS_IMAGE_REGION_RESTRICTIONS_DOMAIN; +import static app.revanced.extension.music.settings.Settings.CHANGE_START_PAGE; +import static app.revanced.extension.music.settings.Settings.CUSTOM_FILTER_STRINGS; +import static app.revanced.extension.music.settings.Settings.CUSTOM_PLAYBACK_SPEEDS; +import static app.revanced.extension.music.settings.Settings.EXTERNAL_DOWNLOADER_PACKAGE_NAME; +import static app.revanced.extension.music.settings.Settings.HIDE_ACCOUNT_MENU_FILTER_STRINGS; +import static app.revanced.extension.music.settings.Settings.OPEN_DEFAULT_APP_SETTINGS; +import static app.revanced.extension.music.settings.Settings.OPTIONAL_SPONSOR_BLOCK_SETTINGS_PREFIX; +import static app.revanced.extension.music.settings.Settings.RETURN_YOUTUBE_USERNAME_ABOUT; +import static app.revanced.extension.music.settings.Settings.SB_API_URL; +import static app.revanced.extension.music.settings.Settings.SETTINGS_IMPORT_EXPORT; +import static app.revanced.extension.music.settings.Settings.SPOOF_APP_VERSION_TARGET; +import static app.revanced.extension.music.utils.ExtendedUtils.getDialogBuilder; +import static app.revanced.extension.music.utils.ExtendedUtils.getLayoutParams; +import static app.revanced.extension.music.utils.RestartUtils.showRestartDialog; +import static app.revanced.extension.shared.settings.BaseSettings.RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT; +import static app.revanced.extension.shared.settings.BaseSettings.RETURN_YOUTUBE_USERNAME_YOUTUBE_DATA_API_V3_DEVELOPER_KEY; +import static app.revanced.extension.shared.settings.Setting.getSettingFromPath; +import static app.revanced.extension.shared.utils.ResourceUtils.getStringArray; +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.shared.utils.Utils.isSDKAbove; +import static app.revanced.extension.shared.utils.Utils.showToastShort; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.icu.text.SimpleDateFormat; +import android.net.Uri; +import android.os.Bundle; +import android.preference.PreferenceFragment; +import android.text.InputType; +import android.util.TypedValue; +import android.widget.EditText; +import android.widget.FrameLayout; + +import androidx.annotation.Nullable; + +import com.google.android.material.textfield.TextInputLayout; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Date; +import java.util.Objects; + +import app.revanced.extension.music.patches.utils.ReturnYouTubeDislikePatch; +import app.revanced.extension.music.returnyoutubedislike.ReturnYouTubeDislike; +import app.revanced.extension.music.settings.ActivityHook; +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.music.utils.ExtendedUtils; +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.shared.settings.EnumSetting; +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.settings.StringSetting; +import app.revanced.extension.shared.settings.preference.YouTubeDataAPIDialogBuilder; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +@SuppressWarnings("all") +public class ReVancedPreferenceFragment extends PreferenceFragment { + + private static final String IMPORT_EXPORT_SETTINGS_ENTRY_KEY = "revanced_extended_settings_import_export_entries"; + private static final int READ_REQUEST_CODE = 42; + private static final int WRITE_REQUEST_CODE = 43; + + private static String existingSettings; + + + public ReVancedPreferenceFragment() { + } + + /** + * Injection point. + */ + public static void onPreferenceChanged(@Nullable String key, boolean newValue) { + if (key == null || key.isEmpty()) + return; + + if (key.equals(Settings.RESTORE_OLD_PLAYER_LAYOUT.key) && newValue) { + Settings.RESTORE_OLD_PLAYER_BACKGROUND.save(newValue); + } else if (key.equals(Settings.RYD_ENABLED.key)) { + ReturnYouTubeDislikePatch.onRYDStatusChange(newValue); + } else if (key.equals(Settings.RYD_DISLIKE_PERCENTAGE.key) || key.equals(Settings.RYD_COMPACT_LAYOUT.key)) { + ReturnYouTubeDislike.clearAllUICaches(); + } + + for (Setting setting : Setting.allLoadedSettings()) { + if (key.equals(setting.key)) { + ((BooleanSetting) setting).save(newValue); + if (setting.rebootApp) { + showRebootDialog(); + } + break; + } + } + } + + public static void showRebootDialog() { + final Activity activity = ActivityHook.getActivity(); + if (activity == null) + return; + + showRestartDialog(activity); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + try { + final Activity baseActivity = this.getActivity(); + final Activity mActivity = ActivityHook.getActivity(); + final Intent savedInstanceStateIntent = baseActivity.getIntent(); + if (savedInstanceStateIntent == null) + return; + + final String dataString = savedInstanceStateIntent.getDataString(); + if (dataString == null || dataString.isEmpty()) + return; + + if (dataString.startsWith(OPTIONAL_SPONSOR_BLOCK_SETTINGS_PREFIX)) { + SponsorBlockCategoryPreference.showDialog(baseActivity, dataString.replaceAll(OPTIONAL_SPONSOR_BLOCK_SETTINGS_PREFIX, "")); + return; + } else if (dataString.equals(OPEN_DEFAULT_APP_SETTINGS)) { + openDefaultAppSetting(); + return; + } + + final Setting settings = getSettingFromPath(dataString); + if (settings instanceof StringSetting stringSetting) { + if (settings.equals(CHANGE_START_PAGE)) { + ResettableListPreference.showDialog(mActivity, stringSetting, 2); + } else if (settings.equals(BYPASS_IMAGE_REGION_RESTRICTIONS_DOMAIN) + || settings.equals(CUSTOM_FILTER_STRINGS) + || settings.equals(CUSTOM_PLAYBACK_SPEEDS) + || settings.equals(HIDE_ACCOUNT_MENU_FILTER_STRINGS) + || settings.equals(RETURN_YOUTUBE_USERNAME_YOUTUBE_DATA_API_V3_DEVELOPER_KEY)) { + ResettableEditTextPreference.showDialog(mActivity, stringSetting); + } else if (settings.equals(EXTERNAL_DOWNLOADER_PACKAGE_NAME)) { + ExternalDownloaderPreference.showDialog(mActivity); + } else if (settings.equals(SB_API_URL)) { + SponsorBlockApiUrlPreference.showDialog(mActivity); + } else if (settings.equals(SPOOF_APP_VERSION_TARGET)) { + ResettableListPreference.showDialog(mActivity, stringSetting, 0); + } else { + Logger.printDebug(() -> "Failed to find the right value: " + dataString); + } + } else if (settings instanceof BooleanSetting) { + if (settings.equals(SETTINGS_IMPORT_EXPORT)) { + importExportListDialogBuilder(); + } else if (settings.equals(RETURN_YOUTUBE_USERNAME_ABOUT)) { + YouTubeDataAPIDialogBuilder.showDialog(mActivity); + } else { + Logger.printDebug(() -> "Failed to find the right value: " + dataString); + } + } else if (settings instanceof EnumSetting enumSetting) { + if (settings.equals(RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT)) { + ResettableListPreference.showDialog(mActivity, enumSetting, 0); + } + } + } catch (Exception ex) { + Logger.printException(() -> "onCreate failure", ex); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + } + + private void openDefaultAppSetting() { + try { + Context context = getActivity(); + final Uri uri = Uri.parse("package:" + context.getPackageName()); + final Intent intent = isSDKAbove(31) + ? new Intent(android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS, uri) + : new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, uri); + context.startActivity(intent); + } catch (Exception exception) { + Logger.printException(() -> "openDefaultAppSetting failed"); + } + } + + /** + * Build a ListDialog for Import / Export settings + * When importing/exporting as file, {@link #onActivityResult} is used, so declare it here. + */ + private void importExportListDialogBuilder() { + try { + final Activity activity = getActivity(); + final String[] mEntries = getStringArray(IMPORT_EXPORT_SETTINGS_ENTRY_KEY); + + getDialogBuilder(activity) + .setTitle(str("revanced_extended_settings_import_export_title")) + .setItems(mEntries, (dialog, index) -> { + switch (index) { + case 0 -> exportActivity(); + case 1 -> importActivity(); + case 2 -> importExportEditTextDialogBuilder(); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } catch (Exception ex) { + Logger.printException(() -> "importExportListDialogBuilder failure", ex); + } + } + + /** + * Build a EditTextDialog for Import / Export settings + */ + private void importExportEditTextDialogBuilder() { + try { + final Activity activity = getActivity(); + final EditText textView = new EditText(activity); + existingSettings = Setting.exportToJson(null); + textView.setText(existingSettings); + textView.setInputType(textView.getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + textView.setTextSize(TypedValue.COMPLEX_UNIT_PT, 8); // Use a smaller font to reduce text wrap. + + TextInputLayout textInputLayout = new TextInputLayout(activity); + textInputLayout.setLayoutParams(getLayoutParams()); + textInputLayout.addView(textView); + + FrameLayout container = new FrameLayout(activity); + container.addView(textInputLayout); + + getDialogBuilder(activity) + .setTitle(str("revanced_extended_settings_import_export_title")) + .setView(container) + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(str("revanced_extended_settings_import_copy"), (dialog, which) -> Utils.setClipboard(textView.getText().toString(), str("revanced_share_copy_settings_success"))) + .setPositiveButton(str("revanced_extended_settings_import"), (dialog, which) -> importSettings(textView.getText().toString())) + .show(); + } catch (Exception ex) { + Logger.printException(() -> "importExportEditTextDialogBuilder failure", ex); + } + } + + /** + * Invoke the SAF(Storage Access Framework) to export settings + */ + private void exportActivity() { + @SuppressLint("SimpleDateFormat") + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); + + var appName = ExtendedUtils.getApplicationLabel(); + var versionName = ExtendedUtils.getVersionName(); + var formatDate = dateFormat.format(new Date(System.currentTimeMillis())); + var fileName = String.format("%s_v%s_%s.txt", appName, versionName, formatDate); + + var intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_TITLE, fileName); + startActivityForResult(intent, WRITE_REQUEST_CODE); + } + + /** + * Invoke the SAF(Storage Access Framework) to import settings + */ + private void importActivity() { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType(isSDKAbove(29) ? "text/plain" : "*/*"); + startActivityForResult(intent, READ_REQUEST_CODE); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == WRITE_REQUEST_CODE && resultCode == Activity.RESULT_OK) { + exportText(data.getData()); + } else if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK && data != null) { + importText(data.getData()); + } + } + + private void exportText(Uri uri) { + try { + final Context context = this.getContext(); + + @SuppressLint("Recycle") + FileWriter jsonFileWriter = + new FileWriter( + Objects.requireNonNull(context.getApplicationContext() + .getContentResolver() + .openFileDescriptor(uri, "w")) + .getFileDescriptor() + ); + PrintWriter printWriter = new PrintWriter(jsonFileWriter); + printWriter.write(Setting.exportToJson(null)); + printWriter.close(); + jsonFileWriter.close(); + + showToastShort(str("revanced_extended_settings_export_success")); + } catch (IOException e) { + showToastShort(str("revanced_extended_settings_export_failed")); + } + } + + private void importText(Uri uri) { + final Context context = this.getContext(); + StringBuilder sb = new StringBuilder(); + String line; + + try { + @SuppressLint("Recycle") + FileReader fileReader = + new FileReader( + Objects.requireNonNull(context.getApplicationContext() + .getContentResolver() + .openFileDescriptor(uri, "r")) + .getFileDescriptor() + ); + BufferedReader bufferedReader = new BufferedReader(fileReader); + while ((line = bufferedReader.readLine()) != null) { + sb.append(line).append("\n"); + } + bufferedReader.close(); + fileReader.close(); + + final boolean restartNeeded = Setting.importFromJSON(sb.toString(), false); + if (restartNeeded) { + ReVancedPreferenceFragment.showRebootDialog(); + } + } catch (IOException e) { + showToastShort(str("revanced_extended_settings_import_failed")); + throw new RuntimeException(e); + } + } + + private void importSettings(String replacementSettings) { + try { + existingSettings = Setting.exportToJson(null); + if (replacementSettings.equals(existingSettings)) { + return; + } + final boolean restartNeeded = Setting.importFromJSON(replacementSettings, false); + if (restartNeeded) { + ReVancedPreferenceFragment.showRebootDialog(); + } + } catch (Exception ex) { + Logger.printException(() -> "importSettings failure", ex); + } + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ResettableEditTextPreference.java b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ResettableEditTextPreference.java new file mode 100644 index 0000000000..d94bfab37e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ResettableEditTextPreference.java @@ -0,0 +1,50 @@ +package app.revanced.extension.music.settings.preference; + +import static app.revanced.extension.music.utils.ExtendedUtils.getDialogBuilder; +import static app.revanced.extension.music.utils.ExtendedUtils.getLayoutParams; +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.app.Activity; +import android.widget.EditText; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; + +import com.google.android.material.textfield.TextInputLayout; + +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.utils.Logger; + +public class ResettableEditTextPreference { + + public static void showDialog(Activity mActivity, @NonNull Setting setting) { + try { + final EditText textView = new EditText(mActivity); + textView.setText(setting.get()); + + TextInputLayout textInputLayout = new TextInputLayout(mActivity); + textInputLayout.setLayoutParams(getLayoutParams()); + textInputLayout.addView(textView); + + FrameLayout container = new FrameLayout(mActivity); + container.addView(textInputLayout); + + getDialogBuilder(mActivity) + .setTitle(str(setting.key + "_title")) + .setView(container) + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(str("revanced_extended_settings_reset"), (dialog, which) -> { + setting.resetToDefault(); + ReVancedPreferenceFragment.showRebootDialog(); + }) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + setting.save(textView.getText().toString().trim()); + ReVancedPreferenceFragment.showRebootDialog(); + }) + .show(); + } catch (Exception ex) { + Logger.printException(() -> "showDialog failure", ex); + } + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ResettableListPreference.java b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ResettableListPreference.java new file mode 100644 index 0000000000..b01f5bf2d1 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ResettableListPreference.java @@ -0,0 +1,81 @@ +package app.revanced.extension.music.settings.preference; + +import static app.revanced.extension.music.utils.ExtendedUtils.getDialogBuilder; +import static app.revanced.extension.shared.utils.ResourceUtils.getStringArray; +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.app.Activity; + +import androidx.annotation.NonNull; + +import java.util.Arrays; + +import app.revanced.extension.shared.settings.EnumSetting; +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.utils.Logger; + +public class ResettableListPreference { + private static int mClickedDialogEntryIndex; + + public static void showDialog(Activity mActivity, @NonNull Setting setting, int defaultIndex) { + try { + final String settingsKey = setting.key; + + final String entryKey = settingsKey + "_entries"; + final String entryValueKey = settingsKey + "_entry_values"; + final String[] mEntries = getStringArray(entryKey); + final String[] mEntryValues = getStringArray(entryValueKey); + + final int findIndex = Arrays.binarySearch(mEntryValues, setting.get()); + mClickedDialogEntryIndex = findIndex >= 0 ? findIndex : defaultIndex; + + getDialogBuilder(mActivity) + .setTitle(str(settingsKey + "_title")) + .setSingleChoiceItems(mEntries, mClickedDialogEntryIndex, + (dialog, id) -> mClickedDialogEntryIndex = id) + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(str("revanced_extended_settings_reset"), (dialog, which) -> { + setting.resetToDefault(); + ReVancedPreferenceFragment.showRebootDialog(); + }) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + setting.save(mEntryValues[mClickedDialogEntryIndex]); + ReVancedPreferenceFragment.showRebootDialog(); + }) + .show(); + } catch (Exception ex) { + Logger.printException(() -> "showDialog failure", ex); + } + } + + public static void showDialog(Activity mActivity, @NonNull EnumSetting setting, int defaultIndex) { + try { + final String settingsKey = setting.key; + + final String entryKey = settingsKey + "_entries"; + final String entryValueKey = settingsKey + "_entry_values"; + final String[] mEntries = getStringArray(entryKey); + final String[] mEntryValues = getStringArray(entryValueKey); + + final int findIndex = Arrays.binarySearch(mEntryValues, setting.get().toString()); + mClickedDialogEntryIndex = findIndex >= 0 ? findIndex : defaultIndex; + + getDialogBuilder(mActivity) + .setTitle(str(settingsKey + "_title")) + .setSingleChoiceItems(mEntries, mClickedDialogEntryIndex, + (dialog, id) -> mClickedDialogEntryIndex = id) + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(str("revanced_extended_settings_reset"), (dialog, which) -> { + setting.resetToDefault(); + ReVancedPreferenceFragment.showRebootDialog(); + }) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + setting.saveValueFromString(mEntryValues[mClickedDialogEntryIndex]); + ReVancedPreferenceFragment.showRebootDialog(); + }) + .show(); + } catch (Exception ex) { + Logger.printException(() -> "showDialog failure", ex); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/SponsorBlockApiUrlPreference.java b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/SponsorBlockApiUrlPreference.java new file mode 100644 index 0000000000..9b6c9a1a7b --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/SponsorBlockApiUrlPreference.java @@ -0,0 +1,70 @@ +package app.revanced.extension.music.settings.preference; + +import static app.revanced.extension.music.utils.ExtendedUtils.getDialogBuilder; +import static app.revanced.extension.music.utils.ExtendedUtils.getLayoutParams; +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.app.Activity; +import android.util.Patterns; +import android.widget.EditText; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; + +import com.google.android.material.textfield.TextInputLayout; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.shared.settings.StringSetting; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +public class SponsorBlockApiUrlPreference { + + public static void showDialog(Activity mActivity) { + try { + final StringSetting apiUrl = Settings.SB_API_URL; + + final EditText textView = new EditText(mActivity); + textView.setText(apiUrl.get()); + + TextInputLayout textInputLayout = new TextInputLayout(mActivity); + textInputLayout.setLayoutParams(getLayoutParams()); + textInputLayout.addView(textView); + + FrameLayout container = new FrameLayout(mActivity); + container.addView(textInputLayout); + + getDialogBuilder(mActivity) + .setTitle(str("revanced_sb_api_url")) + .setView(container) + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(str("revanced_extended_settings_reset"), (dialog, which) -> { + apiUrl.resetToDefault(); + Utils.showToastShort(str("revanced_sb_api_url_reset")); + }) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + String serverAddress = textView.getText().toString().trim(); + if (!isValidSBServerAddress(serverAddress)) { + Utils.showToastShort(str("revanced_sb_api_url_invalid")); + } else if (!serverAddress.equals(Settings.SB_API_URL.get())) { + apiUrl.save(serverAddress); + Utils.showToastShort(str("revanced_sb_api_url_changed")); + } + }) + .show(); + } catch (Exception ex) { + Logger.printException(() -> "showDialog failure", ex); + } + } + + public static boolean isValidSBServerAddress(@NonNull String serverAddress) { + if (!Patterns.WEB_URL.matcher(serverAddress).matches()) { + return false; + } + // Verify url is only the server address and does not contain a path such as: "https://sponsor.ajay.app/api/" + // Could use Patterns.compile, but this is simpler + final int lastDotIndex = serverAddress.lastIndexOf('.'); + return lastDotIndex == -1 || !serverAddress.substring(lastDotIndex).contains("/"); + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/SponsorBlockCategoryPreference.java b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/SponsorBlockCategoryPreference.java new file mode 100644 index 0000000000..14dda2c783 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/SponsorBlockCategoryPreference.java @@ -0,0 +1,124 @@ +package app.revanced.extension.music.settings.preference; + +import static app.revanced.extension.music.utils.ExtendedUtils.getDialogBuilder; +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.app.Activity; +import android.app.AlertDialog; +import android.graphics.Color; +import android.text.Editable; +import android.text.InputType; +import android.text.TextWatcher; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TableLayout; +import android.widget.TableRow; +import android.widget.TextView; + +import java.util.Arrays; +import java.util.Objects; + +import app.revanced.extension.music.sponsorblock.objects.CategoryBehaviour; +import app.revanced.extension.music.sponsorblock.objects.SegmentCategory; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +public class SponsorBlockCategoryPreference { + private static final String[] CategoryBehaviourEntries = {str("revanced_sb_skip_automatically"), str("revanced_sb_skip_ignore")}; + private static final CategoryBehaviour[] CategoryBehaviourEntryValues = {CategoryBehaviour.SKIP_AUTOMATICALLY, CategoryBehaviour.IGNORE}; + private static int mClickedDialogEntryIndex; + + + public static void showDialog(Activity baseActivity, String categoryString) { + try { + SegmentCategory category = Objects.requireNonNull(SegmentCategory.byCategoryKey(categoryString)); + final AlertDialog.Builder builder = getDialogBuilder(baseActivity); + TableLayout table = new TableLayout(baseActivity); + table.setOrientation(LinearLayout.HORIZONTAL); + table.setPadding(70, 0, 150, 0); + + TableRow row = new TableRow(baseActivity); + + TextView colorTextLabel = new TextView(baseActivity); + colorTextLabel.setText(str("revanced_sb_color_dot_label")); + row.addView(colorTextLabel); + + TextView colorDotView = new TextView(baseActivity); + colorDotView.setText(category.getCategoryColorDot()); + colorDotView.setPadding(30, 0, 30, 0); + row.addView(colorDotView); + + final EditText mEditText = new EditText(baseActivity); + mEditText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS); + mEditText.setText(category.colorString()); + mEditText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable s) { + try { + String colorString = s.toString(); + if (!colorString.startsWith("#")) { + s.insert(0, "#"); // recursively calls back into this method + return; + } + if (colorString.length() > 7) { + s.delete(7, colorString.length()); + return; + } + final int color = Color.parseColor(colorString); + colorDotView.setText(SegmentCategory.getCategoryColorDot(color)); + } catch (IllegalArgumentException ex) { + // ignore + } + } + }); + mEditText.setLayoutParams(new TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 1f)); + row.addView(mEditText); + + table.addView(row); + builder.setView(table); + builder.setTitle(category.title.toString()); + + builder.setPositiveButton(android.R.string.ok, (dialog, which) -> { + category.behaviour = CategoryBehaviourEntryValues[mClickedDialogEntryIndex]; + category.setBehaviour(category.behaviour); + SegmentCategory.updateEnabledCategories(); + + String colorString = mEditText.getText().toString(); + try { + if (!colorString.equals(category.colorString())) { + category.setColor(colorString); + Utils.showToastShort(str("revanced_sb_color_changed")); + } + } catch (IllegalArgumentException ex) { + Utils.showToastShort(str("revanced_sb_color_invalid")); + } + }); + builder.setNeutralButton(str("revanced_sb_reset_color"), (dialog, which) -> { + try { + category.resetColor(); + Utils.showToastShort(str("revanced_sb_color_reset")); + } catch (Exception ex) { + Logger.printException(() -> "setNeutralButton failure", ex); + } + }); + builder.setNegativeButton(android.R.string.cancel, null); + + final int index = Arrays.asList(CategoryBehaviourEntryValues).indexOf(category.behaviour); + mClickedDialogEntryIndex = Math.max(index, 0); + + builder.setSingleChoiceItems(CategoryBehaviourEntries, mClickedDialogEntryIndex, + (dialog, id) -> mClickedDialogEntryIndex = id); + builder.show(); + } catch (Exception ex) { + Logger.printException(() -> "dialogBuilder failure", ex); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/shared/PlayerType.kt b/extensions/shared/src/main/java/app/revanced/extension/music/shared/PlayerType.kt new file mode 100644 index 0000000000..5ca6ba944c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/shared/PlayerType.kt @@ -0,0 +1,54 @@ +package app.revanced.extension.music.shared + +import app.revanced.extension.shared.utils.Event +import app.revanced.extension.shared.utils.Logger + +/** + * WatchWhile player type + */ +enum class PlayerType { + DISMISSED, + MINIMIZED, + MAXIMIZED_NOW_PLAYING, + MAXIMIZED_PLAYER_ADDITIONAL_VIEW, + FULLSCREEN, + SLIDING_VERTICALLY, + QUEUE_EXPANDING, + SLIDING_HORIZONTALLY; + + companion object { + + private val nameToPlayerType = values().associateBy { it.name } + + @JvmStatic + fun setFromString(enumName: String) { + val newType = nameToPlayerType[enumName] + if (newType == null) { + Logger.printException { "Unknown PlayerType encountered: $enumName" } + } else if (current != newType) { + Logger.printDebug { "PlayerType changed to: $newType" } + current = newType + } + } + + /** + * The current player type. + */ + @JvmStatic + var current + get() = currentPlayerType + private set(value) { + currentPlayerType = value + onChange(currentPlayerType) + } + + @Volatile // value is read/write from different threads + private var currentPlayerType = MINIMIZED + + /** + * player type change listener + */ + @JvmStatic + val onChange = Event() + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/shared/VideoInformation.java b/extensions/shared/src/main/java/app/revanced/extension/music/shared/VideoInformation.java new file mode 100644 index 0000000000..12ce65258a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/shared/VideoInformation.java @@ -0,0 +1,319 @@ +package app.revanced.extension.music.shared; + +import static app.revanced.extension.shared.utils.ResourceUtils.getString; +import static app.revanced.extension.shared.utils.Utils.getFormattedTimeStamp; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +/** + * Hooking class for the current playing video. + */ +@SuppressWarnings("unused") +public final class VideoInformation { + private static final float DEFAULT_YOUTUBE_MUSIC_PLAYBACK_SPEED = 1.0f; + private static final int DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY = -2; + private static final String DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY_STRING = getString("quality_auto"); + @NonNull + private static String videoId = ""; + + private static long videoLength = 0; + private static long videoTime = -1; + + /** + * The current playback speed + */ + private static float playbackSpeed = DEFAULT_YOUTUBE_MUSIC_PLAYBACK_SPEED; + /** + * The current video quality + */ + private static int videoQuality = DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY; + /** + * The current video quality string + */ + private static String videoQualityString = DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY_STRING; + /** + * The available qualities of the current video in human readable form: [1080, 720, 480] + */ + @Nullable + private static List videoQualities; + + /** + * Injection point. + */ + public static void initialize() { + videoTime = -1; + videoLength = 0; + Logger.printDebug(() -> "Initialized Player"); + } + + /** + * Injection point. + */ + public static void initializeMdx() { + Logger.printDebug(() -> "Initialized Mdx Player"); + } + + /** + * Id of the current video playing. Includes Shorts and YouTube Stories. + * + * @return The id of the video. Empty string if not set yet. + */ + @NonNull + public static String getVideoId() { + return videoId; + } + + /** + * Injection point. + * + * @param newlyLoadedVideoId id of the current video + */ + public static void setVideoId(@NonNull String newlyLoadedVideoId) { + if (Objects.equals(newlyLoadedVideoId, videoId)) { + return; + } + Logger.printDebug(() -> "New video id: " + newlyLoadedVideoId); + videoId = newlyLoadedVideoId; + } + + /** + * Seek on the current video. + * Does not function for playback of Shorts. + *

+ * Caution: If called from a videoTimeHook() callback, + * this will cause a recursive call into the same videoTimeHook() callback. + * + * @param seekTime The millisecond to seek the video to. + * @return if the seek was successful + */ + public static boolean seekTo(final long seekTime) { + Utils.verifyOnMainThread(); + try { + final long videoLength = getVideoLength(); + final long videoTime = getVideoTime(); + final long adjustedSeekTime = getAdjustedSeekTime(seekTime, videoLength); + + if (videoTime <= 0 || videoLength <= 0) { + Logger.printDebug(() -> "Skipping seekTo as the video is not initialized"); + return false; + } + + Logger.printDebug(() -> "Seeking to: " + getFormattedTimeStamp(adjustedSeekTime)); + + // Try regular playback controller first, and it will not succeed if casting. + if (overrideVideoTime(adjustedSeekTime)) return true; + Logger.printDebug(() -> "seekTo did not succeeded. Trying MXD."); + // Else the video is loading or changing videos, or video is casting to a different device. + + // Try calling the seekTo method of the MDX player director (called when casting). + // The difference has to be a different second mark in order to avoid infinite skip loops + // as the Lounge API only supports seconds. + if (adjustedSeekTime / 1000 == videoTime / 1000) { + Logger.printDebug(() -> "Skipping seekTo for MDX because seek time is too small " + + "(" + (adjustedSeekTime - videoTime) + "ms)"); + return false; + } + + return overrideMDXVideoTime(adjustedSeekTime); + } catch (Exception ex) { + Logger.printException(() -> "Failed to seek", ex); + return false; + } + } + + // Prevent issues such as play/pause button or autoplay not working. + private static long getAdjustedSeekTime(final long seekTime, final long videoLength) { + // If the user skips to a section that is 500 ms before the video length, + // it will get stuck in a loop. + if (videoLength - seekTime > 500) { + return seekTime; + } else { + // Otherwise, just skips to a time longer than the video length. + // Paradoxically, if user skips to a section much longer than the video length, does not get stuck in a loop. + return Integer.MAX_VALUE; + } + } + + /** + * @return The current playback speed. + */ + public static float getPlaybackSpeed() { + return playbackSpeed; + } + + /** + * Injection point. + * + * @param newlyLoadedPlaybackSpeed The current playback speed. + */ + public static void setPlaybackSpeed(float newlyLoadedPlaybackSpeed) { + playbackSpeed = newlyLoadedPlaybackSpeed; + } + + /** + * @return The current video quality. + */ + public static int getVideoQuality() { + return videoQuality; + } + + /** + * @return The current video quality string. + */ + public static String getVideoQualityString() { + return videoQualityString; + } + + /** + * Injection point. + * + * @param newlyLoadedQuality The current video quality string. + */ + public static void setVideoQuality(String newlyLoadedQuality) { + if (newlyLoadedQuality == null) { + return; + } + try { + String splitVideoQuality; + if (newlyLoadedQuality.contains("p")) { + splitVideoQuality = newlyLoadedQuality.split("p")[0]; + videoQuality = Integer.parseInt(splitVideoQuality); + videoQualityString = splitVideoQuality + "p"; + } else if (newlyLoadedQuality.contains("s")) { + splitVideoQuality = newlyLoadedQuality.split("s")[0]; + videoQuality = Integer.parseInt(splitVideoQuality); + videoQualityString = splitVideoQuality + "s"; + } else { + videoQuality = DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY; + videoQualityString = DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY_STRING; + } + } catch (NumberFormatException ignored) { + } + } + + /** + * @return available video quality. + */ + public static int getAvailableVideoQuality(int preferredQuality) { + if (videoQualities != null) { + int qualityToUse = videoQualities.get(0); // first element is automatic mode + for (Integer quality : videoQualities) { + if (quality <= preferredQuality && qualityToUse < quality) { + qualityToUse = quality; + } + } + preferredQuality = qualityToUse; + } + return preferredQuality; + } + + /** + * Injection point. + * + * @param qualities Video qualities available, ordered from largest to smallest, with index 0 being the 'automatic' value of -2 + */ + public static void setVideoQualityList(Object[] qualities) { + try { + if (videoQualities == null || videoQualities.size() != qualities.length) { + videoQualities = new ArrayList<>(qualities.length); + for (Object streamQuality : qualities) { + for (Field field : streamQuality.getClass().getFields()) { + if (field.getType().isAssignableFrom(Integer.TYPE) + && field.getName().length() <= 2) { + videoQualities.add(field.getInt(streamQuality)); + } + } + } + Logger.printDebug(() -> "videoQualities: " + videoQualities); + } + } catch (Exception ex) { + Logger.printException(() -> "Failed to set quality list", ex); + } + } + + /** + * Length of the current video playing. Includes Shorts. + * + * @return The length of the video in milliseconds. + * If the video is not yet loaded, or if the video is playing in the background with no video visible, + * then this returns zero. + */ + public static long getVideoLength() { + return videoLength; + } + + /** + * Injection point. + * + * @param length The length of the video in milliseconds. + */ + public static void setVideoLength(final long length) { + if (videoLength != length) { + videoLength = length; + } + } + + /** + * Playback time of the current video playing. Includes Shorts. + *

+ * Value will lag behind the actual playback time by a variable amount based on the playback speed. + *

+ * If playback speed is 2.0x, this value may be up to 2000ms behind the actual playback time. + * If playback speed is 1.0x, this value may be up to 1000ms behind the actual playback time. + * If playback speed is 0.5x, this value may be up to 500ms behind the actual playback time. + * Etc. + * + * @return The time of the video in milliseconds. -1 if not set yet. + */ + public static long getVideoTime() { + return videoTime; + } + + /** + * Injection point. + * Called on the main thread every 1000ms. + * + * @param currentPlaybackTime The current playback time of the video in milliseconds. + */ + public static void setVideoTime(final long currentPlaybackTime) { + videoTime = currentPlaybackTime; + } + + /** + * Overrides the current quality. + * Rest of the implementation added by patch. + */ + public static void overrideVideoQuality(int qualityOverride) { + Logger.printDebug(() -> "Overriding video quality to: " + qualityOverride); + } + + /** + * Overrides the current video time by seeking. + * Rest of the implementation added by patch. + */ + public static boolean overrideVideoTime(final long seekTime) { + // These instructions are ignored by patch. + Logger.printDebug(() -> "Seeking to " + seekTime); + return false; + } + + /** + * Overrides the current video time by seeking. (MDX player) + * Rest of the implementation added by patch. + */ + public static boolean overrideMDXVideoTime(final long seekTime) { + // These instructions are ignored by patch. + Logger.printDebug(() -> "Seeking to " + seekTime); + return false; + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/shared/VideoType.kt b/extensions/shared/src/main/java/app/revanced/extension/music/shared/VideoType.kt new file mode 100644 index 0000000000..87711a27ea --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/shared/VideoType.kt @@ -0,0 +1,63 @@ +package app.revanced.extension.music.shared + +import app.revanced.extension.shared.utils.Event +import app.revanced.extension.shared.utils.Logger + +/** + * Music video type + */ +enum class VideoType { + MUSIC_VIDEO_TYPE_UNKNOWN, + MUSIC_VIDEO_TYPE_ATV, + MUSIC_VIDEO_TYPE_OMV, + MUSIC_VIDEO_TYPE_UGC, + MUSIC_VIDEO_TYPE_SHOULDER, + MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC, + MUSIC_VIDEO_TYPE_PRIVATELY_OWNED_TRACK, + MUSIC_VIDEO_TYPE_LIVE_STREAM, + MUSIC_VIDEO_TYPE_PODCAST_EPISODE; + + companion object { + + private val nameToVideoType = values().associateBy { it.name } + + @JvmStatic + fun setFromString(enumName: String) { + val newType = nameToVideoType[enumName] + if (newType == null) { + Logger.printException { "Unknown VideoType encountered: $enumName" } + } else if (current != newType) { + Logger.printDebug { "VideoType changed to: $newType" } + current = newType + } + } + + /** + * The current video type. + */ + @JvmStatic + var current + get() = currentVideoType + private set(value) { + currentVideoType = value + onChange(currentVideoType) + } + + @Volatile // value is read/write from different threads + private var currentVideoType = MUSIC_VIDEO_TYPE_UNKNOWN + + /** + * player type change listener + */ + @JvmStatic + val onChange = Event() + } + + fun isMusicVideo(): Boolean { + return this == MUSIC_VIDEO_TYPE_OMV + } + + fun isPodCast(): Boolean { + return this == MUSIC_VIDEO_TYPE_PODCAST_EPISODE + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/SegmentPlaybackController.java b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/SegmentPlaybackController.java new file mode 100644 index 0000000000..948a8a92e2 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/SegmentPlaybackController.java @@ -0,0 +1,472 @@ +package app.revanced.extension.music.sponsorblock; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.graphics.Canvas; +import android.graphics.Rect; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Objects; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.music.shared.VideoInformation; +import app.revanced.extension.music.sponsorblock.objects.CategoryBehaviour; +import app.revanced.extension.music.sponsorblock.objects.SponsorSegment; +import app.revanced.extension.music.sponsorblock.requests.SBRequester; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +/** + * Handles showing, scheduling, and skipping of all {@link SponsorSegment} for the current video. + *

+ * Class is not thread safe. All methods must be called on the main thread unless otherwise specified. + */ +@SuppressWarnings("unused") +public class SegmentPlaybackController { + @Nullable + private static String currentVideoId; + @Nullable + private static SponsorSegment[] segments; + /** + * Currently playing (non-highlight) segment that user can manually skip. + */ + @Nullable + private static SponsorSegment segmentCurrentlyPlaying; + /** + * Currently playing manual skip segment that is scheduled to hide. + * This will always be NULL or equal to {@link #segmentCurrentlyPlaying}. + */ + @Nullable + private static SponsorSegment scheduledHideSegment; + /** + * Upcoming segment that is scheduled to either autoskip or show the manual skip button. + */ + @Nullable + private static SponsorSegment scheduledUpcomingSegment; + /** + * System time (in milliseconds) of when to hide the skip button of {@link #segmentCurrentlyPlaying}. + * Value is zero if playback is not inside a segment ({@link #segmentCurrentlyPlaying} is null), + */ + private static long skipSegmentButtonEndTime; + + private static int sponsorBarAbsoluteLeft; + private static int sponsorAbsoluteBarRight; + private static int sponsorBarThickness = 7; + private static SponsorSegment lastSegmentSkipped; + private static long lastSegmentSkippedTime; + private static int toastNumberOfSegmentsSkipped; + @Nullable + private static SponsorSegment toastSegmentSkipped; + + private static void setSegments(@NonNull SponsorSegment[] videoSegments) { + Arrays.sort(videoSegments); + segments = videoSegments; + } + + /** + * Clears all downloaded data. + */ + private static void clearData() { + SponsorBlockSettings.initialize(); + currentVideoId = null; + segments = null; + segmentCurrentlyPlaying = null; + scheduledUpcomingSegment = null; + scheduledHideSegment = null; + skipSegmentButtonEndTime = 0; + toastSegmentSkipped = null; + toastNumberOfSegmentsSkipped = 0; + } + + /** + * Injection point. + */ + public static void setVideoId(@NonNull String videoId) { + try { + if (Objects.equals(currentVideoId, videoId)) { + return; + } + clearData(); + if (!Settings.SB_ENABLED.get()) { + return; + } + if (Utils.isNetworkNotConnected()) { + Logger.printDebug(() -> "Network not connected, ignoring video"); + return; + } + + currentVideoId = videoId; + Logger.printDebug(() -> "setCurrentVideoId: " + videoId); + + Utils.runOnBackgroundThread(() -> { + try { + executeDownloadSegments(videoId); + } catch (Exception e) { + Logger.printException(() -> "Failed to download segments", e); + } + }); + } catch (Exception ex) { + Logger.printException(() -> "setCurrentVideoId failure", ex); + } + } + + /** + * Must be called off main thread + */ + static void executeDownloadSegments(@NonNull String videoId) { + Objects.requireNonNull(videoId); + try { + SponsorSegment[] segments = SBRequester.getSegments(videoId); + + Utils.runOnMainThread(() -> { + if (!videoId.equals(currentVideoId)) { + // user changed videos before get segments network call could complete + Logger.printDebug(() -> "Ignoring segments for prior video: " + videoId); + return; + } + setSegments(segments); + + // check for any skips now, instead of waiting for the next update to setVideoTime() + setVideoTime(VideoInformation.getVideoTime()); + }); + } catch (Exception ex) { + Logger.printException(() -> "executeDownloadSegments failure", ex); + } + } + + /** + * Injection point. + * Updates SponsorBlock every 1000ms. + * When changing videos, this is first called with value 0 and then the video is changed. + */ + public static void setVideoTime(long millis) { + try { + if (!Settings.SB_ENABLED.get() || segments == null || segments.length == 0) { + return; + } + Logger.printDebug(() -> "setVideoTime: " + millis); + + final float playbackSpeed = VideoInformation.getPlaybackSpeed(); + // Amount of time to look ahead for the next segment, + // and the threshold to determine if a scheduled show/hide is at the correct video time when it's run. + // + // This value must be greater than largest time between calls to this method (1000ms), + // and must be adjusted for the video speed. + // + // To debug the stale skip logic, set this to a very large value (5000 or more) + // then try manually seeking just before playback reaches a segment skip. + final long speedAdjustedTimeThreshold = (long) (playbackSpeed * 1200); + final long startTimerLookAheadThreshold = millis + speedAdjustedTimeThreshold; + + SponsorSegment foundSegmentCurrentlyPlaying = null; + SponsorSegment foundUpcomingSegment = null; + + for (final SponsorSegment segment : segments) { + if (segment.category.behaviour == CategoryBehaviour.IGNORE) { + continue; + } + if (segment.end <= millis) { + continue; // past this segment + } + + if (segment.start <= millis) { + // we are in the segment! + if (segment.shouldAutoSkip()) { + skipSegment(segment); + return; // must return, as skipping causes a recursive call back into this method + } + + // first found segment, or it's an embedded segment and fully inside the outer segment + if (foundSegmentCurrentlyPlaying == null || foundSegmentCurrentlyPlaying.containsSegment(segment)) { + // If the found segment is not currently displayed, then do not show if the segment is nearly over. + // This check prevents the skip button text from rapidly changing when multiple segments end at nearly the same time. + // Also prevents showing the skip button if user seeks into the last 800ms of the segment. + final long minMillisOfSegmentRemainingThreshold = 800; + if (segmentCurrentlyPlaying == segment + || !segment.endIsNear(millis, minMillisOfSegmentRemainingThreshold)) { + foundSegmentCurrentlyPlaying = segment; + } else { + Logger.printDebug(() -> "Ignoring segment that ends very soon: " + segment); + } + } + // Keep iterating and looking. There may be an upcoming autoskip, + // or there may be another smaller segment nested inside this segment + continue; + } + + // segment is upcoming + if (startTimerLookAheadThreshold < segment.start) { + break; // segment is not close enough to schedule, and no segments after this are of interest + } + if (segment.shouldAutoSkip()) { // upcoming autoskip + foundUpcomingSegment = segment; + break; // must stop here + } + + // upcoming manual skip + + // do not schedule upcoming segment, if it is not fully contained inside the current segment + if ((foundSegmentCurrentlyPlaying == null || foundSegmentCurrentlyPlaying.containsSegment(segment)) + // use the most inner upcoming segment + && (foundUpcomingSegment == null || foundUpcomingSegment.containsSegment(segment))) { + + // Only schedule, if the segment start time is not near the end time of the current segment. + // This check is needed to prevent scheduled hide and show from clashing with each other. + // Instead the upcoming segment will be handled when the current segment scheduled hide calls back into this method. + final long minTimeBetweenStartEndOfSegments = 1000; + if (foundSegmentCurrentlyPlaying == null + || !foundSegmentCurrentlyPlaying.endIsNear(segment.start, minTimeBetweenStartEndOfSegments)) { + foundUpcomingSegment = segment; + } else { + Logger.printDebug(() -> "Not scheduling segment (start time is near end of current segment): " + segment); + } + } + } + + if (segmentCurrentlyPlaying != foundSegmentCurrentlyPlaying) { + setSegmentCurrentlyPlaying(foundSegmentCurrentlyPlaying); + } else if (foundSegmentCurrentlyPlaying != null + && skipSegmentButtonEndTime != 0 && skipSegmentButtonEndTime <= System.currentTimeMillis()) { + Logger.printDebug(() -> "Auto hiding skip button for segment: " + segmentCurrentlyPlaying); + skipSegmentButtonEndTime = 0; + } + + // schedule a hide, only if the segment end is near + final SponsorSegment segmentToHide = + (foundSegmentCurrentlyPlaying != null && foundSegmentCurrentlyPlaying.endIsNear(millis, speedAdjustedTimeThreshold)) + ? foundSegmentCurrentlyPlaying + : null; + + if (scheduledHideSegment != segmentToHide) { + if (segmentToHide == null) { + Logger.printDebug(() -> "Clearing scheduled hide: " + scheduledHideSegment); + scheduledHideSegment = null; + } else { + scheduledHideSegment = segmentToHide; + Logger.printDebug(() -> "Scheduling hide segment: " + segmentToHide + " playbackSpeed: " + playbackSpeed); + final long delayUntilHide = (long) ((segmentToHide.end - millis) / playbackSpeed); + Utils.runOnMainThreadDelayed(() -> { + if (scheduledHideSegment != segmentToHide) { + Logger.printDebug(() -> "Ignoring old scheduled hide segment: " + segmentToHide); + return; + } + scheduledHideSegment = null; + + final long videoTime = VideoInformation.getVideoTime(); + if (!segmentToHide.endIsNear(videoTime, speedAdjustedTimeThreshold)) { + // current video time is not what's expected. User paused playback + Logger.printDebug(() -> "Ignoring outdated scheduled hide: " + segmentToHide + + " videoInformation time: " + videoTime); + return; + } + Logger.printDebug(() -> "Running scheduled hide segment: " + segmentToHide); + // Need more than just hide the skip button, as this may have been an embedded segment + // Instead call back into setVideoTime to check everything again. + // Should not use VideoInformation time as it is less accurate, + // but this scheduled handler was scheduled precisely so we can just use the segment end time + setSegmentCurrentlyPlaying(null); + setVideoTime(segmentToHide.end); + }, delayUntilHide); + } + } + + if (scheduledUpcomingSegment != foundUpcomingSegment) { + if (foundUpcomingSegment == null) { + Logger.printDebug(() -> "Clearing scheduled segment: " + scheduledUpcomingSegment); + scheduledUpcomingSegment = null; + } else { + scheduledUpcomingSegment = foundUpcomingSegment; + final SponsorSegment segmentToSkip = foundUpcomingSegment; + + Logger.printDebug(() -> "Scheduling segment: " + segmentToSkip + " playbackSpeed: " + playbackSpeed); + final long delayUntilSkip = (long) ((segmentToSkip.start - millis) / playbackSpeed); + Utils.runOnMainThreadDelayed(() -> { + if (scheduledUpcomingSegment != segmentToSkip) { + Logger.printDebug(() -> "Ignoring old scheduled segment: " + segmentToSkip); + return; + } + scheduledUpcomingSegment = null; + + final long videoTime = VideoInformation.getVideoTime(); + if (!segmentToSkip.startIsNear(videoTime, speedAdjustedTimeThreshold)) { + // current video time is not what's expected. User paused playback + Logger.printDebug(() -> "Ignoring outdated scheduled segment: " + segmentToSkip + + " videoInformation time: " + videoTime); + return; + } + if (segmentToSkip.shouldAutoSkip()) { + Logger.printDebug(() -> "Running scheduled skip segment: " + segmentToSkip); + skipSegment(segmentToSkip); + } else { + Logger.printDebug(() -> "Running scheduled show segment: " + segmentToSkip); + setSegmentCurrentlyPlaying(segmentToSkip); + } + }, delayUntilSkip); + } + } + } catch (Exception e) { + Logger.printException(() -> "setVideoTime failure", e); + } + } + + private static void setSegmentCurrentlyPlaying(@Nullable SponsorSegment segment) { + if (segment == null) { + if (segmentCurrentlyPlaying != null) + Logger.printDebug(() -> "Hiding segment: " + segmentCurrentlyPlaying); + segmentCurrentlyPlaying = null; + skipSegmentButtonEndTime = 0; + return; + } + segmentCurrentlyPlaying = segment; + skipSegmentButtonEndTime = 0; + Logger.printDebug(() -> "Showing segment: " + segment); + } + + private static void skipSegment(@NonNull SponsorSegment segmentToSkip) { + try { + // If trying to seek to end of the video, YouTube can seek just before of the actual end. + // (especially if the video does not end on a whole second boundary). + // This causes additional segment skip attempts, even though it cannot seek any closer to the desired time. + // Check for and ignore repeated skip attempts of the same segment over a small time period. + final long now = System.currentTimeMillis(); + final long minimumMillisecondsBetweenSkippingSameSegment = 500; + if ((lastSegmentSkipped == segmentToSkip) && (now - lastSegmentSkippedTime < minimumMillisecondsBetweenSkippingSameSegment)) { + Logger.printDebug(() -> "Ignoring skip segment request (already skipped as close as possible): " + segmentToSkip); + return; + } + + Logger.printDebug(() -> "Skipping segment: " + segmentToSkip); + lastSegmentSkipped = segmentToSkip; + lastSegmentSkippedTime = now; + setSegmentCurrentlyPlaying(null); + scheduledHideSegment = null; + scheduledUpcomingSegment = null; + + // If the seek is successful, then the seek causes a recursive call back into this class. + final boolean seekSuccessful = VideoInformation.seekTo(segmentToSkip.end); + if (!seekSuccessful) { + // can happen when switching videos and is normal + Logger.printDebug(() -> "Could not skip segment (seek unsuccessful): " + segmentToSkip); + return; + } + + // check for any smaller embedded segments, and count those as autoskipped + final boolean showSkipToast = Settings.SB_TOAST_ON_SKIP.get(); + for (final SponsorSegment otherSegment : Objects.requireNonNull(segments)) { + if (segmentToSkip.end < otherSegment.start) { + break; // no other segments can be contained + } + if (otherSegment == segmentToSkip || + segmentToSkip.containsSegment(otherSegment)) { + otherSegment.didAutoSkipped = true; + // Do not show a toast if the user is scrubbing thru a paused video. + // Cannot do this video state check in setTime or earlier in this method, as the video state may not be up to date. + // So instead, only hide toasts because all other skip logic done while paused causes no harm. + if (showSkipToast) { + showSkippedSegmentToast(otherSegment); + } + } + } + } catch (Exception ex) { + Logger.printException(() -> "skipSegment failure", ex); + } + } + + private static void showSkippedSegmentToast(@NonNull SponsorSegment segment) { + Utils.verifyOnMainThread(); + toastNumberOfSegmentsSkipped++; + if (toastNumberOfSegmentsSkipped > 1) { + return; // toast already scheduled + } + toastSegmentSkipped = segment; + + final long delayToToastMilliseconds = 250; // also the maximum time between skips to be considered skipping multiple segments + Utils.runOnMainThreadDelayed(() -> { + try { + if (toastSegmentSkipped == null) { // video was changed just after skipping segment + Logger.printDebug(() -> "Ignoring old scheduled show toast"); + return; + } + Utils.showToastShort(toastNumberOfSegmentsSkipped == 1 + ? toastSegmentSkipped.getSkippedToastText() + : str("revanced_sb_skipped_multiple_segments")); + } catch (Exception ex) { + Logger.printException(() -> "showSkippedSegmentToast failure", ex); + } finally { + toastNumberOfSegmentsSkipped = 0; + toastSegmentSkipped = null; + } + }, delayToToastMilliseconds); + } + + /** + * Injection point + */ + public static void setSponsorBarRect(final Object self, final String fieldName) { + try { + Field field = self.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + Rect rect = (Rect) Objects.requireNonNull(field.get(self)); + setSponsorBarAbsoluteLeft(rect); + setSponsorBarAbsoluteRight(rect); + } catch (Exception ex) { + Logger.printException(() -> "setSponsorBarRect failure", ex); + } + } + + private static void setSponsorBarAbsoluteLeft(Rect rect) { + final int left = rect.left; + if (sponsorBarAbsoluteLeft != left) { + Logger.printDebug(() -> "setSponsorBarAbsoluteLeft: " + left); + sponsorBarAbsoluteLeft = left; + } + } + + private static void setSponsorBarAbsoluteRight(Rect rect) { + final int right = rect.right; + if (sponsorAbsoluteBarRight != right) { + Logger.printDebug(() -> "setSponsorBarAbsoluteRight: " + right); + sponsorAbsoluteBarRight = right; + } + } + + /** + * Injection point + */ + public static void setSponsorBarThickness(int thickness) { + if (sponsorBarThickness != thickness) { + sponsorBarThickness = (int) Math.round(thickness * 1.2); + Logger.printDebug(() -> "setSponsorBarThickness: " + sponsorBarThickness); + } + } + + /** + * Injection point. + */ + public static void drawSponsorTimeBars(final Canvas canvas, final float posY) { + try { + if (segments == null) return; + final long videoLength = VideoInformation.getVideoLength(); + if (videoLength <= 0) return; + + final int thicknessDiv2 = sponsorBarThickness / 2; // rounds down + final float top = posY - (sponsorBarThickness - thicknessDiv2); + final float bottom = posY + thicknessDiv2; + final float videoMillisecondsToPixels = (1f / videoLength) * (sponsorAbsoluteBarRight - sponsorBarAbsoluteLeft); + final float leftPadding = sponsorBarAbsoluteLeft; + + for (SponsorSegment segment : segments) { + final float left = leftPadding + segment.start * videoMillisecondsToPixels; + final float right = leftPadding + segment.end * videoMillisecondsToPixels; + canvas.drawRect(left, top, right, bottom, segment.category.paint); + } + } catch (Exception ex) { + Logger.printException(() -> "drawSponsorTimeBars failure", ex); + } + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/SponsorBlockSettings.java b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/SponsorBlockSettings.java new file mode 100644 index 0000000000..813e0d0f9d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/SponsorBlockSettings.java @@ -0,0 +1,52 @@ +package app.revanced.extension.music.sponsorblock; + +import androidx.annotation.NonNull; + +import java.util.UUID; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.music.sponsorblock.objects.SegmentCategory; +import app.revanced.extension.shared.settings.Setting; + +public class SponsorBlockSettings { + private static boolean initialized; + + /** + * @return if the user has ever voted, created a segment, or imported existing SB settings. + */ + public static boolean userHasSBPrivateId() { + return !Settings.SB_PRIVATE_USER_ID.get().isEmpty(); + } + + /** + * Use this only if a user id is required (creating segments, voting). + */ + @NonNull + public static String getSBPrivateUserID() { + String uuid = Settings.SB_PRIVATE_USER_ID.get(); + if (uuid.isEmpty()) { + uuid = (UUID.randomUUID().toString() + + UUID.randomUUID().toString() + + UUID.randomUUID().toString()) + .replace("-", ""); + Settings.SB_PRIVATE_USER_ID.save(uuid); + } + return uuid; + } + + public static void initialize() { + if (initialized) { + return; + } + initialized = true; + + SegmentCategory.updateEnabledCategories(); + } + + /** + * Updates internal data based on {@link Setting} values. + */ + public static void updateFromImportedSettings() { + SegmentCategory.loadAllCategoriesFromSettings(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/CategoryBehaviour.java b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/CategoryBehaviour.java new file mode 100644 index 0000000000..bba2334dcc --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/CategoryBehaviour.java @@ -0,0 +1,49 @@ +package app.revanced.extension.music.sponsorblock.objects; + +import static app.revanced.extension.shared.utils.StringRef.sf; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Objects; + +import app.revanced.extension.shared.utils.StringRef; + +public enum CategoryBehaviour { + SKIP_AUTOMATICALLY("skip", 2, true, sf("revanced_sb_skip_automatically")), + // ignored categories are not exported to json, and ignore is the default behavior when importing + IGNORE("ignore", -1, false, sf("revanced_sb_skip_ignore")); + + /** + * ReVanced specific value. + */ + @NonNull + public final String reVancedKeyValue; + /** + * Desktop specific value. + */ + public final int desktopKeyValue; + /** + * If the segment should skip automatically + */ + public final boolean skipAutomatically; + @NonNull + public final StringRef description; + + CategoryBehaviour(String reVancedKeyValue, int desktopKeyValue, boolean skipAutomatically, StringRef description) { + this.reVancedKeyValue = Objects.requireNonNull(reVancedKeyValue); + this.desktopKeyValue = desktopKeyValue; + this.skipAutomatically = skipAutomatically; + this.description = Objects.requireNonNull(description); + } + + @Nullable + public static CategoryBehaviour byReVancedKeyValue(@NonNull String keyValue) { + for (CategoryBehaviour behaviour : values()) { + if (behaviour.reVancedKeyValue.equals(keyValue)) { + return behaviour; + } + } + return null; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/SegmentCategory.java b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/SegmentCategory.java new file mode 100644 index 0000000000..d20827e6f3 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/SegmentCategory.java @@ -0,0 +1,293 @@ +package app.revanced.extension.music.sponsorblock.objects; + +import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_FILLER; +import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_FILLER_COLOR; +import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_INTERACTION; +import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_INTERACTION_COLOR; +import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_INTRO; +import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_INTRO_COLOR; +import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_MUSIC_OFFTOPIC; +import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_MUSIC_OFFTOPIC_COLOR; +import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_OUTRO; +import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_OUTRO_COLOR; +import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_PREVIEW; +import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_PREVIEW_COLOR; +import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_SELF_PROMO; +import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_SELF_PROMO_COLOR; +import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_SPONSOR; +import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_SPONSOR_COLOR; +import static app.revanced.extension.shared.utils.StringRef.sf; + +import android.graphics.Color; +import android.graphics.Paint; +import android.text.Html; +import android.text.Spanned; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import app.revanced.extension.shared.settings.StringSetting; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.StringRef; +import app.revanced.extension.shared.utils.Utils; + +public enum SegmentCategory { + SPONSOR("sponsor", sf("revanced_sb_segments_sponsor"), sf("revanced_sb_segments_sponsor_sum"), sf("revanced_sb_skip_button_sponsor"), sf("revanced_sb_skipped_sponsor"), + SB_CATEGORY_SPONSOR, SB_CATEGORY_SPONSOR_COLOR), + SELF_PROMO("selfpromo", sf("revanced_sb_segments_selfpromo"), sf("revanced_sb_segments_selfpromo_sum"), sf("revanced_sb_skip_button_selfpromo"), sf("revanced_sb_skipped_selfpromo"), + SB_CATEGORY_SELF_PROMO, SB_CATEGORY_SELF_PROMO_COLOR), + INTERACTION("interaction", sf("revanced_sb_segments_interaction"), sf("revanced_sb_segments_interaction_sum"), sf("revanced_sb_skip_button_interaction"), sf("revanced_sb_skipped_interaction"), + SB_CATEGORY_INTERACTION, SB_CATEGORY_INTERACTION_COLOR), + INTRO("intro", sf("revanced_sb_segments_intro"), sf("revanced_sb_segments_intro_sum"), + sf("revanced_sb_skip_button_intro_beginning"), sf("revanced_sb_skip_button_intro_middle"), sf("revanced_sb_skip_button_intro_end"), + sf("revanced_sb_skipped_intro_beginning"), sf("revanced_sb_skipped_intro_middle"), sf("revanced_sb_skipped_intro_end"), + SB_CATEGORY_INTRO, SB_CATEGORY_INTRO_COLOR), + OUTRO("outro", sf("revanced_sb_segments_outro"), sf("revanced_sb_segments_outro_sum"), sf("revanced_sb_skip_button_outro"), sf("revanced_sb_skipped_outro"), + SB_CATEGORY_OUTRO, SB_CATEGORY_OUTRO_COLOR), + PREVIEW("preview", sf("revanced_sb_segments_preview"), sf("revanced_sb_segments_preview_sum"), + sf("revanced_sb_skip_button_preview_beginning"), sf("revanced_sb_skip_button_preview_middle"), sf("revanced_sb_skip_button_preview_end"), + sf("revanced_sb_skipped_preview_beginning"), sf("revanced_sb_skipped_preview_middle"), sf("revanced_sb_skipped_preview_end"), + SB_CATEGORY_PREVIEW, SB_CATEGORY_PREVIEW_COLOR), + FILLER("filler", sf("revanced_sb_segments_filler"), sf("revanced_sb_segments_filler_sum"), sf("revanced_sb_skip_button_filler"), sf("revanced_sb_skipped_filler"), + SB_CATEGORY_FILLER, SB_CATEGORY_FILLER_COLOR), + MUSIC_OFFTOPIC("music_offtopic", sf("revanced_sb_segments_nomusic"), sf("revanced_sb_segments_nomusic_sum"), sf("revanced_sb_skip_button_nomusic"), sf("revanced_sb_skipped_nomusic"), + SB_CATEGORY_MUSIC_OFFTOPIC, SB_CATEGORY_MUSIC_OFFTOPIC_COLOR); + + private static final SegmentCategory[] categoriesWithoutUnsubmitted = new SegmentCategory[]{ + SPONSOR, + SELF_PROMO, + INTERACTION, + INTRO, + OUTRO, + PREVIEW, + FILLER, + MUSIC_OFFTOPIC, + }; + private static final Map mValuesMap = new HashMap<>(2 * categoriesWithoutUnsubmitted.length); + + /** + * Categories currently enabled, formatted for an API call + */ + public static String sponsorBlockAPIFetchCategories = "[]"; + + static { + for (SegmentCategory value : categoriesWithoutUnsubmitted) + mValuesMap.put(value.keyValue, value); + } + + @NonNull + public static SegmentCategory[] categoriesWithoutUnsubmitted() { + return categoriesWithoutUnsubmitted; + } + + @Nullable + public static SegmentCategory byCategoryKey(@NonNull String key) { + return mValuesMap.get(key); + } + + /** + * Must be called if behavior of any category is changed + */ + public static void updateEnabledCategories() { + Utils.verifyOnMainThread(); + Logger.printDebug(() -> "updateEnabledCategories"); + SegmentCategory[] categories = categoriesWithoutUnsubmitted(); + List enabledCategories = new ArrayList<>(categories.length); + for (SegmentCategory category : categories) { + if (category.behaviour != CategoryBehaviour.IGNORE) { + enabledCategories.add(category.keyValue); + } + } + + //"[%22sponsor%22,%22outro%22,%22music_offtopic%22,%22intro%22,%22selfpromo%22,%22interaction%22,%22preview%22]"; + if (enabledCategories.isEmpty()) + sponsorBlockAPIFetchCategories = "[]"; + else + sponsorBlockAPIFetchCategories = "[%22" + TextUtils.join("%22,%22", enabledCategories) + "%22]"; + } + + public static void loadAllCategoriesFromSettings() { + for (SegmentCategory category : values()) { + category.loadFromSettings(); + } + updateEnabledCategories(); + } + + @NonNull + public final String keyValue; + @NonNull + private final StringSetting behaviorSetting; + @NonNull + private final StringSetting colorSetting; + + @NonNull + public final StringRef title; + @NonNull + public final StringRef description; + + /** + * Skip button text, if the skip occurs in the first quarter of the video + */ + @NonNull + public final StringRef skipButtonTextBeginning; + /** + * Skip button text, if the skip occurs in the middle half of the video + */ + @NonNull + public final StringRef skipButtonTextMiddle; + /** + * Skip button text, if the skip occurs in the last quarter of the video + */ + @NonNull + public final StringRef skipButtonTextEnd; + /** + * Skipped segment toast, if the skip occurred in the first quarter of the video + */ + @NonNull + public final StringRef skippedToastBeginning; + /** + * Skipped segment toast, if the skip occurred in the middle half of the video + */ + @NonNull + public final StringRef skippedToastMiddle; + /** + * Skipped segment toast, if the skip occurred in the last quarter of the video + */ + @NonNull + public final StringRef skippedToastEnd; + + @NonNull + public final Paint paint; + + /** + * Value must be changed using {@link #setColor(String)}. + */ + public int color; + + /** + * Value must be changed using {@link #setBehaviour(CategoryBehaviour)}. + * Caller must also {@link #updateEnabledCategories()}. + */ + @NonNull + public CategoryBehaviour behaviour = CategoryBehaviour.SKIP_AUTOMATICALLY; + + SegmentCategory(String keyValue, StringRef title, StringRef description, + StringRef skipButtonText, + StringRef skippedToastText, + StringSetting behavior, StringSetting color) { + this(keyValue, title, description, + skipButtonText, skipButtonText, skipButtonText, + skippedToastText, skippedToastText, skippedToastText, + behavior, color); + } + + SegmentCategory(String keyValue, StringRef title, StringRef description, + StringRef skipButtonTextBeginning, StringRef skipButtonTextMiddle, StringRef skipButtonTextEnd, + StringRef skippedToastBeginning, StringRef skippedToastMiddle, StringRef skippedToastEnd, + StringSetting behavior, StringSetting color) { + this.keyValue = Objects.requireNonNull(keyValue); + this.title = Objects.requireNonNull(title); + this.description = Objects.requireNonNull(description); + this.skipButtonTextBeginning = Objects.requireNonNull(skipButtonTextBeginning); + this.skipButtonTextMiddle = Objects.requireNonNull(skipButtonTextMiddle); + this.skipButtonTextEnd = Objects.requireNonNull(skipButtonTextEnd); + this.skippedToastBeginning = Objects.requireNonNull(skippedToastBeginning); + this.skippedToastMiddle = Objects.requireNonNull(skippedToastMiddle); + this.skippedToastEnd = Objects.requireNonNull(skippedToastEnd); + this.behaviorSetting = Objects.requireNonNull(behavior); + this.colorSetting = Objects.requireNonNull(color); + this.paint = new Paint(); + loadFromSettings(); + } + + private void loadFromSettings() { + String behaviorString = behaviorSetting.get(); + CategoryBehaviour savedBehavior = CategoryBehaviour.byReVancedKeyValue(behaviorString); + if (savedBehavior == null) { + Logger.printException(() -> "Invalid behavior: " + behaviorString); + behaviorSetting.resetToDefault(); + loadFromSettings(); + return; + } + this.behaviour = savedBehavior; + + String colorString = colorSetting.get(); + try { + setColor(colorString); + } catch (Exception ex) { + Logger.printException(() -> "Invalid color: " + colorString, ex); + colorSetting.resetToDefault(); + loadFromSettings(); + } + } + + public void setBehaviour(@NonNull CategoryBehaviour behaviour) { + this.behaviour = Objects.requireNonNull(behaviour); + this.behaviorSetting.save(behaviour.reVancedKeyValue); + } + + /** + * @return HTML color format string + */ + @NonNull + public String colorString() { + return String.format("#%06X", color); + } + + public void setColor(@NonNull String colorString) throws IllegalArgumentException { + final int color = Color.parseColor(colorString) & 0xFFFFFF; + this.color = color; + paint.setColor(color); + paint.setAlpha(255); + colorSetting.save(colorString); // Save after parsing. + } + + public void resetColor() { + setColor(colorSetting.defaultValue); + } + + @NonNull + private static String getCategoryColorDotHTML(int color) { + color &= 0xFFFFFF; + return String.format("", color); + } + + /** + * @noinspection deprecation + */ + @NonNull + public static Spanned getCategoryColorDot(int color) { + return Html.fromHtml(getCategoryColorDotHTML(color)); + } + + @NonNull + public Spanned getCategoryColorDot() { + return getCategoryColorDot(color); + } + + /** + * @param segmentStartTime video time the segment category started + * @param videoLength length of the video + * @return 'skipped segment' toast message + */ + @NonNull + StringRef getSkippedToastText(long segmentStartTime, long videoLength) { + if (videoLength == 0) { + return skippedToastBeginning; // video is still loading. Assume it's the beginning + } + final float position = segmentStartTime / (float) videoLength; + if (position < 0.25f) { + return skippedToastBeginning; + } else if (position < 0.75f) { + return skippedToastMiddle; + } + return skippedToastEnd; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/SponsorSegment.java b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/SponsorSegment.java new file mode 100644 index 0000000000..85c2e0c267 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/SponsorSegment.java @@ -0,0 +1,102 @@ +package app.revanced.extension.music.sponsorblock.objects; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Objects; + +import app.revanced.extension.music.shared.VideoInformation; + +public class SponsorSegment implements Comparable { + @NonNull + public final SegmentCategory category; + /** + * NULL if segment is unsubmitted + */ + @Nullable + public final String UUID; + public final long start; + public final long end; + public final boolean isLocked; + public boolean didAutoSkipped = false; + + public SponsorSegment(@NonNull SegmentCategory category, @Nullable String UUID, long start, long end, boolean isLocked) { + this.category = category; + this.UUID = UUID; + this.start = start; + this.end = end; + this.isLocked = isLocked; + } + + public boolean shouldAutoSkip() { + return category.behaviour.skipAutomatically; + } + + /** + * @param nearThreshold threshold to declare the time parameter is near this segment. Must be a positive number + */ + public boolean startIsNear(long videoTime, long nearThreshold) { + return Math.abs(start - videoTime) <= nearThreshold; + } + + /** + * @param nearThreshold threshold to declare the time parameter is near this segment. Must be a positive number + */ + public boolean endIsNear(long videoTime, long nearThreshold) { + return Math.abs(end - videoTime) <= nearThreshold; + } + + /** + * @return if the segment is completely contained inside this segment + */ + public boolean containsSegment(SponsorSegment other) { + return start <= other.start && other.end <= end; + } + + /** + * @return the length of this segment, in milliseconds. Always a positive number. + */ + public long length() { + return end - start; + } + + /** + * @return 'skipped segment' toast message + */ + @NonNull + public String getSkippedToastText() { + return category.getSkippedToastText(start, VideoInformation.getVideoLength()).toString(); + } + + @Override + public int compareTo(SponsorSegment o) { + // If both segments start at the same time, then sort with the longer segment first. + // This keeps the seekbar drawing correct since it draws the segments using the sorted order. + return start == o.start ? Long.compare(o.length(), length()) : Long.compare(start, o.start); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SponsorSegment other)) return false; + return Objects.equals(UUID, other.UUID) + && category == other.category + && start == other.start + && end == other.end; + } + + @Override + public int hashCode() { + return Objects.hashCode(UUID); + } + + @NonNull + @Override + public String toString() { + return "SponsorSegment{" + + "category=" + category + + ", start=" + start + + ", end=" + end + + '}'; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/requests/SBRequester.java b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/requests/SBRequester.java new file mode 100644 index 0000000000..0b520fbfc7 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/requests/SBRequester.java @@ -0,0 +1,145 @@ +package app.revanced.extension.music.sponsorblock.requests; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.music.sponsorblock.SponsorBlockSettings; +import app.revanced.extension.music.sponsorblock.objects.SegmentCategory; +import app.revanced.extension.music.sponsorblock.objects.SponsorSegment; +import app.revanced.extension.shared.requests.Requester; +import app.revanced.extension.shared.requests.Route; +import app.revanced.extension.shared.sponsorblock.requests.SBRoutes; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +public class SBRequester { + /** + * TCP timeout + */ + private static final int TIMEOUT_TCP_DEFAULT_MILLISECONDS = 7000; + + /** + * HTTP response timeout + */ + private static final int TIMEOUT_HTTP_DEFAULT_MILLISECONDS = 10000; + + /** + * Response code of a successful API call + */ + private static final int HTTP_STATUS_CODE_SUCCESS = 200; + + private SBRequester() { + } + + private static void handleConnectionError(@NonNull String toastMessage, @Nullable Exception ex) { + if (Settings.SB_TOAST_ON_CONNECTION_ERROR.get()) { + Utils.showToastShort(toastMessage); + } + if (ex != null) { + Logger.printInfo(() -> toastMessage, ex); + } + } + + @NonNull + public static SponsorSegment[] getSegments(@NonNull String videoId) { + Utils.verifyOffMainThread(); + List segments = new ArrayList<>(); + try { + HttpURLConnection connection = getConnectionFromRoute(SBRoutes.GET_SEGMENTS, videoId, SegmentCategory.sponsorBlockAPIFetchCategories); + final int responseCode = connection.getResponseCode(); + + if (responseCode == HTTP_STATUS_CODE_SUCCESS) { + JSONArray responseArray = Requester.parseJSONArray(connection); + final long minSegmentDuration = 0; + for (int i = 0, length = responseArray.length(); i < length; i++) { + JSONObject obj = (JSONObject) responseArray.get(i); + JSONArray segment = obj.getJSONArray("segment"); + final long start = (long) (segment.getDouble(0) * 1000); + final long end = (long) (segment.getDouble(1) * 1000); + + String uuid = obj.getString("UUID"); + final boolean locked = obj.getInt("locked") == 1; + String categoryKey = obj.getString("category"); + SegmentCategory category = SegmentCategory.byCategoryKey(categoryKey); + if (category == null) { + Logger.printException(() -> "Received unknown category: " + categoryKey); // should never happen + } else if ((end - start) >= minSegmentDuration) { + segments.add(new SponsorSegment(category, uuid, start, end, locked)); + } + } + Logger.printDebug(() -> { + StringBuilder builder = new StringBuilder("Downloaded segments:"); + for (SponsorSegment segment : segments) { + builder.append('\n').append(segment); + } + return builder.toString(); + }); + runVipCheckInBackgroundIfNeeded(); + } else if (responseCode == 404) { + // no segments are found. a normal response + Logger.printDebug(() -> "No segments found for video: " + videoId); + } else { + handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_status", responseCode), null); + connection.disconnect(); // something went wrong, might as well disconnect + } + } catch (SocketTimeoutException ex) { + handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_timeout"), ex); + } catch (IOException ex) { + handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_generic"), ex); + } catch (Exception ex) { + // Should never happen + Logger.printException(() -> "getSegments failure", ex); + } + + return segments.toArray(new SponsorSegment[0]); + } + + public static void runVipCheckInBackgroundIfNeeded() { + if (!SponsorBlockSettings.userHasSBPrivateId()) { + return; // User cannot be a VIP. User has never voted, created any segments, or has imported a SB user id. + } + long now = System.currentTimeMillis(); + if (now < (Settings.SB_LAST_VIP_CHECK.get() + TimeUnit.DAYS.toMillis(3))) { + return; + } + Utils.runOnBackgroundThread(() -> { + try { + JSONObject json = getJSONObject(SponsorBlockSettings.getSBPrivateUserID()); + boolean vip = json.getBoolean("vip"); + Settings.SB_USER_IS_VIP.save(vip); + Settings.SB_LAST_VIP_CHECK.save(now); + } catch (IOException ex) { + Logger.printInfo(() -> "Failed to check VIP (network error)", ex); // info, so no error toast is shown + } catch (Exception ex) { + Logger.printException(() -> "Failed to check VIP", ex); // should never happen + } + }); + } + + // helpers + + private static HttpURLConnection getConnectionFromRoute(@NonNull Route route, String... params) throws IOException { + HttpURLConnection connection = Requester.getConnectionFromRoute(Settings.SB_API_URL.get(), route, params); + connection.setConnectTimeout(TIMEOUT_TCP_DEFAULT_MILLISECONDS); + connection.setReadTimeout(TIMEOUT_HTTP_DEFAULT_MILLISECONDS); + return connection; + } + + private static JSONObject getJSONObject(String... params) throws IOException, JSONException { + return Requester.parseJSONObject(getConnectionFromRoute(SBRoutes.IS_USER_VIP, params)); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/utils/ExtendedUtils.java b/extensions/shared/src/main/java/app/revanced/extension/music/utils/ExtendedUtils.java new file mode 100644 index 0000000000..3a7243544f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/utils/ExtendedUtils.java @@ -0,0 +1,46 @@ +package app.revanced.extension.music.utils; + +import android.app.AlertDialog; +import android.content.Context; +import android.util.TypedValue; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.shared.utils.PackageUtils; + +public class ExtendedUtils extends PackageUtils { + + public static boolean isSpoofingToLessThan(@NonNull String versionName) { + if (!Settings.SPOOF_APP_VERSION.get()) + return false; + + return isVersionToLessThan(Settings.SPOOF_APP_VERSION_TARGET.get(), versionName); + } + + private static int dpToPx(float dp) { + return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics()); + } + + @SuppressWarnings("deprecation") + public static AlertDialog.Builder getDialogBuilder(@NonNull Context context) { + return new AlertDialog.Builder(context, isSDKAbove(22) + ? android.R.style.Theme_DeviceDefault_Dialog_Alert + : AlertDialog.THEME_DEVICE_DEFAULT_DARK + ); + } + + public static FrameLayout.LayoutParams getLayoutParams() { + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + + int left_margin = dpToPx(20); + int top_margin = dpToPx(10); + int right_margin = dpToPx(20); + int bottom_margin = dpToPx(4); + params.setMargins(left_margin, top_margin, right_margin, bottom_margin); + + return params; + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/utils/RestartUtils.java b/extensions/shared/src/main/java/app/revanced/extension/music/utils/RestartUtils.java new file mode 100644 index 0000000000..a4ca376418 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/utils/RestartUtils.java @@ -0,0 +1,36 @@ +package app.revanced.extension.music.utils; + +import static app.revanced.extension.music.utils.ExtendedUtils.getDialogBuilder; +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.shared.utils.Utils.runOnMainThreadDelayed; + +import android.app.Activity; +import android.content.Intent; + +import androidx.annotation.NonNull; + +import java.util.Objects; + +public class RestartUtils { + + public static void restartApp(@NonNull Activity activity) { + final Intent intent = Objects.requireNonNull(activity.getPackageManager().getLaunchIntentForPackage(activity.getPackageName())); + final Intent mainIntent = Intent.makeRestartActivityTask(intent.getComponent()); + + activity.finishAffinity(); + activity.startActivity(mainIntent); + Runtime.getRuntime().exit(0); + } + + public static void showRestartDialog(@NonNull Activity activity) { + showRestartDialog(activity, "revanced_extended_restart_message", 0); + } + + public static void showRestartDialog(@NonNull Activity activity, @NonNull String message, long delay) { + getDialogBuilder(activity) + .setMessage(str(message)) + .setPositiveButton(android.R.string.ok, (dialog, id) -> runOnMainThreadDelayed(() -> restartApp(activity), delay)) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/utils/VideoUtils.java b/extensions/shared/src/main/java/app/revanced/extension/music/utils/VideoUtils.java new file mode 100644 index 0000000000..059c311bd9 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/utils/VideoUtils.java @@ -0,0 +1,87 @@ +package app.revanced.extension.music.utils; + +import static app.revanced.extension.music.settings.preference.ExternalDownloaderPreference.checkPackageIsEnabled; +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.media.AudioManager; +import android.util.Log; + +import androidx.annotation.NonNull; + +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.music.shared.VideoInformation; +import app.revanced.extension.shared.settings.StringSetting; +import app.revanced.extension.shared.utils.IntentUtils; +import app.revanced.extension.shared.utils.Logger; + +@SuppressWarnings("unused") +public class VideoUtils extends IntentUtils { + private static final StringSetting externalDownloaderPackageName = + Settings.EXTERNAL_DOWNLOADER_PACKAGE_NAME; + + public static void launchExternalDownloader() { + launchExternalDownloader(VideoInformation.getVideoId()); + } + + public static void launchExternalDownloader(@NonNull String videoId) { + try { + String downloaderPackageName = externalDownloaderPackageName.get().trim(); + + if (downloaderPackageName.isEmpty()) { + externalDownloaderPackageName.resetToDefault(); + downloaderPackageName = externalDownloaderPackageName.defaultValue; + } + + if (!checkPackageIsEnabled()) { + return; + } + + final String content = String.format("https://music.youtube.com/watch?v=%s", videoId); + launchExternalDownloader(content, downloaderPackageName); + } catch (Exception ex) { + Logger.printException(() -> "launchExternalDownloader failure", ex); + } + } + + @SuppressLint("IntentReset") + public static void openInYouTube() { + final String videoId = VideoInformation.getVideoId(); + if (videoId.isEmpty()) { + showToastShort(str("revanced_replace_flyout_menu_dismiss_queue_watch_on_youtube_warning")); + return; + } + + if (context.getApplicationContext().getSystemService(Context.AUDIO_SERVICE) instanceof AudioManager audioManager) { + audioManager.requestAudioFocus(null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); + } + + String url = String.format("vnd.youtube://%s", videoId); + if (Settings.REPLACE_FLYOUT_MENU_DISMISS_QUEUE_CONTINUE_WATCH.get()) { + long seconds = VideoInformation.getVideoTime() / 1000; + url += String.format("?t=%s", seconds); + } + + launchView(url); + } + + public static void openInYouTubeMusic(@NonNull String songId) { + final String url = String.format("vnd.youtube.music://%s", songId); + launchView(url, context.getPackageName()); + } + + /** + * Rest of the implementation added by patch. + */ + public static void shuffleTracks() { + Log.d("Extended: VideoUtils", "Tracks are shuffled"); + } + + /** + * Rest of the implementation added by patch. + */ + public static void showPlaybackSpeedFlyoutMenu() { + Logger.printDebug(() -> "Playback speed flyout menu opened"); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/GeneralAdsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/GeneralAdsPatch.java new file mode 100644 index 0000000000..f108a49d74 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/GeneralAdsPatch.java @@ -0,0 +1,42 @@ +package app.revanced.extension.reddit.patches; + +import com.reddit.domain.model.ILink; + +import java.util.ArrayList; +import java.util.List; + +import app.revanced.extension.reddit.settings.Settings; + +@SuppressWarnings("unused") +public final class GeneralAdsPatch { + + private static List filterChildren(final Iterable links) { + final List filteredList = new ArrayList<>(); + + for (Object item : links) { + if (item instanceof ILink iLink && iLink.getPromoted()) continue; + + filteredList.add(item); + } + + return filteredList; + } + + public static boolean hideCommentAds() { + return Settings.HIDE_COMMENT_ADS.get(); + } + + public static List hideOldPostAds(List list) { + if (!Settings.HIDE_OLD_POST_ADS.get()) + return list; + + return filterChildren(list); + } + + public static void hideNewPostAds(ArrayList arrayList, Object object) { + if (Settings.HIDE_NEW_POST_ADS.get()) + return; + + arrayList.add(object); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/NavigationButtonsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/NavigationButtonsPatch.java new file mode 100644 index 0000000000..301616c3e7 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/NavigationButtonsPatch.java @@ -0,0 +1,53 @@ +package app.revanced.extension.reddit.patches; + +import android.view.View; +import android.view.ViewGroup; + +import java.util.List; + +import app.revanced.extension.reddit.settings.Settings; +import app.revanced.extension.shared.utils.Logger; + +@SuppressWarnings("unused") +public final class NavigationButtonsPatch { + + public static List hideNavigationButtons(List list) { + try { + for (NavigationButton button : NavigationButton.values()) { + if (button.enabled && list.size() > button.index) { + list.remove(button.index); + } + } + } catch (Exception exception) { + Logger.printException(() -> "Failed to remove button list", exception); + } + return list; + } + + public static void hideNavigationButtons(ViewGroup viewGroup) { + try { + if (viewGroup == null) return; + for (NavigationButton button : NavigationButton.values()) { + if (button.enabled && viewGroup.getChildCount() > button.index) { + View view = viewGroup.getChildAt(button.index); + if (view != null) view.setVisibility(View.GONE); + } + } + } catch (Exception exception) { + Logger.printException(() -> "Failed to remove button view", exception); + } + } + + private enum NavigationButton { + CHAT(Settings.HIDE_CHAT_BUTTON.get(), 3), + CREATE(Settings.HIDE_CREATE_BUTTON.get(), 2), + DISCOVER(Settings.HIDE_DISCOVER_BUTTON.get(), 1); + private final boolean enabled; + private final int index; + + NavigationButton(final boolean enabled, final int index) { + this.enabled = enabled; + this.index = index; + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/OpenLinksDirectlyPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/OpenLinksDirectlyPatch.java new file mode 100644 index 0000000000..caab44f0e8 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/OpenLinksDirectlyPatch.java @@ -0,0 +1,30 @@ +package app.revanced.extension.reddit.patches; + +import android.net.Uri; + +import app.revanced.extension.reddit.settings.Settings; +import app.revanced.extension.shared.utils.Logger; + +@SuppressWarnings("unused") +public final class OpenLinksDirectlyPatch { + + /** + * Parses the given Reddit redirect uri by extracting the redirect query. + * + * @param uri The Reddit redirect uri. + * @return The redirect query. + */ + public static Uri parseRedirectUri(Uri uri) { + try { + if (Settings.OPEN_LINKS_DIRECTLY.get()) { + final String parsedUri = uri.getQueryParameter("url"); + if (parsedUri != null && !parsedUri.isEmpty()) + return Uri.parse(parsedUri); + } + } catch (Exception e) { + Logger.printException(() -> "Can not parse URL: " + uri, e); + } + return uri; + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/OpenLinksExternallyPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/OpenLinksExternallyPatch.java new file mode 100644 index 0000000000..387f120ad7 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/OpenLinksExternallyPatch.java @@ -0,0 +1,33 @@ +package app.revanced.extension.reddit.patches; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; + +import app.revanced.extension.reddit.settings.Settings; +import app.revanced.extension.shared.utils.Logger; + +@SuppressWarnings("unused") +public class OpenLinksExternallyPatch { + + /** + * Override 'CustomTabsIntent', in order to open links in the default browser. + * Instead of doing CustomTabsActivity, + * + * @param activity The activity, to start an Intent. + * @param uri The URL to be opened in the default browser. + */ + public static boolean openLinksExternally(Activity activity, Uri uri) { + try { + if (activity != null && uri != null && Settings.OPEN_LINKS_EXTERNALLY.get()) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(uri); + activity.startActivity(intent); + return true; + } + } catch (Exception e) { + Logger.printException(() -> "Can not open URL: " + uri, e); + } + return false; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RecentlyVisitedShelfPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RecentlyVisitedShelfPatch.java new file mode 100644 index 0000000000..5363688dfb --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RecentlyVisitedShelfPatch.java @@ -0,0 +1,14 @@ +package app.revanced.extension.reddit.patches; + +import java.util.Collections; +import java.util.List; + +import app.revanced.extension.reddit.settings.Settings; + +@SuppressWarnings("unused") +public final class RecentlyVisitedShelfPatch { + + public static List hideRecentlyVisitedShelf(List list) { + return Settings.HIDE_RECENTLY_VISITED_SHELF.get() ? Collections.emptyList() : list; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RecommendedCommunitiesPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RecommendedCommunitiesPatch.java new file mode 100644 index 0000000000..126d79761e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RecommendedCommunitiesPatch.java @@ -0,0 +1,12 @@ +package app.revanced.extension.reddit.patches; + +import app.revanced.extension.reddit.settings.Settings; + +@SuppressWarnings("unused") +public final class RecommendedCommunitiesPatch { + + public static boolean hideRecommendedCommunitiesShelf() { + return Settings.HIDE_RECOMMENDED_COMMUNITIES_SHELF.get(); + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RemoveSubRedditDialogPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RemoveSubRedditDialogPatch.java new file mode 100644 index 0000000000..98dd6c53b5 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RemoveSubRedditDialogPatch.java @@ -0,0 +1,41 @@ +package app.revanced.extension.reddit.patches; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import app.revanced.extension.reddit.settings.Settings; +import app.revanced.extension.shared.utils.Utils; + +@SuppressWarnings("unused") +public class RemoveSubRedditDialogPatch { + + public static void confirmDialog(@NonNull TextView textView) { + if (!Settings.REMOVE_NSFW_DIALOG.get()) + return; + + if (!textView.getText().toString().equals(str("nsfw_continue_non_anonymously"))) + return; + + clickViewDelayed(textView); + } + + public static void dismissDialog(View cancelButtonView) { + if (!Settings.REMOVE_NOTIFICATION_DIALOG.get()) + return; + + clickViewDelayed(cancelButtonView); + } + + private static void clickViewDelayed(View view) { + Utils.runOnMainThreadDelayed(() -> { + if (view != null) { + view.setSoundEffectsEnabled(false); + view.performClick(); + } + }, 0); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/SanitizeUrlQueryPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/SanitizeUrlQueryPatch.java new file mode 100644 index 0000000000..f19398376a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/SanitizeUrlQueryPatch.java @@ -0,0 +1,12 @@ +package app.revanced.extension.reddit.patches; + +import app.revanced.extension.reddit.settings.Settings; + +@SuppressWarnings("unused") +public final class SanitizeUrlQueryPatch { + + public static boolean stripQueryParameters() { + return Settings.SANITIZE_URL_QUERY.get(); + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/ScreenshotPopupPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/ScreenshotPopupPatch.java new file mode 100644 index 0000000000..7216ea55c4 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/ScreenshotPopupPatch.java @@ -0,0 +1,11 @@ +package app.revanced.extension.reddit.patches; + +import app.revanced.extension.reddit.settings.Settings; + +@SuppressWarnings("unused") +public class ScreenshotPopupPatch { + + public static boolean disableScreenshotPopup() { + return Settings.DISABLE_SCREENSHOT_POPUP.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/ToolBarButtonPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/ToolBarButtonPatch.java new file mode 100644 index 0000000000..46a82cd0df --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/ToolBarButtonPatch.java @@ -0,0 +1,16 @@ +package app.revanced.extension.reddit.patches; + +import android.view.View; + +import app.revanced.extension.reddit.settings.Settings; + +@SuppressWarnings("unused") +public class ToolBarButtonPatch { + + public static void hideToolBarButton(View view) { + if (!Settings.HIDE_TOOLBAR_BUTTON.get()) + return; + + view.setVisibility(View.GONE); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/ActivityHook.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/ActivityHook.java new file mode 100644 index 0000000000..ffe8bfd7d4 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/ActivityHook.java @@ -0,0 +1,35 @@ +package app.revanced.extension.reddit.settings; + +import android.app.Activity; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.LinearLayout; + +import app.revanced.extension.reddit.settings.preference.ReVancedPreferenceFragment; + +/** + * @noinspection ALL + */ +public class ActivityHook { + public static void initialize(Activity activity) { + SettingsStatus.load(); + + final int fragmentId = View.generateViewId(); + final FrameLayout fragment = new FrameLayout(activity); + fragment.setLayoutParams(new FrameLayout.LayoutParams(-1, -1)); + fragment.setId(fragmentId); + + final LinearLayout linearLayout = new LinearLayout(activity); + linearLayout.setLayoutParams(new LinearLayout.LayoutParams(-1, -1)); + linearLayout.setOrientation(LinearLayout.VERTICAL); + linearLayout.setFitsSystemWindows(true); + linearLayout.setTransitionGroup(true); + linearLayout.addView(fragment); + activity.setContentView(linearLayout); + + activity.getFragmentManager() + .beginTransaction() + .replace(fragmentId, new ReVancedPreferenceFragment()) + .commit(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/Settings.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/Settings.java new file mode 100644 index 0000000000..2efc2eb372 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/Settings.java @@ -0,0 +1,30 @@ +package app.revanced.extension.reddit.settings; + +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; + +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.shared.settings.BooleanSetting; + +public class Settings extends BaseSettings { + // Ads + public static final BooleanSetting HIDE_COMMENT_ADS = new BooleanSetting("revanced_hide_comment_ads", TRUE, true); + public static final BooleanSetting HIDE_OLD_POST_ADS = new BooleanSetting("revanced_hide_old_post_ads", TRUE, true); + public static final BooleanSetting HIDE_NEW_POST_ADS = new BooleanSetting("revanced_hide_new_post_ads", TRUE, true); + + // Layout + public static final BooleanSetting DISABLE_SCREENSHOT_POPUP = new BooleanSetting("revanced_disable_screenshot_popup", TRUE); + public static final BooleanSetting HIDE_CHAT_BUTTON = new BooleanSetting("revanced_hide_chat_button", FALSE, true); + public static final BooleanSetting HIDE_CREATE_BUTTON = new BooleanSetting("revanced_hide_create_button", FALSE, true); + public static final BooleanSetting HIDE_DISCOVER_BUTTON = new BooleanSetting("revanced_hide_discover_button", FALSE, true); + public static final BooleanSetting HIDE_RECENTLY_VISITED_SHELF = new BooleanSetting("revanced_hide_recently_visited_shelf", FALSE); + public static final BooleanSetting HIDE_RECOMMENDED_COMMUNITIES_SHELF = new BooleanSetting("revanced_hide_recommended_communities_shelf", FALSE, true); + public static final BooleanSetting HIDE_TOOLBAR_BUTTON = new BooleanSetting("revanced_hide_toolbar_button", FALSE, true); + public static final BooleanSetting REMOVE_NSFW_DIALOG = new BooleanSetting("revanced_remove_nsfw_dialog", FALSE, true); + public static final BooleanSetting REMOVE_NOTIFICATION_DIALOG = new BooleanSetting("revanced_remove_notification_dialog", FALSE, true); + + // Miscellaneous + public static final BooleanSetting OPEN_LINKS_DIRECTLY = new BooleanSetting("revanced_open_links_directly", TRUE); + public static final BooleanSetting OPEN_LINKS_EXTERNALLY = new BooleanSetting("revanced_open_links_externally", TRUE); + public static final BooleanSetting SANITIZE_URL_QUERY = new BooleanSetting("revanced_sanitize_url_query", TRUE); +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/SettingsStatus.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/SettingsStatus.java new file mode 100644 index 0000000000..a71521dabb --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/SettingsStatus.java @@ -0,0 +1,78 @@ +package app.revanced.extension.reddit.settings; + +@SuppressWarnings("unused") +public class SettingsStatus { + public static boolean generalAdsEnabled = false; + public static boolean navigationButtonsEnabled = false; + public static boolean openLinksDirectlyEnabled = false; + public static boolean openLinksExternallyEnabled = false; + public static boolean recentlyVisitedShelfEnabled = false; + public static boolean recommendedCommunitiesShelfEnabled = false; + public static boolean sanitizeUrlQueryEnabled = false; + public static boolean screenshotPopupEnabled = false; + public static boolean subRedditDialogEnabled = false; + public static boolean toolBarButtonEnabled = false; + + + public static void enableGeneralAds() { + generalAdsEnabled = true; + } + + public static void enableNavigationButtons() { + navigationButtonsEnabled = true; + } + + public static void enableOpenLinksDirectly() { + openLinksDirectlyEnabled = true; + } + + public static void enableOpenLinksExternally() { + openLinksExternallyEnabled = true; + } + + public static void enableRecentlyVisitedShelf() { + recentlyVisitedShelfEnabled = true; + } + + public static void enableRecommendedCommunitiesShelf() { + recommendedCommunitiesShelfEnabled = true; + } + + public static void enableSubRedditDialog() { + subRedditDialogEnabled = true; + } + + public static void enableSanitizeUrlQuery() { + sanitizeUrlQueryEnabled = true; + } + + public static void enableScreenshotPopup() { + screenshotPopupEnabled = true; + } + + public static void enableToolBarButton() { + toolBarButtonEnabled = true; + } + + public static boolean adsCategoryEnabled() { + return generalAdsEnabled; + } + + public static boolean layoutCategoryEnabled() { + return navigationButtonsEnabled || + recentlyVisitedShelfEnabled || + screenshotPopupEnabled || + subRedditDialogEnabled || + toolBarButtonEnabled; + } + + public static boolean miscellaneousCategoryEnabled() { + return openLinksDirectlyEnabled || + openLinksExternallyEnabled || + sanitizeUrlQueryEnabled; + } + + public static void load() { + + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/ReVancedPreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/ReVancedPreferenceFragment.java new file mode 100644 index 0000000000..8451a5819b --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/ReVancedPreferenceFragment.java @@ -0,0 +1,46 @@ +package app.revanced.extension.reddit.settings.preference; + +import android.content.Context; +import android.preference.Preference; +import android.preference.PreferenceScreen; + +import androidx.annotation.NonNull; + +import org.jetbrains.annotations.NotNull; + +import app.revanced.extension.reddit.settings.preference.categories.AdsPreferenceCategory; +import app.revanced.extension.reddit.settings.preference.categories.LayoutPreferenceCategory; +import app.revanced.extension.reddit.settings.preference.categories.MiscellaneousPreferenceCategory; +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment; + +/** + * Preference fragment for ReVanced settings + */ +@SuppressWarnings("deprecation") +public class ReVancedPreferenceFragment extends AbstractPreferenceFragment { + + @Override + protected void syncSettingWithPreference(@NonNull @NotNull Preference pref, + @NonNull @NotNull Setting setting, + boolean applySettingToPreference) { + super.syncSettingWithPreference(pref, setting, applySettingToPreference); + } + + @Override + protected void initialize() { + final Context context = getContext(); + + // Currently no resources can be compiled for Reddit (fails with aapt error). + // So all Reddit Strings are hard coded in integrations. + restartDialogMessage = "Refresh and restart"; + + PreferenceScreen preferenceScreen = getPreferenceManager().createPreferenceScreen(context); + setPreferenceScreen(preferenceScreen); + + // Custom categories reference app specific Settings class. + new AdsPreferenceCategory(context, preferenceScreen); + new LayoutPreferenceCategory(context, preferenceScreen); + new MiscellaneousPreferenceCategory(context, preferenceScreen); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/TogglePreference.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/TogglePreference.java new file mode 100644 index 0000000000..fed5a7c4b4 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/TogglePreference.java @@ -0,0 +1,17 @@ +package app.revanced.extension.reddit.settings.preference; + +import android.content.Context; +import android.preference.SwitchPreference; + +import app.revanced.extension.shared.settings.BooleanSetting; + +@SuppressWarnings("deprecation") +public class TogglePreference extends SwitchPreference { + public TogglePreference(Context context, String title, String summary, BooleanSetting setting) { + super(context); + this.setTitle(title); + this.setSummary(summary); + this.setKey(setting.key); + this.setChecked(setting.get()); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/AdsPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/AdsPreferenceCategory.java new file mode 100644 index 0000000000..a51fc397d4 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/AdsPreferenceCategory.java @@ -0,0 +1,43 @@ +package app.revanced.extension.reddit.settings.preference.categories; + +import android.content.Context; +import android.preference.PreferenceScreen; + +import app.revanced.extension.reddit.settings.Settings; +import app.revanced.extension.reddit.settings.SettingsStatus; +import app.revanced.extension.reddit.settings.preference.TogglePreference; + +@SuppressWarnings("deprecation") +public class AdsPreferenceCategory extends ConditionalPreferenceCategory { + public AdsPreferenceCategory(Context context, PreferenceScreen screen) { + super(context, screen); + setTitle("Ads"); + } + + @Override + public boolean getSettingsStatus() { + return SettingsStatus.adsCategoryEnabled(); + } + + @Override + public void addPreferences(Context context) { + addPreference(new TogglePreference( + context, + "Hide comment ads", + "Hides ads in the comments section.", + Settings.HIDE_COMMENT_ADS + )); + addPreference(new TogglePreference( + context, + "Hide feed ads", + "Hides ads in the feed (old method).", + Settings.HIDE_OLD_POST_ADS + )); + addPreference(new TogglePreference( + context, + "Hide feed ads", + "Hides ads in the feed (new method).", + Settings.HIDE_NEW_POST_ADS + )); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/ConditionalPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/ConditionalPreferenceCategory.java new file mode 100644 index 0000000000..c82b7c129d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/ConditionalPreferenceCategory.java @@ -0,0 +1,22 @@ +package app.revanced.extension.reddit.settings.preference.categories; + +import android.content.Context; +import android.preference.PreferenceCategory; +import android.preference.PreferenceScreen; + +@SuppressWarnings("deprecation") +public abstract class ConditionalPreferenceCategory extends PreferenceCategory { + public ConditionalPreferenceCategory(Context context, PreferenceScreen screen) { + super(context); + + if (getSettingsStatus()) { + screen.addPreference(this); + addPreferences(context); + } + } + + public abstract boolean getSettingsStatus(); + + public abstract void addPreferences(Context context); +} + diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/LayoutPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/LayoutPreferenceCategory.java new file mode 100644 index 0000000000..18dfd3349d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/LayoutPreferenceCategory.java @@ -0,0 +1,91 @@ +package app.revanced.extension.reddit.settings.preference.categories; + +import android.content.Context; +import android.preference.PreferenceScreen; + +import app.revanced.extension.reddit.settings.Settings; +import app.revanced.extension.reddit.settings.SettingsStatus; +import app.revanced.extension.reddit.settings.preference.TogglePreference; + +@SuppressWarnings("deprecation") +public class LayoutPreferenceCategory extends ConditionalPreferenceCategory { + public LayoutPreferenceCategory(Context context, PreferenceScreen screen) { + super(context, screen); + setTitle("Layout"); + } + + @Override + public boolean getSettingsStatus() { + return SettingsStatus.layoutCategoryEnabled(); + } + + @Override + public void addPreferences(Context context) { + if (SettingsStatus.screenshotPopupEnabled) { + addPreference(new TogglePreference( + context, + "Disable screenshot popup", + "Disables the popup that appears when taking a screenshot.", + Settings.DISABLE_SCREENSHOT_POPUP + )); + } + if (SettingsStatus.navigationButtonsEnabled) { + addPreference(new TogglePreference( + context, + "Hide Chat button", + "Hides the Chat button in the navigation bar.", + Settings.HIDE_CHAT_BUTTON + )); + addPreference(new TogglePreference( + context, + "Hide Create button", + "Hides the Create button in the navigation bar.", + Settings.HIDE_CREATE_BUTTON + )); + addPreference(new TogglePreference( + context, + "Hide Discover or Communities button", + "Hides the Discover or Communities button in the navigation bar.", + Settings.HIDE_DISCOVER_BUTTON + )); + } + if (SettingsStatus.recentlyVisitedShelfEnabled) { + addPreference(new TogglePreference( + context, + "Hide Recently Visited shelf", + "Hides the Recently Visited shelf in the sidebar.", + Settings.HIDE_RECENTLY_VISITED_SHELF + )); + } + if (SettingsStatus.recommendedCommunitiesShelfEnabled) { + addPreference(new TogglePreference( + context, + "Hide recommended communities", + "Hides the recommended communities shelves in subreddits.", + Settings.HIDE_RECOMMENDED_COMMUNITIES_SHELF + )); + } + if (SettingsStatus.toolBarButtonEnabled) { + addPreference(new TogglePreference( + context, + "Hide toolbar button", + "Hide toolbar button", + Settings.HIDE_TOOLBAR_BUTTON + )); + } + if (SettingsStatus.subRedditDialogEnabled) { + addPreference(new TogglePreference( + context, + "Remove NSFW warning dialog", + "Removes the NSFW warning dialog that appears when visiting a subreddit by accepting it automatically.", + Settings.REMOVE_NSFW_DIALOG + )); + addPreference(new TogglePreference( + context, + "Remove notification suggestion dialog", + "Removes the notifications suggestion dialog that appears when visiting a subreddit by dismissing it automatically.", + Settings.REMOVE_NOTIFICATION_DIALOG + )); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/MiscellaneousPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/MiscellaneousPreferenceCategory.java new file mode 100644 index 0000000000..5e16cf5b82 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/MiscellaneousPreferenceCategory.java @@ -0,0 +1,49 @@ +package app.revanced.extension.reddit.settings.preference.categories; + +import android.content.Context; +import android.preference.PreferenceScreen; + +import app.revanced.extension.reddit.settings.Settings; +import app.revanced.extension.reddit.settings.SettingsStatus; +import app.revanced.extension.reddit.settings.preference.TogglePreference; + +@SuppressWarnings("deprecation") +public class MiscellaneousPreferenceCategory extends ConditionalPreferenceCategory { + public MiscellaneousPreferenceCategory(Context context, PreferenceScreen screen) { + super(context, screen); + setTitle("Miscellaneous"); + } + + @Override + public boolean getSettingsStatus() { + return SettingsStatus.miscellaneousCategoryEnabled(); + } + + @Override + public void addPreferences(Context context) { + if (SettingsStatus.openLinksDirectlyEnabled) { + addPreference(new TogglePreference( + context, + "Open links directly", + "Skips over redirection URLs in external links.", + Settings.OPEN_LINKS_DIRECTLY + )); + } + if (SettingsStatus.openLinksExternallyEnabled) { + addPreference(new TogglePreference( + context, + "Open links externally", + "Opens links in your browser instead of in the in-app-browser.", + Settings.OPEN_LINKS_EXTERNALLY + )); + } + if (SettingsStatus.sanitizeUrlQueryEnabled) { + addPreference(new TogglePreference( + context, + "Sanitize sharing links", + "Removes tracking query parameters from URLs when sharing links.", + Settings.SANITIZE_URL_QUERY + )); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/AutoCaptionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/AutoCaptionsPatch.java new file mode 100644 index 0000000000..2a9752df81 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/AutoCaptionsPatch.java @@ -0,0 +1,18 @@ +package app.revanced.extension.shared.patches; + +import app.revanced.extension.shared.settings.BaseSettings; + +@SuppressWarnings("unused") +public final class AutoCaptionsPatch { + + private static boolean captionsButtonStatus; + + public static boolean disableAutoCaptions() { + return BaseSettings.DISABLE_AUTO_CAPTIONS.get() && + !captionsButtonStatus; + } + + public static void setCaptionsButtonStatus(boolean status) { + captionsButtonStatus = status; + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/BaseSettingsMenuPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/BaseSettingsMenuPatch.java new file mode 100644 index 0000000000..4ce7e63a8e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/BaseSettingsMenuPatch.java @@ -0,0 +1,16 @@ +package app.revanced.extension.shared.patches; + +import android.util.Log; + +import androidx.preference.PreferenceScreen; + +@SuppressWarnings("unused") +public class BaseSettingsMenuPatch { + + /** + * Rest of the implementation added by patch. + */ + public static void removePreference(PreferenceScreen mPreferenceScreen, String key) { + Log.d("Extended: SettingsMenuPatch", "key: " + key); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/BypassImageRegionRestrictionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/BypassImageRegionRestrictionsPatch.java new file mode 100644 index 0000000000..a43849f404 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/BypassImageRegionRestrictionsPatch.java @@ -0,0 +1,34 @@ +package app.revanced.extension.shared.patches; + +import java.util.regex.Pattern; + +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.shared.utils.Logger; + +@SuppressWarnings("unused") +public final class BypassImageRegionRestrictionsPatch { + + private static final boolean BYPASS_IMAGE_REGION_RESTRICTIONS_ENABLED = BaseSettings.BYPASS_IMAGE_REGION_RESTRICTIONS.get(); + private static final String REPLACEMENT_IMAGE_DOMAIN = BaseSettings.BYPASS_IMAGE_REGION_RESTRICTIONS_DOMAIN.get(); + + /** + * YouTube static images domain. Includes user and channel avatar images and community post images. + */ + private static final Pattern YOUTUBE_STATIC_IMAGE_DOMAIN_PATTERN = Pattern.compile("(ap[1-2]|gm[1-4]|gz0|(cp|ci|gp|lh)[3-6]|sp[1-3]|yt[3-4]|(play|ccp)-lh)\\.(ggpht|googleusercontent)\\.com"); + + public static String overrideImageURL(String originalUrl) { + try { + if (BYPASS_IMAGE_REGION_RESTRICTIONS_ENABLED) { + final String replacement = YOUTUBE_STATIC_IMAGE_DOMAIN_PATTERN + .matcher(originalUrl).replaceFirst(REPLACEMENT_IMAGE_DOMAIN); + if (!replacement.equals(originalUrl)) { + Logger.printDebug(() -> "Replaced: '" + originalUrl + "' with: '" + replacement + "'"); + } + return replacement; + } + } catch (Exception ex) { + Logger.printException(() -> "overrideImageURL failure", ex); + } + return originalUrl; + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/FullscreenAdsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/FullscreenAdsPatch.java new file mode 100644 index 0000000000..341f8748e4 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/FullscreenAdsPatch.java @@ -0,0 +1,74 @@ +package app.revanced.extension.shared.patches; + +import static app.revanced.extension.shared.utils.Utils.hideViewBy0dpUnderCondition; + +import android.view.View; + +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup; +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.shared.utils.Logger; + +@SuppressWarnings("unused") +public class FullscreenAdsPatch { + private static final boolean hideFullscreenAdsEnabled = BaseSettings.HIDE_FULLSCREEN_ADS.get(); + private static final ByteArrayFilterGroup exception = + new ByteArrayFilterGroup( + null, + "post_image_lightbox.eml" // Community post image in fullscreen + ); + + public static boolean disableFullscreenAds(final byte[] bytes, int type) { + if (!hideFullscreenAdsEnabled) { + return false; + } + + final DialogType dialogType = DialogType.getDialogType(type); + final String dialogName = dialogType.name(); + + // The dialog type of a fullscreen dialog is always {@code DialogType.FULLSCREEN} + if (dialogType != DialogType.FULLSCREEN) { + Logger.printDebug(() -> "Ignoring dialogType " + dialogName); + return false; + } + + // Image in community post in fullscreen is not filtered + final boolean isException = bytes != null && + exception.check(bytes).isFiltered(); + + if (isException) { + Logger.printDebug(() -> "Ignoring exception"); + } else { + Logger.printDebug(() -> "Blocked fullscreen ads"); + } + + return !isException; + } + + public static void hideFullscreenAds(View view) { + hideViewBy0dpUnderCondition( + hideFullscreenAdsEnabled, + view + ); + } + + private enum DialogType { + NULL(0), + ALERT(1), + FULLSCREEN(2), + LAYOUT_FULLSCREEN(3); + + private final int type; + + DialogType(int type) { + this.type = type; + } + + private static DialogType getDialogType(int type) { + for (DialogType val : values()) + if (type == val.type) return val; + + return DialogType.NULL; + } + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/GmsCoreSupport.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/GmsCoreSupport.java new file mode 100644 index 0000000000..96a049b877 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/GmsCoreSupport.java @@ -0,0 +1,233 @@ +package app.revanced.extension.shared.patches; + +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.shared.utils.Utils.isSDKAbove; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.SearchManager; +import android.content.ContentProviderClient; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.PowerManager; +import android.provider.Settings; + +import org.apache.commons.lang3.StringUtils; + +import java.net.MalformedURLException; +import java.net.URL; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +@SuppressWarnings("unused") +public class GmsCoreSupport { + private static final String PACKAGE_NAME_YOUTUBE = "com.google.android.youtube"; + private static final String PACKAGE_NAME_YOUTUBE_MUSIC = "com.google.android.apps.youtube.music"; + + private static final String GMS_CORE_PACKAGE_NAME + = getGmsCoreVendorGroupId() + ".android.gms"; + private static final Uri GMS_CORE_PROVIDER + = Uri.parse("content://" + getGmsCoreVendorGroupId() + ".android.gsf.gservices/prefix"); + private static final String DONT_KILL_MY_APP_LINK + = "https://dontkillmyapp.com"; + + private static final String META_SPOOF_PACKAGE_NAME = + GMS_CORE_PACKAGE_NAME + ".SPOOFED_PACKAGE_NAME"; + + private static void open(Activity mActivity, String queryOrLink) { + Intent intent; + try { + // Check if queryOrLink is a valid URL. + new URL(queryOrLink); + + intent = new Intent(Intent.ACTION_VIEW, Uri.parse(queryOrLink)); + } catch (MalformedURLException e) { + intent = new Intent(Intent.ACTION_WEB_SEARCH); + intent.putExtra(SearchManager.QUERY, queryOrLink); + } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mActivity.startActivity(intent); + + // Gracefully exit, otherwise the broken app will continue to run. + System.exit(0); + } + + private static void showBatteryOptimizationDialog(Activity context, + String dialogMessageRef, + String positiveButtonStringRef, + DialogInterface.OnClickListener onPositiveClickListener) { + // Use a delay to allow the activity to finish initializing. + // Otherwise, if device is in dark mode the dialog is shown with wrong color scheme. + Utils.runOnMainThreadDelayed(() -> new AlertDialog.Builder(context) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setTitle(str("gms_core_dialog_title")) + .setMessage(str(dialogMessageRef)) + .setPositiveButton(str(positiveButtonStringRef), onPositiveClickListener) + // Allow using back button to skip the action, just in case the check can never be satisfied. + .setCancelable(true) + .show(), 100); + } + + /** + * Injection point. + */ + public static void checkGmsCore(Activity mActivity) { + try { + // Verify the user has not included GmsCore for a root installation. + // GmsCore Support changes the package name, but with a mounted installation + // all manifest changes are ignored and the original package name is used. + if (StringUtils.equalsAny(mActivity.getPackageName(), PACKAGE_NAME_YOUTUBE, PACKAGE_NAME_YOUTUBE_MUSIC)) { + Logger.printInfo(() -> "App is mounted with root, but GmsCore patch was included"); + // Cannot use localize text here, since the app will load + // resources from the unpatched app and all patch strings are missing. + Utils.showToastLong("The 'GmsCore support' patch breaks mount installations"); + + // Do not exit. If the app exits before launch completes (and without + // opening another activity), then on some devices such as Pixel phone Android 10 + // no toast will be shown and the app will continually be relaunched + // with the appearance of a hung app. + } + + // Verify GmsCore is installed. + try { + PackageManager manager = mActivity.getPackageManager(); + manager.getPackageInfo(GMS_CORE_PACKAGE_NAME, PackageManager.GET_ACTIVITIES); + } catch (PackageManager.NameNotFoundException exception) { + Logger.printInfo(() -> "GmsCore was not found"); + // Cannot show a dialog and must show a toast, + // because on some installations the app crashes before a dialog can be displayed. + Utils.showToastLong(str("gms_core_toast_not_installed_message")); + open(mActivity, getGmsCoreDownload()); + return; + } + + if (contentProviderClientUnAvailable(mActivity)) { + Logger.printInfo(() -> "GmsCore is not running in the background"); + + showBatteryOptimizationDialog(mActivity, + "gms_core_dialog_not_whitelisted_not_allowed_in_background_message", + "gms_core_dialog_open_website_text", + (dialog, id) -> open(mActivity, DONT_KILL_MY_APP_LINK)); + return; + } + + // Check if GmsCore is whitelisted from battery optimizations. + if (batteryOptimizationsEnabled(mActivity)) { + Logger.printInfo(() -> "GmsCore is not whitelisted from battery optimizations"); + showBatteryOptimizationDialog(mActivity, + "gms_core_dialog_not_whitelisted_using_battery_optimizations_message", + "gms_core_dialog_continue_text", + (dialog, id) -> openGmsCoreDisableBatteryOptimizationsIntent(mActivity)); + } + } catch (Exception ex) { + Logger.printException(() -> "checkGmsCore failure", ex); + } + } + + /** + * @return If GmsCore is not running in the background. + */ + @SuppressWarnings("deprecation") + private static boolean contentProviderClientUnAvailable(Context context) { + // Check if GmsCore is running in the background. + // Do this check before the battery optimization check. + if (isSDKAbove(24)) { + try (ContentProviderClient client = context.getContentResolver().acquireContentProviderClient(GMS_CORE_PROVIDER)) { + return client == null; + } + } else { + ContentProviderClient client = null; + try { + //noinspection resource + client = context.getContentResolver() + .acquireContentProviderClient(GMS_CORE_PROVIDER); + return client == null; + } finally { + if (client != null) client.release(); + } + } + } + + @SuppressLint("BatteryLife") // Permission is part of GmsCore + private static void openGmsCoreDisableBatteryOptimizationsIntent(Activity mActivity) { + if (!isSDKAbove(23)) return; + Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); + intent.setData(Uri.fromParts("package", GMS_CORE_PACKAGE_NAME, null)); + mActivity.startActivityForResult(intent, 0); + } + + /** + * @return If GmsCore is not whitelisted from battery optimizations. + */ + private static boolean batteryOptimizationsEnabled(Context context) { + if (isSDKAbove(23) && context.getSystemService(Context.POWER_SERVICE) instanceof PowerManager powerManager) { + return !powerManager.isIgnoringBatteryOptimizations(GMS_CORE_PACKAGE_NAME); + } + return false; + } + + /** + * Injection point. + */ + public static String spoofPackageName(Context context) { + // Package name of ReVanced. + final String packageName = context.getPackageName(); + + try { + final PackageManager packageManager = context.getPackageManager(); + + // Package name of YouTube or YouTube Music. + String originalPackageName; + + try { + originalPackageName = packageManager + .getPackageInfo(packageName, PackageManager.GET_META_DATA) + .applicationInfo + .metaData + .getString(META_SPOOF_PACKAGE_NAME); + } catch (PackageManager.NameNotFoundException exception) { + Logger.printDebug(() -> "Failed to parsing metadata"); + return packageName; + } + + if (StringUtils.isBlank(originalPackageName)) { + Logger.printDebug(() -> "Failed to parsing spoofed package name"); + return packageName; + } + + try { + packageManager.getPackageInfo(originalPackageName, PackageManager.GET_ACTIVITIES); + } catch (PackageManager.NameNotFoundException exception) { + Logger.printDebug(() -> "Original app '" + originalPackageName + "' was not found"); + return packageName; + } + + Logger.printDebug(() -> "Package name of '" + packageName + "' spoofed to '" + originalPackageName + "'"); + + return originalPackageName; + } catch (Exception ex) { + Logger.printException(() -> "spoofPackageName failure", ex); + } + + return packageName; + } + + private static String getGmsCoreDownload() { + final String vendorGroupId = getGmsCoreVendorGroupId(); + return switch (vendorGroupId) { + case "app.revanced" -> "https://github.com/revanced/gmscore/releases/latest"; + case "com.mgoogle" -> "https://github.com/inotia00/VancedMicroG/releases/latest"; + default -> vendorGroupId + ".android.gms"; + }; + } + + // Modified by a patch. Do not touch. + private static String getGmsCoreVendorGroupId() { + return "app.revanced"; + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/ReturnYouTubeUsernamePatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/ReturnYouTubeUsernamePatch.java new file mode 100644 index 0000000000..32e177c17d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/ReturnYouTubeUsernamePatch.java @@ -0,0 +1,113 @@ +package app.revanced.extension.shared.patches; + +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; + +import android.text.SpannableString; +import android.text.Spanned; + +import androidx.annotation.NonNull; + +import app.revanced.extension.shared.returnyoutubeusername.requests.ChannelRequest; +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.shared.utils.Logger; + +@SuppressWarnings("unused") +public class ReturnYouTubeUsernamePatch { + private static final boolean RETURN_YOUTUBE_USERNAME_ENABLED = BaseSettings.RETURN_YOUTUBE_USERNAME_ENABLED.get(); + private static final Boolean RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT = BaseSettings.RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT.get().userNameFirst; + private static final String YOUTUBE_API_KEY = BaseSettings.RETURN_YOUTUBE_USERNAME_YOUTUBE_DATA_API_V3_DEVELOPER_KEY.get(); + + private static final String AUTHOR_BADGE_PATH = "|author_badge.eml|"; + private static volatile String lastFetchedHandle = ""; + + /** + * Injection point. + * + * @param original The original string before the SpannableString is built. + */ + public static CharSequence preFetchLithoText(@NonNull Object conversionContext, + @NonNull CharSequence original) { + onLithoTextLoaded(conversionContext, original, true); + return original; + } + + /** + * Injection point. + * + * @param original The original string after the SpannableString is built. + */ + @NonNull + public static CharSequence onLithoTextLoaded(@NonNull Object conversionContext, + @NonNull CharSequence original) { + return onLithoTextLoaded(conversionContext, original, false); + } + + @NonNull + private static CharSequence onLithoTextLoaded(@NonNull Object conversionContext, + @NonNull CharSequence original, + boolean fetchNeeded) { + try { + if (!RETURN_YOUTUBE_USERNAME_ENABLED) { + return original; + } + if (YOUTUBE_API_KEY.isEmpty()) { + Logger.printDebug(() -> "API key is empty"); + return original; + } + // In comments, the path to YouTube Handle(@youtube) always includes [AUTHOR_BADGE_PATH]. + if (!conversionContext.toString().contains(AUTHOR_BADGE_PATH)) { + return original; + } + String handle = original.toString(); + if (fetchNeeded && !handle.equals(lastFetchedHandle)) { + lastFetchedHandle = handle; + // Get the original username using YouTube Data API v3. + ChannelRequest.fetchRequestIfNeeded(handle, YOUTUBE_API_KEY, RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT); + return original; + } + // If the username is not in the cache, put it in the cache. + ChannelRequest channelRequest = ChannelRequest.getRequestForHandle(handle); + if (channelRequest == null) { + Logger.printDebug(() -> "ChannelRequest is null, handle:" + handle); + return original; + } + final String userName = channelRequest.getStream(); + if (userName == null) { + Logger.printDebug(() -> "ChannelRequest Stream is null, handle:" + handle); + return original; + } + final CharSequence copiedSpannableString = copySpannableString(original, userName); + Logger.printDebug(() -> "Replaced: '" + original + "' with: '" + copiedSpannableString + "'"); + return copiedSpannableString; + } catch (Exception ex) { + Logger.printException(() -> "onLithoTextLoaded failure", ex); + } + return original; + } + + private static CharSequence copySpannableString(CharSequence original, String userName) { + if (original instanceof Spanned spanned) { + SpannableString newString = new SpannableString(userName); + Object[] spans = spanned.getSpans(0, spanned.length(), Object.class); + for (Object span : spans) { + int flags = spanned.getSpanFlags(span); + newString.setSpan(span, 0, newString.length(), flags); + } + return newString; + } + return original; + } + + public enum DisplayFormat { + USERNAME_ONLY(null), + USERNAME_HANDLE(TRUE), + HANDLE_USERNAME(FALSE); + + final Boolean userNameFirst; + + DisplayFormat(Boolean userNameFirst) { + this.userNameFirst = userNameFirst; + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/SanitizeUrlQueryPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/SanitizeUrlQueryPatch.java new file mode 100644 index 0000000000..c9e6c5d403 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/SanitizeUrlQueryPatch.java @@ -0,0 +1,50 @@ +package app.revanced.extension.shared.patches; + +import android.content.Intent; + +import app.revanced.extension.shared.settings.BaseSettings; + +@SuppressWarnings("all") +public final class SanitizeUrlQueryPatch { + /** + * This tracking parameter is mainly used. + */ + private static final String NEW_TRACKING_REGEX = ".si=.+"; + /** + * This tracking parameter is outdated. + * Used when patching old versions or enabling spoof app version. + */ + private static final String OLD_TRACKING_REGEX = ".feature=.+"; + private static final String URL_PROTOCOL = "http"; + + /** + * Strip query parameters from a given URL string. + *

+ * URL example containing tracking parameter: + * https://youtu.be/ZWgr7qP6yhY?si=kKA_-9cygieuFY7R + * https://youtu.be/ZWgr7qP6yhY?feature=shared + * https://youtube.com/watch?v=ZWgr7qP6yhY&si=s_PZAxnJHKX1Mc8C + * https://youtube.com/watch?v=ZWgr7qP6yhY&feature=shared + * https://youtube.com/playlist?list=PLBsP89CPrMeO7uztAu6YxSB10cRMpjgiY&si=N0U8xncY2ZmQoSMp + * https://youtube.com/playlist?list=PLBsP89CPrMeO7uztAu6YxSB10cRMpjgiY&feature=shared + *

+ * Since we need to support support all these examples, + * We cannot use [URL.getpath()] or [Uri.getQueryParameter()]. + * + * @param urlString URL string to strip query parameters from. + * @return URL string without query parameters if possible, otherwise the original string. + */ + public static String stripQueryParameters(final String urlString) { + if (!BaseSettings.SANITIZE_SHARING_LINKS.get()) + return urlString; + + return urlString.replaceAll(NEW_TRACKING_REGEX, "").replaceAll(OLD_TRACKING_REGEX, ""); + } + + public static void stripQueryParameters(final Intent intent, final String extraName, final String extraValue) { + intent.putExtra(extraName, extraValue.startsWith(URL_PROTOCOL) + ? stripQueryParameters(extraValue) + : extraValue + ); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/ByteArrayFilterGroup.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/ByteArrayFilterGroup.java new file mode 100644 index 0000000000..18a94365cf --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/ByteArrayFilterGroup.java @@ -0,0 +1,98 @@ +package app.revanced.extension.shared.patches.components; + +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.shared.utils.ByteTrieSearch; +import app.revanced.extension.shared.utils.Logger; + +/** + * If you have more than 1 filter patterns, then all instances of + * this class should filtered using {@link ByteArrayFilterGroupList#check(byte[])}, + * which uses a prefix tree to give better performance. + */ +@SuppressWarnings("unused") +public class ByteArrayFilterGroup extends FilterGroup { + + private volatile int[][] failurePatterns; + + // Modified implementation from https://stackoverflow.com/a/1507813 + private static int indexOf(final byte[] data, final byte[] pattern, final int[] failure) { + // Finds the first occurrence of the pattern in the byte array using + // KMP matching algorithm. + int patternLength = pattern.length; + for (int i = 0, j = 0, dataLength = data.length; i < dataLength; i++) { + while (j > 0 && pattern[j] != data[i]) { + j = failure[j - 1]; + } + if (pattern[j] == data[i]) { + j++; + } + if (j == patternLength) { + return i - patternLength + 1; + } + } + return -1; + } + + private static int[] createFailurePattern(byte[] pattern) { + // Computes the failure function using a boot-strapping process, + // where the pattern is matched against itself. + final int patternLength = pattern.length; + final int[] failure = new int[patternLength]; + + for (int i = 1, j = 0; i < patternLength; i++) { + while (j > 0 && pattern[j] != pattern[i]) { + j = failure[j - 1]; + } + if (pattern[j] == pattern[i]) { + j++; + } + failure[i] = j; + } + return failure; + } + + public ByteArrayFilterGroup(BooleanSetting setting, byte[]... filters) { + super(setting, filters); + } + + /** + * Converts the Strings into byte arrays. Used to search for text in binary data. + */ + public ByteArrayFilterGroup(BooleanSetting setting, String... filters) { + super(setting, ByteTrieSearch.convertStringsToBytes(filters)); + } + + private synchronized void buildFailurePatterns() { + if (failurePatterns != null) + return; // Thread race and another thread already initialized the search. + Logger.printDebug(() -> "Building failure array for: " + this); + int[][] failurePatterns = new int[filters.length][]; + int i = 0; + for (byte[] pattern : filters) { + failurePatterns[i++] = createFailurePattern(pattern); + } + this.failurePatterns = failurePatterns; // Must set after initialization finishes. + } + + @Override + public FilterGroupResult check(final byte[] bytes) { + int matchedLength = 0; + int matchedIndex = -1; + if (isEnabled()) { + int[][] failures = failurePatterns; + if (failures == null) { + buildFailurePatterns(); // Lazy load. + failures = failurePatterns; + } + for (int i = 0, length = filters.length; i < length; i++) { + byte[] filter = filters[i]; + matchedIndex = indexOf(bytes, filter, failures[i]); + if (matchedIndex >= 0) { + matchedLength = filter.length; + break; + } + } + } + return new FilterGroupResult(setting, matchedIndex, matchedLength); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/ByteArrayFilterGroupList.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/ByteArrayFilterGroupList.java new file mode 100644 index 0000000000..52bbbbab0a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/ByteArrayFilterGroupList.java @@ -0,0 +1,14 @@ +package app.revanced.extension.shared.patches.components; + +import app.revanced.extension.shared.utils.ByteTrieSearch; + +/** + * If searching for a single byte pattern, then it is slightly better to use + * {@link ByteArrayFilterGroup#check(byte[])} as it uses KMP which is faster + * than a prefix tree to search for only 1 pattern. + */ +public final class ByteArrayFilterGroupList extends FilterGroupList { + protected ByteTrieSearch createSearchGraph() { + return new ByteTrieSearch(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/Filter.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/Filter.java new file mode 100644 index 0000000000..77123be16d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/Filter.java @@ -0,0 +1,106 @@ +package app.revanced.extension.shared.patches.components; + +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.shared.utils.Logger; + +/** + * Filters litho based components. + *

+ * Callbacks to filter content are added using {@link #addIdentifierCallbacks(StringFilterGroup...)} + * and {@link #addPathCallbacks(StringFilterGroup...)}. + *

+ * To filter {@link FilterContentType#PROTOBUFFER}, first add a callback to + * either an identifier or a path. + * Then inside {@link #isFiltered(String, String, String, byte[], StringFilterGroup, FilterContentType, int)} + * search for the buffer content using either a {@link ByteArrayFilterGroup} (if searching for 1 pattern) + * or a {@link ByteArrayFilterGroupList} (if searching for more than 1 pattern). + *

+ * All callbacks must be registered before the constructor completes. + */ +@SuppressWarnings("unused") +public abstract class Filter { + + public enum FilterContentType { + IDENTIFIER, + PATH, + ALLVALUE, + PROTOBUFFER + } + + /** + * Identifier callbacks. Do not add to this instance, + * and instead use {@link #addIdentifierCallbacks(StringFilterGroup...)}. + */ + protected final List identifierCallbacks = new ArrayList<>(); + /** + * Path callbacks. Do not add to this instance, + * and instead use {@link #addPathCallbacks(StringFilterGroup...)}. + */ + protected final List pathCallbacks = new ArrayList<>(); + /** + * Path callbacks. Do not add to this instance, + * and instead use {@link #addAllValueCallbacks(StringFilterGroup...)}. + */ + protected final List allValueCallbacks = new ArrayList<>(); + + /** + * Adds callbacks to {@link #isFiltered(String, String, String, byte[], StringFilterGroup, FilterContentType, int)} + * if any of the groups are found. + */ + protected final void addIdentifierCallbacks(StringFilterGroup... groups) { + identifierCallbacks.addAll(Arrays.asList(groups)); + } + + /** + * Adds callbacks to {@link #isFiltered(String, String, String, byte[], StringFilterGroup, FilterContentType, int)} + * if any of the groups are found. + */ + protected final void addPathCallbacks(StringFilterGroup... groups) { + pathCallbacks.addAll(Arrays.asList(groups)); + } + + /** + * Adds callbacks to {@link #isFiltered(String, String, String, byte[], StringFilterGroup, FilterContentType, int)} + * if any of the groups are found. + */ + protected final void addAllValueCallbacks(StringFilterGroup... groups) { + allValueCallbacks.addAll(Arrays.asList(groups)); + } + + /** + * Called after an enabled filter has been matched. + * Default implementation is to always filter the matched component and log the action. + * Subclasses can perform additional or different checks if needed. + *

+ * If the content is to be filtered, subclasses should always + * call this method (and never return a plain 'true'). + * That way the logs will always show when a component was filtered and which filter hide it. + *

+ * Method is called off the main thread. + * + * @param matchedGroup The actual filter that matched. + * @param contentType The type of content matched. + * @param contentIndex Matched index of the identifier or path. + * @return True if the litho component should be filtered out. + */ + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (BaseSettings.ENABLE_DEBUG_LOGGING.get()) { + String filterSimpleName = getClass().getSimpleName(); + if (contentType == FilterContentType.IDENTIFIER) { + Logger.printDebug(() -> filterSimpleName + " Filtered identifier: " + identifier); + } else if (contentType == FilterContentType.PATH) { + Logger.printDebug(() -> filterSimpleName + " Filtered path: " + path); + } else if (contentType == FilterContentType.ALLVALUE) { + Logger.printDebug(() -> filterSimpleName + " Filtered object: " + allValue); + } + } + return true; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/FilterGroup.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/FilterGroup.java new file mode 100644 index 0000000000..e580ea5ceb --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/FilterGroup.java @@ -0,0 +1,95 @@ +package app.revanced.extension.shared.patches.components; + +import androidx.annotation.NonNull; + +import app.revanced.extension.shared.settings.BooleanSetting; + +@SuppressWarnings("unused") +public abstract class FilterGroup { + public final static class FilterGroupResult { + private BooleanSetting setting; + private int matchedIndex; + private int matchedLength; + // In the future it might be useful to include which pattern matched, + // but for now that is not needed. + + FilterGroupResult() { + this(null, -1, 0); + } + + FilterGroupResult(BooleanSetting setting, int matchedIndex, int matchedLength) { + setValues(setting, matchedIndex, matchedLength); + } + + public void setValues(BooleanSetting setting, int matchedIndex, int matchedLength) { + this.setting = setting; + this.matchedIndex = matchedIndex; + this.matchedLength = matchedLength; + } + + /** + * A null value if the group has no setting, + * or if no match is returned from {@link FilterGroupList#check(Object)}. + */ + public BooleanSetting getSetting() { + return setting; + } + + public boolean isFiltered() { + return matchedIndex >= 0; + } + + /** + * Matched index of first pattern that matched, or -1 if nothing matched. + */ + public int getMatchedIndex() { + return matchedIndex; + } + + /** + * Length of the matched filter pattern. + */ + public int getMatchedLength() { + return matchedLength; + } + } + + protected final BooleanSetting setting; + protected final T[] filters; + + /** + * Initialize a new filter group. + * + * @param setting The associated setting. + * @param filters The filters. + */ + @SafeVarargs + public FilterGroup(final BooleanSetting setting, final T... filters) { + this.setting = setting; + this.filters = filters; + if (filters.length == 0) { + throw new IllegalArgumentException("Must use one or more filter patterns (zero specified)"); + } + } + + public boolean isEnabled() { + return setting == null || setting.get(); + } + + /** + * @return If {@link FilterGroupList} should include this group when searching. + * By default, all filters are included except non enabled settings that require reboot. + */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public boolean includeInSearch() { + return isEnabled() || !setting.rebootApp; + } + + @NonNull + @Override + public String toString() { + return getClass().getSimpleName() + ": " + (setting == null ? "(null setting)" : setting); + } + + public abstract FilterGroupResult check(final T stack); +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/FilterGroupList.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/FilterGroupList.java new file mode 100644 index 0000000000..62e08a7e24 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/FilterGroupList.java @@ -0,0 +1,69 @@ +package app.revanced.extension.shared.patches.components; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Spliterator; +import java.util.function.Consumer; + +import app.revanced.extension.shared.utils.TrieSearch; + +@SuppressWarnings("unused") +public abstract class FilterGroupList> implements Iterable { + + private final List filterGroups = new ArrayList<>(); + private final TrieSearch search = createSearchGraph(); + + @SafeVarargs + public final void addAll(final T... groups) { + filterGroups.addAll(Arrays.asList(groups)); + + for (T group : groups) { + if (!group.includeInSearch()) { + continue; + } + for (V pattern : group.filters) { + search.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> { + if (group.isEnabled()) { + FilterGroup.FilterGroupResult result = (FilterGroup.FilterGroupResult) callbackParameter; + result.setValues(group.setting, matchedStartIndex, matchedLength); + return true; + } + return false; + }); + } + } + } + + @NonNull + @Override + public Iterator iterator() { + return filterGroups.iterator(); + } + + @RequiresApi(24) + @Override + public void forEach(@NonNull Consumer action) { + filterGroups.forEach(action); + } + + @RequiresApi(24) + @NonNull + @Override + public Spliterator spliterator() { + return filterGroups.spliterator(); + } + + public FilterGroup.FilterGroupResult check(V stack) { + FilterGroup.FilterGroupResult result = new FilterGroup.FilterGroupResult(); + search.matches(stack, result); + return result; + + } + + protected abstract TrieSearch createSearchGraph(); +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/LithoFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/LithoFilterPatch.java new file mode 100644 index 0000000000..6e59379af9 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/LithoFilterPatch.java @@ -0,0 +1,191 @@ +package app.revanced.extension.shared.patches.components; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.nio.ByteBuffer; +import java.util.List; + +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.StringTrieSearch; + +@SuppressWarnings("unused") +public final class LithoFilterPatch { + /** + * Simple wrapper to pass the litho parameters through the prefix search. + */ + private static final class LithoFilterParameters { + @Nullable + final String identifier; + final String path; + final String allValue; + final byte[] protoBuffer; + + LithoFilterParameters(String lithoPath, @Nullable String lithoIdentifier, String allValues, byte[] bufferArray) { + this.path = lithoPath; + this.identifier = lithoIdentifier; + this.allValue = allValues; + this.protoBuffer = bufferArray; + } + + @NonNull + @Override + public String toString() { + // Estimate the percentage of the buffer that are Strings. + StringBuilder builder = new StringBuilder(Math.max(100, protoBuffer.length / 2)); + builder.append("\nID: "); + builder.append(identifier); + builder.append("\nPath: "); + builder.append(path); + if (BaseSettings.ENABLE_DEBUG_BUFFER_LOGGING.get()) { + builder.append("\nBufferStrings: "); + findAsciiStrings(builder, protoBuffer); + } + + return builder.toString(); + } + + /** + * Search through a byte array for all ASCII strings. + */ + private static void findAsciiStrings(StringBuilder builder, byte[] buffer) { + // Valid ASCII values (ignore control characters). + final int minimumAscii = 32; // 32 = space character + final int maximumAscii = 126; // 127 = delete character + final int minimumAsciiStringLength = 4; // Minimum length of an ASCII string to include. + String delimitingCharacter = "❙"; // Non ascii character, to allow easier log filtering. + + final int length = buffer.length; + int start = 0; + int end = 0; + while (end < length) { + int value = buffer[end]; + if (value < minimumAscii || value > maximumAscii || end == length - 1) { + if (end - start >= minimumAsciiStringLength) { + for (int i = start; i < end; i++) { + builder.append((char) buffer[i]); + } + builder.append(delimitingCharacter); + } + start = end + 1; + } + end++; + } + } + } + + private static final Filter[] filters = new Filter[]{ + new DummyFilter() // Replaced by patch. + }; + + private static final StringTrieSearch pathSearchTree = new StringTrieSearch(); + private static final StringTrieSearch identifierSearchTree = new StringTrieSearch(); + private static final StringTrieSearch allValueSearchTree = new StringTrieSearch(); + + private static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; + + /** + * Because litho filtering is multi-threaded and the buffer is passed in from a different injection point, + * the buffer is saved to a ThreadLocal so each calling thread does not interfere with other threads. + */ + private static final ThreadLocal bufferThreadLocal = new ThreadLocal<>(); + + static { + for (Filter filter : filters) { + filterUsingCallbacks(identifierSearchTree, filter, + filter.identifierCallbacks, Filter.FilterContentType.IDENTIFIER); + filterUsingCallbacks(pathSearchTree, filter, + filter.pathCallbacks, Filter.FilterContentType.PATH); + filterUsingCallbacks(allValueSearchTree, filter, + filter.allValueCallbacks, Filter.FilterContentType.ALLVALUE); + } + + Logger.printDebug(() -> "Using: " + + identifierSearchTree.numberOfPatterns() + " identifier filters" + + " (" + identifierSearchTree.getEstimatedMemorySize() + " KB), " + + pathSearchTree.numberOfPatterns() + " path filters" + + " (" + pathSearchTree.getEstimatedMemorySize() + " KB)"); + } + + private static void filterUsingCallbacks(StringTrieSearch pathSearchTree, + Filter filter, List groups, + Filter.FilterContentType type) { + for (StringFilterGroup group : groups) { + if (!group.includeInSearch()) { + continue; + } + for (String pattern : group.filters) { + pathSearchTree.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> { + if (!group.isEnabled()) return false; + LithoFilterParameters parameters = (LithoFilterParameters) callbackParameter; + return filter.isFiltered(parameters.path, parameters.identifier, parameters.allValue, parameters.protoBuffer, + group, type, matchedStartIndex); + } + ); + } + } + } + + /** + * Injection point. Called off the main thread. + */ + public static void setProtoBuffer(@Nullable ByteBuffer protobufBuffer) { + // Set the buffer to a thread local. The buffer will remain in memory, even after the call to #filter completes. + // This is intentional, as it appears the buffer can be set once and then filtered multiple times. + // The buffer will be cleared from memory after a new buffer is set by the same thread, + // or when the calling thread eventually dies. + bufferThreadLocal.set(protobufBuffer); + } + + /** + * Injection point. Called off the main thread, and commonly called by multiple threads at the same time. + */ + public static boolean filter(@NonNull StringBuilder pathBuilder, @Nullable String identifier, @NonNull Object object) { + try { + if (pathBuilder.length() == 0) { + return false; + } + + ByteBuffer protobufBuffer = bufferThreadLocal.get(); + final byte[] bufferArray; + // Potentially the buffer may have been null or never set up until now. + // Use an empty buffer so the litho id or path filters still work correctly. + if (protobufBuffer == null) { + Logger.printDebug(() -> "Proto buffer is null, using an empty buffer array"); + bufferArray = EMPTY_BYTE_ARRAY; + } else if (!protobufBuffer.hasArray()) { + Logger.printDebug(() -> "Proto buffer does not have an array, using an empty buffer array"); + bufferArray = EMPTY_BYTE_ARRAY; + } else { + bufferArray = protobufBuffer.array(); + } + + LithoFilterParameters parameter = new LithoFilterParameters(pathBuilder.toString(), identifier, + object.toString(), bufferArray); + Logger.printDebug(() -> "Searching " + parameter); + + if (parameter.identifier != null && identifierSearchTree.matches(parameter.identifier, parameter)) { + return true; + } + + if (pathSearchTree.matches(parameter.path, parameter)) { + return true; + } + + if (allValueSearchTree.matches(parameter.allValue, parameter)) { + return true; + } + } catch (Exception ex) { + Logger.printException(() -> "Litho filter failure", ex); + } + + return false; + } +} + +/** + * Placeholder for actual filters. + */ +final class DummyFilter extends Filter { +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/StringFilterGroup.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/StringFilterGroup.java new file mode 100644 index 0000000000..9ac111cf9c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/StringFilterGroup.java @@ -0,0 +1,29 @@ +package app.revanced.extension.shared.patches.components; + +import app.revanced.extension.shared.settings.BooleanSetting; + +public class StringFilterGroup extends FilterGroup { + + public StringFilterGroup(final BooleanSetting setting, final String... filters) { + super(setting, filters); + } + + @Override + public FilterGroupResult check(final String string) { + int matchedIndex = -1; + int matchedLength = 0; + if (isEnabled()) { + for (String pattern : filters) { + if (!string.isEmpty()) { + final int indexOf = string.indexOf(pattern); + if (indexOf >= 0) { + matchedIndex = indexOf; + matchedLength = pattern.length(); + break; + } + } + } + } + return new FilterGroupResult(setting, matchedIndex, matchedLength); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/StringFilterGroupList.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/StringFilterGroupList.java new file mode 100644 index 0000000000..ae6c189e93 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/StringFilterGroupList.java @@ -0,0 +1,9 @@ +package app.revanced.extension.shared.patches.components; + +import app.revanced.extension.shared.utils.StringTrieSearch; + +public final class StringFilterGroupList extends FilterGroupList { + protected StringTrieSearch createSearchGraph() { + return new StringTrieSearch(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/Filter.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/Filter.java new file mode 100644 index 0000000000..e22d73f521 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/Filter.java @@ -0,0 +1,70 @@ +package app.revanced.extension.shared.patches.spans; + +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.text.SpannableString; +import android.text.style.ImageSpan; +import android.text.style.RelativeSizeSpan; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.shared.utils.Logger; + +/** + * Filters litho based components. + *

+ * All callbacks must be registered before the constructor completes. + */ +public abstract class Filter { + private static final RelativeSizeSpan relativeSizeSpanDummy = new RelativeSizeSpan(0f); + private static final Drawable transparentDrawable = new ColorDrawable(Color.TRANSPARENT); + private static final ImageSpan imageSpanDummy = new ImageSpan(transparentDrawable); + + /** + * Path callbacks. Do not add to this instance, + * and instead use {@link #addCallbacks(StringFilterGroup...)}. + */ + protected final List callbacks = new ArrayList<>(); + + /** + * Adds callbacks to {@link #skip(String, SpannableString , Object, int, int, int, boolean, SpanType, StringFilterGroup)} + * if any of the groups are found. + */ + protected final void addCallbacks(StringFilterGroup... groups) { + callbacks.addAll(Arrays.asList(groups)); + } + + protected final void hideSpan(SpannableString spannableString, int start, int end, int flags) { + spannableString.setSpan(relativeSizeSpanDummy, start, end, flags); + } + + protected final void hideImageSpan(SpannableString spannableString, int start, int end, int flags) { + spannableString.setSpan(imageSpanDummy, start, end, flags); + } + + /** + * Called after an enabled filter has been matched. + * Default implementation is to always filter the matched component and log the action. + * Subclasses can perform additional or different checks if needed. + *

+ * If the content is to be filtered, subclasses should always + * call this method (and never return a plain 'true'). + * That way the logs will always show when a component was filtered and which filter hide it. + *

+ * Method is called off the main thread. + * + * @param matchedGroup The actual filter that matched. + */ + public boolean skip(String conversionContext, SpannableString spannableString, Object span, int start, int end, + int flags, boolean isWord, SpanType spanType, StringFilterGroup matchedGroup) { + if (BaseSettings.ENABLE_DEBUG_LOGGING.get()) { + String filterSimpleName = getClass().getSimpleName(); + Logger.printDebug(() -> filterSimpleName + " Removed setSpan: " + spanType.type); + } + return true; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/FilterGroup.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/FilterGroup.java new file mode 100644 index 0000000000..d1dc3c2a07 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/FilterGroup.java @@ -0,0 +1,78 @@ +package app.revanced.extension.shared.patches.spans; + +import androidx.annotation.NonNull; + +import app.revanced.extension.shared.settings.BooleanSetting; + +public abstract class FilterGroup { + final static class FilterGroupResult { + private BooleanSetting setting; + private int matchedIndex; + // In the future it might be useful to include which pattern matched, + // but for now that is not needed. + + FilterGroupResult() { + this(null, -1); + } + + FilterGroupResult(BooleanSetting setting, int matchedIndex) { + setValues(setting, matchedIndex); + } + + public void setValues(BooleanSetting setting, int matchedIndex) { + this.setting = setting; + this.matchedIndex = matchedIndex; + } + + /** + * A null value if the group has no setting, + * or if no match is returned from {@link FilterGroupList#check(Object)}. + */ + public BooleanSetting getSetting() { + return setting; + } + + public boolean isFiltered() { + return matchedIndex >= 0; + } + } + + protected final BooleanSetting setting; + protected final T[] filters; + + /** + * Initialize a new filter group. + * + * @param setting The associated setting. + * @param filters The filters. + */ + @SafeVarargs + public FilterGroup(final BooleanSetting setting, final T... filters) { + this.setting = setting; + this.filters = filters; + if (filters.length == 0) { + throw new IllegalArgumentException("Must use one or more filter patterns (zero specified)"); + } + } + + public boolean isEnabled() { + return setting == null || setting.get(); + } + + /** + * @return If {@link FilterGroupList} should include this group when searching. + * By default, all filters are included except non enabled settings that require reboot. + */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public boolean includeInSearch() { + return isEnabled() || !setting.rebootApp; + } + + @NonNull + @Override + public String toString() { + return getClass().getSimpleName() + ": " + (setting == null ? "(null setting)" : setting); + } + + public abstract FilterGroupResult check(final T stack); +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/FilterGroupList.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/FilterGroupList.java new file mode 100644 index 0000000000..16c82cf61c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/FilterGroupList.java @@ -0,0 +1,65 @@ +package app.revanced.extension.shared.patches.spans; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Spliterator; +import java.util.function.Consumer; + +import app.revanced.extension.shared.utils.TrieSearch; + +public abstract class FilterGroupList> implements Iterable { + + private final List filterGroups = new ArrayList<>(); + private final TrieSearch search = createSearchGraph(); + + @SafeVarargs + protected final void addAll(final T... groups) { + filterGroups.addAll(Arrays.asList(groups)); + + for (T group : groups) { + if (!group.includeInSearch()) { + continue; + } + for (V pattern : group.filters) { + search.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> { + if (group.isEnabled()) { + FilterGroup.FilterGroupResult result = (FilterGroup.FilterGroupResult) callbackParameter; + result.setValues(group.setting, matchedStartIndex); + return true; + } + return false; + }); + } + } + } + + @NonNull + @Override + public Iterator iterator() { + return filterGroups.iterator(); + } + + @Override + public void forEach(@NonNull Consumer action) { + filterGroups.forEach(action); + } + + @NonNull + @Override + public Spliterator spliterator() { + return filterGroups.spliterator(); + } + + protected FilterGroup.FilterGroupResult check(V stack) { + FilterGroup.FilterGroupResult result = new FilterGroup.FilterGroupResult(); + search.matches(stack, result); + return result; + + } + + protected abstract TrieSearch createSearchGraph(); +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/InclusiveSpanPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/InclusiveSpanPatch.java new file mode 100644 index 0000000000..f82bcfe877 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/InclusiveSpanPatch.java @@ -0,0 +1,201 @@ +package app.revanced.extension.shared.patches.spans; + +import android.text.Spannable; +import android.text.SpannableString; +import android.text.style.AbsoluteSizeSpan; +import android.text.style.CharacterStyle; +import android.text.style.ClickableSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.ImageSpan; +import android.text.style.TypefaceSpan; + +import androidx.annotation.NonNull; + +import java.util.List; + +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.StringTrieSearch; + + +/** + * Placeholder for actual filters. + */ +final class DummyFilter extends Filter { } + +@SuppressWarnings("unused") +public final class InclusiveSpanPatch { + private static final BooleanSetting ENABLE_DEBUG_LOGGING = BaseSettings.ENABLE_DEBUG_LOGGING; + + /** + * Simple wrapper to pass the litho parameters through the prefix search. + */ + private static final class LithoFilterParameters { + final String conversionContext; + final SpannableString spannableString; + final Object span; + final int start; + final int end; + final int flags; + final String originalString; + final int originalLength; + final SpanType spanType; + final boolean isWord; + + public LithoFilterParameters(String conversionContext, SpannableString spannableString, + Object span, int start, int end, int flags) { + this.conversionContext = conversionContext; + this.spannableString = spannableString; + this.span = span; + this.start = start; + this.end = end; + this.flags = flags; + this.originalString = spannableString.toString(); + this.originalLength = spannableString.length(); + this.spanType = getSpanType(span); + this.isWord = !(start == 0 && end == originalLength); + } + + @NonNull + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("CharSequence:'") + .append(originalString) + .append("'\nSpanType:'") + .append(getSpanType(spanType, span)) + .append("'\nLength:'") + .append(originalLength) + .append("'\nStart:'") + .append(start) + .append("'\nEnd:'") + .append(end) + .append("'\nisWord:'") + .append(isWord) + .append("'"); + if (isWord) { + builder.append("\nWord:'") + .append(originalString.substring(start, end)) + .append("'"); + } + return builder.toString(); + } + } + + private static SpanType getSpanType(Object span) { + if (span instanceof ClickableSpan) { + return SpanType.CLICKABLE; + } else if (span instanceof ForegroundColorSpan) { + return SpanType.FOREGROUND_COLOR; + } else if (span instanceof AbsoluteSizeSpan) { + return SpanType.ABSOLUTE_SIZE; + } else if (span instanceof TypefaceSpan) { + return SpanType.TYPEFACE; + } else if (span instanceof ImageSpan) { + return SpanType.IMAGE; + } else if (span instanceof CharacterStyle) { // Replaced by patch. + return SpanType.CUSTOM_CHARACTER_STYLE; + } else { + return SpanType.UNKNOWN; + } + } + + private static String getSpanType(SpanType spanType, Object span) { + return spanType == SpanType.UNKNOWN + ? span.getClass().getSimpleName() + : spanType.type; + } + + private static final Filter[] filters = new Filter[] { + new DummyFilter() // Replaced by patch. + }; + + private static final StringTrieSearch searchTree = new StringTrieSearch(); + + + /** + * Because litho filtering is multi-threaded and the buffer is passed in from a different injection point, + * the buffer is saved to a ThreadLocal so each calling thread does not interfere with other threads. + */ + private static final ThreadLocal conversionContextThreadLocal = new ThreadLocal<>(); + + static { + for (Filter filter : filters) { + filterUsingCallbacks(filter, filter.callbacks); + } + + if (ENABLE_DEBUG_LOGGING.get()) { + Logger.printDebug(() -> "Using: " + + searchTree.numberOfPatterns() + " conversion context filters" + + " (" + searchTree.getEstimatedMemorySize() + " KB)"); + } + } + + private static void filterUsingCallbacks(Filter filter, List groups) { + for (StringFilterGroup group : groups) { + if (!group.includeInSearch()) { + continue; + } + for (String pattern : group.filters) { + InclusiveSpanPatch.searchTree.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> { + if (!group.isEnabled()) return false; + LithoFilterParameters parameters = (LithoFilterParameters) callbackParameter; + return filter.skip(parameters.conversionContext, parameters.spannableString, parameters.span, + parameters.start, parameters.end, parameters.flags, parameters.isWord, parameters.spanType, group); + } + ); + } + } + } + + /** + * Injection point. + * + * @param conversionContext ConversionContext is used to identify whether it is a comment thread or not. + */ + public static CharSequence setConversionContext(@NonNull Object conversionContext, + @NonNull CharSequence original) { + conversionContextThreadLocal.set(conversionContext.toString()); + return original; + } + + private static boolean returnEarly(SpannableString spannableString, Object span, int start, int end, int flags) { + try { + final String conversionContext = conversionContextThreadLocal.get(); + if (conversionContext == null || conversionContext.isEmpty()) { + return false; + } + + LithoFilterParameters parameter = + new LithoFilterParameters(conversionContext, spannableString, span, start, end, flags); + + if (ENABLE_DEBUG_LOGGING.get()) { + Logger.printDebug(() -> "Searching...\n\u200B\n" + parameter); + } + + return searchTree.matches(parameter.conversionContext, parameter); + } catch (Exception ex) { + Logger.printException(() -> "Spans filter failure", ex); + } + + return false; + } + + /** + * Injection point. + * + * @param spannableString Original SpannableString. + * @param span Span such as {@link ClickableSpan}, {@link ForegroundColorSpan}, + * {@link AbsoluteSizeSpan}, {@link TypefaceSpan}, {@link ImageSpan}. + * @param start Start index of {@link Spannable#setSpan(Object, int, int, int)}. + * @param end End index of {@link Spannable#setSpan(Object, int, int, int)}. + * @param flags Flags of {@link Spannable#setSpan(Object, int, int, int)}. + */ + public static void setSpan(SpannableString spannableString, Object span, int start, int end, int flags) { + if (returnEarly(spannableString, span, start, end, flags)) { + return; + } + spannableString.setSpan(span, start, end, flags); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/SpanType.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/SpanType.java new file mode 100644 index 0000000000..0ba705410c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/SpanType.java @@ -0,0 +1,20 @@ +package app.revanced.extension.shared.patches.spans; + +import androidx.annotation.NonNull; + +public enum SpanType { + CLICKABLE("ClickableSpan"), + FOREGROUND_COLOR("ForegroundColorSpan"), + ABSOLUTE_SIZE("AbsoluteSizeSpan"), + TYPEFACE("TypefaceSpan"), + IMAGE("ImageSpan"), + CUSTOM_CHARACTER_STYLE("CustomCharacterStyle"), + UNKNOWN("Unknown"); + + @NonNull + public final String type; + + SpanType(@NonNull String type) { + this.type = type; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/StringFilterGroup.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/StringFilterGroup.java new file mode 100644 index 0000000000..3841533181 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/StringFilterGroup.java @@ -0,0 +1,27 @@ +package app.revanced.extension.shared.patches.spans; + +import app.revanced.extension.shared.settings.BooleanSetting; + +public class StringFilterGroup extends FilterGroup { + + public StringFilterGroup(final BooleanSetting setting, final String... filters) { + super(setting, filters); + } + + @Override + public FilterGroupResult check(final String string) { + int matchedIndex = -1; + if (isEnabled()) { + for (String pattern : filters) { + if (!string.isEmpty()) { + final int indexOf = string.indexOf(pattern); + if (indexOf >= 0) { + matchedIndex = indexOf; + break; + } + } + } + } + return new FilterGroupResult(setting, matchedIndex); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/requests/Requester.java b/extensions/shared/src/main/java/app/revanced/extension/shared/requests/Requester.java new file mode 100644 index 0000000000..8ab950f256 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/requests/Requester.java @@ -0,0 +1,142 @@ +package app.revanced.extension.shared.requests; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; + +@SuppressWarnings("unused") +public class Requester { + public Requester() { + } + + public static HttpURLConnection getConnectionFromRoute(String apiUrl, Route route, String... params) throws IOException { + return getConnectionFromCompiledRoute(apiUrl, route.compile(params)); + } + + public static HttpURLConnection getConnectionFromCompiledRoute(String apiUrl, Route.CompiledRoute route) throws IOException { + String url = apiUrl + route.getCompiledRoute(); + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + // Request data is in the URL parameters and no body is sent. + // The calling code must set a length if using a request body. + connection.setFixedLengthStreamingMode(0); + connection.setRequestMethod(route.getMethod().name()); + connection.setRequestProperty("User-Agent", System.getProperty("http.agent") + ";"); + + return connection; + } + + /** + * Parse the {@link HttpURLConnection}, and closes the underlying InputStream. + */ + private static String parseInputStreamAndClose(InputStream inputStream) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + StringBuilder jsonBuilder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + jsonBuilder.append(line); + jsonBuilder.append('\n'); + } + return jsonBuilder.toString(); + } + } + + /** + * Parse the {@link HttpURLConnection} response as a String. + * This does not close the url connection. If further requests to this host are unlikely + * in the near future, then instead use {@link #parseStringAndDisconnect(HttpURLConnection)}. + */ + public static String parseString(HttpURLConnection connection) throws IOException { + return parseInputStreamAndClose(connection.getInputStream()); + } + + /** + * Parse the {@link HttpURLConnection} response as a String, and disconnect. + *

+ * Should only be used if other requests to the server in the near future are unlikely + * + * @see #parseString(HttpURLConnection) + */ + public static String parseStringAndDisconnect(HttpURLConnection connection) throws IOException { + String result = parseString(connection); + connection.disconnect(); + return result; + } + + /** + * Parse the {@link HttpURLConnection} error stream as a String. + * If the server sent no error response data, this returns an empty string. + */ + public static String parseErrorString(HttpURLConnection connection) throws IOException { + InputStream errorStream = connection.getErrorStream(); + if (errorStream == null) { + return ""; + } + return parseInputStreamAndClose(errorStream); + } + + /** + * Parse the {@link HttpURLConnection} error stream as a String, and disconnect. + * If the server sent no error response data, this returns an empty string. + *

+ * Should only be used if other requests to the server are unlikely in the near future. + * + * @see #parseErrorString(HttpURLConnection) + */ + public static String parseErrorStringAndDisconnect(HttpURLConnection connection) throws IOException { + String result = parseErrorString(connection); + connection.disconnect(); + return result; + } + + /** + * Parse the {@link HttpURLConnection} response into a JSONObject. + * This does not close the url connection. If further requests to this host are unlikely + * in the near future, then instead use {@link #parseJSONObjectAndDisconnect(HttpURLConnection)}. + */ + public static JSONObject parseJSONObject(HttpURLConnection connection) throws JSONException, IOException { + return new JSONObject(parseString(connection)); + } + + /** + * Parse the {@link HttpURLConnection}, close the underlying InputStream, and disconnect. + *

+ * Should only be used if other requests to the server in the near future are unlikely + * + * @see #parseJSONObject(HttpURLConnection) + */ + public static JSONObject parseJSONObjectAndDisconnect(HttpURLConnection connection) throws JSONException, IOException { + JSONObject object = parseJSONObject(connection); + connection.disconnect(); + return object; + } + + /** + * Parse the {@link HttpURLConnection}, and closes the underlying InputStream. + * This does not close the url connection. If further requests to this host are unlikely + * in the near future, then instead use {@link #parseJSONArrayAndDisconnect(HttpURLConnection)}. + */ + public static JSONArray parseJSONArray(HttpURLConnection connection) throws JSONException, IOException { + return new JSONArray(parseString(connection)); + } + + /** + * Parse the {@link HttpURLConnection}, close the underlying InputStream, and disconnect. + *

+ * Should only be used if other requests to the server in the near future are unlikely + * + * @see #parseJSONArray(HttpURLConnection) + */ + public static JSONArray parseJSONArrayAndDisconnect(HttpURLConnection connection) throws JSONException, IOException { + JSONArray array = parseJSONArray(connection); + connection.disconnect(); + return array; + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/requests/Route.java b/extensions/shared/src/main/java/app/revanced/extension/shared/requests/Route.java new file mode 100644 index 0000000000..9ce0c7654b --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/requests/Route.java @@ -0,0 +1,66 @@ +package app.revanced.extension.shared.requests; + +public class Route { + private final String route; + private final Route.Method method; + private final int paramCount; + + public Route(Route.Method method, String route) { + this.method = method; + this.route = route; + this.paramCount = countMatches(route, '{'); + + if (paramCount != countMatches(route, '}')) + throw new IllegalArgumentException("Not enough parameters"); + } + + public Route.Method getMethod() { + return method; + } + + public Route.CompiledRoute compile(String... params) { + if (params.length != paramCount) + throw new IllegalArgumentException("Error compiling route [" + route + "], incorrect amount of parameters provided. " + + "Expected: " + paramCount + ", provided: " + params.length); + + StringBuilder compiledRoute = new StringBuilder(route); + for (int i = 0; i < paramCount; i++) { + int paramStart = compiledRoute.indexOf("{"); + int paramEnd = compiledRoute.indexOf("}"); + compiledRoute.replace(paramStart, paramEnd + 1, params[i]); + } + return new Route.CompiledRoute(this, compiledRoute.toString()); + } + + private int countMatches(CharSequence seq, char c) { + int count = 0; + for (int i = 0; i < seq.length(); i++) { + if (seq.charAt(i) == c) + count++; + } + return count; + } + + public enum Method { + GET, + POST + } + + public static class CompiledRoute { + private final Route baseRoute; + private final String compiledRoute; + + private CompiledRoute(Route baseRoute, String compiledRoute) { + this.baseRoute = baseRoute; + this.compiledRoute = compiledRoute; + } + + public String getCompiledRoute() { + return compiledRoute; + } + + public Route.Method getMethod() { + return baseRoute.method; + } + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/ReturnYouTubeDislike.java b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/ReturnYouTubeDislike.java new file mode 100644 index 0000000000..c7031d02fb --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/ReturnYouTubeDislike.java @@ -0,0 +1,17 @@ +package app.revanced.extension.shared.returnyoutubedislike; + +public class ReturnYouTubeDislike { + + public enum Vote { + LIKE(1), + DISLIKE(-1), + LIKE_REMOVE(0); + + public final int value; + + Vote(int value) { + this.value = value; + } + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/RYDVoteData.java b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/RYDVoteData.java new file mode 100644 index 0000000000..a4a56de04f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/RYDVoteData.java @@ -0,0 +1,179 @@ +package app.revanced.extension.shared.returnyoutubedislike.requests; + +import static app.revanced.extension.shared.returnyoutubedislike.ReturnYouTubeDislike.Vote; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import app.revanced.extension.shared.utils.Logger; + +/** + * ReturnYouTubeDislike API estimated like/dislike/view counts. + *

+ * ReturnYouTubeDislike does not guarantee when the counts are updated. + * So these values may lag behind what YouTube shows. + */ +@SuppressWarnings("unused") +public final class RYDVoteData { + @NonNull + public final String videoId; + + /** + * Estimated number of views + */ + public final long viewCount; + + private final long fetchedLikeCount; + private volatile long likeCount; // Read/write from different threads. + /** + * Like count can be hidden by video creator, but RYD still tracks the number + * of like/dislikes it received thru it's browser extension and and API. + * The raw like/dislikes can be used to calculate a percentage. + *

+ * Raw values can be null, especially for older videos with little to no views. + */ + @Nullable + private final Long fetchedRawLikeCount; + private volatile float likePercentage; + + private final long fetchedDislikeCount; + private volatile long dislikeCount; // Read/write from different threads. + @Nullable + private final Long fetchedRawDislikeCount; + private volatile float dislikePercentage; + + @Nullable + private static Long getLongIfExist(JSONObject json, String key) throws JSONException { + return json.isNull(key) + ? null + : json.getLong(key); + } + + /** + * @throws JSONException if JSON parse error occurs, or if the values make no sense (ie: negative values) + */ + public RYDVoteData(@NonNull JSONObject json) throws JSONException { + videoId = json.getString("id"); + viewCount = json.getLong("viewCount"); + + fetchedLikeCount = json.getLong("likes"); + fetchedRawLikeCount = getLongIfExist(json, "rawLikes"); + + fetchedDislikeCount = json.getLong("dislikes"); + fetchedRawDislikeCount = getLongIfExist(json, "rawDislikes"); + + if (viewCount < 0 || fetchedLikeCount < 0 || fetchedDislikeCount < 0) { + throw new JSONException("Unexpected JSON values: " + json); + } + likeCount = fetchedLikeCount; + dislikeCount = fetchedDislikeCount; + updateUsingVote(Vote.LIKE_REMOVE); // Calculate percentages. + } + + /** + * Public like count of the video, as reported by YT when RYD last updated it's data. + *

+ * If the likes were hidden by the video creator, then this returns an + * estimated likes using the same extrapolation as the dislikes. + */ + public long getLikeCount() { + return likeCount; + } + + /** + * Estimated total dislike count, extrapolated from the public like count using RYD data. + */ + public long getDislikeCount() { + return dislikeCount; + } + + /** + * Estimated percentage of likes for all votes. Value has range of [0, 1] + *

+ * A video with 400 positive votes, and 100 negative votes, has a likePercentage of 0.8 + */ + public float getLikePercentage() { + return likePercentage; + } + + /** + * Estimated percentage of dislikes for all votes. Value has range of [0, 1] + *

+ * A video with 400 positive votes, and 100 negative votes, has a dislikePercentage of 0.2 + */ + public float getDislikePercentage() { + return dislikePercentage; + } + + public void updateUsingVote(Vote vote) { + final int likesToAdd, dislikesToAdd; + + switch (vote) { + case LIKE: + likesToAdd = 1; + dislikesToAdd = 0; + break; + case DISLIKE: + likesToAdd = 0; + dislikesToAdd = 1; + break; + case LIKE_REMOVE: + likesToAdd = 0; + dislikesToAdd = 0; + break; + default: + throw new IllegalStateException(); + } + + // If a video has no public likes but RYD has raw like data, + // then use the raw data instead. + final boolean videoHasNoPublicLikes = fetchedLikeCount == 0; + final boolean hasRawData = fetchedRawLikeCount != null && fetchedRawDislikeCount != null; + + if (videoHasNoPublicLikes && hasRawData && fetchedRawDislikeCount > 0) { + // YT creator has hidden the likes count, and this is an older video that + // RYD does not provide estimated like counts. + // + // But we can calculate the public likes the same way RYD does for newer videos with hidden likes, + // by using the same raw to estimated scale factor applied to dislikes. + // This calculation exactly matches the public likes RYD provides for newer hidden videos. + final float estimatedRawScaleFactor = (float) fetchedDislikeCount / fetchedRawDislikeCount; + likeCount = (long) (estimatedRawScaleFactor * fetchedRawLikeCount) + likesToAdd; + Logger.printDebug(() -> "Using locally calculated estimated likes since RYD did not return an estimate"); + } else { + likeCount = fetchedLikeCount + likesToAdd; + } + // RYD now always returns an estimated dislike count, even if the likes are hidden. + dislikeCount = fetchedDislikeCount + dislikesToAdd; + + // Update percentages. + + final float totalCount = likeCount + dislikeCount; + if (totalCount == 0) { + likePercentage = 0; + dislikePercentage = 0; + } else { + likePercentage = likeCount / totalCount; + dislikePercentage = dislikeCount / totalCount; + } + } + + @NonNull + @Override + public String toString() { + return "RYDVoteData{" + + "videoId=" + videoId + + ", viewCount=" + viewCount + + ", likeCount=" + likeCount + + ", dislikeCount=" + dislikeCount + + ", likePercentage=" + likePercentage + + ", dislikePercentage=" + dislikePercentage + + '}'; + } + + // equals and hashcode is not implemented (currently not needed) + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java new file mode 100644 index 0000000000..df1e503b59 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java @@ -0,0 +1,476 @@ +package app.revanced.extension.shared.returnyoutubedislike.requests; + +import static app.revanced.extension.shared.returnyoutubedislike.requests.ReturnYouTubeDislikeRoutes.getRYDConnectionFromRoute; +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.util.Base64; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.ProtocolException; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Objects; + +import app.revanced.extension.shared.requests.Requester; +import app.revanced.extension.shared.returnyoutubedislike.ReturnYouTubeDislike; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +public class ReturnYouTubeDislikeApi { + /** + * {@link #fetchVotes(String)} TCP connection timeout + */ + private static final int API_GET_VOTES_TCP_TIMEOUT_MILLISECONDS = 2 * 1000; // 2 Seconds. + + /** + * {@link #fetchVotes(String)} HTTP read timeout. + * To locally debug and force timeouts, change this to a very small number (ie: 100) + */ + private static final int API_GET_VOTES_HTTP_TIMEOUT_MILLISECONDS = 4 * 1000; // 4 Seconds. + + /** + * Default connection and response timeout for voting and registration. + *

+ * Voting and user registration runs in the background and has has no urgency + * so this can be a larger value. + */ + private static final int API_REGISTER_VOTE_TIMEOUT_MILLISECONDS = 60 * 1000; // 60 Seconds. + + /** + * Response code of a successful API call + */ + private static final int HTTP_STATUS_CODE_SUCCESS = 200; + + /** + * Indicates a client rate limit has been reached and the client must back off. + */ + private static final int HTTP_STATUS_CODE_RATE_LIMIT = 429; + + /** + * How long to wait until API calls are resumed, if the API requested a back off. + * No clear guideline of how long to wait until resuming. + */ + private static final int BACKOFF_RATE_LIMIT_MILLISECONDS = 10 * 60 * 1000; // 10 Minutes. + + /** + * How long to wait until API calls are resumed, if any connection error occurs. + */ + private static final int BACKOFF_CONNECTION_ERROR_MILLISECONDS = 2 * 60 * 1000; // 2 Minutes. + + /** + * If non zero, then the system time of when API calls can resume. + */ + private static volatile long timeToResumeAPICalls; // must be volatile, since different threads read/write to this + + /** + * If the last API getVotes call failed for any reason (including server requested rate limit). + * Used to prevent showing repeat connection toasts when the API is down. + */ + private static volatile boolean lastApiCallFailed; + + public static boolean toastOnConnectionError = false; + + private ReturnYouTubeDislikeApi() { + } // utility class + + /** + * Clears any backoff rate limits in effect. + * Should be called if RYD is turned on/off. + */ + public static void resetRateLimits() { + if (lastApiCallFailed || timeToResumeAPICalls != 0) { + Logger.printDebug(() -> "Reset rate limit"); + } + lastApiCallFailed = false; + timeToResumeAPICalls = 0; + } + + /** + * @return True, if api rate limit is in effect. + */ + private static boolean checkIfRateLimitInEffect(String apiEndPointName) { + if (timeToResumeAPICalls == 0) { + return false; + } + final long now = System.currentTimeMillis(); + if (now > timeToResumeAPICalls) { + timeToResumeAPICalls = 0; + return false; + } + Logger.printDebug(() -> "Ignoring api call " + apiEndPointName + " as rate limit is in effect"); + return true; + } + + /** + * @return True, if a client rate limit was requested + */ + private static boolean checkIfRateLimitWasHit(int httpResponseCode) { + return httpResponseCode == HTTP_STATUS_CODE_RATE_LIMIT; + } + + private static void updateRateLimitAndStats(boolean connectionError, boolean rateLimitHit) { + if (connectionError && rateLimitHit) { + throw new IllegalArgumentException(); + } + if (connectionError) { + timeToResumeAPICalls = System.currentTimeMillis() + BACKOFF_CONNECTION_ERROR_MILLISECONDS; + lastApiCallFailed = true; + } else if (rateLimitHit) { + Logger.printDebug(() -> "API rate limit was hit. Stopping API calls for the next " + + BACKOFF_RATE_LIMIT_MILLISECONDS + " seconds"); + timeToResumeAPICalls = System.currentTimeMillis() + BACKOFF_RATE_LIMIT_MILLISECONDS; + if (!lastApiCallFailed && toastOnConnectionError) { + Utils.showToastLong(str("revanced_ryd_failure_client_rate_limit_requested")); + } + lastApiCallFailed = true; + } else { + lastApiCallFailed = false; + } + } + + private static void handleConnectionError(@NonNull String toastMessage, @Nullable Exception ex) { + if (!lastApiCallFailed && toastOnConnectionError) { + Utils.showToastShort(toastMessage); + } + if (ex != null) { + Logger.printInfo(() -> toastMessage, ex); + } + } + + /** + * @return NULL if fetch failed, or if a rate limit is in effect. + */ + @Nullable + public static RYDVoteData fetchVotes(String videoId) { + Utils.verifyOffMainThread(); + Objects.requireNonNull(videoId); + + if (checkIfRateLimitInEffect("fetchVotes")) { + return null; + } + Logger.printDebug(() -> "Fetching votes for: " + videoId); + + try { + HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.GET_DISLIKES, videoId); + // request headers, as per https://returnyoutubedislike.com/docs/fetching + // the documentation says to use 'Accept:text/html', but the RYD browser plugin uses 'Accept:application/json' + connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty("Connection", "keep-alive"); // keep-alive is on by default with http 1.1, but specify anyways + connection.setRequestProperty("Pragma", "no-cache"); + connection.setRequestProperty("Cache-Control", "no-cache"); + connection.setUseCaches(false); + connection.setConnectTimeout(API_GET_VOTES_TCP_TIMEOUT_MILLISECONDS); // timeout for TCP connection to server + connection.setReadTimeout(API_GET_VOTES_HTTP_TIMEOUT_MILLISECONDS); // timeout for server response + + final int responseCode = connection.getResponseCode(); + if (checkIfRateLimitWasHit(responseCode)) { + connection.disconnect(); // rate limit hit, should disconnect + updateRateLimitAndStats(false, true); + return null; + } + + if (responseCode == HTTP_STATUS_CODE_SUCCESS) { + // Do not disconnect, the same server connection will likely be used again soon. + JSONObject json = Requester.parseJSONObject(connection); + try { + RYDVoteData votingData = new RYDVoteData(json); + updateRateLimitAndStats(false, false); + Logger.printDebug(() -> "Voting data fetched: " + votingData); + return votingData; + } catch (JSONException ex) { + Logger.printException(() -> "Failed to parse video: " + videoId + " json: " + json, ex); + // fall thru to update statistics + } + } else { + handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), null); + } + connection.disconnect(); // something went wrong, might as well disconnect + } catch ( + SocketTimeoutException ex) { // connection timed out, response timeout, or some other network error + handleConnectionError((str("revanced_ryd_failure_connection_timeout")), ex); + } catch (IOException ex) { + handleConnectionError((str("revanced_ryd_failure_generic", ex.getMessage())), ex); + } catch (Exception ex) { + // should never happen + Logger.printException(() -> "fetchVotes failure", ex); + } + + updateRateLimitAndStats(true, false); + return null; + } + + /** + * @return The newly created and registered user id. Returns NULL if registration failed. + */ + @Nullable + public static String registerAsNewUser() { + Utils.verifyOffMainThread(); + try { + if (checkIfRateLimitInEffect("registerAsNewUser")) { + return null; + } + String userId = randomString(); + Logger.printDebug(() -> "Trying to register new user"); + + HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.GET_REGISTRATION, userId); + connection.setRequestProperty("Accept", "application/json"); + connection.setConnectTimeout(API_REGISTER_VOTE_TIMEOUT_MILLISECONDS); + connection.setReadTimeout(API_REGISTER_VOTE_TIMEOUT_MILLISECONDS); + + final int responseCode = connection.getResponseCode(); + if (checkIfRateLimitWasHit(responseCode)) { + connection.disconnect(); // disconnect, as no more connections will be made for a little while + return null; + } + if (responseCode == HTTP_STATUS_CODE_SUCCESS) { + JSONObject json = Requester.parseJSONObject(connection); + String challenge = json.getString("challenge"); + int difficulty = json.getInt("difficulty"); + + String solution = solvePuzzle(challenge, difficulty); + return confirmRegistration(userId, solution); + } + handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), null); + connection.disconnect(); + } catch (SocketTimeoutException ex) { + handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex); + } catch (IOException ex) { + handleConnectionError(str("revanced_ryd_failure_generic", "registration failed"), ex); + } catch (Exception ex) { + Logger.printException(() -> "Failed to register user", ex); // should never happen + } + return null; + } + + @Nullable + private static String confirmRegistration(String userId, String solution) { + Utils.verifyOffMainThread(); + Objects.requireNonNull(userId); + Objects.requireNonNull(solution); + try { + if (checkIfRateLimitInEffect("confirmRegistration")) { + return null; + } + Logger.printDebug(() -> "Trying to confirm registration with solution: " + solution); + + HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.CONFIRM_REGISTRATION, userId); + applyCommonPostRequestSettings(connection); + + String jsonInputString = "{\"solution\": \"" + solution + "\"}"; + byte[] body = jsonInputString.getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(body.length); + try (OutputStream os = connection.getOutputStream()) { + os.write(body); + } + + final int responseCode = connection.getResponseCode(); + if (checkIfRateLimitWasHit(responseCode)) { + connection.disconnect(); // disconnect, as no more connections will be made for a little while + return null; + } + if (responseCode == HTTP_STATUS_CODE_SUCCESS) { + Logger.printDebug(() -> "Registration confirmation successful"); + return userId; + } + // Something went wrong, might as well disconnect. + String response = Requester.parseStringAndDisconnect(connection); + Logger.printInfo(() -> "Failed to confirm registration for user: " + userId + + " solution: " + solution + " responseCode: " + responseCode + " response: '" + response + "''"); + handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), null); + } catch (SocketTimeoutException ex) { + handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex); + } catch (IOException ex) { + handleConnectionError(str("revanced_ryd_failure_generic", "confirm registration failed"), ex); + } catch (Exception ex) { + Logger.printException(() -> "Failed to confirm registration for user: " + userId + + "solution: " + solution, ex); + } + return null; + } + + public static void sendVote(String userId, String videoId, ReturnYouTubeDislike.Vote vote) { + Utils.verifyOffMainThread(); + Objects.requireNonNull(videoId); + Objects.requireNonNull(vote); + + try { + if (userId == null) return; + + if (checkIfRateLimitInEffect("sendVote")) { + return; + } + Logger.printDebug(() -> "Trying to vote for video: " + videoId + " with vote: " + vote); + + HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.SEND_VOTE); + applyCommonPostRequestSettings(connection); + + String voteJsonString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"value\": \"" + vote.value + "\"}"; + byte[] body = voteJsonString.getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(body.length); + try (OutputStream os = connection.getOutputStream()) { + os.write(body); + } + + final int responseCode = connection.getResponseCode(); + if (checkIfRateLimitWasHit(responseCode)) { + connection.disconnect(); // disconnect, as no more connections will be made for a little while + return; + } + if (responseCode == HTTP_STATUS_CODE_SUCCESS) { + JSONObject json = Requester.parseJSONObject(connection); + String challenge = json.getString("challenge"); + int difficulty = json.getInt("difficulty"); + + String solution = solvePuzzle(challenge, difficulty); + confirmVote(videoId, userId, solution); + return; + } + + Logger.printInfo(() -> "Failed to send vote for video: " + videoId + " vote: " + vote + + " response code was: " + responseCode); + handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), null); + connection.disconnect(); // something went wrong, might as well disconnect + } catch (SocketTimeoutException ex) { + handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex); + } catch (IOException ex) { + handleConnectionError(str("revanced_ryd_failure_generic", "send vote failed"), ex); + } catch (Exception ex) { + // should never happen + Logger.printException(() -> "Failed to send vote for video: " + videoId + " vote: " + vote, ex); + } + } + + private static void confirmVote(String videoId, String userId, String solution) { + Utils.verifyOffMainThread(); + Objects.requireNonNull(videoId); + Objects.requireNonNull(userId); + Objects.requireNonNull(solution); + + try { + if (checkIfRateLimitInEffect("confirmVote")) { + return; + } + Logger.printDebug(() -> "Trying to confirm vote for video: " + videoId + " solution: " + solution); + HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.CONFIRM_VOTE); + applyCommonPostRequestSettings(connection); + + String jsonInputString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"solution\": \"" + solution + "\"}"; + byte[] body = jsonInputString.getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(body.length); + try (OutputStream os = connection.getOutputStream()) { + os.write(body); + } + + final int responseCode = connection.getResponseCode(); + if (checkIfRateLimitWasHit(responseCode)) { + connection.disconnect(); // disconnect, as no more connections will be made for a little while + return; + } + if (responseCode == HTTP_STATUS_CODE_SUCCESS) { + Logger.printDebug(() -> "Vote confirm successful for video: " + videoId); + return; + } + // Something went wrong, might as well disconnect. + String response = Requester.parseStringAndDisconnect(connection); + Logger.printInfo(() -> "Failed to confirm vote for video: " + videoId + + " solution: " + solution + " responseCode: " + responseCode + " response: '" + response + "'"); + handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), null); + } catch (SocketTimeoutException ex) { + handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex); + } catch (IOException ex) { + handleConnectionError(str("revanced_ryd_failure_generic", "confirm vote failed"), ex); + } catch (Exception ex) { + Logger.printException(() -> "Failed to confirm vote for video: " + videoId + + " solution: " + solution, ex); // should never happen + } + } + + private static void applyCommonPostRequestSettings(HttpURLConnection connection) throws ProtocolException { + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty("Pragma", "no-cache"); + connection.setRequestProperty("Cache-Control", "no-cache"); + connection.setUseCaches(false); + connection.setDoOutput(true); + connection.setConnectTimeout(API_REGISTER_VOTE_TIMEOUT_MILLISECONDS); // timeout for TCP connection to server + connection.setReadTimeout(API_REGISTER_VOTE_TIMEOUT_MILLISECONDS); // timeout for server response + } + + + private static String solvePuzzle(String challenge, int difficulty) { + byte[] decodedChallenge = Base64.decode(challenge, Base64.NO_WRAP); + + byte[] buffer = new byte[20]; + System.arraycopy(decodedChallenge, 0, buffer, 4, 16); + + MessageDigest md; + try { + md = MessageDigest.getInstance("SHA-512"); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException(ex); // should never happen + } + + final int maxCount = (int) (Math.pow(2, difficulty + 1) * 5); + for (int i = 0; i < maxCount; i++) { + buffer[0] = (byte) i; + buffer[1] = (byte) (i >> 8); + buffer[2] = (byte) (i >> 16); + buffer[3] = (byte) (i >> 24); + byte[] messageDigest = md.digest(buffer); + + if (countLeadingZeroes(messageDigest) >= difficulty) { + return Base64.encodeToString(new byte[]{buffer[0], buffer[1], buffer[2], buffer[3]}, Base64.NO_WRAP); + } + } + + // should never be reached + throw new IllegalStateException("Failed to solve puzzle challenge: " + challenge + " of difficulty: " + difficulty); + } + + // https://stackoverflow.com/a/157202 + private static String randomString() { + String AB = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + SecureRandom rnd = new SecureRandom(); + + StringBuilder sb = new StringBuilder(36); + for (int i = 0; i < 36; i++) + sb.append(AB.charAt(rnd.nextInt(AB.length()))); + return sb.toString(); + } + + private static int countLeadingZeroes(byte[] uInt8View) { + int zeroes = 0; + int value; + for (byte b : uInt8View) { + value = b & 0xFF; + if (value == 0) { + zeroes += 8; + } else { + int count = 1; + if (value >>> 4 == 0) { + count += 4; + value <<= 4; + } + if (value >>> 6 == 0) { + count += 2; + value <<= 2; + } + zeroes += count - (value >>> 7); + break; + } + } + return zeroes; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/ReturnYouTubeDislikeRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/ReturnYouTubeDislikeRoutes.java new file mode 100644 index 0000000000..98c9fe6764 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/ReturnYouTubeDislikeRoutes.java @@ -0,0 +1,28 @@ +package app.revanced.extension.shared.returnyoutubedislike.requests; + +import static app.revanced.extension.shared.requests.Route.Method.GET; +import static app.revanced.extension.shared.requests.Route.Method.POST; + +import java.io.IOException; +import java.net.HttpURLConnection; + +import app.revanced.extension.shared.requests.Requester; +import app.revanced.extension.shared.requests.Route; + +public class ReturnYouTubeDislikeRoutes { + public static final String RYD_API_URL = "https://returnyoutubedislikeapi.com/"; + + public static final Route SEND_VOTE = new Route(POST, "interact/vote"); + public static final Route CONFIRM_VOTE = new Route(POST, "interact/confirmVote"); + public static final Route GET_DISLIKES = new Route(GET, "votes?videoId={video_id}"); + public static final Route GET_REGISTRATION = new Route(GET, "puzzle/registration?userId={user_id}"); + public static final Route CONFIRM_REGISTRATION = new Route(POST, "puzzle/registration?userId={user_id}"); + + public ReturnYouTubeDislikeRoutes() { + } + + public static HttpURLConnection getRYDConnectionFromRoute(Route route, String... params) throws IOException { + return Requester.getConnectionFromRoute(RYD_API_URL, route, params); + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubeusername/requests/ChannelRequest.java b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubeusername/requests/ChannelRequest.java new file mode 100644 index 0000000000..84e02755b6 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubeusername/requests/ChannelRequest.java @@ -0,0 +1,167 @@ +package app.revanced.extension.shared.returnyoutubeusername.requests; + +import static java.lang.Boolean.TRUE; +import static app.revanced.extension.shared.returnyoutubeusername.requests.ChannelRoutes.GET_CHANNEL_DETAILS; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import app.revanced.extension.shared.requests.Requester; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +public class ChannelRequest { + /** + * TCP connection and HTTP read timeout. + */ + private static final int HTTP_TIMEOUT_MILLISECONDS = 3 * 1000; + + /** + * Any arbitrarily large value, but must be at least twice {@link #HTTP_TIMEOUT_MILLISECONDS} + */ + private static final int MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 6 * 1000; + + @GuardedBy("itself") + private static final Map cache = Collections.synchronizedMap( + new LinkedHashMap<>(200) { + private static final int CACHE_LIMIT = 100; + + @Override + protected boolean removeEldestEntry(Entry eldest) { + return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit. + } + }); + + public static void fetchRequestIfNeeded(@NonNull String handle, @NonNull String apiKey, Boolean userNameFirst) { + if (!cache.containsKey(handle)) { + cache.put(handle, new ChannelRequest(handle, apiKey, userNameFirst)); + } + } + + @Nullable + public static ChannelRequest getRequestForHandle(@NonNull String handle) { + return cache.get(handle); + } + + private static void handleConnectionError(String toastMessage, @Nullable Exception ex) { + Logger.printInfo(() -> toastMessage, ex); + } + + @Nullable + private static JSONObject send(String handle, String apiKey) { + Objects.requireNonNull(handle); + Objects.requireNonNull(apiKey); + + final long startTime = System.currentTimeMillis(); + Logger.printDebug(() -> "Fetching channel handle for: " + handle); + + try { + HttpURLConnection connection = ChannelRoutes.getChannelConnectionFromRoute(GET_CHANNEL_DETAILS, handle, apiKey); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("Connection", "keep-alive"); // keep-alive is on by default with http 1.1, but specify anyways + connection.setRequestProperty("Pragma", "no-cache"); + connection.setRequestProperty("Cache-Control", "no-cache"); + connection.setUseCaches(false); + connection.setConnectTimeout(HTTP_TIMEOUT_MILLISECONDS); + connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS); + + final int responseCode = connection.getResponseCode(); + if (responseCode == 200) return Requester.parseJSONObject(connection); + + handleConnectionError("API not available with response code: " + + responseCode + " message: " + connection.getResponseMessage(), + null); + } catch (SocketTimeoutException ex) { + handleConnectionError("Connection timeout", ex); + } catch (IOException ex) { + handleConnectionError("Network error", ex); + } catch (Exception ex) { + Logger.printException(() -> "send failed", ex); + } finally { + Logger.printDebug(() -> "handle: " + handle + " took: " + (System.currentTimeMillis() - startTime) + "ms"); + } + + return null; + } + + private static String fetch(@NonNull String handle, @NonNull String apiKey, Boolean userNameFirst) { + final JSONObject channelJsonObject = send(handle, apiKey); + if (channelJsonObject != null) { + try { + final String userName = channelJsonObject + .getJSONArray("items") + .getJSONObject(0) + .getJSONObject("snippet") + .getString("title"); + return authorBadgeBuilder(handle, userName, userNameFirst); + } catch (JSONException e) { + Logger.printDebug(() -> "Fetch failed while processing response data for response: " + channelJsonObject); + } + } + return null; + } + + private static final String AUTHOR_BADGE_FORMAT = "\u202D%s\u2009%s"; + private static final String PARENTHESES_FORMAT = "(%s)"; + + private static String authorBadgeBuilder(@NonNull String handle, @NonNull String userName, Boolean userNameFirst) { + if (userNameFirst == null) { + return userName; + } else if (TRUE.equals(userNameFirst)) { + handle = String.format(Locale.ENGLISH, PARENTHESES_FORMAT, handle); + if (!Utils.isRightToLeftTextLayout()) { + return String.format(Locale.ENGLISH, AUTHOR_BADGE_FORMAT, userName, handle); + } + } else { + userName = String.format(Locale.ENGLISH, PARENTHESES_FORMAT, userName); + } + return String.format(Locale.ENGLISH, AUTHOR_BADGE_FORMAT, handle, userName); + } + + private final String handle; + private final Future future; + + private ChannelRequest(String handle, String apiKey, Boolean append) { + this.handle = handle; + this.future = Utils.submitOnBackgroundThread(() -> fetch(handle, apiKey, append)); + } + + @Nullable + public String getStream() { + try { + return future.get(MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS); + } catch (TimeoutException ex) { + Logger.printInfo(() -> "getStream timed out", ex); + } catch (InterruptedException ex) { + Logger.printException(() -> "getStream interrupted", ex); + Thread.currentThread().interrupt(); // Restore interrupt status flag. + } catch (ExecutionException ex) { + Logger.printException(() -> "getStream failure", ex); + } + + return null; + } + + @NonNull + @Override + public String toString() { + return "ChannelRequest{" + "handle='" + handle + '\'' + '}'; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubeusername/requests/ChannelRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubeusername/requests/ChannelRoutes.java new file mode 100644 index 0000000000..14da596034 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubeusername/requests/ChannelRoutes.java @@ -0,0 +1,22 @@ +package app.revanced.extension.shared.returnyoutubeusername.requests; + +import static app.revanced.extension.shared.requests.Route.Method.GET; + +import java.io.IOException; +import java.net.HttpURLConnection; + +import app.revanced.extension.shared.requests.Requester; +import app.revanced.extension.shared.requests.Route; + +public class ChannelRoutes { + public static final String YOUTUBEI_V3_GAPIS_URL = "https://www.googleapis.com/youtube/v3/"; + + public static final Route GET_CHANNEL_DETAILS = new Route(GET, "channels?part=snippet&forHandle={handle}&key={api_key}"); + + public ChannelRoutes() { + } + + public static HttpURLConnection getChannelConnectionFromRoute(Route route, String... params) throws IOException { + return Requester.getConnectionFromRoute(YOUTUBEI_V3_GAPIS_URL, route, params); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BaseSettings.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BaseSettings.java new file mode 100644 index 0000000000..c75290bd29 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BaseSettings.java @@ -0,0 +1,44 @@ +package app.revanced.extension.shared.settings; + +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; + +import app.revanced.extension.shared.patches.ReturnYouTubeUsernamePatch.DisplayFormat; + +/** + * Settings shared across multiple apps. + *

+ * To ensure this class is loaded when the UI is created, app specific setting bundles should extend + * or reference this class. + */ +public class BaseSettings { + public static final BooleanSetting ENABLE_DEBUG_LOGGING = new BooleanSetting("revanced_enable_debug_logging", FALSE); + /** + * When enabled, share the debug logs with care. + * The buffer contains select user data, including the client ip address and information that could identify the end user. + */ + public static final BooleanSetting ENABLE_DEBUG_BUFFER_LOGGING = new BooleanSetting("revanced_enable_debug_buffer_logging", FALSE); + public static final BooleanSetting SETTINGS_INITIALIZED = new BooleanSetting("revanced_settings_initialized", FALSE, false, false); + + /** + * These settings are used by YouTube and YouTube Music. + */ + public static final BooleanSetting HIDE_FULLSCREEN_ADS = new BooleanSetting("revanced_hide_fullscreen_ads", TRUE, true); + public static final BooleanSetting HIDE_PROMOTION_ALERT_BANNER = new BooleanSetting("revanced_hide_promotion_alert_banner", TRUE); + + public static final BooleanSetting DISABLE_AUTO_CAPTIONS = new BooleanSetting("revanced_disable_auto_captions", FALSE, true); + + public static final BooleanSetting BYPASS_IMAGE_REGION_RESTRICTIONS = new BooleanSetting("revanced_bypass_image_region_restrictions", FALSE, true); + public static final BooleanSetting RETURN_YOUTUBE_USERNAME_ENABLED = new BooleanSetting("revanced_return_youtube_username_enabled", FALSE, true); + public static final EnumSetting RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT = new EnumSetting<>("revanced_return_youtube_username_display_format", DisplayFormat.USERNAME_ONLY, true); + public static final StringSetting RETURN_YOUTUBE_USERNAME_YOUTUBE_DATA_API_V3_DEVELOPER_KEY = new StringSetting("revanced_return_youtube_username_youtube_data_api_v3_developer_key", "", true, false); + + /** + * @noinspection DeprecatedIsStillUsed + */ + @Deprecated + // The official ReVanced does not offer this, so it has been removed from the settings only. Users can still access settings through import / export settings. + public static final StringSetting BYPASS_IMAGE_REGION_RESTRICTIONS_DOMAIN = new StringSetting("revanced_bypass_image_region_restrictions_domain", "yt4.ggpht.com", true); + + public static final BooleanSetting SANITIZE_SHARING_LINKS = new BooleanSetting("revanced_sanitize_sharing_links", TRUE, true); +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BooleanSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BooleanSetting.java new file mode 100644 index 0000000000..b517924b47 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BooleanSetting.java @@ -0,0 +1,93 @@ +package app.revanced.extension.shared.settings; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Objects; + +@SuppressWarnings("unused") +public class BooleanSetting extends Setting { + public BooleanSetting(String key, Boolean defaultValue) { + super(key, defaultValue); + } + + public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp) { + super(key, defaultValue, rebootApp); + } + + public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, boolean includeWithImportExport) { + super(key, defaultValue, rebootApp, includeWithImportExport); + } + + public BooleanSetting(String key, Boolean defaultValue, String userDialogMessage) { + super(key, defaultValue, userDialogMessage); + } + + public BooleanSetting(String key, Boolean defaultValue, Availability availability) { + super(key, defaultValue, availability); + } + + public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, String userDialogMessage) { + super(key, defaultValue, rebootApp, userDialogMessage); + } + + public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, Availability availability) { + super(key, defaultValue, rebootApp, availability); + } + + public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) { + super(key, defaultValue, rebootApp, userDialogMessage, availability); + } + + public BooleanSetting(@NonNull String key, @NonNull Boolean defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) { + super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability); + } + + /** + * Sets, but does _not_ persistently save the value. + * This method is only to be used by the Settings preference code. + *

+ * This intentionally is a static method to deter + * accidental usage when {@link #save(Boolean)} was intnded. + */ + public static void privateSetValue(@NonNull BooleanSetting setting, @NonNull Boolean newValue) { + setting.value = Objects.requireNonNull(newValue); + } + + @Override + protected void load() { + value = preferences.getBoolean(key, defaultValue); + } + + @Override + protected Boolean readFromJSON(JSONObject json, String importExportKey) throws JSONException { + return json.getBoolean(importExportKey); + } + + @Override + protected void setValueFromString(@NonNull String newValue) { + value = Boolean.valueOf(Objects.requireNonNull(newValue)); + } + + @Override + public void save(@NonNull Boolean newValue) { + // Must set before saving to preferences (otherwise importing fails to update UI correctly). + value = Objects.requireNonNull(newValue); + preferences.saveBoolean(key, newValue); + } + + @Override + public void saveValueFromString(@NonNull String newValue) { + setValueFromString(newValue); + preferences.saveString(key, newValue); + } + + @NonNull + @Override + public Boolean get() { + return value; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/EnumSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/EnumSetting.java new file mode 100644 index 0000000000..36c6ebfb75 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/EnumSetting.java @@ -0,0 +1,131 @@ +package app.revanced.extension.shared.settings; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Locale; +import java.util.Objects; + +import app.revanced.extension.shared.utils.Logger; + +/** + * If an Enum value is removed or changed, any saved or imported data using the + * non-existent value will be reverted to the default value + * (the event is logged, but no user error is displayed). + *

+ * All saved JSON text is converted to lowercase to keep the output less obnoxious. + */ +@SuppressWarnings("unused") +public class EnumSetting> extends Setting { + public EnumSetting(String key, T defaultValue) { + super(key, defaultValue); + } + + public EnumSetting(String key, T defaultValue, boolean rebootApp) { + super(key, defaultValue, rebootApp); + } + + public EnumSetting(String key, T defaultValue, boolean rebootApp, boolean includeWithImportExport) { + super(key, defaultValue, rebootApp, includeWithImportExport); + } + + public EnumSetting(String key, T defaultValue, String userDialogMessage) { + super(key, defaultValue, userDialogMessage); + } + + public EnumSetting(String key, T defaultValue, Availability availability) { + super(key, defaultValue, availability); + } + + public EnumSetting(String key, T defaultValue, boolean rebootApp, String userDialogMessage) { + super(key, defaultValue, rebootApp, userDialogMessage); + } + + public EnumSetting(String key, T defaultValue, boolean rebootApp, Availability availability) { + super(key, defaultValue, rebootApp, availability); + } + + public EnumSetting(String key, T defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) { + super(key, defaultValue, rebootApp, userDialogMessage, availability); + } + + public EnumSetting(@NonNull String key, @NonNull T defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) { + super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability); + } + + @Override + protected void load() { + value = preferences.getEnum(key, defaultValue); + } + + @Override + protected T readFromJSON(JSONObject json, String importExportKey) throws JSONException { + String enumName = json.getString(importExportKey); + try { + return getEnumFromString(enumName); + } catch (IllegalArgumentException ex) { + // Info level to allow removing enum values in the future without showing any user errors. + Logger.printInfo(() -> "Using default, and ignoring unknown enum value: " + enumName, ex); + return defaultValue; + } + } + + @Override + protected void writeToJSON(JSONObject json, String importExportKey) throws JSONException { + // Use lowercase to keep the output less ugly. + json.put(importExportKey, value.name().toLowerCase(Locale.ENGLISH)); + } + + @NonNull + private T getEnumFromString(String enumName) { + //noinspection ConstantConditions + for (Enum value : defaultValue.getClass().getEnumConstants()) { + if (value.name().equalsIgnoreCase(enumName)) { + // noinspection unchecked + return (T) value; + } + } + throw new IllegalArgumentException("Unknown enum value: " + enumName); + } + + @Override + protected void setValueFromString(@NonNull String newValue) { + value = getEnumFromString(Objects.requireNonNull(newValue)); + } + + @Override + public void save(@NonNull T newValue) { + // Must set before saving to preferences (otherwise importing fails to update UI correctly). + value = Objects.requireNonNull(newValue); + preferences.saveEnumAsString(key, newValue); + } + + @Override + public void saveValueFromString(@NonNull String newValue) { + setValueFromString(newValue); + preferences.saveString(key, newValue); + } + + @NonNull + @Override + public T get() { + return value; + } + + /** + * Availability based on if this setting is currently set to any of the provided types. + */ + @SafeVarargs + public final Setting.Availability availability(@NonNull T... types) { + return () -> { + T currentEnumType = get(); + for (T enumType : types) { + if (currentEnumType == enumType) return true; + } + return false; + }; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/FloatSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/FloatSetting.java new file mode 100644 index 0000000000..fe6190d651 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/FloatSetting.java @@ -0,0 +1,83 @@ +package app.revanced.extension.shared.settings; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Objects; + +@SuppressWarnings("unused") +public class FloatSetting extends Setting { + + public FloatSetting(String key, Float defaultValue) { + super(key, defaultValue); + } + + public FloatSetting(String key, Float defaultValue, boolean rebootApp) { + super(key, defaultValue, rebootApp); + } + + public FloatSetting(String key, Float defaultValue, boolean rebootApp, boolean includeWithImportExport) { + super(key, defaultValue, rebootApp, includeWithImportExport); + } + + public FloatSetting(String key, Float defaultValue, String userDialogMessage) { + super(key, defaultValue, userDialogMessage); + } + + public FloatSetting(String key, Float defaultValue, Availability availability) { + super(key, defaultValue, availability); + } + + public FloatSetting(String key, Float defaultValue, boolean rebootApp, String userDialogMessage) { + super(key, defaultValue, rebootApp, userDialogMessage); + } + + public FloatSetting(String key, Float defaultValue, boolean rebootApp, Availability availability) { + super(key, defaultValue, rebootApp, availability); + } + + public FloatSetting(String key, Float defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) { + super(key, defaultValue, rebootApp, userDialogMessage, availability); + } + + public FloatSetting(@NonNull String key, @NonNull Float defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) { + super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability); + } + + @Override + protected void load() { + value = preferences.getFloatString(key, defaultValue); + } + + @Override + protected Float readFromJSON(JSONObject json, String importExportKey) throws JSONException { + return (float) json.getDouble(importExportKey); + } + + @Override + protected void setValueFromString(@NonNull String newValue) { + value = Float.valueOf(Objects.requireNonNull(newValue)); + } + + @Override + public void save(@NonNull Float newValue) { + // Must set before saving to preferences (otherwise importing fails to update UI correctly). + value = Objects.requireNonNull(newValue); + preferences.saveFloatString(key, newValue); + } + + @Override + public void saveValueFromString(@NonNull String newValue) { + setValueFromString(newValue); + preferences.saveString(key, newValue); + } + + @NonNull + @Override + public Float get() { + return value; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/IntegerSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/IntegerSetting.java new file mode 100644 index 0000000000..d4d34728fb --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/IntegerSetting.java @@ -0,0 +1,83 @@ +package app.revanced.extension.shared.settings; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Objects; + +@SuppressWarnings("unused") +public class IntegerSetting extends Setting { + + public IntegerSetting(String key, Integer defaultValue) { + super(key, defaultValue); + } + + public IntegerSetting(String key, Integer defaultValue, boolean rebootApp) { + super(key, defaultValue, rebootApp); + } + + public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, boolean includeWithImportExport) { + super(key, defaultValue, rebootApp, includeWithImportExport); + } + + public IntegerSetting(String key, Integer defaultValue, String userDialogMessage) { + super(key, defaultValue, userDialogMessage); + } + + public IntegerSetting(String key, Integer defaultValue, Availability availability) { + super(key, defaultValue, availability); + } + + public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, String userDialogMessage) { + super(key, defaultValue, rebootApp, userDialogMessage); + } + + public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, Availability availability) { + super(key, defaultValue, rebootApp, availability); + } + + public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) { + super(key, defaultValue, rebootApp, userDialogMessage, availability); + } + + public IntegerSetting(@NonNull String key, @NonNull Integer defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) { + super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability); + } + + @Override + protected void load() { + value = preferences.getIntegerString(key, defaultValue); + } + + @Override + protected Integer readFromJSON(JSONObject json, String importExportKey) throws JSONException { + return json.getInt(importExportKey); + } + + @Override + protected void setValueFromString(@NonNull String newValue) { + value = Integer.valueOf(Objects.requireNonNull(newValue)); + } + + @Override + public void save(@NonNull Integer newValue) { + // Must set before saving to preferences (otherwise importing fails to update UI correctly). + value = Objects.requireNonNull(newValue); + preferences.saveIntegerString(key, newValue); + } + + @Override + public void saveValueFromString(@NonNull String newValue) { + setValueFromString(newValue); + preferences.saveString(key, newValue); + } + + @NonNull + @Override + public Integer get() { + return value; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/LongSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/LongSetting.java new file mode 100644 index 0000000000..91d1b5a937 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/LongSetting.java @@ -0,0 +1,83 @@ +package app.revanced.extension.shared.settings; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Objects; + +@SuppressWarnings("unused") +public class LongSetting extends Setting { + + public LongSetting(String key, Long defaultValue) { + super(key, defaultValue); + } + + public LongSetting(String key, Long defaultValue, boolean rebootApp) { + super(key, defaultValue, rebootApp); + } + + public LongSetting(String key, Long defaultValue, boolean rebootApp, boolean includeWithImportExport) { + super(key, defaultValue, rebootApp, includeWithImportExport); + } + + public LongSetting(String key, Long defaultValue, String userDialogMessage) { + super(key, defaultValue, userDialogMessage); + } + + public LongSetting(String key, Long defaultValue, Availability availability) { + super(key, defaultValue, availability); + } + + public LongSetting(String key, Long defaultValue, boolean rebootApp, String userDialogMessage) { + super(key, defaultValue, rebootApp, userDialogMessage); + } + + public LongSetting(String key, Long defaultValue, boolean rebootApp, Availability availability) { + super(key, defaultValue, rebootApp, availability); + } + + public LongSetting(String key, Long defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) { + super(key, defaultValue, rebootApp, userDialogMessage, availability); + } + + public LongSetting(@NonNull String key, @NonNull Long defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) { + super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability); + } + + @Override + protected void load() { + value = preferences.getLongString(key, defaultValue); + } + + @Override + protected Long readFromJSON(JSONObject json, String importExportKey) throws JSONException { + return json.getLong(importExportKey); + } + + @Override + protected void setValueFromString(@NonNull String newValue) { + value = Long.valueOf(Objects.requireNonNull(newValue)); + } + + @Override + public void save(@NonNull Long newValue) { + // Must set before saving to preferences (otherwise importing fails to update UI correctly). + value = Objects.requireNonNull(newValue); + preferences.saveLongString(key, newValue); + } + + @Override + public void saveValueFromString(@NonNull String newValue) { + setValueFromString(newValue); + preferences.saveString(key, newValue); + } + + @NonNull + @Override + public Long get() { + return value; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/Setting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/Setting.java new file mode 100644 index 0000000000..cdd81b5b41 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/Setting.java @@ -0,0 +1,475 @@ +package app.revanced.extension.shared.settings; + +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.shared.utils.Utils.isSDKAbove; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.jetbrains.annotations.NotNull; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import app.revanced.extension.shared.settings.preference.SharedPrefCategory; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.StringRef; +import app.revanced.extension.shared.utils.Utils; + +/** + * @noinspection rawtypes + */ +@SuppressWarnings("unused") +public abstract class Setting { + + /** + * Indicates if a {@link Setting} is available to edit and use. + * Typically this is dependent upon other BooleanSetting(s) set to 'true', + * but this can be used to call into integrations code and check other conditions. + */ + public interface Availability { + boolean isAvailable(); + } + + /** + * Availability based on a single parent setting being enabled. + */ + @NonNull + public static Availability parent(@NonNull BooleanSetting parent) { + return parent::get; + } + + /** + * Availability based on all parents being enabled. + */ + @NonNull + public static Availability parentsAll(@NonNull BooleanSetting... parents) { + return () -> { + for (BooleanSetting parent : parents) { + if (!parent.get()) return false; + } + return true; + }; + } + + /** + * Availability based on any parent being enabled. + */ + @NonNull + public static Availability parentsAny(@NonNull BooleanSetting... parents) { + return () -> { + for (BooleanSetting parent : parents) { + if (parent.get()) return true; + } + return false; + }; + } + + /** + * All settings that were instantiated. + * When a new setting is created, it is automatically added to this list. + */ + private static final List> SETTINGS = new ArrayList<>(); + + /** + * Map of setting path to setting object. + */ + private static final Map> PATH_TO_SETTINGS = new HashMap<>(); + + /** + * Preference all instances are saved to. + */ + public static final SharedPrefCategory preferences = new SharedPrefCategory("revanced"); + + @Nullable + public static Setting getSettingFromPath(@NonNull String str) { + return PATH_TO_SETTINGS.get(str); + } + + /** + * @return All settings that have been created. + */ + @NonNull + public static List> allLoadedSettings() { + return Collections.unmodifiableList(SETTINGS); + } + + /** + * @return All settings that have been created, sorted by keys. + */ + @NonNull + private static List> allLoadedSettingsSorted() { + if (isSDKAbove(24)) { + SETTINGS.sort(Comparator.comparing((Setting o) -> o.key)); + } else { + //noinspection ComparatorCombinators + Collections.sort(SETTINGS, (o1, o2) -> o1.key.compareTo(o2.key)); + } + return allLoadedSettings(); + } + + /** + * The key used to store the value in the shared preferences. + */ + @NonNull + public final String key; + + /** + * The default value of the setting. + */ + @NonNull + public final T defaultValue; + + /** + * If the app should be rebooted, if this setting is changed + */ + public final boolean rebootApp; + + /** + * If this setting should be included when importing/exporting settings. + */ + public final boolean includeWithImportExport; + + /** + * If this setting is available to edit and use. + * Not to be confused with it's status returned from {@link #get()}. + */ + @Nullable + private final Availability availability; + + /** + * Confirmation message to display, if the user tries to change the setting from the default value. + * Currently this works only for Boolean setting types. + */ + @Nullable + public final StringRef userDialogMessage; + + // Must be volatile, as some settings are read/write from different threads. + // Of note, the object value is persistently stored using SharedPreferences (which is thread safe). + /** + * The value of the setting. + */ + @NonNull + protected volatile T value; + + public Setting(String key, T defaultValue) { + this(key, defaultValue, false, true, null, null); + } + + public Setting(String key, T defaultValue, boolean rebootApp) { + this(key, defaultValue, rebootApp, true, null, null); + } + + public Setting(String key, T defaultValue, boolean rebootApp, boolean includeWithImportExport) { + this(key, defaultValue, rebootApp, includeWithImportExport, null, null); + } + + public Setting(String key, T defaultValue, String userDialogMessage) { + this(key, defaultValue, false, true, userDialogMessage, null); + } + + public Setting(String key, T defaultValue, Availability availability) { + this(key, defaultValue, false, true, null, availability); + } + + public Setting(String key, T defaultValue, boolean rebootApp, String userDialogMessage) { + this(key, defaultValue, rebootApp, true, userDialogMessage, null); + } + + public Setting(String key, T defaultValue, boolean rebootApp, Availability availability) { + this(key, defaultValue, rebootApp, true, null, availability); + } + + public Setting(String key, T defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) { + this(key, defaultValue, rebootApp, true, userDialogMessage, availability); + } + + /** + * A setting backed by a shared preference. + * + * @param key The key used to store the value in the shared preferences. + * @param defaultValue The default value of the setting. + * @param rebootApp If the app should be rebooted, if this setting is changed. + * @param includeWithImportExport If this setting should be shown in the import/export dialog. + * @param userDialogMessage Confirmation message to display, if the user tries to change the setting from the default value. + * @param availability Condition that must be true, for this setting to be available to configure. + */ + public Setting(@NonNull String key, + @NonNull T defaultValue, + boolean rebootApp, + boolean includeWithImportExport, + @Nullable String userDialogMessage, + @Nullable Availability availability + ) { + this.key = Objects.requireNonNull(key); + this.value = this.defaultValue = Objects.requireNonNull(defaultValue); + this.rebootApp = rebootApp; + this.includeWithImportExport = includeWithImportExport; + this.userDialogMessage = (userDialogMessage == null) ? null : new StringRef(userDialogMessage); + this.availability = availability; + + SETTINGS.add(this); + if (PATH_TO_SETTINGS.put(key, this) != null) { + // Debug setting may not be created yet so using Logger may cause an initialization crash. + // Show a toast instead. + Utils.showToastShort(this.getClass().getSimpleName() + + " error: Duplicate Setting key found: " + key); + } + + load(); + } + + /** + * Migrate a setting value if the path is renamed but otherwise the old and new settings are identical. + */ + public static void migrateOldSettingToNew(@NonNull Setting oldSetting, @NonNull Setting newSetting) { + if (!oldSetting.isSetToDefault()) { + Logger.printInfo(() -> "Migrating old setting value: " + oldSetting + " into replacement setting: " + newSetting); + newSetting.save(oldSetting.value); + oldSetting.resetToDefault(); + } + } + + /** + * Migrate an old Setting value previously stored in a different SharedPreference. + *

+ * This method will be deleted in the future. + */ + public static void migrateFromOldPreferences(@NonNull SharedPrefCategory oldPrefs, @NonNull Setting setting, String settingKey) { + if (!oldPrefs.preferences.contains(settingKey)) { + return; // Nothing to do. + } + + Object newValue = setting.get(); + final Object migratedValue; + if (setting instanceof BooleanSetting) { + migratedValue = oldPrefs.getBoolean(settingKey, (Boolean) newValue); + } else if (setting instanceof IntegerSetting) { + migratedValue = oldPrefs.getIntegerString(settingKey, (Integer) newValue); + } else if (setting instanceof LongSetting) { + migratedValue = oldPrefs.getLongString(settingKey, (Long) newValue); + } else if (setting instanceof FloatSetting) { + migratedValue = oldPrefs.getFloatString(settingKey, (Float) newValue); + } else if (setting instanceof StringSetting) { + migratedValue = oldPrefs.getString(settingKey, (String) newValue); + } else { + Logger.printException(() -> "Unknown setting: " + setting); + // Remove otherwise it'll show a toast on every launch + oldPrefs.preferences.edit().remove(settingKey).apply(); + return; + } + + oldPrefs.preferences.edit().remove(settingKey).apply(); // Remove the old setting. + if (migratedValue.equals(newValue)) { + Logger.printDebug(() -> "Value does not need migrating: " + settingKey); + return; // Old value is already equal to the new setting value. + } + + Logger.printDebug(() -> "Migrating old preference value into current preference: " + settingKey); + //noinspection unchecked + setting.save(migratedValue); + } + + /** + * Sets, but does _not_ persistently save the value. + * This method is only to be used by the Settings preference code. + *

+ * This intentionally is a static method to deter + * accidental usage when {@link #save(Object)} was intended. + */ + public static void privateSetValueFromString(@NonNull Setting setting, @NonNull String newValue) { + setting.setValueFromString(newValue); + } + + /** + * Sets the value of {@link #value}, but do not save to {@link #preferences}. + */ + protected abstract void setValueFromString(@NonNull String newValue); + + /** + * Load and set the value of {@link #value}. + */ + protected abstract void load(); + + /** + * Persistently saves the value. + */ + public abstract void save(@NonNull T newValue); + + /** + * Persistently saves the value using strings. + */ + public abstract void saveValueFromString(@NonNull String newValue); + + @NonNull + public abstract T get(); + + /** + * Identical to calling {@link #save(Object)} using {@link #defaultValue}. + */ + public void resetToDefault() { + save(defaultValue); + } + + /** + * @return if this setting can be configured and used. + */ + public boolean isAvailable() { + return availability == null || availability.isAvailable(); + } + + /** + * @return if the currently set value is the same as {@link #defaultValue} + * @noinspection BooleanMethodIsAlwaysInverted + */ + public boolean isSetToDefault() { + return value.equals(defaultValue); + } + + @NotNull + @Override + public String toString() { + return key + "=" + get(); + } + + // region Import / export + + /** + * If a setting path has this prefix, then remove it before importing/exporting. + */ + private static final String OPTIONAL_REVANCED_SETTINGS_PREFIX = "revanced_"; + + /** + * The path, minus any 'revanced' prefix to keep json concise. + */ + private String getImportExportKey() { + if (key.startsWith(OPTIONAL_REVANCED_SETTINGS_PREFIX)) { + return key.substring(OPTIONAL_REVANCED_SETTINGS_PREFIX.length()); + } + return key; + } + + /** + * @param importExportKey The JSON key. The JSONObject parameter will contain data for this key. + * @return the value stored using the import/export key. Do not set any values in this method. + */ + protected abstract T readFromJSON(JSONObject json, String importExportKey) throws JSONException; + + /** + * Saves this instance to JSON. + *

+ * To keep the JSON simple and readable, + * subclasses should not write out any embedded types (such as JSON Array or Dictionaries). + *

+ * If this instance is not a type supported natively by JSON (ie: it's not a String/Integer/Float/Long), + * then subclasses can override this method and write out a String value representing the value. + */ + protected void writeToJSON(JSONObject json, String importExportKey) throws JSONException { + json.put(importExportKey, value); + } + + @NonNull + public static String exportToJson(@Nullable Context alertDialogContext) { + try { + JSONObject json = new JSONObject(); + for (Setting setting : allLoadedSettingsSorted()) { + String importExportKey = setting.getImportExportKey(); + if (json.has(importExportKey)) { + throw new IllegalArgumentException("duplicate key found: " + importExportKey); + } + + final boolean exportDefaultValues = false; // Enable to see what all settings looks like in the UI. + //noinspection ConstantValue + if (setting.includeWithImportExport && (!setting.isSetToDefault() || exportDefaultValues)) { + setting.writeToJSON(json, importExportKey); + } + } + if (alertDialogContext != null) { + app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings.showExportWarningIfNeeded(alertDialogContext); + } + + if (json.length() == 0) { + return ""; + } + + String export = json.toString(0); + + // Remove the outer JSON braces to make the output more compact, + // and leave less chance of the user forgetting to copy it + return export.substring(2, export.length() - 2); + } catch (JSONException e) { + Logger.printException(() -> "Export failure", e); // should never happen + return ""; + } + } + + public static boolean importFromJSON(@NonNull String settingsJsonString) { + return importFromJSON(settingsJsonString, true); + } + + /** + * @return if any settings that require a reboot were changed. + */ + public static boolean importFromJSON(@NonNull String settingsJsonString, boolean isYouTube) { + try { + if (!settingsJsonString.matches("[\\s\\S]*\\{")) { + settingsJsonString = '{' + settingsJsonString + '}'; // Restore outer JSON braces + } + JSONObject json = new JSONObject(settingsJsonString); + + boolean rebootSettingChanged = false; + int numberOfSettingsImported = 0; + for (Setting setting : SETTINGS) { + String key = setting.getImportExportKey(); + if (json.has(key)) { + Object value = setting.readFromJSON(json, key); + if (!setting.get().equals(value)) { + rebootSettingChanged |= setting.rebootApp; + //noinspection unchecked + setting.save(value); + } + numberOfSettingsImported++; + } else if (setting.includeWithImportExport && !setting.isSetToDefault()) { + Logger.printDebug(() -> "Resetting to default: " + setting); + rebootSettingChanged |= setting.rebootApp; + setting.resetToDefault(); + } + } + + // SB Enum categories are saved using StringSettings. + // Which means they need to reload again if changed by other code (such as here). + // This call could be removed by creating a custom Setting class that manages the + // "String <-> Enum" logic or by adding an event hook of when settings are imported. + // But for now this is simple and works. + if (isYouTube) { + app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings.updateFromImportedSettings(); + } else { + app.revanced.extension.music.sponsorblock.SponsorBlockSettings.updateFromImportedSettings(); + } + + Utils.showToastLong(numberOfSettingsImported == 0 + ? str("revanced_extended_settings_import_reset") + : str("revanced_extended_settings_import_success", numberOfSettingsImported)); + + return rebootSettingChanged; + } catch (JSONException | IllegalArgumentException ex) { + Utils.showToastLong(str("revanced_extended_settings_import_failed", ex.getMessage())); + Logger.printInfo(() -> "", ex); + } catch (Exception ex) { + Logger.printException(() -> "Import failure: " + ex.getMessage(), ex); // should never happen + } + return false; + } + + // End import / export + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/StringSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/StringSetting.java new file mode 100644 index 0000000000..fda7e516cc --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/StringSetting.java @@ -0,0 +1,83 @@ +package app.revanced.extension.shared.settings; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Objects; + +@SuppressWarnings("unused") +public class StringSetting extends Setting { + + public StringSetting(String key, String defaultValue) { + super(key, defaultValue); + } + + public StringSetting(String key, String defaultValue, boolean rebootApp) { + super(key, defaultValue, rebootApp); + } + + public StringSetting(String key, String defaultValue, boolean rebootApp, boolean includeWithImportExport) { + super(key, defaultValue, rebootApp, includeWithImportExport); + } + + public StringSetting(String key, String defaultValue, String userDialogMessage) { + super(key, defaultValue, userDialogMessage); + } + + public StringSetting(String key, String defaultValue, Availability availability) { + super(key, defaultValue, availability); + } + + public StringSetting(String key, String defaultValue, boolean rebootApp, String userDialogMessage) { + super(key, defaultValue, rebootApp, userDialogMessage); + } + + public StringSetting(String key, String defaultValue, boolean rebootApp, Availability availability) { + super(key, defaultValue, rebootApp, availability); + } + + public StringSetting(String key, String defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) { + super(key, defaultValue, rebootApp, userDialogMessage, availability); + } + + public StringSetting(@NonNull String key, @NonNull String defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) { + super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability); + } + + @Override + protected void load() { + value = preferences.getString(key, defaultValue); + } + + @Override + protected String readFromJSON(JSONObject json, String importExportKey) throws JSONException { + return json.getString(importExportKey); + } + + @Override + protected void setValueFromString(@NonNull String newValue) { + value = Objects.requireNonNull(newValue); + } + + @Override + public void save(@NonNull String newValue) { + // Must set before saving to preferences (otherwise importing fails to update UI correctly). + value = Objects.requireNonNull(newValue); + preferences.saveString(key, newValue); + } + + @Override + public void saveValueFromString(@NonNull String newValue) { + setValueFromString(newValue); + preferences.saveString(key, newValue); + } + + @NonNull + @Override + public String get() { + return value; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java new file mode 100644 index 0000000000..b2bac3d67c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java @@ -0,0 +1,287 @@ +package app.revanced.extension.shared.settings.preference; + +import static app.revanced.extension.shared.utils.ResourceUtils.getXmlIdentifier; +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.EditTextPreference; +import android.preference.ListPreference; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.preference.PreferenceManager; +import android.preference.PreferenceScreen; +import android.preference.SwitchPreference; +import android.view.View; +import android.widget.ListView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +@SuppressWarnings({"unused", "deprecation"}) +public abstract class AbstractPreferenceFragment extends PreferenceFragment { + /** + * Indicates that if a preference changes, + * to apply the change from the Setting to the UI component. + */ + public static boolean settingImportInProgress; + + /** + * Confirm and restart dialog button text and title. + * Set by subclasses if Strings cannot be added as a resource. + */ + @Nullable + protected static String restartDialogMessage; + + /** + * Used to prevent showing reboot dialog, if user cancels a setting user dialog. + */ + private boolean showingUserDialogMessage; + + private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> { + try { + if (str == null) { + return; + } + Setting setting = Setting.getSettingFromPath(str); + if (setting == null) { + return; + } + Preference pref = findPreference(str); + if (pref == null) { + return; + } + + // Apply 'Setting <- Preference', unless during importing when it needs to be 'Setting -> Preference'. + updatePreference(pref, setting, true, settingImportInProgress); + // Update any other preference availability that may now be different. + updateUIAvailability(); + + if (settingImportInProgress) { + return; + } + + if (!showingUserDialogMessage) { + if (setting.userDialogMessage != null && ((SwitchPreference) pref).isChecked() != (Boolean) setting.defaultValue) { + showSettingUserDialogConfirmation((SwitchPreference) pref, (BooleanSetting) setting); + } else if (setting.rebootApp) { + showRestartDialog(getActivity()); + } + } + + } catch (Exception ex) { + Logger.printException(() -> "OnSharedPreferenceChangeListener failure", ex); + } + }; + + /** + * Initialize this instance, and do any custom behavior. + *

+ * To ensure all {@link Setting} instances are correctly synced to the UI, + * it is important that subclasses make a call or otherwise reference their Settings class bundle + * so all app specific {@link Setting} instances are loaded before this method returns. + */ + protected void initialize() { + final int id = getXmlIdentifier("revanced_prefs"); + + if (id == 0) return; + addPreferencesFromResource(id); + Utils.sortPreferenceGroups(getPreferenceScreen()); + } + + private void showSettingUserDialogConfirmation(SwitchPreference switchPref, BooleanSetting setting) { + Utils.verifyOnMainThread(); + + final var context = getActivity(); + showingUserDialogMessage = true; + assert setting.userDialogMessage != null; + new AlertDialog.Builder(context) + .setTitle(android.R.string.dialog_alert_title) + .setMessage(setting.userDialogMessage.toString()) + .setPositiveButton(android.R.string.ok, (dialog, id) -> { + if (setting.rebootApp) { + showRestartDialog(context); + } + }) + .setNegativeButton(android.R.string.cancel, (dialog, id) -> { + switchPref.setChecked(setting.defaultValue); // Recursive call that resets the Setting value. + }) + .setOnDismissListener(dialog -> showingUserDialogMessage = false) + .setCancelable(false) + .show(); + } + + /** + * Updates all Preferences values and their availability using the current values in {@link Setting}. + */ + protected void updateUIToSettingValues() { + updatePreferenceScreen(getPreferenceScreen(), true, true); + } + + /** + * Updates Preferences availability only using the status of {@link Setting}. + */ + protected void updateUIAvailability() { + updatePreferenceScreen(getPreferenceScreen(), false, false); + } + + /** + * Syncs all UI Preferences to any {@link Setting} they represent. + */ + private void updatePreferenceScreen(@NonNull PreferenceScreen screen, + boolean syncSettingValue, + boolean applySettingToPreference) { + // Alternatively this could iterate thru all Settings and check for any matching Preferences, + // but there are many more Settings than UI preferences so it's more efficient to only check + // the Preferences. + for (int i = 0, prefCount = screen.getPreferenceCount(); i < prefCount; i++) { + Preference pref = screen.getPreference(i); + if (pref instanceof PreferenceScreen preferenceScreen) { + updatePreferenceScreen(preferenceScreen, syncSettingValue, applySettingToPreference); + } else if (pref.hasKey()) { + String key = pref.getKey(); + Setting setting = Setting.getSettingFromPath(key); + if (setting != null) { + updatePreference(pref, setting, syncSettingValue, applySettingToPreference); + } + } + } + } + + /** + * Handles syncing a UI Preference with the {@link Setting} that backs it. + * If needed, subclasses can override this to handle additional UI Preference types. + * + * @param applySettingToPreference If true, then apply {@link Setting} -> Preference. + * If false, then apply {@link Setting} <- Preference. + */ + protected void syncSettingWithPreference(@NonNull Preference pref, + @NonNull Setting setting, + boolean applySettingToPreference) { + if (pref instanceof SwitchPreference switchPreference) { + BooleanSetting boolSetting = (BooleanSetting) setting; + if (applySettingToPreference) { + switchPreference.setChecked(boolSetting.get()); + } else { + BooleanSetting.privateSetValue(boolSetting, switchPreference.isChecked()); + } + } else if (pref instanceof EditTextPreference editTextPreference) { + if (applySettingToPreference) { + editTextPreference.setText(setting.get().toString()); + } else { + Setting.privateSetValueFromString(setting, editTextPreference.getText()); + } + } else if (pref instanceof ListPreference listPreference) { + if (applySettingToPreference) { + listPreference.setValue(setting.get().toString()); + } else { + Setting.privateSetValueFromString(setting, listPreference.getValue()); + } + updateListPreferenceSummary(listPreference, setting); + } else { + Logger.printException(() -> "Setting cannot be handled: " + pref.getClass() + ": " + pref); + } + } + + /** + * Updates a UI Preference with the {@link Setting} that backs it. + * + * @param syncSetting If the UI should be synced {@link Setting} <-> Preference + * @param applySettingToPreference If true, then apply {@link Setting} -> Preference. + * If false, then apply {@link Setting} <- Preference. + */ + private void updatePreference(@NonNull Preference pref, @NonNull Setting setting, + boolean syncSetting, boolean applySettingToPreference) { + if (!syncSetting && applySettingToPreference) { + throw new IllegalArgumentException(); + } + + if (syncSetting) { + syncSettingWithPreference(pref, setting, applySettingToPreference); + } + + updatePreferenceAvailability(pref, setting); + } + + protected void updatePreferenceAvailability(@NonNull Preference pref, @NonNull Setting setting) { + pref.setEnabled(setting.isAvailable()); + } + + public static void updateListPreferenceSummary(ListPreference listPreference, Setting setting) { + String objectStringValue = setting.get().toString(); + int entryIndex = listPreference.findIndexOfValue(objectStringValue); + if (entryIndex >= 0) { + listPreference.setValue(objectStringValue); + objectStringValue = listPreference.getEntries()[entryIndex].toString(); + } + listPreference.setSummary(objectStringValue); + } + + public static void showRestartDialog(@NonNull final Context context) { + if (restartDialogMessage == null) { + restartDialogMessage = str("revanced_extended_restart_message"); + } + showRestartDialog(context, restartDialogMessage); + } + + public static void showRestartDialog(@NonNull final Context context, String message) { + showRestartDialog(context, message, 0); + } + + public static void showRestartDialog(@NonNull final Context context, String message, long delay) { + Utils.verifyOnMainThread(); + + new AlertDialog.Builder(context) + .setMessage(message) + .setPositiveButton(android.R.string.ok, (dialog, id) + -> Utils.runOnMainThreadDelayed(() -> Utils.restartApp(context), delay)) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + @SuppressLint("ResourceType") + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + try { + PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setSharedPreferencesName(Setting.preferences.name); + + // Must initialize before adding change listener, + // otherwise the syncing of Setting -> UI + // causes a callback to the listener even though nothing changed. + initialize(); + updateUIToSettingValues(); + + preferenceManager.getSharedPreferences().registerOnSharedPreferenceChangeListener(listener); + } catch (Exception ex) { + Logger.printException(() -> "onCreate() failure", ex); + } + } + + @Override + public void onResume() { + super.onResume(); + + final View rootView = getView(); + if (rootView == null) return; + ListView listView = getView().findViewById(android.R.id.list); + if (listView == null) return; + listView.setDivider(null); + listView.setDividerHeight(0); + } + + @Override + public void onDestroy() { + getPreferenceManager().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(listener); + super.onDestroy(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/HtmlPreference.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/HtmlPreference.java new file mode 100644 index 0000000000..3023ee2aa8 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/HtmlPreference.java @@ -0,0 +1,34 @@ +package app.revanced.extension.shared.settings.preference; + +import static android.text.Html.FROM_HTML_MODE_COMPACT; + +import android.content.Context; +import android.preference.Preference; +import android.text.Html; +import android.util.AttributeSet; + +/** + * Allows using basic html for the summary text. + */ +@SuppressWarnings({"unused", "deprecation"}) +public class HtmlPreference extends Preference { + { + setSummary(Html.fromHtml(getSummary().toString(), FROM_HTML_MODE_COMPACT)); + } + + public HtmlPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public HtmlPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public HtmlPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public HtmlPreference(Context context) { + super(context); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java new file mode 100644 index 0000000000..414ca0a185 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java @@ -0,0 +1,104 @@ +package app.revanced.extension.shared.settings.preference; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.annotation.TargetApi; +import android.app.AlertDialog; +import android.content.Context; +import android.preference.EditTextPreference; +import android.preference.Preference; +import android.text.InputType; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.widget.EditText; + +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +@SuppressWarnings({"unused", "deprecation"}) +public class ImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener { + + private String existingSettings; + + @TargetApi(26) + private void init() { + setSelectable(true); + + EditText editText = getEditText(); + editText.setTextIsSelectable(true); + editText.setAutofillHints((String) null); + editText.setInputType(editText.getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + editText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 7); // Use a smaller font to reduce text wrap. + + setOnPreferenceClickListener(this); + } + + public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public ImportExportPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ImportExportPreference(Context context) { + super(context); + init(); + } + + @Override + public boolean onPreferenceClick(Preference preference) { + try { + // Must set text before preparing dialog, otherwise text is non selectable if this preference is later reopened. + existingSettings = Setting.exportToJson(getContext()); + getEditText().setText(existingSettings); + } catch (Exception ex) { + Logger.printException(() -> "showDialog failure", ex); + } + return true; + } + + @Override + protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { + try { + Utils.setEditTextDialogTheme(builder, true); + super.onPrepareDialogBuilder(builder); + // Show the user the settings in JSON format. + builder.setNeutralButton( + str("revanced_extended_settings_import_copy"), (dialog, which) -> + Utils.setClipboard(getEditText().getText().toString()) + ).setPositiveButton( + str("revanced_extended_settings_import"), (dialog, which) -> + importSettings(getEditText().getText().toString()) + ); + } catch (Exception ex) { + Logger.printException(() -> "onPrepareDialogBuilder failure", ex); + } + } + + private void importSettings(String replacementSettings) { + try { + if (replacementSettings.equals(existingSettings)) { + return; + } + AbstractPreferenceFragment.settingImportInProgress = true; + final boolean rebootNeeded = Setting.importFromJSON(replacementSettings); + if (rebootNeeded) { + AbstractPreferenceFragment.showRestartDialog(getContext()); + } + } catch (Exception ex) { + Logger.printException(() -> "importSettings failure", ex); + } finally { + AbstractPreferenceFragment.settingImportInProgress = false; + } + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ResettableEditTextPreference.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ResettableEditTextPreference.java new file mode 100644 index 0000000000..43305c23c3 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ResettableEditTextPreference.java @@ -0,0 +1,78 @@ +package app.revanced.extension.shared.settings.preference; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.app.AlertDialog; +import android.content.Context; +import android.os.Bundle; +import android.preference.EditTextPreference; +import android.util.AttributeSet; +import android.widget.Button; +import android.widget.EditText; + +import java.util.Objects; + +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +@SuppressWarnings({"unused", "deprecation"}) +public class ResettableEditTextPreference extends EditTextPreference { + + public ResettableEditTextPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public ResettableEditTextPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public ResettableEditTextPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ResettableEditTextPreference(Context context) { + super(context); + } + + @Override + protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { + Utils.setEditTextDialogTheme(builder); + super.onPrepareDialogBuilder(builder); + + final CharSequence title = getTitle(); + if (title != null) { + builder.setTitle(getTitle()); + } + final Setting setting = Setting.getSettingFromPath(getKey()); + if (setting != null) { + builder.setNeutralButton(str("revanced_extended_settings_reset"), null); + } + } + + @Override + protected void showDialog(Bundle state) { + super.showDialog(state); + + if (!(getDialog() instanceof AlertDialog alertDialog)) { + return; + } + + // Override the button click listener to prevent dismissing the dialog. + Button button = alertDialog.getButton(AlertDialog.BUTTON_NEUTRAL); + if (button == null) { + return; + } + button.setOnClickListener(v -> { + try { + Setting setting = Objects.requireNonNull(Setting.getSettingFromPath(getKey())); + String defaultStringValue = setting.defaultValue.toString(); + EditText editText = getEditText(); + editText.setText(defaultStringValue); + editText.setSelection(defaultStringValue.length()); // move cursor to end of text + } catch (Exception ex) { + Logger.printException(() -> "reset failure", ex); + } + }); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/SharedPrefCategory.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/SharedPrefCategory.java new file mode 100644 index 0000000000..5122ba191d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/SharedPrefCategory.java @@ -0,0 +1,193 @@ +package app.revanced.extension.shared.settings.preference; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceFragment; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Objects; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +/** + * Shared categories, and helper methods. + *

+ * The various save methods store numbers as Strings, + * which is required if using {@link PreferenceFragment}. + *

+ * If saved numbers will not be used with a preference fragment, + * then store the primitive numbers using the {@link #preferences} itself. + */ +public class SharedPrefCategory { + @NonNull + public final String name; + @NonNull + public final SharedPreferences preferences; + + public SharedPrefCategory(@NonNull String name) { + this.name = Objects.requireNonNull(name); + preferences = Objects.requireNonNull(Utils.getContext()).getSharedPreferences(name, Context.MODE_PRIVATE); + } + + private void removeConflictingPreferenceKeyValue(@NonNull String key) { + Logger.printException(() -> "Found conflicting preference: " + key); + removeKey(key); + } + + private void saveObjectAsString(@NonNull String key, @Nullable Object value) { + preferences.edit().putString(key, (value == null ? null : value.toString())).apply(); + } + + /** + * Removes any preference data type that has the specified key. + */ + public void removeKey(@NonNull String key) { + preferences.edit().remove(Objects.requireNonNull(key)).apply(); + } + + public void saveBoolean(@NonNull String key, boolean value) { + preferences.edit().putBoolean(key, value).apply(); + } + + /** + * @param value a NULL parameter removes the value from the preferences + */ + public void saveEnumAsString(@NonNull String key, @Nullable Enum value) { + saveObjectAsString(key, value); + } + + /** + * @param value a NULL parameter removes the value from the preferences + */ + public void saveIntegerString(@NonNull String key, @Nullable Integer value) { + saveObjectAsString(key, value); + } + + /** + * @param value a NULL parameter removes the value from the preferences + */ + public void saveLongString(@NonNull String key, @Nullable Long value) { + saveObjectAsString(key, value); + } + + /** + * @param value a NULL parameter removes the value from the preferences + */ + public void saveFloatString(@NonNull String key, @Nullable Float value) { + saveObjectAsString(key, value); + } + + /** + * @param value a NULL parameter removes the value from the preferences + */ + public void saveString(@NonNull String key, @Nullable String value) { + saveObjectAsString(key, value); + } + + @NonNull + public String getString(@NonNull String key, @NonNull String _default) { + Objects.requireNonNull(_default); + try { + return preferences.getString(key, _default); + } catch (ClassCastException ex) { + // Value stored is a completely different type (should never happen). + removeConflictingPreferenceKeyValue(key); + return _default; + } + } + + @NonNull + public > T getEnum(@NonNull String key, @NonNull T _default) { + Objects.requireNonNull(_default); + try { + String enumName = preferences.getString(key, null); + if (enumName != null) { + try { + // noinspection unchecked + return (T) Enum.valueOf(_default.getClass(), enumName); + } catch (IllegalArgumentException ex) { + // Info level to allow removing enum values in the future without showing any user errors. + Logger.printInfo(() -> "Using default, and ignoring unknown enum value: " + enumName); + removeKey(key); + } + } + } catch (ClassCastException ex) { + // Value stored is a completely different type (should never happen). + removeConflictingPreferenceKeyValue(key); + } + return _default; + } + + public boolean getBoolean(@NonNull String key, boolean _default) { + try { + return preferences.getBoolean(key, _default); + } catch (ClassCastException ex) { + // Value stored is a completely different type (should never happen). + removeConflictingPreferenceKeyValue(key); + return _default; + } + } + + @NonNull + public Integer getIntegerString(@NonNull String key, @NonNull Integer _default) { + try { + String value = preferences.getString(key, null); + if (value != null) { + return Integer.valueOf(value); + } + return _default; + } catch (ClassCastException | NumberFormatException ex) { + try { + // Old data previously stored as primitive. + return preferences.getInt(key, _default); + } catch (ClassCastException ex2) { + // Value stored is a completely different type (should never happen). + removeConflictingPreferenceKeyValue(key); + } + } + return _default; + } + + @NonNull + public Long getLongString(@NonNull String key, @NonNull Long _default) { + try { + String value = preferences.getString(key, null); + if (value != null) { + return Long.valueOf(value); + } + } catch (ClassCastException | NumberFormatException ex) { + try { + return preferences.getLong(key, _default); + } catch (ClassCastException ex2) { + removeConflictingPreferenceKeyValue(key); + } + } + return _default; + } + + @NonNull + public Float getFloatString(@NonNull String key, @NonNull Float _default) { + try { + String value = preferences.getString(key, null); + if (value != null) { + return Float.valueOf(value); + } + } catch (ClassCastException | NumberFormatException ex) { + try { + return preferences.getFloat(key, _default); + } catch (ClassCastException ex2) { + removeConflictingPreferenceKeyValue(key); + } + } + return _default; + } + + @NonNull + @Override + public String toString() { + return name; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/WebViewDialog.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/WebViewDialog.java new file mode 100644 index 0000000000..d971540eef --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/WebViewDialog.java @@ -0,0 +1,62 @@ +package app.revanced.extension.shared.settings.preference; + +import android.annotation.SuppressLint; +import android.app.Dialog; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.Window; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import androidx.annotation.NonNull; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +/** + * Displays html content as a dialog. Any links a user taps on are opened in an external browser. + */ +@SuppressWarnings("deprecation") +public class WebViewDialog extends Dialog { + + private final String htmlContent; + + public WebViewDialog(@NonNull Context context, @NonNull String htmlContent) { + super(context); + this.htmlContent = htmlContent; + } + + // JS required to hide any broken images. No remote javascript is ever loaded. + @SuppressLint("SetJavaScriptEnabled") + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); + + WebView webView = new WebView(getContext()); + webView.getSettings().setJavaScriptEnabled(true); + webView.setWebViewClient(new OpenLinksExternallyWebClient()); + webView.loadDataWithBaseURL(null, htmlContent, "text/html", "utf-8", null); + + setContentView(webView); + } + + private class OpenLinksExternallyWebClient extends WebViewClient { + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + try { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + getContext().startActivity(intent); + } catch (Exception ex) { + Logger.printException(() -> "Open link failure", ex); + } + // Dismiss the about dialog using a delay, + // otherwise without a delay the UI looks hectic with the dialog dismissing + // to show the settings while simultaneously a web browser is opening. + Utils.runOnMainThreadDelayed(WebViewDialog.this::dismiss, 500); + return true; + } + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/WideListPreference.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/WideListPreference.java new file mode 100644 index 0000000000..08bee7bf66 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/WideListPreference.java @@ -0,0 +1,34 @@ +package app.revanced.extension.shared.settings.preference; + +import android.app.AlertDialog; +import android.content.Context; +import android.preference.ListPreference; +import android.util.AttributeSet; + +import app.revanced.extension.shared.utils.Utils; + +@SuppressWarnings({"unused", "deprecation"}) +public class WideListPreference extends ListPreference { + + public WideListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public WideListPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public WideListPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public WideListPreference(Context context) { + super(context); + } + + @Override + protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { + Utils.setEditTextDialogTheme(builder, true); + super.onPrepareDialogBuilder(builder); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/YouTubeDataAPIDialogBuilder.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/YouTubeDataAPIDialogBuilder.java new file mode 100644 index 0000000000..872183e971 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/YouTubeDataAPIDialogBuilder.java @@ -0,0 +1,61 @@ +package app.revanced.extension.shared.settings.preference; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.app.Activity; +import android.graphics.Point; +import android.view.Display; +import android.view.Window; +import android.view.WindowManager; + +import app.revanced.extension.shared.utils.BaseThemeUtils; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +/** + * Used by YouTube and YouTube Music. + */ +public class YouTubeDataAPIDialogBuilder { + private static final String URL_CREATE_PROJECT = "https://console.cloud.google.com/projectcreate"; + private static final String URL_MARKET_PLACE = "https://console.cloud.google.com/marketplace/product/google/youtube.googleapis.com"; + + public static void showDialog(Activity mActivity) { + try { + final String backgroundColorHex = BaseThemeUtils.getBackgroundColorHexString(); + final String foregroundColorHex = BaseThemeUtils.getForegroundColorHexString(); + + final String htmlDialog = "" + + "

" + + String.format( + "", + backgroundColorHex, foregroundColorHex, foregroundColorHex) + + "

" + + str("revanced_return_youtube_username_youtube_data_api_v3_dialog_title") + + "

" + + String.format( + str("revanced_return_youtube_username_youtube_data_api_v3_dialog_message"), + URL_CREATE_PROJECT, + URL_MARKET_PLACE + ) + + "

"; + + Utils.runOnMainThreadNowOrLater(() -> { + WebViewDialog webViewDialog = new WebViewDialog(mActivity, htmlDialog); + webViewDialog.show(); + + final Window window = webViewDialog.getWindow(); + if (window == null) return; + Display display = mActivity.getWindowManager().getDefaultDisplay(); + Point size = new Point(); + display.getSize(size); + + WindowManager.LayoutParams params = window.getAttributes(); + params.height = (int) (size.y * 0.6); + + window.setAttributes(params); + }); + } catch (Exception ex) { + Logger.printException(() -> "dialogBuilder failure", ex); + } + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/sponsorblock/requests/SBRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/shared/sponsorblock/requests/SBRoutes.java new file mode 100644 index 0000000000..5e972d5853 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/sponsorblock/requests/SBRoutes.java @@ -0,0 +1,20 @@ +package app.revanced.extension.shared.sponsorblock.requests; + +import static app.revanced.extension.shared.requests.Route.Method.GET; +import static app.revanced.extension.shared.requests.Route.Method.POST; + +import app.revanced.extension.shared.requests.Route; + +public class SBRoutes { + public static final Route IS_USER_VIP = new Route(GET, "/api/isUserVIP?userID={user_id}"); + public static final Route GET_SEGMENTS = new Route(GET, "/api/skipSegments?videoID={video_id}&categories={categories}"); + public static final Route VIEWED_SEGMENT = new Route(POST, "/api/viewedVideoSponsorTime?UUID={segment_id}"); + public static final Route GET_USER_STATS = new Route(GET, "/api/userInfo?userID={user_id}&values=[\"userID\",\"userName\",\"reputation\",\"segmentCount\",\"ignoredSegmentCount\",\"viewCount\",\"minutesSaved\"]"); + public static final Route CHANGE_USERNAME = new Route(POST, "/api/setUsername?userID={user_id}&username={username}"); + public static final Route SUBMIT_SEGMENTS = new Route(POST, "/api/skipSegments?userID={user_id}&videoID={video_id}&category={category}&startTime={start_time}&endTime={end_time}&videoDuration={duration}"); + public static final Route VOTE_ON_SEGMENT_QUALITY = new Route(POST, "/api/voteOnSponsorTime?userID={user_id}&UUID={segment_id}&type={type}"); + public static final Route VOTE_ON_SEGMENT_CATEGORY = new Route(POST, "/api/voteOnSponsorTime?userID={user_id}&UUID={segment_id}&category={category}"); + + public SBRoutes() { + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/BaseThemeUtils.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/BaseThemeUtils.java new file mode 100644 index 0000000000..ffc80d19a1 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/BaseThemeUtils.java @@ -0,0 +1,73 @@ +package app.revanced.extension.shared.utils; + +import static app.revanced.extension.shared.utils.ResourceUtils.getColor; +import static app.revanced.extension.shared.utils.ResourceUtils.getColorIdentifier; + +import android.graphics.Color; + +@SuppressWarnings("unused") +public class BaseThemeUtils { + private static int themeValue = 1; + + /** + * Injection point. + */ + public static void setTheme(Enum value) { + final int newOrdinalValue = value.ordinal(); + if (themeValue != newOrdinalValue) { + themeValue = newOrdinalValue; + Logger.printDebug(() -> "Theme value: " + newOrdinalValue); + } + } + + public static boolean isDarkTheme() { + return themeValue == 1; + } + + public static String getColorHexString(int color) { + return String.format("#%06X", (0xFFFFFF & color)); + } + + /** + * Subclasses can override this and provide a themed color. + */ + public static int getLightColor() { + return Color.WHITE; + } + + /** + * Subclasses can override this and provide a themed color. + */ + public static int getDarkColor() { + return Color.BLACK; + } + + public static String getBackgroundColorHexString() { + return getColorHexString(getBackgroundColor()); + } + + public static String getForegroundColorHexString() { + return getColorHexString(getForegroundColor()); + } + + public static int getBackgroundColor() { + final String colorName = isDarkTheme() ? "yt_black1" : "yt_white1"; + final int colorIdentifier = getColorIdentifier(colorName); + if (colorIdentifier != 0) { + return getColor(colorName); + } else { + return isDarkTheme() ? getDarkColor() : getLightColor(); + } + } + + public static int getForegroundColor() { + final String colorName = isDarkTheme() ? "yt_white1" : "yt_black1"; + final int colorIdentifier = getColorIdentifier(colorName); + if (colorIdentifier != 0) { + return getColor(colorName); + } else { + return isDarkTheme() ? getLightColor() : getDarkColor(); + } + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/ByteTrieSearch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/ByteTrieSearch.java new file mode 100644 index 0000000000..1708f567ce --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/ByteTrieSearch.java @@ -0,0 +1,50 @@ +package app.revanced.extension.shared.utils; + +import androidx.annotation.NonNull; + +import java.nio.charset.StandardCharsets; + +public final class ByteTrieSearch extends TrieSearch { + + private static final class ByteTrieNode extends TrieNode { + ByteTrieNode() { + super(); + } + + ByteTrieNode(char nodeCharacterValue) { + super(nodeCharacterValue); + } + + @Override + TrieNode createNode(char nodeCharacterValue) { + return new ByteTrieNode(nodeCharacterValue); + } + + @Override + char getCharValue(byte[] text, int index) { + return (char) text[index]; + } + + @Override + int getTextLength(byte[] text) { + return text.length; + } + } + + /** + * Helper method for the common usage of converting Strings to raw UTF-8 bytes. + */ + public static byte[][] convertStringsToBytes(String... strings) { + final int length = strings.length; + byte[][] replacement = new byte[length][]; + for (int i = 0; i < length; i++) { + replacement[i] = strings[i].getBytes(StandardCharsets.UTF_8); + } + return replacement; + } + + public ByteTrieSearch(@NonNull byte[]... patterns) { + super(new ByteTrieNode(), patterns); + } +} + diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Event.kt b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Event.kt new file mode 100644 index 0000000000..a4f76152ad --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Event.kt @@ -0,0 +1,30 @@ +package app.revanced.extension.shared.utils + +/** + * generic event provider class + */ +class Event { + private val eventListeners = mutableSetOf<(T) -> Unit>() + + operator fun plusAssign(observer: (T) -> Unit) { + addObserver(observer) + } + + fun addObserver(observer: (T) -> Unit) { + eventListeners.add(observer) + } + + operator fun minusAssign(observer: (T) -> Unit) { + removeObserver(observer) + } + + private fun removeObserver(observer: (T) -> Unit) { + eventListeners.remove(observer) + } + + operator fun invoke(value: T) { + for (observer in eventListeners) + observer.invoke(value) + } +} + diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/IntentUtils.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/IntentUtils.java new file mode 100644 index 0000000000..6c15a67ee0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/IntentUtils.java @@ -0,0 +1,44 @@ +package app.revanced.extension.shared.utils; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class IntentUtils extends Utils { + + public static void launchExternalDownloader(@NonNull String content, @NonNull String downloaderPackageName) { + Intent intent = new Intent("android.intent.action.SEND"); + intent.setType("text/plain"); + intent.setPackage(downloaderPackageName); + intent.putExtra("android.intent.extra.TEXT", content); + launchIntent(intent); + } + + private static void launchIntent(@NonNull Intent intent) { + // If possible, use the main activity as the context. + // Otherwise fall back on using the application context. + Context mContext = getActivity(); + if (mContext == null) { + // Utils context is the application context, and not an activity context. + mContext = context; + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + } + mContext.startActivity(intent); + } + + public static void launchView(@NonNull String content) { + launchView(content, null); + } + + public static void launchView(@NonNull String content, @Nullable String packageName) { + Intent intent = new Intent("android.intent.action.VIEW", Uri.parse(content)); + if (packageName != null) { + intent.setPackage(packageName); + } + launchIntent(intent); + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Logger.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Logger.java new file mode 100644 index 0000000000..6583fc7ef8 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Logger.java @@ -0,0 +1,126 @@ +package app.revanced.extension.shared.utils; + +import static app.revanced.extension.shared.settings.BaseSettings.ENABLE_DEBUG_LOGGING; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.settings.BaseSettings; + +public class Logger { + + /** + * Log messages using lambdas. + */ + public interface LogMessage { + @NonNull + String buildMessageString(); + + /** + * @return For outer classes, this returns {@link Class#getSimpleName()}. + * For static, inner, or anonymous classes, this returns the simple name of the enclosing class. + *
+ * For example, each of these classes return 'SomethingView': + * + * com.company.SomethingView + * com.company.SomethingView$StaticClass + * com.company.SomethingView$1 + * + */ + default String findOuterClassSimpleName() { + Class selfClass = this.getClass(); + + String fullClassName = selfClass.getName(); + final int dollarSignIndex = fullClassName.indexOf('$'); + if (dollarSignIndex < 0) { + return selfClass.getSimpleName(); // Already an outer class. + } + + // Class is inner, static, or anonymous. + // Parse the simple name full name. + // A class with no package returns index of -1, but incrementing gives index zero which is correct. + final int simpleClassNameStartIndex = fullClassName.lastIndexOf('.') + 1; + return fullClassName.substring(simpleClassNameStartIndex, dollarSignIndex); + } + } + + private static final String REVANCED_LOG_PREFIX = "Extended: "; + + /** + * Logs debug messages under the outer class name of the code calling this method. + * Whenever possible, the log string should be constructed entirely inside {@link LogMessage#buildMessageString()} + * so the performance cost of building strings is paid only if {@link BaseSettings#ENABLE_DEBUG_LOGGING} is enabled. + */ + public static void printDebug(@NonNull LogMessage message) { + if (ENABLE_DEBUG_LOGGING.get()) { + Log.d(REVANCED_LOG_PREFIX + message.findOuterClassSimpleName(), message.buildMessageString()); + } + } + + /** + * Logs information messages using the outer class name of the code calling this method. + */ + public static void printInfo(@NonNull LogMessage message) { + printInfo(message, null); + } + + /** + * Logs information messages using the outer class name of the code calling this method. + */ + public static void printInfo(@NonNull LogMessage message, @Nullable Exception ex) { + String logTag = REVANCED_LOG_PREFIX + message.findOuterClassSimpleName(); + String logMessage = message.buildMessageString(); + if (ex == null) { + Log.i(logTag, logMessage); + } else { + Log.i(logTag, logMessage, ex); + } + } + + /** + * Logs exceptions under the outer class name of the code calling this method. + */ + public static void printException(@NonNull LogMessage message) { + printException(message, null); + } + + /** + * Logs exceptions under the outer class name of the code calling this method. + *

+ * If the calling code is showing it's own error toast, + * instead use {@link #printInfo(LogMessage, Exception)} + * + * @param message log message + * @param ex exception (optional) + */ + public static void printException(@NonNull LogMessage message, @Nullable Throwable ex) { + String messageString = message.buildMessageString(); + String outerClassSimpleName = message.findOuterClassSimpleName(); + String logMessage = REVANCED_LOG_PREFIX + outerClassSimpleName; + if (ex == null) { + Log.e(logMessage, messageString); + } else { + Log.e(logMessage, messageString, ex); + } + } + + /** + * Logging to use if {@link BaseSettings#ENABLE_DEBUG_LOGGING} or {@link Utils#getContext()} may not be initialized. + * Normally this method should not be used. + */ + public static void initializationInfo(@NonNull Class callingClass, @NonNull String message) { + Log.i(REVANCED_LOG_PREFIX + callingClass.getSimpleName(), message); + } + + /** + * Logging to use if {@link BaseSettings#ENABLE_DEBUG_LOGGING} or {@link Utils#getContext()} may not be initialized. + * Normally this method should not be used. + */ + public static void initializationException(@NonNull Class callingClass, @NonNull String message, + @Nullable Exception ex) { + Log.e(REVANCED_LOG_PREFIX + callingClass.getSimpleName(), message, ex); + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/PackageUtils.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/PackageUtils.java new file mode 100644 index 0000000000..7975ba063e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/PackageUtils.java @@ -0,0 +1,91 @@ +package app.revanced.extension.shared.utils; + +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class PackageUtils extends Utils { + private static String applicationLabel = ""; + private static int smallestScreenWidthDp = 0; + private static String versionName = ""; + + public static String getApplicationLabel() { + return applicationLabel; + } + + public static String getVersionName() { + return versionName; + } + + public static boolean isPackageEnabled(@NonNull String packageName) { + try { + return context.getPackageManager().getApplicationInfo(packageName, 0).enabled; + } catch (PackageManager.NameNotFoundException ignored) { + } + + return false; + } + + public static boolean isTablet() { + return smallestScreenWidthDp >= 600; + } + + public static void setApplicationLabel() { + final PackageInfo packageInfo = getPackageInfo(); + if (packageInfo != null) { + final ApplicationInfo applicationInfo = packageInfo.applicationInfo; + if (applicationInfo != null) { + applicationLabel = (String) applicationInfo.loadLabel(getPackageManager()); + } + } + } + + public static void setSmallestScreenWidthDp() { + smallestScreenWidthDp = context.getResources().getConfiguration().smallestScreenWidthDp; + } + + public static void setVersionName() { + final PackageInfo packageInfo = getPackageInfo(); + if (packageInfo != null) { + versionName = packageInfo.versionName; + } + } + + public static int getSmallestScreenWidthDp() { + return smallestScreenWidthDp; + } + + // utils + @Nullable + private static PackageInfo getPackageInfo() { + try { + final PackageManager packageManager = getPackageManager(); + final String packageName = context.getPackageName(); + return isSDKAbove(33) + ? packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) + : packageManager.getPackageInfo(packageName, 0); + } catch (PackageManager.NameNotFoundException e) { + Logger.printException(() -> "Failed to get package Info!" + e); + } + return null; + } + + @NonNull + private static PackageManager getPackageManager() { + return context.getPackageManager(); + } + + public static boolean isVersionToLessThan(@NonNull String compareVersion, @NonNull String targetVersion) { + try { + final int compareVersionNumber = Integer.parseInt(compareVersion.replaceAll("\\.", "")); + final int targetVersionNumber = Integer.parseInt(targetVersion.replaceAll("\\.", "")); + return compareVersionNumber < targetVersionNumber; + } catch (NumberFormatException ex) { + Logger.printException(() -> "Failed to compare version: " + compareVersion + ", " + targetVersion, ex); + } + return false; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/ResourceUtils.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/ResourceUtils.java new file mode 100644 index 0000000000..55b7c1ac62 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/ResourceUtils.java @@ -0,0 +1,186 @@ +package app.revanced.extension.shared.utils; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; + +import androidx.annotation.NonNull; + +/** + * @noinspection ALL + */ +public class ResourceUtils extends Utils { + + private ResourceUtils() { + } // utility class + + public static int getIdentifier(@NonNull String str, @NonNull ResourceType resourceType) { + return getIdentifier(str, resourceType, getContext()); + } + + public static int getIdentifier(@NonNull String str, @NonNull ResourceType resourceType, + @NonNull Context context) { + return getResources().getIdentifier(str, resourceType.getType(), context.getPackageName()); + } + + public static int getAnimIdentifier(@NonNull String str) { + return getIdentifier(str, ResourceType.ANIM); + } + + public static int getArrayIdentifier(@NonNull String str) { + return getIdentifier(str, ResourceType.ARRAY); + } + + public static int getAttrIdentifier(@NonNull String str) { + return getIdentifier(str, ResourceType.ATTR); + } + + public static int getColorIdentifier(@NonNull String str) { + return getIdentifier(str, ResourceType.COLOR); + } + + public static int getDimenIdentifier(@NonNull String str) { + return getIdentifier(str, ResourceType.DIMEN); + } + + public static int getDrawableIdentifier(@NonNull String str) { + return getIdentifier(str, ResourceType.DRAWABLE); + } + + public static int getFontIdentifier(@NonNull String str) { + return getIdentifier(str, ResourceType.FONT); + } + + public static int getIdIdentifier(@NonNull String str) { + return getIdentifier(str, ResourceType.ID); + } + + public static int getIntegerIdentifier(@NonNull String str) { + return getIdentifier(str, ResourceType.INTEGER); + } + + public static int getLayoutIdentifier(@NonNull String str) { + return getIdentifier(str, ResourceType.LAYOUT); + } + + public static int getMenuIdentifier(@NonNull String str) { + return getIdentifier(str, ResourceType.MENU); + } + + public static int getMipmapIdentifier(@NonNull String str) { + return getIdentifier(str, ResourceType.MIPMAP); + } + + public static int getRawIdentifier(@NonNull String str) { + return getIdentifier(str, ResourceType.RAW); + } + + public static int getStringIdentifier(@NonNull String str) { + return getIdentifier(str, ResourceType.STRING); + } + + public static int getStyleIdentifier(@NonNull String str) { + return getIdentifier(str, ResourceType.STYLE); + } + + public static int getXmlIdentifier(@NonNull String str) { + return getIdentifier(str, ResourceType.XML); + } + + public static Animation getAnimation(@NonNull String str) { + int identifier = getAnimIdentifier(str); + if (identifier == 0) { + handleException(str, ResourceType.ANIM); + identifier = android.R.anim.fade_in; + } + return AnimationUtils.loadAnimation(getContext(), identifier); + } + + public static int getColor(@NonNull String str) { + final int identifier = getColorIdentifier(str); + if (identifier == 0) { + handleException(str, ResourceType.COLOR); + return 0; + } + return getResources().getColor(identifier); + } + + public static int getDimension(@NonNull String str) { + final int identifier = getDimenIdentifier(str); + if (identifier == 0) { + handleException(str, ResourceType.DIMEN); + return 0; + } + return getResources().getDimensionPixelSize(identifier); + } + + public static Drawable getDrawable(@NonNull String str) { + final int identifier = getDrawableIdentifier(str); + if (identifier == 0) { + handleException(str, ResourceType.DRAWABLE); + return null; + } + return getResources().getDrawable(identifier); + } + + public static String getString(@NonNull String str) { + final int identifier = getStringIdentifier(str); + if (identifier == 0) { + handleException(str, ResourceType.STRING); + return str; + } + return getResources().getString(identifier); + } + + public static String[] getStringArray(@NonNull String str) { + final int identifier = getArrayIdentifier(str); + if (identifier == 0) { + handleException(str, ResourceType.ARRAY); + return new String[0]; + } + return getResources().getStringArray(identifier); + } + + public static int getInteger(@NonNull String str) { + final int identifier = getIntegerIdentifier(str); + if (identifier == 0) { + handleException(str, ResourceType.INTEGER); + return 0; + } + return getResources().getInteger(identifier); + } + + private static void handleException(@NonNull String str, ResourceType resourceType) { + Logger.printException(() -> "R." + resourceType.getType() + "." + str + " is null"); + } + + public enum ResourceType { + ANIM("anim"), + ARRAY("array"), + ATTR("attr"), + COLOR("color"), + DIMEN("dimen"), + DRAWABLE("drawable"), + FONT("font"), + ID("id"), + INTEGER("integer"), + LAYOUT("layout"), + MENU("menu"), + MIPMAP("mipmap"), + RAW("raw"), + STRING("string"), + STYLE("style"), + XML("xml"); + + private final String type; + + ResourceType(String type) { + this.type = type; + } + + public final String getType() { + return type; + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/StringRef.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/StringRef.java new file mode 100644 index 0000000000..f51b49ed09 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/StringRef.java @@ -0,0 +1,135 @@ +package app.revanced.extension.shared.utils; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Resources; + +import androidx.annotation.NonNull; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +@SuppressLint("DiscouragedApi") +public class StringRef extends Utils { + private static Resources resources; + + // must use a thread safe map, as this class is used both on and off the main thread + private static final Map strings = Collections.synchronizedMap(new HashMap<>()); + + /** + * Returns a cached instance. + * Should be used if the same String could be loaded more than once. + * + * @param id string resource name/id + * @see #sf(String) + */ + @NonNull + public static StringRef sfc(@NonNull String id) { + StringRef ref = strings.get(id); + if (ref == null) { + ref = new StringRef(id); + strings.put(id, ref); + } + return ref; + } + + /** + * Creates a new instance, but does not cache the value. + * Should be used for Strings that are loaded exactly once. + * + * @param id string resource name/id + * @see #sfc(String) + */ + @NonNull + public static StringRef sf(@NonNull String id) { + return new StringRef(id); + } + + /** + * Gets string value by string id, shorthand for sfc(id).toString() + * + * @param id string resource name/id + * @return String value from string.xml + */ + @NonNull + public static String str(@NonNull String id) { + return sfc(id).toString(); + } + + /** + * Gets string value by string id, shorthand for sfc(id).toString() and formats the string + * with given args. + * + * @param id string resource name/id + * @param args the args to format the string with + * @return String value from string.xml formatted with given args + */ + @NonNull + public static String str(@NonNull String id, Object... args) { + return String.format(str(id), args); + } + + /** + * Creates a StringRef object that'll not change it's value + * + * @param value value which toString() method returns when invoked on returned object + * @return Unique StringRef instance, its value will never change + */ + @NonNull + public static StringRef constant(@NonNull String value) { + final StringRef ref = new StringRef(value); + ref.resolved = true; + return ref; + } + + /** + * Shorthand for constant("") + * Its value always resolves to empty string + */ + @SuppressLint("StaticFieldLeak") + @NonNull + public static final StringRef empty = constant(""); + + @NonNull + private String value; + private boolean resolved; + + public StringRef(@NonNull String resName) { + this.value = resName; + } + + @Override + @NonNull + public String toString() { + if (!resolved) { + try { + Context context = getContext(); + if (resources == null) { + resources = getResources(); + } + if (resources != null) { + value = ResourceUtils.getString(value); + resolved = true; + return value; + } + resources = context.getResources(); + if (resources != null) { + final String packageName = context.getPackageName(); + final int identifier = resources.getIdentifier(value, "string", packageName); + if (identifier == 0) + Logger.printException(() -> "Resource not found: " + value); + else + value = resources.getString(identifier); + resolved = true; + } else { + Logger.printException(() -> "Could not resolve resources!"); + } + } catch (Exception ex) { + Logger.initializationException(StringRef.class, "Context is null!", ex); + } + } + + return value; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/StringTrieSearch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/StringTrieSearch.java new file mode 100644 index 0000000000..e4df4a57bf --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/StringTrieSearch.java @@ -0,0 +1,38 @@ +package app.revanced.extension.shared.utils; + +import androidx.annotation.NonNull; + +/** + * Text pattern searching using a prefix tree (trie). + */ +public final class StringTrieSearch extends TrieSearch { + + private static final class StringTrieNode extends TrieNode { + StringTrieNode() { + super(); + } + + StringTrieNode(char nodeCharacterValue) { + super(nodeCharacterValue); + } + + @Override + TrieNode createNode(char nodeValue) { + return new StringTrieNode(nodeValue); + } + + @Override + char getCharValue(String text, int index) { + return text.charAt(index); + } + + @Override + int getTextLength(String text) { + return text.length(); + } + } + + public StringTrieSearch(@NonNull String... patterns) { + super(new StringTrieNode(), patterns); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/TrieSearch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/TrieSearch.java new file mode 100644 index 0000000000..01ecf28c54 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/TrieSearch.java @@ -0,0 +1,416 @@ +package app.revanced.extension.shared.utils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Searches for a group of different patterns using a trie (prefix tree). + * Can significantly speed up searching for multiple patterns. + */ +public abstract class TrieSearch { + + public interface TriePatternMatchedCallback { + /** + * Called when a pattern is matched. + * + * @param textSearched Text that was searched. + * @param matchedStartIndex Start index of the search text, where the pattern was matched. + * @param matchedLength Length of the match. + * @param callbackParameter Optional parameter passed into {@link TrieSearch#matches(Object, Object)}. + * @return True, if the search should stop here. + * If false, searching will continue to look for other matches. + */ + boolean patternMatched(T textSearched, int matchedStartIndex, int matchedLength, Object callbackParameter); + } + + /** + * Represents a compressed tree path for a single pattern that shares no sibling nodes. + *

+ * For example, if a tree contains the patterns: "foobar", "football", "feet", + * it would contain 3 compressed paths of: "bar", "tball", "eet". + *

+ * And the tree would contain children arrays only for the first level containing 'f', + * the second level containing 'o', + * and the third level containing 'o'. + *

+ * This is done to reduce memory usage, which can be substantial if many long patterns are used. + */ + private static final class TrieCompressedPath { + final T pattern; + final int patternStartIndex; + final int patternLength; + final TriePatternMatchedCallback callback; + + TrieCompressedPath(T pattern, int patternStartIndex, int patternLength, TriePatternMatchedCallback callback) { + this.pattern = pattern; + this.patternStartIndex = patternStartIndex; + this.patternLength = patternLength; + this.callback = callback; + } + + boolean matches(TrieNode enclosingNode, // Used only for the get character method. + T searchText, int searchTextLength, int searchTextIndex, Object callbackParameter) { + if (searchTextLength - searchTextIndex < patternLength - patternStartIndex) { + return false; // Remaining search text is shorter than the remaining leaf pattern and they cannot match. + } + for (int i = searchTextIndex, j = patternStartIndex; j < patternLength; i++, j++) { + if (enclosingNode.getCharValue(searchText, i) != enclosingNode.getCharValue(pattern, j)) { + return false; + } + } + return callback == null || callback.patternMatched(searchText, + searchTextIndex - patternStartIndex, patternLength, callbackParameter); + } + } + + static abstract class TrieNode { + /** + * Dummy value used for root node. Value can be anything as it's never referenced. + */ + private static final char ROOT_NODE_CHARACTER_VALUE = 0; // ASCII null character. + + /** + * How much to expand the children array when resizing. + */ + private static final int CHILDREN_ARRAY_INCREASE_SIZE_INCREMENT = 2; + + /** + * Character this node represents. + * This field is ignored for the root node (which does not represent any character). + */ + private final char nodeValue; + + /** + * A compressed graph path that represents the remaining pattern characters of a single child node. + *

+ * If present then child array is always null, although callbacks for other + * end of patterns can also exist on this same node. + */ + @Nullable + private TrieCompressedPath leaf; + + /** + * All child nodes. Only present if no compressed leaf exist. + *

+ * Array is dynamically increased in size as needed, + * and uses perfect hashing for the elements it contains. + *

+ * So if the array contains a given character, + * the character will always map to the node with index: (character % arraySize). + *

+ * Elements not contained can collide with elements the array does contain, + * so must compare the nodes character value. + *

+ * Alternatively this array could be a sorted and densely packed array, + * and lookup is done using binary search. + * That would save a small amount of memory because there's no null children entries, + * but would give a worst case search of O(nlog(m)) where n is the number of + * characters in the searched text and m is the maximum size of the sorted character arrays. + * Using a hash table array always gives O(n) search time. + * The memory usage here is very small (all Litho filters use ~10KB of memory), + * so the more performant hash implementation is chosen. + */ + @Nullable + private TrieNode[] children; + + /** + * Callbacks for all patterns that end at this node. + */ + @Nullable + private List> endOfPatternCallback; + + TrieNode() { + this.nodeValue = ROOT_NODE_CHARACTER_VALUE; + } + + TrieNode(char nodeCharacterValue) { + this.nodeValue = nodeCharacterValue; + } + + /** + * @param pattern Pattern to add. + * @param patternIndex Current recursive index of the pattern. + * @param patternLength Length of the pattern. + * @param callback Callback, where a value of NULL indicates to always accept a pattern match. + */ + private void addPattern(@NonNull T pattern, int patternIndex, int patternLength, + @Nullable TriePatternMatchedCallback callback) { + if (patternIndex == patternLength) { // Reached the end of the pattern. + if (endOfPatternCallback == null) { + endOfPatternCallback = new ArrayList<>(1); + } + endOfPatternCallback.add(callback); + return; + } + if (leaf != null) { + // Reached end of the graph and a leaf exist. + // Recursively call back into this method and push the existing leaf down 1 level. + if (children != null) throw new IllegalStateException(); + //noinspection unchecked + children = new TrieNode[1]; + TrieCompressedPath temp = leaf; + leaf = null; + addPattern(temp.pattern, temp.patternStartIndex, temp.patternLength, temp.callback); + // Continue onward and add the parameter pattern. + } else if (children == null) { + leaf = new TrieCompressedPath<>(pattern, patternIndex, patternLength, callback); + return; + } + final char character = getCharValue(pattern, patternIndex); + final int arrayIndex = hashIndexForTableSize(children.length, character); + TrieNode child = children[arrayIndex]; + if (child == null) { + child = createNode(character); + children[arrayIndex] = child; + } else if (child.nodeValue != character) { + // Hash collision. Resize the table until perfect hashing is found. + child = createNode(character); + expandChildArray(child); + } + child.addPattern(pattern, patternIndex + 1, patternLength, callback); + } + + /** + * Resizes the children table until all nodes hash to exactly one array index. + */ + private void expandChildArray(TrieNode child) { + int replacementArraySize = Objects.requireNonNull(children).length; + while (true) { + replacementArraySize += CHILDREN_ARRAY_INCREASE_SIZE_INCREMENT; + //noinspection unchecked + TrieNode[] replacement = new TrieNode[replacementArraySize]; + addNodeToArray(replacement, child); + boolean collision = false; + for (TrieNode existingChild : children) { + if (existingChild != null) { + if (!addNodeToArray(replacement, existingChild)) { + collision = true; + break; + } + } + } + if (collision) { + continue; + } + children = replacement; + return; + } + } + + private static boolean addNodeToArray(TrieNode[] array, TrieNode childToAdd) { + final int insertIndex = hashIndexForTableSize(array.length, childToAdd.nodeValue); + if (array[insertIndex] != null) { + return false; // Collision. + } + array[insertIndex] = childToAdd; + return true; + } + + private static int hashIndexForTableSize(int arraySize, char nodeValue) { + return nodeValue % arraySize; + } + + /** + * This method is static and uses a loop to avoid all recursion. + * This is done for performance since the JVM does not optimize tail recursion. + * + * @param startNode Node to start the search from. + * @param searchText Text to search for patterns in. + * @param searchTextIndex Start index, inclusive. + * @param searchTextEndIndex End index, exclusive. + * @return If any pattern matches, and it's associated callback halted the search. + */ + private static boolean matches(final TrieNode startNode, final T searchText, + int searchTextIndex, final int searchTextEndIndex, + final Object callbackParameter) { + TrieNode node = startNode; + int currentMatchLength = 0; + + while (true) { + TrieCompressedPath leaf = node.leaf; + if (leaf != null && leaf.matches(startNode, searchText, searchTextEndIndex, searchTextIndex, callbackParameter)) { + return true; // Leaf exists and it matched the search text. + } + List> endOfPatternCallback = node.endOfPatternCallback; + if (endOfPatternCallback != null) { + final int matchStartIndex = searchTextIndex - currentMatchLength; + for (@Nullable TriePatternMatchedCallback callback : endOfPatternCallback) { + if (callback == null) { + return true; // No callback and all matches are valid. + } + if (callback.patternMatched(searchText, matchStartIndex, currentMatchLength, callbackParameter)) { + return true; // Callback confirmed the match. + } + } + } + TrieNode[] children = node.children; + if (children == null) { + return false; // Reached a graph end point and there's no further patterns to search. + } + if (searchTextIndex == searchTextEndIndex) { + return false; // Reached end of the search text and found no matches. + } + + // Use the start node to reduce VM method lookup, since all nodes are the same class type. + final char character = startNode.getCharValue(searchText, searchTextIndex); + final int arrayIndex = hashIndexForTableSize(children.length, character); + TrieNode child = children[arrayIndex]; + if (child == null || child.nodeValue != character) { + return false; + } + + node = child; + searchTextIndex++; + currentMatchLength++; + } + } + + /** + * Gives an approximate memory usage. + * + * @return Estimated number of memory pointers used, starting from this node and including all children. + */ + private int estimatedNumberOfPointersUsed() { + int numberOfPointers = 4; // Number of fields in this class. + if (leaf != null) { + numberOfPointers += 4; // Number of fields in leaf node. + } + if (endOfPatternCallback != null) { + numberOfPointers += endOfPatternCallback.size(); + } + if (children != null) { + numberOfPointers += children.length; + for (TrieNode child : children) { + if (child != null) { + numberOfPointers += child.estimatedNumberOfPointersUsed(); + } + } + } + return numberOfPointers; + } + + abstract TrieNode createNode(char nodeValue); + + abstract char getCharValue(T text, int index); + + abstract int getTextLength(T text); + } + + /** + * Root node, and it's children represent the first pattern characters. + */ + private final TrieNode root; + + /** + * Patterns to match. + */ + private final List patterns = new ArrayList<>(); + + @SafeVarargs + TrieSearch(@NonNull TrieNode root, @NonNull T... patterns) { + this.root = Objects.requireNonNull(root); + addPatterns(patterns); + } + + @SafeVarargs + public final void addPatterns(@NonNull T... patterns) { + for (T pattern : patterns) { + addPattern(pattern); + } + } + + /** + * Adds a pattern that will always return a positive match if found. + * + * @param pattern Pattern to add. Calling this with a zero length pattern does nothing. + */ + public void addPattern(@NonNull T pattern) { + addPattern(pattern, root.getTextLength(pattern), null); + } + + /** + * @param pattern Pattern to add. Calling this with a zero length pattern does nothing. + * @param callback Callback to determine if searching should halt when a match is found. + */ + public void addPattern(@NonNull T pattern, @NonNull TriePatternMatchedCallback callback) { + addPattern(pattern, root.getTextLength(pattern), Objects.requireNonNull(callback)); + } + + void addPattern(@NonNull T pattern, int patternLength, @Nullable TriePatternMatchedCallback callback) { + if (patternLength == 0) return; // Nothing to match + + patterns.add(pattern); + root.addPattern(pattern, 0, patternLength, callback); + } + + public final boolean matches(@NonNull T textToSearch) { + return matches(textToSearch, 0); + } + + public boolean matches(@NonNull T textToSearch, @NonNull Object callbackParameter) { + return matches(textToSearch, 0, root.getTextLength(textToSearch), + Objects.requireNonNull(callbackParameter)); + } + + public boolean matches(@NonNull T textToSearch, int startIndex) { + return matches(textToSearch, startIndex, root.getTextLength(textToSearch)); + } + + public final boolean matches(@NonNull T textToSearch, int startIndex, int endIndex) { + return matches(textToSearch, startIndex, endIndex, null); + } + + /** + * Searches through text, looking for any substring that matches any pattern in this tree. + * + * @param textToSearch Text to search through. + * @param startIndex Index to start searching, inclusive value. + * @param endIndex Index to stop matching, exclusive value. + * @param callbackParameter Optional parameter passed to the callbacks. + * @return If any pattern matched, and it's callback halted searching. + */ + public boolean matches(@NonNull T textToSearch, int startIndex, int endIndex, @Nullable Object callbackParameter) { + return matches(textToSearch, root.getTextLength(textToSearch), startIndex, endIndex, callbackParameter); + } + + private boolean matches(@NonNull T textToSearch, int textToSearchLength, int startIndex, int endIndex, + @Nullable Object callbackParameter) { + if (endIndex > textToSearchLength) { + throw new IllegalArgumentException("endIndex: " + endIndex + + " is greater than texToSearchLength: " + textToSearchLength); + } + if (patterns.isEmpty()) { + return false; // No patterns were added. + } + for (int i = startIndex; i < endIndex; i++) { + if (TrieNode.matches(root, textToSearch, i, endIndex, callbackParameter)) return true; + } + return false; + } + + /** + * @return Estimated memory size (in kilobytes) of this instance. + */ + public int getEstimatedMemorySize() { + if (patterns.isEmpty()) { + return 0; + } + // Assume the device has less than 32GB of ram (and can use pointer compression), + // or the device is 32-bit. + final int numberOfBytesPerPointer = 4; + return (int) Math.ceil((numberOfBytesPerPointer * root.estimatedNumberOfPointersUsed()) / 1024.0); + } + + public int numberOfPatterns() { + return patterns.size(); + } + + public List getPatterns() { + return Collections.unmodifiableList(patterns); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Utils.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Utils.java new file mode 100644 index 0000000000..aaf9f21c78 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Utils.java @@ -0,0 +1,737 @@ +package app.revanced.extension.shared.utils; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Fragment; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.preference.Preference; +import android.preference.PreferenceGroup; +import android.preference.PreferenceScreen; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.Toast; +import android.widget.Toolbar; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.ref.WeakReference; +import java.text.Bidi; +import java.time.Duration; +import java.util.Locale; +import java.util.Objects; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.concurrent.Callable; +import java.util.concurrent.Future; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import app.revanced.extension.shared.settings.BooleanSetting; +import kotlin.text.Regex; + +@SuppressWarnings("deprecation") +public class Utils { + + private static WeakReference activityRef = new WeakReference<>(null); + + @SuppressLint("StaticFieldLeak") + public static Context context; + + private static Resources resources; + + protected Utils() { + } // utility class + + public static void clickView(View view) { + if (view == null) return; + view.callOnClick(); + } + + /** + * Hide a view by setting its layout height and width to 1dp. + * + * @param condition The setting to check for hiding the view. + * @param view The view to hide. + */ + public static void hideViewBy0dpUnderCondition(BooleanSetting condition, View view) { + hideViewBy0dpUnderCondition(condition.get(), view); + } + + public static void hideViewBy0dpUnderCondition(boolean enabled, View view) { + if (!enabled) return; + + hideViewByLayoutParams(view); + } + + /** + * Hide a view by setting its visibility to GONE. + * + * @param condition The setting to check for hiding the view. + * @param view The view to hide. + */ + public static void hideViewUnderCondition(BooleanSetting condition, View view) { + hideViewUnderCondition(condition.get(), view); + } + + /** + * Hide a view by setting its visibility to GONE. + * + * @param condition The setting to check for hiding the view. + * @param view The view to hide. + */ + public static void hideViewUnderCondition(boolean condition, View view) { + if (!condition) return; + if (view == null) return; + + view.setVisibility(View.GONE); + } + + @SuppressWarnings("unused") + public static void hideViewByRemovingFromParentUnderCondition(BooleanSetting condition, View view) { + hideViewByRemovingFromParentUnderCondition(condition.get(), view); + } + + public static void hideViewByRemovingFromParentUnderCondition(boolean condition, View view) { + if (!condition) return; + if (view == null) return; + if (!(view.getParent() instanceof ViewGroup viewGroup)) + return; + + viewGroup.removeView(view); + } + + /** + * General purpose pool for network calls and other background tasks. + * All tasks run at max thread priority. + */ + private static final ThreadPoolExecutor backgroundThreadPool = new ThreadPoolExecutor( + 3, // 3 threads always ready to go + Integer.MAX_VALUE, + 10, // For any threads over the minimum, keep them alive 10 seconds after they go idle + TimeUnit.SECONDS, + new SynchronousQueue<>(), + r -> { // ThreadFactory + Thread t = new Thread(r); + t.setPriority(Thread.MAX_PRIORITY); // run at max priority + return t; + }); + + public static void runOnBackgroundThread(@NonNull Runnable task) { + backgroundThreadPool.execute(task); + } + + @NonNull + public static Future submitOnBackgroundThread(@NonNull Callable call) { + return backgroundThreadPool.submit(call); + } + + + public static boolean containsAny(@NonNull String value, @NonNull String... targets) { + return indexOfFirstFound(value, targets) >= 0; + } + + public static int indexOfFirstFound(@NonNull String value, @NonNull String... targets) { + for (String string : targets) { + if (!string.isEmpty()) { + final int indexOf = value.indexOf(string); + if (indexOf >= 0) return indexOf; + } + } + return -1; + } + + public interface MatchFilter { + boolean matches(T object); + } + + public static R getChildView(@NonNull Activity activity, @NonNull String str) { + final View decorView = activity.getWindow().getDecorView(); + return getChildView(decorView, str); + } + + /** + * @noinspection unchecked + */ + public static R getChildView(@NonNull View view, @NonNull String str) { + view = view.findViewById(ResourceUtils.getIdIdentifier(str)); + if (view != null) { + return (R) view; + } else { + throw new IllegalArgumentException("View with name" + str + " not found"); + } + } + + /** + * @param searchRecursively If children ViewGroups should also be + * recursively searched using depth first search. + * @return The first child view that matches the filter. + */ + @Nullable + public static T getChildView(@NonNull ViewGroup viewGroup, boolean searchRecursively, + @NonNull MatchFilter filter) { + for (int i = 0, childCount = viewGroup.getChildCount(); i < childCount; i++) { + View childAt = viewGroup.getChildAt(i); + if (filter.matches(childAt)) { + //noinspection unchecked + return (T) childAt; + } + // Must do recursive after filter check, in case the filter is looking for a ViewGroup. + if (searchRecursively && childAt instanceof ViewGroup) { + T match = getChildView((ViewGroup) childAt, true, filter); + if (match != null) return match; + } + } + return null; + } + + /** + * @return The first child view that matches the filter. + * @noinspection rawtypes, unchecked + */ + @Nullable + public static T getChildView(@NonNull ViewGroup viewGroup, @NonNull MatchFilter filter) { + for (int i = 0, childCount = viewGroup.getChildCount(); i < childCount; i++) { + View childAt = viewGroup.getChildAt(i); + if (filter.matches(childAt)) { + return (T) childAt; + } + } + return null; + } + + @Nullable + public static ViewParent getParentView(@NonNull View view, int nthParent) { + ViewParent parent = view.getParent(); + + int currentDepth = 0; + while (++currentDepth < nthParent && parent != null) { + parent = parent.getParent(); + } + + if (currentDepth == nthParent) { + return parent; + } + + final int finalDepthLog = currentDepth; + final ViewParent finalParent = parent; + Logger.printDebug(() -> "Could not find parent view of depth: " + nthParent + + " and instead found at: " + finalDepthLog + " view: " + finalParent); + return null; + } + + public static void restartApp(@NonNull Context mContext) { + String packageName = mContext.getPackageName(); + Intent intent = mContext.getPackageManager().getLaunchIntentForPackage(packageName); + if (intent == null) return; + Intent mainIntent = Intent.makeRestartActivityTask(intent.getComponent()); + // Required for API 34 and later + // Ref: https://developer.android.com/about/versions/14/behavior-changes-14#safer-intents + mainIntent.setPackage(packageName); + if (mContext instanceof Activity mActivity) { + mActivity.finishAndRemoveTask(); + } + mContext.startActivity(mainIntent); + System.runFinalizersOnExit(true); + System.exit(0); + } + + public static Activity getActivity() { + return activityRef.get(); + } + + public static Context getContext() { + if (context == null) { + Logger.initializationException(Utils.class, "Context is null, returning null!", null); + } + return context; + } + + public static Resources getResources() { + if (resources == null) { + return getLocalizedContextAndSetResources(getContext()).getResources(); + } else { + return resources; + } + } + + /** + * Compare MainActivity's Locale and Context's Locale. + * If the Locale of MainActivity and the Locale of Context are different, the Locale of MainActivity is applied. + *

+ * If Locale changes, resources should also change and be saved locally. + * Otherwise, {@link ResourceUtils#getString(String)} will be updated to the incorrect language. + * + * @param mContext Context to check locale. + * @return Context with locale applied. + */ + public static Context getLocalizedContextAndSetResources(Context mContext) { + Activity mActivity = activityRef.get(); + if (mActivity == null) { + return mContext; + } + + // Locale of MainActivity. + Locale applicationLocale; + + // Locale of Context. + Locale contextLocale; + + if (isSDKAbove(24)) { + applicationLocale = mActivity.getResources().getConfiguration().getLocales().get(0); + contextLocale = mContext.getResources().getConfiguration().getLocales().get(0); + } else { + applicationLocale = mActivity.getResources().getConfiguration().locale; + contextLocale = mContext.getResources().getConfiguration().locale; + } + + // If they are identical, no need to override them. + if (applicationLocale == contextLocale) { + resources = mActivity.getResources(); + return mContext; + } + + // If they are different, overrides the Locale of the Context and resource. + Locale.setDefault(applicationLocale); + Configuration configuration = new Configuration(mContext.getResources().getConfiguration()); + configuration.setLocale(applicationLocale); + Context localizedContext = mContext.createConfigurationContext(configuration); + resources = localizedContext.getResources(); + return localizedContext; + } + + public static void setActivity(Activity mainActivity) { + activityRef = new WeakReference<>(mainActivity); + } + + public static void setContext(@Nullable Context appContext) { + // Typically, Context is invoked in the constructor method, so it is not null. + // Since some are invoked from methods other than the constructor method, + // it may be necessary to check whether Context is null. + if (appContext == null) { + return; + } + + context = appContext; + + // In some apps like TikTok, the Setting classes can load in weird orders due to cyclic class dependencies. + // Calling the regular printDebug method here can cause a Settings context null pointer exception, + // even though the context is already set before the call. + // + // The initialization logger methods do not directly or indirectly + // reference the Context or any Settings and are unaffected by this problem. + // + // Info level also helps debug if a patch hook is called before + // the context is set since debug logging is off by default. + Logger.initializationInfo(Utils.class, "Set context: " + appContext); + } + + public static void setClipboard(@NonNull String text) { + setClipboard(text, null); + } + + public static void setClipboard(@NonNull String text, @Nullable String toastMessage) { + if (!(context.getSystemService(Context.CLIPBOARD_SERVICE) instanceof ClipboardManager clipboard)) + return; + android.content.ClipData clip = android.content.ClipData.newPlainText("ReVanced", text); + clipboard.setPrimaryClip(clip); + + // Do not show a toast if using Android 13+ as it shows it's own toast. + // But if the user copied with a timestamp then show a toast. + // Unfortunately this will show 2 toasts on Android 13+, but no way around this. + if (isSDKAbove(33) || toastMessage == null) return; + showToastShort(toastMessage); + } + + public static String getFormattedTimeStamp(long videoTime) { + return "'" + videoTime + + "' (" + + getTimeStamp(videoTime) + + ")\n"; + } + + @SuppressLint("DefaultLocale") + public static String getTimeStamp(long time) { + long hours; + long minutes; + long seconds; + + if (isSDKAbove(26)) { + final Duration duration = Duration.ofMillis(time); + + hours = duration.toHours(); + minutes = duration.toMinutes() % 60; + seconds = duration.getSeconds() % 60; + } else { + final long currentVideoTimeInSeconds = time / 1000; + + hours = currentVideoTimeInSeconds / (60 * 60); + minutes = (currentVideoTimeInSeconds / 60) % 60; + seconds = currentVideoTimeInSeconds % 60; + } + + if (hours > 0) { + return String.format("%02d:%02d:%02d", hours, minutes, seconds); + } else { + return String.format("%02d:%02d", minutes, seconds); + } + } + + public static void setEditTextDialogTheme(final AlertDialog.Builder builder) { + setEditTextDialogTheme(builder, false); + } + + /** + * If {@link Fragment} uses [Android library] rather than [AndroidX library], + * the Dialog theme corresponding to [Android library] should be used. + *

+ * If not, the following issues will occur: + * ReVanced/revanced-patches#3061 + *

+ * To prevent these issues, apply the Dialog theme corresponding to [Android library]. + * + * @param builder Alertdialog builder to apply theme to. + * When used in a method containing an override, it must be called before 'super'. + * @param maxWidth Whether to use alertdialog as max width. + * It is used when there is a lot of content to show, such as an import/export dialog. + */ + public static void setEditTextDialogTheme(final AlertDialog.Builder builder, boolean maxWidth) { + final String styleIdentifier = maxWidth + ? "revanced_edit_text_dialog_max_width_style" + : "revanced_edit_text_dialog_style"; + final int editTextDialogStyle = ResourceUtils.getStyleIdentifier(styleIdentifier); + if (editTextDialogStyle != 0) { + builder.getContext().setTheme(editTextDialogStyle); + } + } + + public static AlertDialog.Builder getEditTextDialogBuilder(final Context context) { + return getEditTextDialogBuilder(context, false); + } + + public static AlertDialog.Builder getEditTextDialogBuilder(final Context context, boolean maxWidth) { + final AlertDialog.Builder builder = new AlertDialog.Builder(context); + setEditTextDialogTheme(builder, maxWidth); + return builder; + } + + @Nullable + private static Boolean isRightToLeftTextLayout; + + /** + * If the device language uses right to left text layout (hebrew, arabic, etc) + */ + public static boolean isRightToLeftTextLayout() { + if (isRightToLeftTextLayout == null) { + String displayLanguage = Locale.getDefault().getDisplayLanguage(); + isRightToLeftTextLayout = new Bidi(displayLanguage, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT).isRightToLeft(); + } + return isRightToLeftTextLayout; + } + + /** + * @return if the text contains at least 1 number character, + * including any unicode numbers such as Arabic. + */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public static boolean containsNumber(@NonNull CharSequence text) { + for (int index = 0, length = text.length(); index < length; ) { + final int codePoint = Character.codePointAt(text, index); + if (Character.isDigit(codePoint)) { + return true; + } + index += Character.charCount(codePoint); + } + + return false; + } + + /** + * @return whether the device's API level is higher than a specific SDK version. + */ + public static boolean isSDKAbove(int sdk) { + return Build.VERSION.SDK_INT >= sdk; + } + + /** + * Safe to call from any thread + */ + public static void showToastShort(@NonNull String messageToToast) { + showToast(messageToToast, Toast.LENGTH_SHORT); + } + + /** + * Safe to call from any thread + */ + public static void showToastLong(@NonNull String messageToToast) { + showToast(messageToToast, Toast.LENGTH_LONG); + } + + private static void showToast(@NonNull String messageToToast, int toastDuration) { + Objects.requireNonNull(messageToToast); + runOnMainThreadNowOrLater(() -> { + if (context == null) { + Logger.initializationException(Utils.class, "Cannot show toast (context is null): " + messageToToast, null); + } else { + Logger.printDebug(() -> "Showing toast: " + messageToToast); + Toast.makeText(context, messageToToast, toastDuration).show(); + } + } + ); + } + + /** + * Automatically logs any exceptions the runnable throws. + * + * @see #runOnMainThreadNowOrLater(Runnable) + */ + public static void runOnMainThread(@NonNull Runnable runnable) { + runOnMainThreadDelayed(runnable, 0); + } + + /** + * Automatically logs any exceptions the runnable throws + */ + public static void runOnMainThreadDelayed(@NonNull Runnable runnable, long delayMillis) { + Runnable loggingRunnable = () -> { + try { + runnable.run(); + } catch (Exception ex) { + Logger.printException(() -> runnable.getClass().getSimpleName() + ": " + ex.getMessage(), ex); + } + }; + new Handler(Looper.getMainLooper()).postDelayed(loggingRunnable, delayMillis); + } + + /** + * If called from the main thread, the code is run immediately.

+ * If called off the main thread, this is the same as {@link #runOnMainThread(Runnable)}. + */ + public static void runOnMainThreadNowOrLater(@NonNull Runnable runnable) { + if (isCurrentlyOnMainThread()) { + runnable.run(); + } else { + runOnMainThread(runnable); + } + } + + /** + * @return if the calling thread is on the main thread + */ + public static boolean isCurrentlyOnMainThread() { + if (isSDKAbove(23)) { + return Looper.getMainLooper().isCurrentThread(); + } else { + return Looper.getMainLooper().getThread() == Thread.currentThread(); + } + } + + /** + * @throws IllegalStateException if the calling thread is _off_ the main thread + */ + public static void verifyOnMainThread() throws IllegalStateException { + if (!isCurrentlyOnMainThread()) { + throw new IllegalStateException("Must call _on_ the main thread"); + } + } + + /** + * @throws IllegalStateException if the calling thread is _on_ the main thread + */ + public static void verifyOffMainThread() throws IllegalStateException { + if (isCurrentlyOnMainThread()) { + throw new IllegalStateException("Must call _off_ the main thread"); + } + } + + public enum NetworkType { + MOBILE("mobile"), + WIFI("wifi"), + NONE("none"); + + private final String name; + + NetworkType(String name) { + this.name = name; + } + + public String getName() { + return name; + } + } + + public static boolean isNetworkNotConnected() { + final NetworkType networkType = getNetworkType(); + return networkType == NetworkType.NONE; + } + + @SuppressLint("MissingPermission") // permission already included in YouTube + public static NetworkType getNetworkType() { + if (context == null || !(context.getSystemService(Context.CONNECTIVITY_SERVICE) instanceof ConnectivityManager cm)) + return NetworkType.NONE; + + final NetworkInfo networkInfo = cm.getActiveNetworkInfo(); + + if (networkInfo == null || !networkInfo.isConnected()) + return NetworkType.NONE; + + return switch (networkInfo.getType()) { + case ConnectivityManager.TYPE_MOBILE, ConnectivityManager.TYPE_BLUETOOTH -> + NetworkType.MOBILE; + default -> NetworkType.WIFI; + }; + } + + /** + * Hide a view by setting its layout params to 0x0 + * + * @param view The view to hide. + */ + public static void hideViewByLayoutParams(View view) { + if (view == null) return; + + if (view instanceof LinearLayout) { + LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(0, 0); + view.setLayoutParams(layoutParams); + } else if (view instanceof FrameLayout) { + FrameLayout.LayoutParams layoutParams2 = new FrameLayout.LayoutParams(0, 0); + view.setLayoutParams(layoutParams2); + } else if (view instanceof RelativeLayout) { + RelativeLayout.LayoutParams layoutParams3 = new RelativeLayout.LayoutParams(0, 0); + view.setLayoutParams(layoutParams3); + } else if (view instanceof Toolbar) { + Toolbar.LayoutParams layoutParams4 = new Toolbar.LayoutParams(0, 0); + view.setLayoutParams(layoutParams4); + } else if (view instanceof ViewGroup) { + ViewGroup.LayoutParams layoutParams5 = new ViewGroup.LayoutParams(0, 0); + view.setLayoutParams(layoutParams5); + } else { + ViewGroup.LayoutParams params = view.getLayoutParams(); + params.width = 0; + params.height = 0; + view.setLayoutParams(params); + } + } + + public static void hideViewGroupByMarginLayoutParams(ViewGroup viewGroup) { + // Rest of the implementation added by patch. + viewGroup.setVisibility(View.GONE); + } + + /** + * {@link PreferenceScreen} and {@link PreferenceGroup} sorting styles. + */ + private enum Sort { + /** + * Sort by the localized preference title. + */ + BY_TITLE("_sort_by_title"), + + /** + * Sort by the preference keys. + */ + BY_KEY("_sort_by_key"), + + /** + * Unspecified sorting. + */ + UNSORTED("_sort_by_unsorted"); + + final String keySuffix; + + Sort(String keySuffix) { + this.keySuffix = keySuffix; + } + + @NonNull + static Sort fromKey(@Nullable String key, @NonNull Sort defaultSort) { + if (key != null) { + for (Sort sort : values()) { + if (key.endsWith(sort.keySuffix)) { + return sort; + } + } + } + return defaultSort; + } + } + + private static final Regex punctuationRegex = new Regex("\\p{P}+"); + + /** + * Strips all punctuation and converts to lower case. A null parameter returns an empty string. + */ + public static String removePunctuationConvertToLowercase(@Nullable CharSequence original) { + if (original == null) return ""; + return punctuationRegex.replace(original, "").toLowerCase(); + } + + /** + * Sort a PreferenceGroup and all it's sub groups by title or key. + *

+ * Sort order is determined by the preferences key {@link Sort} suffix. + *

+ * If a preference has no key or no {@link Sort} suffix, + * then the preferences are left unsorted. + */ + @SuppressWarnings("deprecation") + public static void sortPreferenceGroups(@NonNull PreferenceGroup group) { + Sort groupSort = Sort.fromKey(group.getKey(), Sort.UNSORTED); + SortedMap preferences = new TreeMap<>(); + + for (int i = 0, prefCount = group.getPreferenceCount(); i < prefCount; i++) { + Preference preference = group.getPreference(i); + + final Sort preferenceSort; + if (preference instanceof PreferenceGroup preferenceGroup) { + sortPreferenceGroups(preferenceGroup); + preferenceSort = groupSort; // Sort value for groups is for it's content, not itself. + } else { + // Allow individual preferences to set a key sorting. + // Used to force a preference to the top or bottom of a group. + preferenceSort = Sort.fromKey(preference.getKey(), groupSort); + } + + final String sortValue; + switch (preferenceSort) { + case BY_TITLE -> + sortValue = removePunctuationConvertToLowercase(preference.getTitle()); + case BY_KEY -> sortValue = preference.getKey(); + case UNSORTED -> { + continue; // Keep original sorting. + } + default -> throw new IllegalStateException(); + } + + preferences.put(sortValue, preference); + } + + int index = 0; + for (Preference pref : preferences.values()) { + int order = index++; + + // If the preference is a PreferenceScreen or is an intent preference, move to the top. + if (pref instanceof PreferenceScreen || pref.getIntent() != null) { + // Arbitrary high number. + order -= 1000; + } + + pref.setOrder(order); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ads/AdsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ads/AdsPatch.java new file mode 100644 index 0000000000..9eb1aa7b1e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ads/AdsPatch.java @@ -0,0 +1,46 @@ +package app.revanced.extension.youtube.patches.ads; + +import static app.revanced.extension.shared.utils.Utils.hideViewBy0dpUnderCondition; + +import android.view.View; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class AdsPatch { + private static final boolean hideGeneralAdsEnabled = Settings.HIDE_GENERAL_ADS.get(); + private static final boolean hideGetPremiumAdsEnabled = Settings.HIDE_GET_PREMIUM.get(); + private static final boolean hideVideoAdsEnabled = Settings.HIDE_VIDEO_ADS.get(); + + /** + * Injection point. + * Hide the view, which shows ads in the homepage. + * + * @param view The view, which shows ads. + */ + public static void hideAdAttributionView(View view) { + hideViewBy0dpUnderCondition(hideGeneralAdsEnabled, view); + } + + public static boolean hideGetPremium() { + return hideGetPremiumAdsEnabled; + } + + /** + * Injection point. + */ + public static boolean hideVideoAds() { + return !hideVideoAdsEnabled; + } + + /** + * Injection point. + *

+ * Only used by old clients. + * It is presumed to have been deprecated, and if it is confirmed that it is no longer used, remove it. + */ + public static boolean hideVideoAds(boolean original) { + return !hideVideoAdsEnabled && original; + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/alternativethumbnails/AlternativeThumbnailsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/alternativethumbnails/AlternativeThumbnailsPatch.java new file mode 100644 index 0000000000..aa97508533 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/alternativethumbnails/AlternativeThumbnailsPatch.java @@ -0,0 +1,721 @@ +package app.revanced.extension.youtube.patches.alternativethumbnails; + +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.youtube.settings.Settings.ALT_THUMBNAIL_HOME; +import static app.revanced.extension.youtube.settings.Settings.ALT_THUMBNAIL_LIBRARY; +import static app.revanced.extension.youtube.settings.Settings.ALT_THUMBNAIL_PLAYER; +import static app.revanced.extension.youtube.settings.Settings.ALT_THUMBNAIL_SEARCH; +import static app.revanced.extension.youtube.settings.Settings.ALT_THUMBNAIL_SUBSCRIPTIONS; +import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton; + +import android.net.Uri; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.chromium.net.UrlRequest; +import org.chromium.net.UrlResponseInfo; +import org.chromium.net.impl.CronetUrlRequest; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.regex.Pattern; + +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.RootView; + +/** + * @noinspection ALL + * Alternative YouTube thumbnails. + *

+ * Can show YouTube provided screen captures of beginning/middle/end of the video. + * (ie: sd1.jpg, sd2.jpg, sd3.jpg). + *

+ * Or can show crowd-sourced thumbnails provided by DeArrow (...). + *

+ * Or can use DeArrow and fall back to screen captures if DeArrow is not available. + *

+ * Has an additional option to use 'fast' video still thumbnails, + * where it forces sd thumbnail quality and skips verifying if the alt thumbnail image exists. + * The UI loading time will be the same or better than using original thumbnails, + * but thumbnails will initially fail to load for all live streams, unreleased, and occasionally very old videos. + * If a failed thumbnail load is reloaded (ie: scroll off, then on screen), then the original thumbnail + * is reloaded instead. Fast thumbnails requires using SD or lower thumbnail resolution, + * because a noticeable number of videos do not have hq720 and too much fail to load. + */ +public final class AlternativeThumbnailsPatch { + + // These must be class declarations if declared here, + // otherwise the app will not load due to cyclic initialization errors. + public static final class DeArrowAvailability implements Setting.Availability { + public static boolean usingDeArrowAnywhere() { + return ALT_THUMBNAIL_HOME.get().useDeArrow + || ALT_THUMBNAIL_SUBSCRIPTIONS.get().useDeArrow + || ALT_THUMBNAIL_LIBRARY.get().useDeArrow + || ALT_THUMBNAIL_PLAYER.get().useDeArrow + || ALT_THUMBNAIL_SEARCH.get().useDeArrow; + } + + @Override + public boolean isAvailable() { + return usingDeArrowAnywhere(); + } + } + + public static final class StillImagesAvailability implements Setting.Availability { + public static boolean usingStillImagesAnywhere() { + return ALT_THUMBNAIL_HOME.get().useStillImages + || ALT_THUMBNAIL_SUBSCRIPTIONS.get().useStillImages + || ALT_THUMBNAIL_LIBRARY.get().useStillImages + || ALT_THUMBNAIL_PLAYER.get().useStillImages + || ALT_THUMBNAIL_SEARCH.get().useStillImages; + } + + @Override + public boolean isAvailable() { + return usingStillImagesAnywhere(); + } + } + + public enum ThumbnailOption { + ORIGINAL(false, false), + DEARROW(true, false), + DEARROW_STILL_IMAGES(true, true), + STILL_IMAGES(false, true); + + final boolean useDeArrow; + final boolean useStillImages; + + ThumbnailOption(boolean useDeArrow, boolean useStillImages) { + this.useDeArrow = useDeArrow; + this.useStillImages = useStillImages; + } + } + + public enum ThumbnailStillTime { + BEGINNING(1), + MIDDLE(2), + END(3); + + /** + * The url alt image number. Such as the 2 in 'hq720_2.jpg' + */ + final int altImageNumber; + + ThumbnailStillTime(int altImageNumber) { + this.altImageNumber = altImageNumber; + } + } + + private static final Uri dearrowApiUri; + + /** + * The scheme and host of {@link #dearrowApiUri}. + */ + private static final String deArrowApiUrlPrefix; + + /** + * How long to temporarily turn off DeArrow if it fails for any reason. + */ + private static final long DEARROW_FAILURE_API_BACKOFF_MILLISECONDS = 5 * 60 * 1000; // 5 Minutes. + + /** + * Regex to match youtube static thumbnails domain. + * Used to find and replace blocked domain with a working ones + */ + private static final String YOUTUBE_STATIC_THUMBNAILS_DOMAIN_REGEX = "(yt[3-4]|lh[3-6]|play-lh)\\.(ggpht|googleusercontent)\\.com"; + + private static final Pattern YOUTUBE_STATIC_THUMBNAILS_DOMAIN_PATTERN = Pattern.compile(YOUTUBE_STATIC_THUMBNAILS_DOMAIN_REGEX); + + /** + * If non zero, then the system time of when DeArrow API calls can resume. + */ + private static volatile long timeToResumeDeArrowAPICalls; + + static { + dearrowApiUri = validateSettings(); + final int port = dearrowApiUri.getPort(); + String portString = port == -1 ? "" : (":" + port); + deArrowApiUrlPrefix = dearrowApiUri.getScheme() + "://" + dearrowApiUri.getHost() + portString + "/"; + Logger.printDebug(() -> "Using DeArrow API address: " + deArrowApiUrlPrefix); + } + + /** + * Fix any bad imported data. + */ + private static Uri validateSettings() { + Uri apiUri = Uri.parse(Settings.ALT_THUMBNAIL_DEARROW_API_URL.get()); + // Cannot use unsecured 'http', otherwise the connections fail to start and no callbacks hooks are made. + String scheme = apiUri.getScheme(); + if (scheme == null || scheme.equals("http") || apiUri.getHost() == null) { + Utils.showToastLong(str("revanced_alt_thumbnail_dearrow_api_url_invalid_toast")); + Utils.showToastShort(str("revanced_extended_reset_to_default_toast")); + Settings.ALT_THUMBNAIL_DEARROW_API_URL.resetToDefault(); + return validateSettings(); + } + return apiUri; + } + + private static ThumbnailOption optionSettingForCurrentNavigation() { + // Must check player type first, as search bar can be active behind the player. + if (RootView.isPlayerActive()) { + return ALT_THUMBNAIL_PLAYER.get(); + } + + // Must check second, as search can be from any tab. + if (RootView.isSearchBarActive()) { + return ALT_THUMBNAIL_SEARCH.get(); + } + + // Avoid checking which navigation button is selected, if all other settings are the same. + ThumbnailOption homeOption = ALT_THUMBNAIL_HOME.get(); + ThumbnailOption subscriptionsOption = ALT_THUMBNAIL_SUBSCRIPTIONS.get(); + ThumbnailOption libraryOption = ALT_THUMBNAIL_LIBRARY.get(); + if ((homeOption == subscriptionsOption) && (homeOption == libraryOption)) { + return homeOption; // All are the same option. + } + + NavigationButton selectedNavButton = NavigationButton.getSelectedNavigationButton(); + if (selectedNavButton == null) { + // Unknown tab, treat as the home tab; + return homeOption; + } + if (selectedNavButton == NavigationButton.HOME) { + return homeOption; + } + if (selectedNavButton == NavigationButton.SUBSCRIPTIONS || selectedNavButton == NavigationButton.NOTIFICATIONS) { + return subscriptionsOption; + } + // A library tab variant is active. + return libraryOption; + } + + /** + * Build the alternative thumbnail url using YouTube provided still video captures. + * + * @param decodedUrl Decoded original thumbnail request url. + * @return The alternative thumbnail url, or the original url. Both without tracking parameters. + */ + @NonNull + private static String buildYoutubeVideoStillURL(@NonNull DecodedThumbnailUrl decodedUrl, + @NonNull ThumbnailQuality qualityToUse) { + String sanitizedReplacement = decodedUrl.createStillsUrl(qualityToUse, false); + if (VerifiedQualities.verifyAltThumbnailExist(decodedUrl.videoId, qualityToUse, sanitizedReplacement)) { + return sanitizedReplacement; + } + return decodedUrl.sanitizedUrl; + } + + /** + * Build the alternative thumbnail url using DeArrow thumbnail cache. + * + * @param videoId ID of the video to get a thumbnail of. Can be any video (regular or Short). + * @param fallbackUrl URL to fall back to in case. + * @return The alternative thumbnail url, without tracking parameters. + */ + @NonNull + private static String buildDeArrowThumbnailURL(String videoId, String fallbackUrl) { + // Build thumbnail request url. + // See https://github.com/ajayyy/DeArrowThumbnailCache/blob/29eb4359ebdf823626c79d944a901492d760bbbc/app.py#L29. + return dearrowApiUri + .buildUpon() + .appendQueryParameter("videoID", videoId) + .appendQueryParameter("redirectUrl", fallbackUrl) + .build() + .toString(); + } + + private static boolean urlIsDeArrow(@NonNull String imageUrl) { + return imageUrl.startsWith(deArrowApiUrlPrefix); + } + + /** + * @return If this client has not recently experienced any DeArrow API errors. + */ + private static boolean canUseDeArrowAPI() { + if (timeToResumeDeArrowAPICalls == 0) { + return true; + } + if (timeToResumeDeArrowAPICalls < System.currentTimeMillis()) { + Logger.printDebug(() -> "Resuming DeArrow API calls"); + timeToResumeDeArrowAPICalls = 0; + return true; + } + return false; + } + + private static void handleDeArrowError(@NonNull String url, int statusCode) { + Logger.printDebug(() -> "Encountered DeArrow error. Url: " + url); + final long now = System.currentTimeMillis(); + if (timeToResumeDeArrowAPICalls < now) { + timeToResumeDeArrowAPICalls = now + DEARROW_FAILURE_API_BACKOFF_MILLISECONDS; + if (Settings.ALT_THUMBNAIL_DEARROW_CONNECTION_TOAST.get()) { + String toastMessage = (statusCode != 0) + ? str("revanced_alt_thumbnail_dearrow_error", statusCode) + : str("revanced_alt_thumbnail_dearrow_error_generic"); + Utils.showToastLong(toastMessage); + } + } + } + + /** + * Injection point. Called off the main thread and by multiple threads at the same time. + * + * @param originalUrl Image url for all url images loaded, including video thumbnails. + */ + public static String overrideImageURL(String originalUrl) { + try { + ThumbnailOption option = optionSettingForCurrentNavigation(); + + if (option == ThumbnailOption.ORIGINAL) { + return originalUrl; + } + + final var decodedUrl = DecodedThumbnailUrl.decodeImageUrl(originalUrl); + if (decodedUrl == null) { + return originalUrl; // Not a thumbnail. + } + + Logger.printDebug(() -> "Original url: " + decodedUrl.sanitizedUrl); + + ThumbnailQuality qualityToUse = ThumbnailQuality.getQualityToUse(decodedUrl.imageQuality); + if (qualityToUse == null) { + // Thumbnail is a Short or a Storyboard image used for seekbar thumbnails (must not replace these). + return originalUrl; + } + + String sanitizedReplacementUrl; + final boolean includeTracking; + if (option.useDeArrow && canUseDeArrowAPI()) { + includeTracking = false; // Do not include view tracking parameters with API call. + final String fallbackUrl = option.useStillImages + ? buildYoutubeVideoStillURL(decodedUrl, qualityToUse) + : decodedUrl.sanitizedUrl; + + sanitizedReplacementUrl = buildDeArrowThumbnailURL(decodedUrl.videoId, fallbackUrl); + } else if (option.useStillImages) { + includeTracking = true; // Include view tracking parameters if present. + sanitizedReplacementUrl = buildYoutubeVideoStillURL(decodedUrl, qualityToUse); + } else { + return originalUrl; // Recently experienced DeArrow failure and video stills are not enabled. + } + + // Do not log any tracking parameters. + Logger.printDebug(() -> "Replacement url: " + sanitizedReplacementUrl); + + return includeTracking + ? sanitizedReplacementUrl + decodedUrl.viewTrackingParameters + : sanitizedReplacementUrl; + } catch (Exception ex) { + Logger.printException(() -> "overrideImageURL failure", ex); + return originalUrl; + } + } + + /** + * Injection point. + *

+ * Cronet considers all completed connections as a success, even if the response is 404 or 5xx. + */ + public static void handleCronetSuccess(UrlRequest request, @NonNull UrlResponseInfo responseInfo) { + try { + final int statusCode = responseInfo.getHttpStatusCode(); + if (statusCode == 200) { + return; + } + + String url = responseInfo.getUrl(); + + if (urlIsDeArrow(url)) { + Logger.printDebug(() -> "handleCronetSuccess, statusCode: " + statusCode); + if (statusCode == 304) { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304 + return; // Normal response. + } + handleDeArrowError(url, statusCode); + return; + } + + if (statusCode == 404) { + // Fast alt thumbnails is enabled and the thumbnail is not available. + // The video is: + // - live stream + // - upcoming unreleased video + // - very old + // - very low view count + // Take note of this, so if the image reloads the original thumbnail will be used. + DecodedThumbnailUrl decodedUrl = DecodedThumbnailUrl.decodeImageUrl(url); + if (decodedUrl == null) { + return; // Not a thumbnail. + } + + Logger.printDebug(() -> "handleCronetSuccess, image not available: " + url); + + ThumbnailQuality quality = ThumbnailQuality.altImageNameToQuality(decodedUrl.imageQuality); + if (quality == null) { + // Video is a short or a seekbar thumbnail, but somehow did not load. Should not happen. + Logger.printDebug(() -> "Failed to recognize image quality of url: " + decodedUrl.sanitizedUrl); + return; + } + + VerifiedQualities.setAltThumbnailDoesNotExist(decodedUrl.videoId, quality); + } + } catch (Exception ex) { + Logger.printException(() -> "Callback success error", ex); + } + } + + /** + * Injection point. + *

+ * To test failure cases, try changing the API URL to each of: + * - A non-existent domain. + * - A url path of something incorrect (ie: /v1/nonExistentEndPoint). + *

+ * Cronet uses a very timeout (several minutes), so if the API never responds this hook can take a while to be called. + * But this does not appear to be a problem, as the DeArrow API has not been observed to 'go silent' + * Instead if there's a problem it returns an error code status response, which is handled in this patch. + */ + public static void handleCronetFailure(UrlRequest request, + @Nullable UrlResponseInfo responseInfo, + IOException exception) { + try { + String url = ((CronetUrlRequest) request).getHookedUrl(); + if (urlIsDeArrow(url)) { + Logger.printDebug(() -> "handleCronetFailure, exception: " + exception); + final int statusCode = (responseInfo != null) + ? responseInfo.getHttpStatusCode() + : 0; + handleDeArrowError(url, statusCode); + } + } catch (Exception ex) { + Logger.printException(() -> "Callback failure error", ex); + } + } + + private enum ThumbnailQuality { + // In order of lowest to highest resolution. + DEFAULT("default", ""), // effective alt name is 1.jpg, 2.jpg, 3.jpg + MQDEFAULT("mqdefault", "mq"), + HQDEFAULT("hqdefault", "hq"), + SDDEFAULT("sddefault", "sd"), + HQ720("hq720", "hq720_"), + MAXRESDEFAULT("maxresdefault", "maxres"); + + /** + * Lookup map of original name to enum. + */ + private static final Map originalNameToEnum = new HashMap<>(); + + /** + * Lookup map of alt name to enum. ie: "hq720_1" to {@link #HQ720}. + */ + private static final Map altNameToEnum = new HashMap<>(); + + static { + for (ThumbnailQuality quality : values()) { + originalNameToEnum.put(quality.originalName, quality); + + for (ThumbnailStillTime time : ThumbnailStillTime.values()) { + // 'custom' thumbnails set by the content creator. + // These show up in place of regular thumbnails + // and seem to be limited to the same [1, 3] range as the still captures. + originalNameToEnum.put(quality.originalName + "_custom_" + time.altImageNumber, quality); + + altNameToEnum.put(quality.altImageName + time.altImageNumber, quality); + } + } + } + + /** + * Convert an alt image name to enum. + * ie: "hq720_2" returns {@link #HQ720}. + */ + @Nullable + static ThumbnailQuality altImageNameToQuality(@NonNull String altImageName) { + return altNameToEnum.get(altImageName); + } + + /** + * Original quality to effective alt quality to use. + * ie: If fast alt image is enabled, then "hq720" returns {@link #SDDEFAULT}. + */ + @Nullable + static ThumbnailQuality getQualityToUse(@NonNull String originalSize) { + ThumbnailQuality quality = originalNameToEnum.get(originalSize); + if (quality == null) { + return null; // Not a thumbnail for a regular video. + } + + final boolean useFastQuality = Settings.ALT_THUMBNAIL_STILLS_FAST.get(); + switch (quality) { + case SDDEFAULT: + // SD alt images have somewhat worse quality with washed out color and poor contrast. + // But the 720 images look much better and don't suffer from these issues. + // For unknown reasons, the 720 thumbnails are used only for the home feed, + // while SD is used for the search and subscription feed + // (even though search and subscriptions use the exact same layout as the home feed). + // Of note, this image quality issue only appears with the alt thumbnail images, + // and the regular thumbnails have identical color/contrast quality for all sizes. + // Fix this by falling thru and upgrading SD to 720. + case HQ720: + if (useFastQuality) { + return SDDEFAULT; // SD is max resolution for fast alt images. + } + return HQ720; + case MAXRESDEFAULT: + if (useFastQuality) { + return SDDEFAULT; + } + return MAXRESDEFAULT; + default: + return quality; + } + } + + final String originalName; + final String altImageName; + + ThumbnailQuality(String originalName, String altImageName) { + this.originalName = originalName; + this.altImageName = altImageName; + } + + String getAltImageNameToUse() { + return altImageName + Settings.ALT_THUMBNAIL_STILLS_TIME.get().altImageNumber; + } + } + + /** + * Uses HTTP HEAD requests to verify and keep track of which thumbnail sizes + * are available and not available. + */ + private static class VerifiedQualities { + /** + * After a quality is verified as not available, how long until the quality is re-verified again. + * Used only if fast mode is not enabled. Intended for live streams and unreleased videos + * that are now finished and available (and thus, the alt thumbnails are also now available). + */ + private static final long NOT_AVAILABLE_TIMEOUT_MILLISECONDS = 10 * 60 * 1000; // 10 minutes. + + /** + * Cache used to verify if an alternative thumbnails exists for a given video id. + */ + @GuardedBy("itself") + private static final Map altVideoIdLookup = new LinkedHashMap<>(100) { + private static final int CACHE_LIMIT = 1000; + + @Override + protected boolean removeEldestEntry(Entry eldest) { + return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit. + } + }; + + private static VerifiedQualities getVerifiedQualities(@NonNull String videoId, boolean returnNullIfDoesNotExist) { + synchronized (altVideoIdLookup) { + VerifiedQualities verified = altVideoIdLookup.get(videoId); + if (verified == null) { + if (returnNullIfDoesNotExist) { + return null; + } + verified = new VerifiedQualities(); + altVideoIdLookup.put(videoId, verified); + } + return verified; + } + } + + static boolean verifyAltThumbnailExist(@NonNull String videoId, @NonNull ThumbnailQuality quality, + @NonNull String imageUrl) { + VerifiedQualities verified = getVerifiedQualities(videoId, Settings.ALT_THUMBNAIL_STILLS_FAST.get()); + if (verified == null) return true; // Fast alt thumbnails is enabled. + return verified.verifyYouTubeThumbnailExists(videoId, quality, imageUrl); + } + + static void setAltThumbnailDoesNotExist(@NonNull String videoId, @NonNull ThumbnailQuality quality) { + VerifiedQualities verified = getVerifiedQualities(videoId, false); + //noinspection ConstantConditions + verified.setQualityVerified(videoId, quality, false); + } + + /** + * Highest quality verified as existing. + */ + @Nullable + private ThumbnailQuality highestQualityVerified; + /** + * Lowest quality verified as not existing. + */ + @Nullable + private ThumbnailQuality lowestQualityNotAvailable; + + /** + * System time, of when to invalidate {@link #lowestQualityNotAvailable}. + * Used only if fast mode is not enabled. + */ + private long timeToReVerifyLowestQuality; + + private synchronized void setQualityVerified(String videoId, ThumbnailQuality quality, boolean isVerified) { + if (isVerified) { + if (highestQualityVerified == null || highestQualityVerified.ordinal() < quality.ordinal()) { + highestQualityVerified = quality; + } + } else { + if (lowestQualityNotAvailable == null || lowestQualityNotAvailable.ordinal() > quality.ordinal()) { + lowestQualityNotAvailable = quality; + timeToReVerifyLowestQuality = System.currentTimeMillis() + NOT_AVAILABLE_TIMEOUT_MILLISECONDS; + } + Logger.printDebug(() -> quality + " not available for video: " + videoId); + } + } + + /** + * Verify if a video alt thumbnail exists. Does so by making a minimal HEAD http request. + */ + synchronized boolean verifyYouTubeThumbnailExists(@NonNull String videoId, @NonNull ThumbnailQuality quality, + @NonNull String imageUrl) { + if (highestQualityVerified != null && highestQualityVerified.ordinal() >= quality.ordinal()) { + return true; // Previously verified as existing. + } + + final boolean fastQuality = Settings.ALT_THUMBNAIL_STILLS_FAST.get(); + if (lowestQualityNotAvailable != null && lowestQualityNotAvailable.ordinal() <= quality.ordinal()) { + if (fastQuality || System.currentTimeMillis() < timeToReVerifyLowestQuality) { + return false; // Previously verified as not existing. + } + // Enough time has passed, and should re-verify again. + Logger.printDebug(() -> "Resetting lowest verified quality for: " + videoId); + lowestQualityNotAvailable = null; + } + + if (fastQuality) { + return true; // Unknown if it exists or not. Use the URL anyways and update afterwards if loading fails. + } + + boolean imageFileFound; + try { + // This hooked code is running on a low priority thread, and it's slightly faster + // to run the url connection thru the integrations thread pool which runs at the highest priority. + final long start = System.currentTimeMillis(); + imageFileFound = Utils.submitOnBackgroundThread(() -> { + final int connectionTimeoutMillis = 10000; // 10 seconds. + HttpURLConnection connection = (HttpURLConnection) new URL(imageUrl).openConnection(); + connection.setConnectTimeout(connectionTimeoutMillis); + connection.setReadTimeout(connectionTimeoutMillis); + connection.setRequestMethod("HEAD"); + // Even with a HEAD request, the response is the same size as a full GET request. + // Using an empty range fixes this. + connection.setRequestProperty("Range", "bytes=0-0"); + final int responseCode = connection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_PARTIAL) { + String contentType = connection.getContentType(); + return (contentType != null && contentType.startsWith("image")); + } + if (responseCode != HttpURLConnection.HTTP_NOT_FOUND) { + Logger.printDebug(() -> "Unexpected response code: " + responseCode + " for url: " + imageUrl); + } + return false; + }).get(); + Logger.printDebug(() -> "Verification took: " + (System.currentTimeMillis() - start) + "ms for image: " + imageUrl); + } catch (ExecutionException | InterruptedException ex) { + Logger.printInfo(() -> "Could not verify alt url: " + imageUrl, ex); + imageFileFound = false; + } + + setQualityVerified(videoId, quality, imageFileFound); + return imageFileFound; + } + } + + /** + * YouTube video thumbnail url, decoded into it's relevant parts. + */ + private static class DecodedThumbnailUrl { + /** + * YouTube thumbnail URL prefix. Can be '/vi/' or '/vi_webp/' + */ + private static final String YOUTUBE_THUMBNAIL_PREFIX = "https://i.ytimg.com/vi"; + + @Nullable + static DecodedThumbnailUrl decodeImageUrl(String url) { + final int videoIdStartIndex = url.indexOf('/', YOUTUBE_THUMBNAIL_PREFIX.length()) + 1; + if (videoIdStartIndex <= 0) return null; + + final int videoIdEndIndex = url.indexOf('/', videoIdStartIndex); + if (videoIdEndIndex < 0) return null; + + final int imageSizeStartIndex = videoIdEndIndex + 1; + final int imageSizeEndIndex = url.indexOf('.', imageSizeStartIndex); + if (imageSizeEndIndex < 0) return null; + + int imageExtensionEndIndex = url.indexOf('?', imageSizeEndIndex); + if (imageExtensionEndIndex < 0) imageExtensionEndIndex = url.length(); + + return new DecodedThumbnailUrl(url, videoIdStartIndex, videoIdEndIndex, + imageSizeStartIndex, imageSizeEndIndex, imageExtensionEndIndex); + } + + final String originalFullUrl; + /** + * Full usable url, but stripped of any tracking information. + */ + final String sanitizedUrl; + /** + * Url up to the video ID. + */ + final String urlPrefix; + final String videoId; + /** + * Quality, such as hq720 or sddefault. + */ + final String imageQuality; + /** + * JPG or WEBP + */ + final String imageExtension; + /** + * User view tracking parameters, only present on some images. + */ + final String viewTrackingParameters; + + DecodedThumbnailUrl(String fullUrl, int videoIdStartIndex, int videoIdEndIndex, + int imageSizeStartIndex, int imageSizeEndIndex, int imageExtensionEndIndex) { + originalFullUrl = fullUrl; + sanitizedUrl = fullUrl.substring(0, imageExtensionEndIndex); + urlPrefix = fullUrl.substring(0, videoIdStartIndex); + videoId = fullUrl.substring(videoIdStartIndex, videoIdEndIndex); + imageQuality = fullUrl.substring(imageSizeStartIndex, imageSizeEndIndex); + imageExtension = fullUrl.substring(imageSizeEndIndex + 1, imageExtensionEndIndex); + viewTrackingParameters = (imageExtensionEndIndex == fullUrl.length()) + ? "" : fullUrl.substring(imageExtensionEndIndex); + } + + /** + * @noinspection SameParameterValue + */ + String createStillsUrl(@NonNull ThumbnailQuality qualityToUse, boolean includeViewTracking) { + // Images could be upgraded to webp if they are not already, but this fails quite often, + // especially for new videos uploaded in the last hour. + // And even if alt webp images do exist, sometimes they can load much slower than the original jpg alt images. + // (as much as 4x slower has been observed, despite the alt webp image being a smaller file). + StringBuilder builder = new StringBuilder(originalFullUrl.length() + 2); + builder.append(urlPrefix); + builder.append(videoId).append('/'); + builder.append(qualityToUse.getAltImageNameToUse()); + builder.append('.').append(imageExtension); + if (includeViewTracking) { + builder.append(viewTrackingParameters); + } + return builder.toString(); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ActionButtonsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ActionButtonsFilter.java new file mode 100644 index 0000000000..69386f21fb --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ActionButtonsFilter.java @@ -0,0 +1,118 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup; +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroupList; +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class ActionButtonsFilter extends Filter { + private static final String VIDEO_ACTION_BAR_PATH_PREFIX = "video_action_bar.eml"; + private static final String ANIMATED_VECTOR_TYPE_PATH = "AnimatedVectorType"; + + private final StringFilterGroup actionBarRule; + private final StringFilterGroup bufferFilterPathRule; + private final StringFilterGroup likeSubscribeGlow; + private final ByteArrayFilterGroupList bufferButtonsGroupList = new ByteArrayFilterGroupList(); + + public ActionButtonsFilter() { + actionBarRule = new StringFilterGroup( + null, + VIDEO_ACTION_BAR_PATH_PREFIX + ); + addIdentifierCallbacks(actionBarRule); + + bufferFilterPathRule = new StringFilterGroup( + null, + "|ContainerType|button.eml|" + ); + likeSubscribeGlow = new StringFilterGroup( + Settings.DISABLE_LIKE_DISLIKE_GLOW, + "animated_button_border.eml" + ); + addPathCallbacks( + new StringFilterGroup( + Settings.HIDE_LIKE_DISLIKE_BUTTON, + "|segmented_like_dislike_button" + ), + new StringFilterGroup( + Settings.HIDE_DOWNLOAD_BUTTON, + "|download_button.eml|" + ), + new StringFilterGroup( + Settings.HIDE_CLIP_BUTTON, + "|clip_button.eml|" + ), + new StringFilterGroup( + Settings.HIDE_PLAYLIST_BUTTON, + "|save_to_playlist_button" + ), + new StringFilterGroup( + Settings.HIDE_REWARDS_BUTTON, + "account_link_button" + ), + bufferFilterPathRule, + likeSubscribeGlow + ); + + bufferButtonsGroupList.addAll( + new ByteArrayFilterGroup( + Settings.HIDE_REPORT_BUTTON, + "yt_outline_flag" + ), + new ByteArrayFilterGroup( + Settings.HIDE_SHARE_BUTTON, + "yt_outline_share" + ), + new ByteArrayFilterGroup( + Settings.HIDE_REMIX_BUTTON, + "yt_outline_youtube_shorts_plus" + ), + new ByteArrayFilterGroup( + Settings.HIDE_SHOP_BUTTON, + "yt_outline_bag" + ), + new ByteArrayFilterGroup( + Settings.HIDE_THANKS_BUTTON, + "yt_outline_dollar_sign_heart" + ) + ); + } + + private boolean isEveryFilterGroupEnabled() { + for (StringFilterGroup group : pathCallbacks) + if (!group.isEnabled()) return false; + + for (ByteArrayFilterGroup group : bufferButtonsGroupList) + if (!group.isEnabled()) return false; + + return true; + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (!path.startsWith(VIDEO_ACTION_BAR_PATH_PREFIX)) { + return false; + } + if (matchedGroup == actionBarRule && !isEveryFilterGroupEnabled()) { + return false; + } + if (matchedGroup == likeSubscribeGlow) { + if (!path.contains(ANIMATED_VECTOR_TYPE_PATH)) { + return false; + } + } + if (matchedGroup == bufferFilterPathRule) { + // In case the group list has no match, return false. + if (!bufferButtonsGroupList.check(protobufBufferArray).isFiltered()) { + return false; + } + } + + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/AdsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/AdsFilter.java new file mode 100644 index 0000000000..e19532662a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/AdsFilter.java @@ -0,0 +1,160 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup; +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.youtube.settings.Settings; + +/** + * If A/B testing is applied, ad components can only be filtered by identifier + *

+ * Before A/B testing: + * Identifier: video_display_button_group_layout.eml + * Path: video_display_button_group_layout.eml|ContainerType|.... + * (Path always starts with an Identifier) + *

+ * After A/B testing: + * Identifier: video_display_button_group_layout.eml + * Path: video_lockup_with_attachment.eml|ContainerType|.... + * (Path does not contain an Identifier) + */ +@SuppressWarnings("unused") +public final class AdsFilter extends Filter { + + private final StringFilterGroup playerShoppingShelf; + private final ByteArrayFilterGroup playerShoppingShelfBuffer; + + public AdsFilter() { + + // Identifiers. + + final StringFilterGroup alertBannerPromo = new StringFilterGroup( + Settings.HIDE_PROMOTION_ALERT_BANNER, + "alert_banner_promo.eml" + ); + + // Keywords checked in 2024: + final StringFilterGroup generalAdsIdentifier = new StringFilterGroup( + Settings.HIDE_GENERAL_ADS, + // "brand_video_shelf.eml" + "brand_video", + + // "carousel_footered_layout.eml" + "carousel_footered_layout", + + // "composite_concurrent_carousel_layout" + "composite_concurrent_carousel_layout", + + // "landscape_image_wide_button_layout.eml" + "landscape_image_wide_button_layout", + + // "square_image_layout.eml" + "square_image_layout", + + // "statement_banner.eml" + "statement_banner", + + // "video_display_full_layout.eml" + "video_display_full_layout", + + // "text_image_button_group_layout.eml" + // "video_display_button_group_layout.eml" + "_button_group_layout", + + // "banner_text_icon_buttoned_layout.eml" + // "video_display_compact_buttoned_layout.eml" + // "video_display_full_buttoned_layout.eml" + "_buttoned_layout", + + // "compact_landscape_image_layout.eml" + // "full_width_portrait_image_layout.eml" + // "full_width_square_image_layout.eml" + "_image_layout" + ); + + final StringFilterGroup merchandise = new StringFilterGroup( + Settings.HIDE_MERCHANDISE_SHELF, + "product_carousel", + "shopping_carousel" + ); + + final StringFilterGroup paidContent = new StringFilterGroup( + Settings.HIDE_PAID_PROMOTION_LABEL, + "paid_content_overlay" + ); + + final StringFilterGroup selfSponsor = new StringFilterGroup( + Settings.HIDE_SELF_SPONSOR_CARDS, + "cta_shelf_card" + ); + + final StringFilterGroup viewProducts = new StringFilterGroup( + Settings.HIDE_VIEW_PRODUCTS, + "product_item", + "products_in_video", + "shopping_overlay" + ); + + final StringFilterGroup webSearchPanel = new StringFilterGroup( + Settings.HIDE_WEB_SEARCH_RESULTS, + "web_link_panel", + "web_result_panel" + ); + + addIdentifierCallbacks( + alertBannerPromo, + generalAdsIdentifier, + merchandise, + paidContent, + selfSponsor, + viewProducts, + webSearchPanel + ); + + // Path. + + final StringFilterGroup generalAdsPath = new StringFilterGroup( + Settings.HIDE_GENERAL_ADS, + "carousel_ad", + "carousel_headered_layout", + "hero_promo_image", + "legal_disclosure", + "lumiere_promo_carousel", + "primetime_promo", + "product_details", + "text_image_button_layout", + "video_display_carousel_button", + "watch_metadata_app_promo" + ); + + playerShoppingShelf = new StringFilterGroup( + null, + "horizontal_shelf.eml" + ); + + playerShoppingShelfBuffer = new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_STORE_SHELF, + "shopping_item_card_list.eml" + ); + + addPathCallbacks( + generalAdsPath, + playerShoppingShelf, + viewProducts + ); + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (matchedGroup == playerShoppingShelf) { + if (contentIndex == 0 && playerShoppingShelfBuffer.check(protobufBufferArray).isFiltered()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + return false; + } + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CarouselShelfFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CarouselShelfFilter.java new file mode 100644 index 0000000000..0f31c1e058 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CarouselShelfFilter.java @@ -0,0 +1,95 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import java.util.function.Supplier; +import java.util.stream.Stream; + +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.StringTrieSearch; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.NavigationBar.NavigationButton; +import app.revanced.extension.youtube.shared.RootView; + +@SuppressWarnings("unused") +public final class CarouselShelfFilter extends Filter { + private static final String BROWSE_ID_HOME = "FEwhat_to_watch"; + private static final String BROWSE_ID_LIBRARY = "FElibrary"; + private static final String BROWSE_ID_NOTIFICATION = "FEactivity"; + private static final String BROWSE_ID_NOTIFICATION_INBOX = "FEnotifications_inbox"; + private static final String BROWSE_ID_PLAYLIST = "VLPL"; + private static final String BROWSE_ID_SUBSCRIPTION = "FEsubscriptions"; + + private static final Supplier> knownBrowseId = () -> Stream.of( + BROWSE_ID_HOME, + BROWSE_ID_NOTIFICATION, + BROWSE_ID_PLAYLIST, + BROWSE_ID_SUBSCRIPTION + ); + + private static final Supplier> whitelistBrowseId = () -> Stream.of( + BROWSE_ID_LIBRARY, + BROWSE_ID_NOTIFICATION_INBOX + ); + + private final StringTrieSearch exceptions = new StringTrieSearch(); + public final StringFilterGroup horizontalShelf; + + public CarouselShelfFilter() { + exceptions.addPattern("library_recent_shelf.eml"); + + final StringFilterGroup carouselShelf = new StringFilterGroup( + Settings.HIDE_CAROUSEL_SHELF, + "horizontal_shelf_inline.eml", + "horizontal_tile_shelf.eml", + "horizontal_video_shelf.eml" + ); + + horizontalShelf = new StringFilterGroup( + Settings.HIDE_CAROUSEL_SHELF, + "horizontal_shelf.eml" + ); + + addPathCallbacks(carouselShelf, horizontalShelf); + } + + private static boolean hideShelves(boolean playerActive, boolean searchBarActive, NavigationButton selectedNavButton, String browseId) { + // Must check player type first, as search bar can be active behind the player. + if (playerActive) { + return false; + } + // Must check second, as search can be from any tab. + if (searchBarActive) { + return true; + } + // Unknown tab, treat the same as home. + if (selectedNavButton == null) { + return true; + } + return knownBrowseId.get().anyMatch(browseId::equals) || whitelistBrowseId.get().noneMatch(browseId::equals); + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (exceptions.matches(path)) { + return false; + } + final boolean playerActive = RootView.isPlayerActive(); + final boolean searchBarActive = RootView.isSearchBarActive(); + final NavigationButton navigationButton = NavigationButton.getSelectedNavigationButton(); + final String navigation = navigationButton == null ? "null" : navigationButton.name(); + final String browseId = RootView.getBrowseId(); + final boolean hideShelves = matchedGroup != horizontalShelf || hideShelves(playerActive, searchBarActive, navigationButton, browseId); + if (contentIndex != 0) { + return false; + } + Logger.printDebug(() -> "hideShelves: " + hideShelves + "\nplayerActive: " + playerActive + "\nsearchBarActive: " + searchBarActive + "\nbrowseId: " + browseId + "\nnavigation: " + navigation); + if (!hideShelves) { + return false; + } + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CommentsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CommentsFilter.java new file mode 100644 index 0000000000..d4525cff61 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CommentsFilter.java @@ -0,0 +1,133 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import java.util.regex.Pattern; + +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.shared.utils.StringTrieSearch; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class CommentsFilter extends Filter { + private static final String COMMENT_COMPOSER_PATH = "comment_composer"; + private static final String COMMENT_ENTRY_POINT_TEASER_PATH = "comments_entry_point_teaser"; + private static final Pattern COMMENT_PREVIEW_TEXT_PATTERN = Pattern.compile("comments_entry_point_teaser.+ContainerType"); + private static final String FEED_VIDEO_PATH = "video_lockup_with_attachment"; + private static final String VIDEO_METADATA_CAROUSEL_PATH = "video_metadata_carousel.eml"; + + private final StringFilterGroup comments; + private final StringFilterGroup commentsPreviewDots; + private final StringFilterGroup createShorts; + private final StringFilterGroup previewCommentText; + private final StringFilterGroup thanks; + private final StringFilterGroup timeStampAndEmojiPicker; + private final StringTrieSearch exceptions = new StringTrieSearch(); + + public CommentsFilter() { + exceptions.addPatterns("macro_markers_list_item"); + + final StringFilterGroup channelGuidelines = new StringFilterGroup( + Settings.HIDE_CHANNEL_GUIDELINES, + "channel_guidelines_entry_banner", + "community_guidelines", + "sponsorships_comments_upsell" + ); + + comments = new StringFilterGroup( + null, + VIDEO_METADATA_CAROUSEL_PATH, + "comments_" + ); + + commentsPreviewDots = new StringFilterGroup( + Settings.HIDE_PREVIEW_COMMENT_OLD_METHOD, + "|ContainerType|ContainerType|ContainerType|" + ); + + createShorts = new StringFilterGroup( + Settings.HIDE_COMMENT_CREATE_SHORTS_BUTTON, + "composer_short_creation_button" + ); + + final StringFilterGroup membersBanner = new StringFilterGroup( + Settings.HIDE_COMMENTS_BY_MEMBERS, + "sponsorships_comments_header.eml", + "sponsorships_comments_footer.eml" + ); + + final StringFilterGroup previewComment = new StringFilterGroup( + Settings.HIDE_PREVIEW_COMMENT_OLD_METHOD, + "|carousel_item.", + "|carousel_listener", + COMMENT_ENTRY_POINT_TEASER_PATH, + "comments_entry_point_simplebox" + ); + + previewCommentText = new StringFilterGroup( + Settings.HIDE_PREVIEW_COMMENT_NEW_METHOD, + COMMENT_ENTRY_POINT_TEASER_PATH + ); + + thanks = new StringFilterGroup( + Settings.HIDE_COMMENT_THANKS_BUTTON, + "|super_thanks_button.eml" + ); + + timeStampAndEmojiPicker = new StringFilterGroup( + Settings.HIDE_COMMENT_TIMESTAMP_AND_EMOJI_BUTTONS, + "|CellType|ContainerType|ContainerType|ContainerType|ContainerType|ContainerType|" + ); + + + addIdentifierCallbacks(channelGuidelines); + + addPathCallbacks( + comments, + commentsPreviewDots, + createShorts, + membersBanner, + previewComment, + previewCommentText, + thanks, + timeStampAndEmojiPicker + ); + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (exceptions.matches(path)) + return false; + + if (matchedGroup == createShorts || matchedGroup == thanks || matchedGroup == timeStampAndEmojiPicker) { + if (path.startsWith(COMMENT_COMPOSER_PATH)) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + return false; + } else if (matchedGroup == comments) { + if (path.startsWith(FEED_VIDEO_PATH)) { + if (Settings.HIDE_COMMENTS_SECTION_IN_HOME_FEED.get()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + return false; + } else if (Settings.HIDE_COMMENTS_SECTION.get()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + return false; + } else if (matchedGroup == commentsPreviewDots) { + if (path.startsWith(VIDEO_METADATA_CAROUSEL_PATH)) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + return false; + } else if (matchedGroup == previewCommentText) { + if (COMMENT_PREVIEW_TEXT_PATTERN.matcher(path).find()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + return false; + } + + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CustomFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CustomFilter.java new file mode 100644 index 0000000000..2c165c084e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CustomFilter.java @@ -0,0 +1,164 @@ +package app.revanced.extension.youtube.patches.components; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.shared.utils.ByteTrieSearch; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; + +/** + * Allows custom filtering using a path and optionally a proto buffer string. + */ +@SuppressWarnings("unused") +public final class CustomFilter extends Filter { + + private static void showInvalidSyntaxToast(@NonNull String expression) { + Utils.showToastLong(str("revanced_custom_filter_toast_invalid_syntax", expression)); + } + + private static class CustomFilterGroup extends StringFilterGroup { + /** + * Optional character for the path that indicates the custom filter path must match the start. + * Must be the first character of the expression. + */ + public static final String SYNTAX_STARTS_WITH = "^"; + + /** + * Optional character that separates the path from a proto buffer string pattern. + */ + public static final String SYNTAX_BUFFER_SYMBOL = "$"; + + /** + * @return the parsed objects + */ + @NonNull + @SuppressWarnings("ConstantConditions") + static Collection parseCustomFilterGroups() { + String rawCustomFilterText = Settings.CUSTOM_FILTER_STRINGS.get(); + if (rawCustomFilterText.isBlank()) { + return Collections.emptyList(); + } + + // Map key is the path including optional special characters (^ and/or $) + Map result = new HashMap<>(); + Pattern pattern = Pattern.compile( + "(" // map key group + + "(\\Q" + SYNTAX_STARTS_WITH + "\\E?)" // optional starts with + + "([^\\Q" + SYNTAX_BUFFER_SYMBOL + "\\E]*)" // path + + "(\\Q" + SYNTAX_BUFFER_SYMBOL + "\\E?)" // optional buffer symbol + + ")" // end map key group + + "(.*)"); // optional buffer string + + for (String expression : rawCustomFilterText.split("\n")) { + if (expression.isBlank()) continue; + + Matcher matcher = pattern.matcher(expression); + if (!matcher.find()) { + showInvalidSyntaxToast(expression); + continue; + } + + final String mapKey = matcher.group(1); + final boolean pathStartsWith = !matcher.group(2).isEmpty(); + final String path = matcher.group(3); + final boolean hasBufferSymbol = !matcher.group(4).isEmpty(); + final String bufferString = matcher.group(5); + + if (path.isBlank() || (hasBufferSymbol && bufferString.isBlank())) { + showInvalidSyntaxToast(expression); + continue; + } + + // Use one group object for all expressions with the same path. + // This ensures the buffer is searched exactly once + // when multiple paths are used with different buffer strings. + CustomFilterGroup group = result.get(mapKey); + if (group == null) { + group = new CustomFilterGroup(pathStartsWith, path); + result.put(mapKey, group); + } + if (hasBufferSymbol) { + group.addBufferString(bufferString); + } + } + + return result.values(); + } + + final boolean startsWith; + ByteTrieSearch bufferSearch; + + CustomFilterGroup(boolean startsWith, @NonNull String path) { + super(Settings.CUSTOM_FILTER, path); + this.startsWith = startsWith; + } + + void addBufferString(@NonNull String bufferString) { + if (bufferSearch == null) { + bufferSearch = new ByteTrieSearch(); + } + bufferSearch.addPattern(bufferString.getBytes()); + } + + @NonNull + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("CustomFilterGroup{"); + builder.append("path="); + if (startsWith) builder.append(SYNTAX_STARTS_WITH); + builder.append(filters[0]); + + if (bufferSearch != null) { + String delimitingCharacter = "❙"; + builder.append(", bufferStrings="); + builder.append(delimitingCharacter); + for (byte[] bufferString : bufferSearch.getPatterns()) { + builder.append(new String(bufferString)); + builder.append(delimitingCharacter); + } + } + builder.append("}"); + return builder.toString(); + } + } + + public CustomFilter() { + Collection groups = CustomFilterGroup.parseCustomFilterGroups(); + + if (!groups.isEmpty()) { + CustomFilterGroup[] groupsArray = groups.toArray(new CustomFilterGroup[0]); + Logger.printDebug(() -> "Using Custom filters: " + Arrays.toString(groupsArray)); + addPathCallbacks(groupsArray); + } + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + // All callbacks are custom filter groups. + CustomFilterGroup custom = (CustomFilterGroup) matchedGroup; + if (custom.startsWith && contentIndex != 0) { + return false; + } + if (custom.bufferSearch != null && !custom.bufferSearch.matches(protobufBufferArray)) { + return false; + } + + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/DescriptionsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/DescriptionsFilter.java new file mode 100644 index 0000000000..fb2224d181 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/DescriptionsFilter.java @@ -0,0 +1,111 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup; +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroupList; +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class DescriptionsFilter extends Filter { + private final ByteArrayFilterGroupList macroMarkerShelfGroupList = new ByteArrayFilterGroupList(); + + private final StringFilterGroup howThisWasMadeSection; + private final StringFilterGroup infoCardsSection; + private final StringFilterGroup macroMarkerShelf; + private final StringFilterGroup shoppingLinks; + + public DescriptionsFilter() { + // game section, music section and places section now use the same identifier in the latest version. + final StringFilterGroup attributesSection = new StringFilterGroup( + Settings.HIDE_ATTRIBUTES_SECTION, + "gaming_section.eml", + "music_section.eml", + "place_section.eml", + "video_attributes_section.eml" + ); + + final StringFilterGroup podcastSection = new StringFilterGroup( + Settings.HIDE_PODCAST_SECTION, + "playlist_section.eml" + ); + + final StringFilterGroup transcriptSection = new StringFilterGroup( + Settings.HIDE_TRANSCRIPT_SECTION, + "transcript_section.eml" + ); + + final StringFilterGroup videoSummarySection = new StringFilterGroup( + Settings.HIDE_AI_GENERATED_VIDEO_SUMMARY_SECTION, + "cell_expandable_metadata.eml-js" + ); + + addIdentifierCallbacks( + attributesSection, + podcastSection, + transcriptSection, + videoSummarySection + ); + + howThisWasMadeSection = new StringFilterGroup( + Settings.HIDE_CONTENTS_SECTION, + "how_this_was_made_section.eml" + ); + + infoCardsSection = new StringFilterGroup( + Settings.HIDE_INFO_CARDS_SECTION, + "infocards_section.eml" + ); + + macroMarkerShelf = new StringFilterGroup( + null, + "macro_markers_carousel.eml" + ); + + shoppingLinks = new StringFilterGroup( + Settings.HIDE_SHOPPING_LINKS, + "expandable_list.", + "shopping_description_shelf" + ); + + addPathCallbacks( + howThisWasMadeSection, + infoCardsSection, + macroMarkerShelf, + shoppingLinks + ); + + macroMarkerShelfGroupList.addAll( + new ByteArrayFilterGroup( + Settings.HIDE_CHAPTERS_SECTION, + "chapters_horizontal_shelf" + ), + new ByteArrayFilterGroup( + Settings.HIDE_KEY_CONCEPTS_SECTION, + "learning_concept_macro_markers_carousel_shelf" + ) + ); + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + // Check for the index because of likelihood of false positives. + if (matchedGroup == howThisWasMadeSection || matchedGroup == infoCardsSection || matchedGroup == shoppingLinks) { + if (contentIndex != 0) { + return false; + } + } else if (matchedGroup == macroMarkerShelf) { + if (contentIndex != 0) { + return false; + } + if (!macroMarkerShelfGroupList.check(protobufBufferArray).isFiltered()) { + return false; + } + } + + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedComponentsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedComponentsFilter.java new file mode 100644 index 0000000000..52c791c9e3 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedComponentsFilter.java @@ -0,0 +1,268 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup; +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.shared.patches.components.StringFilterGroupList; +import app.revanced.extension.shared.utils.StringTrieSearch; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class FeedComponentsFilter extends Filter { + private static final String CONVERSATION_CONTEXT_FEED_IDENTIFIER = + "horizontalCollectionSwipeProtector=null"; + private static final String CONVERSATION_CONTEXT_SUBSCRIPTIONS_IDENTIFIER = + "heightConstraint=null"; + private static final String INLINE_EXPANSION_PATH = "inline_expansion"; + private static final String FEED_VIDEO_PATH = "video_lockup_with_attachment"; + + private static final ByteArrayFilterGroup inlineExpansion = + new ByteArrayFilterGroup( + Settings.HIDE_EXPANDABLE_CHIP, + "inline_expansion" + ); + + private static final ByteArrayFilterGroup mixPlaylists = + new ByteArrayFilterGroup( + Settings.HIDE_MIX_PLAYLISTS, + "&list=" + ); + private static final ByteArrayFilterGroup mixPlaylistsBufferExceptions = + new ByteArrayFilterGroup( + null, + "cell_description_body", + "channel_profile" + ); + private static final StringTrieSearch mixPlaylistsContextExceptions = new StringTrieSearch(); + + private final StringFilterGroup channelProfile; + private final StringFilterGroup communityPosts; + private final StringFilterGroup expandableChip; + private final ByteArrayFilterGroup visitStoreButton; + private final StringFilterGroup videoLockup; + + private static final StringTrieSearch communityPostsFeedGroupSearch = new StringTrieSearch(); + private final StringFilterGroupList communityPostsFeedGroup = new StringFilterGroupList(); + + + public FeedComponentsFilter() { + communityPostsFeedGroupSearch.addPatterns( + CONVERSATION_CONTEXT_FEED_IDENTIFIER, + CONVERSATION_CONTEXT_SUBSCRIPTIONS_IDENTIFIER + ); + mixPlaylistsContextExceptions.addPatterns( + "V.ED", // playlist browse id + "java.lang.ref.WeakReference" + ); + + // Identifiers. + + final StringFilterGroup chipsShelf = new StringFilterGroup( + Settings.HIDE_CHIPS_SHELF, + "chips_shelf" + ); + + communityPosts = new StringFilterGroup( + null, + "post_base_wrapper", + "images_post_root", + "images_post_slim", + "text_post_root" + ); + + final StringFilterGroup expandableShelf = new StringFilterGroup( + Settings.HIDE_EXPANDABLE_SHELF, + "expandable_section" + ); + + final StringFilterGroup feedSearchBar = new StringFilterGroup( + Settings.HIDE_FEED_SEARCH_BAR, + "search_bar_entry_point" + ); + + final StringFilterGroup tasteBuilder = new StringFilterGroup( + Settings.HIDE_FEED_SURVEY, + "selectable_item.eml", + "cell_button.eml" + ); + + videoLockup = new StringFilterGroup( + null, + FEED_VIDEO_PATH + ); + + addIdentifierCallbacks( + chipsShelf, + communityPosts, + expandableShelf, + feedSearchBar, + tasteBuilder, + videoLockup + ); + + // Paths. + + final StringFilterGroup albumCard = new StringFilterGroup( + Settings.HIDE_ALBUM_CARDS, + "browsy_bar", + "official_card" + ); + + channelProfile = new StringFilterGroup( + Settings.HIDE_BROWSE_STORE_BUTTON, + "channel_profile.eml", + "page_header.eml" // new layout + ); + + visitStoreButton = new ByteArrayFilterGroup( + null, + "header_store_button" + ); + + final StringFilterGroup channelMemberShelf = new StringFilterGroup( + Settings.HIDE_CHANNEL_MEMBER_SHELF, + "member_recognition_shelf" + ); + + final StringFilterGroup channelProfileLinks = new StringFilterGroup( + Settings.HIDE_CHANNEL_PROFILE_LINKS, + "channel_header_links", + "attribution.eml" // new layout + ); + + expandableChip = new StringFilterGroup( + Settings.HIDE_EXPANDABLE_CHIP, + INLINE_EXPANSION_PATH, + "inline_expander", + "expandable_metadata.eml" + ); + + final StringFilterGroup feedSurvey = new StringFilterGroup( + Settings.HIDE_FEED_SURVEY, + "feed_nudge", + "_survey" + ); + + final StringFilterGroup forYouShelf = new StringFilterGroup( + Settings.HIDE_FOR_YOU_SHELF, + "mixed_content_shelf" + ); + + final StringFilterGroup imageShelf = new StringFilterGroup( + Settings.HIDE_IMAGE_SHELF, + "image_shelf" + ); + + final StringFilterGroup latestPosts = new StringFilterGroup( + Settings.HIDE_LATEST_POSTS, + "post_shelf" + ); + + final StringFilterGroup movieShelf = new StringFilterGroup( + Settings.HIDE_MOVIE_SHELF, + "compact_movie", + "horizontal_movie_shelf", + "movie_and_show_upsell_card", + "compact_tvfilm_item", + "offer_module" + ); + + final StringFilterGroup notifyMe = new StringFilterGroup( + Settings.HIDE_NOTIFY_ME_BUTTON, + "set_reminder_button" + ); + + final StringFilterGroup playables = new StringFilterGroup( + Settings.HIDE_PLAYABLES, + "horizontal_gaming_shelf.eml", + "mini_game_card.eml" + ); + + final StringFilterGroup subscriptionsChannelBar = new StringFilterGroup( + Settings.HIDE_SUBSCRIPTIONS_CAROUSEL, + "subscriptions_channel_bar" + ); + + final StringFilterGroup ticketShelf = new StringFilterGroup( + Settings.HIDE_TICKET_SHELF, + "ticket_horizontal_shelf", + "ticket_shelf" + ); + + addPathCallbacks( + albumCard, + channelProfile, + channelMemberShelf, + channelProfileLinks, + expandableChip, + feedSurvey, + forYouShelf, + imageShelf, + latestPosts, + movieShelf, + notifyMe, + playables, + subscriptionsChannelBar, + ticketShelf, + videoLockup + ); + + final StringFilterGroup communityPostsHomeAndRelatedVideos = + new StringFilterGroup( + Settings.HIDE_COMMUNITY_POSTS_HOME_RELATED_VIDEOS, + CONVERSATION_CONTEXT_FEED_IDENTIFIER + ); + + final StringFilterGroup communityPostsSubscriptions = + new StringFilterGroup( + Settings.HIDE_COMMUNITY_POSTS_SUBSCRIPTIONS, + CONVERSATION_CONTEXT_SUBSCRIPTIONS_IDENTIFIER + ); + + communityPostsFeedGroup.addAll(communityPostsHomeAndRelatedVideos, communityPostsSubscriptions); + } + + /** + * Injection point. + *

+ * Called from a different place then the other filters. + */ + public static boolean filterMixPlaylists(final Object conversionContext, final byte[] bytes) { + return bytes != null + && mixPlaylists.check(bytes).isFiltered() + && !mixPlaylistsBufferExceptions.check(bytes).isFiltered() + && !mixPlaylistsContextExceptions.matches(conversionContext.toString()); + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (matchedGroup == channelProfile) { + if (contentIndex == 0 && visitStoreButton.check(protobufBufferArray).isFiltered()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + return false; + } else if (matchedGroup == communityPosts) { + if (!communityPostsFeedGroupSearch.matches(allValue) && Settings.HIDE_COMMUNITY_POSTS_CHANNEL.get()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + if (!communityPostsFeedGroup.check(allValue).isFiltered()) { + return false; + } + } else if (matchedGroup == expandableChip) { + if (path.startsWith(FEED_VIDEO_PATH)) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + return false; + } else if (matchedGroup == videoLockup) { + if (contentIndex == 0 && path.startsWith("CellType|") && inlineExpansion.check(protobufBufferArray).isFiltered()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + return false; + } + + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedVideoFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedVideoFilter.java new file mode 100644 index 0000000000..6a3587cffb --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedVideoFilter.java @@ -0,0 +1,99 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup; +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroupList; +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.shared.patches.components.StringFilterGroupList; +import app.revanced.extension.shared.utils.StringTrieSearch; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.RootView; + +@SuppressWarnings("unused") +public final class FeedVideoFilter extends Filter { + private static final String CONVERSATION_CONTEXT_FEED_IDENTIFIER = + "horizontalCollectionSwipeProtector=null"; + private static final String ENDORSEMENT_FOOTER_PATH = "endorsement_header_footer"; + + private static final StringTrieSearch feedOnlyVideoPattern = new StringTrieSearch(); + // In search results, vertical video with shorts labels mostly include videos with gray descriptions. + // Filters without check process. + private final StringFilterGroup inlineShorts; + // Used for home, related videos, subscriptions, and search results. + private final StringFilterGroup videoLockup = new StringFilterGroup( + null, + "video_lockup_with_attachment.eml" + ); + private final ByteArrayFilterGroupList feedAndDrawerGroupList = new ByteArrayFilterGroupList(); + private final ByteArrayFilterGroupList feedOnlyGroupList = new ByteArrayFilterGroupList(); + private final StringFilterGroupList videoLockupFilterGroup = new StringFilterGroupList(); + private static final ByteArrayFilterGroup relatedVideo = + new ByteArrayFilterGroup( + Settings.HIDE_RELATED_VIDEOS, + "relatedH" + ); + + public FeedVideoFilter() { + feedOnlyVideoPattern.addPattern(CONVERSATION_CONTEXT_FEED_IDENTIFIER); + + inlineShorts = new StringFilterGroup( + Settings.HIDE_RECOMMENDED_VIDEO, + "inline_shorts.eml" // vertical video with shorts label + ); + + addIdentifierCallbacks(inlineShorts); + + addPathCallbacks(videoLockup); + + feedAndDrawerGroupList.addAll( + new ByteArrayFilterGroup( + Settings.HIDE_RECOMMENDED_VIDEO, + ENDORSEMENT_FOOTER_PATH, // videos with gray descriptions + "high-ptsZ" // videos for membership only + ) + ); + + feedOnlyGroupList.addAll( + new ByteArrayFilterGroup( + Settings.HIDE_LOW_VIEWS_VIDEO, + "g-highZ" // videos with less than 1000 views + ) + ); + + videoLockupFilterGroup.addAll( + new StringFilterGroup( + Settings.HIDE_RECOMMENDED_VIDEO, + ENDORSEMENT_FOOTER_PATH + ) + ); + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (matchedGroup == inlineShorts) { + if (RootView.isSearchBarActive()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + return false; + } else if (matchedGroup == videoLockup) { + if (relatedVideo.check(protobufBufferArray).isFiltered()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + if (feedOnlyVideoPattern.matches(allValue)) { + if (feedOnlyGroupList.check(protobufBufferArray).isFiltered()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } else if (videoLockupFilterGroup.check(allValue).isFiltered()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + } else { + if (feedAndDrawerGroupList.check(protobufBufferArray).isFiltered()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + } + } + return false; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedVideoViewsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedVideoViewsFilter.java new file mode 100644 index 0000000000..9b0779ecc5 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedVideoViewsFilter.java @@ -0,0 +1,180 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.NavigationBar; +import app.revanced.extension.youtube.shared.RootView; + +@SuppressWarnings("all") +public final class FeedVideoViewsFilter extends Filter { + + private final StringFilterGroup feedVideoFilter = new StringFilterGroup( + null, + "video_lockup_with_attachment.eml" + ); + + public FeedVideoViewsFilter() { + addPathCallbacks(feedVideoFilter); + } + + private boolean hideFeedVideoViewsSettingIsActive() { + final boolean hideHome = Settings.HIDE_VIDEO_BY_VIEW_COUNTS_HOME.get(); + final boolean hideSearch = Settings.HIDE_VIDEO_BY_VIEW_COUNTS_SEARCH.get(); + final boolean hideSubscriptions = Settings.HIDE_VIDEO_BY_VIEW_COUNTS_SUBSCRIPTIONS.get(); + + if (!hideHome && !hideSearch && !hideSubscriptions) { + return false; + } else if (hideHome && hideSearch && hideSubscriptions) { + return true; + } + + // Must check player type first, as search bar can be active behind the player. + if (RootView.isPlayerActive()) { + // For now, consider the under video results the same as the home feed. + return hideHome; + } + + // Must check second, as search can be from any tab. + if (RootView.isSearchBarActive()) { + return hideSearch; + } + + NavigationBar.NavigationButton selectedNavButton = NavigationBar.NavigationButton.getSelectedNavigationButton(); + if (selectedNavButton == null) { + return hideHome; // Unknown tab, treat the same as home. + } else if (selectedNavButton == NavigationBar.NavigationButton.HOME) { + return hideHome; + } else if (selectedNavButton == NavigationBar.NavigationButton.SUBSCRIPTIONS) { + return hideSubscriptions; + } + // User is in the Library or Notifications tab. + return false; + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (hideFeedVideoViewsSettingIsActive() && + filterByViews(protobufBufferArray)) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + + return false; + } + + private final String ARROW = " -> "; + private final String VIEWS = "views"; + private final String[] parts = Settings.HIDE_VIDEO_VIEW_COUNTS_MULTIPLIER.get().split("\\n"); + private Pattern[] viewCountPatterns = null; + + /** + * Hide videos based on views count + */ + private synchronized boolean filterByViews(byte[] protobufBufferArray) { + final String protobufString = new String(protobufBufferArray); + final long lessThan = Settings.HIDE_VIDEO_VIEW_COUNTS_LESS_THAN.get(); + final long greaterThan = Settings.HIDE_VIDEO_VIEW_COUNTS_GREATER_THAN.get(); + + if (viewCountPatterns == null) { + viewCountPatterns = getViewCountPatterns(parts); + } + + for (Pattern pattern : viewCountPatterns) { + final Matcher matcher = pattern.matcher(protobufString); + if (matcher.find()) { + String numString = Objects.requireNonNull(matcher.group(1)); + double num = parseNumber(numString); + String multiplierKey = matcher.group(2); + long multiplierValue = getMultiplierValue(parts, multiplierKey); + return num * multiplierValue < lessThan || num * multiplierValue > greaterThan; + } + } + + return false; + } + + private synchronized double parseNumber(String numString) { + /** + * Some languages have comma (,) as a decimal separator. + * In order to detect those numbers as doubles in Java + * we convert commas (,) to dots (.). + * Unless we find a language that has commas used in + * a different manner, it should work. + */ + numString = numString.replace(",", "."); + + /** + * Some languages have dot (.) as a kilo separator. + * So we check with regex if there is a number with 3+ + * digits after dot (.), we replace it with nothing + * to make Java understand the number as a whole. + */ + if (numString.matches("\\d+\\.\\d{3,}")) { + numString = numString.replace(".", ""); + } + + return Double.parseDouble(numString); + } + + private synchronized Pattern[] getViewCountPatterns(String[] parts) { + StringBuilder prefixPatternBuilder = new StringBuilder("(\\d+(?:[.,]\\d+)?)\\s?("); // LTR layout + StringBuilder secondPatternBuilder = new StringBuilder(); // RTL layout + StringBuilder suffixBuilder = getSuffixBuilder(parts, prefixPatternBuilder, secondPatternBuilder); + + prefixPatternBuilder.deleteCharAt(prefixPatternBuilder.length() - 1); // Remove the trailing | + prefixPatternBuilder.append(")?\\s*"); + prefixPatternBuilder.append(suffixBuilder.length() > 0 ? suffixBuilder.toString() : VIEWS); + + secondPatternBuilder.deleteCharAt(secondPatternBuilder.length() - 1); // Remove the trailing | + secondPatternBuilder.append(")?"); + + final Pattern[] patterns = new Pattern[2]; + patterns[0] = Pattern.compile(prefixPatternBuilder.toString()); + patterns[1] = Pattern.compile(secondPatternBuilder.toString()); + + return patterns; + } + + @NonNull + private synchronized StringBuilder getSuffixBuilder(String[] parts, StringBuilder prefixPatternBuilder, StringBuilder secondPatternBuilder) { + StringBuilder suffixBuilder = new StringBuilder(); + + for (String part : parts) { + final String[] pair = part.split(ARROW); + final String pair0 = pair[0].trim(); + final String pair1 = pair[1].trim(); + + if (pair.length == 2 && !pair1.equals(VIEWS)) { + prefixPatternBuilder.append(pair0).append("|"); + } + + if (pair.length == 2 && pair1.equals(VIEWS)) { + suffixBuilder.append(pair0); + secondPatternBuilder.append(pair0).append("\\s*").append(prefixPatternBuilder); + } + } + return suffixBuilder; + } + + private synchronized long getMultiplierValue(String[] parts, String multiplier) { + for (String part : parts) { + final String[] pair = part.split(ARROW); + final String pair0 = pair[0].trim(); + final String pair1 = pair[1].trim(); + + if (pair.length == 2 && pair0.equals(multiplier) && !pair1.equals(VIEWS)) { + return Long.parseLong(pair[1].replaceAll("[^\\d]", "")); + } + } + + return 1L; // Default value if not found + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/KeywordContentFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/KeywordContentFilter.java new file mode 100644 index 0000000000..bef4712de0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/KeywordContentFilter.java @@ -0,0 +1,632 @@ +package app.revanced.extension.youtube.patches.components; + +import static java.lang.Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS; +import static java.lang.Character.UnicodeBlock.HIRAGANA; +import static java.lang.Character.UnicodeBlock.KATAKANA; +import static java.lang.Character.UnicodeBlock.KHMER; +import static java.lang.Character.UnicodeBlock.LAO; +import static java.lang.Character.UnicodeBlock.MYANMAR; +import static java.lang.Character.UnicodeBlock.THAI; +import static java.lang.Character.UnicodeBlock.TIBETAN; +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.shared.utils.ByteTrieSearch; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.StringTrieSearch; +import app.revanced.extension.shared.utils.TrieSearch; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.RootView; + +/** + *

+ * Allows hiding home feed and search results based on video title keywords and/or channel names.
+ *
+ * Limitations:
+ * - Searching for a keyword phrase will give no search results.
+ *   This is because the buffer for each video contains the text the user searched for, and everything
+ *   will be filtered away (even if that video title/channel does not contain any keywords).
+ * - Filtering a channel name can still show Shorts from that channel in the search results.
+ *   The most common Shorts layouts do not include the channel name, so they will not be filtered.
+ * - Some layout component residue will remain, such as the video chapter previews for some search results.
+ *   These components do not include the video title or channel name, and they
+ *   appear outside the filtered components so they are not caught.
+ * - Keywords are case sensitive, but some casing variation is manually added.
+ *   (ie: "mr beast" automatically filters "Mr Beast" and "MR BEAST").
+ * - Keywords present in the layout or video data cannot be used as filters, otherwise all videos
+ *   will always be hidden.  This patch checks for some words of these words.
+ * - When using whole word syntax, some keywords may need additional pluralized variations.
+ */
+@SuppressWarnings("unused")
+public final class KeywordContentFilter extends Filter {
+
+    /**
+     * Strings found in the buffer for every videos.  Full strings should be specified.
+     * 

+ * This list does not include every common buffer string, and this can be added/changed as needed. + * Words must be entered with the exact casing as found in the buffer. + */ + private static final String[] STRINGS_IN_EVERY_BUFFER = { + // Video playback data. + "googlevideo.com/initplayback?source=youtube", // Video url. + "ANDROID", // Video url parameter. + "https://i.ytimg.com/vi/", // Thumbnail url. + "mqdefault.jpg", + "hqdefault.jpg", + "sddefault.jpg", + "hq720.jpg", + "webp", + "_custom_", // Custom thumbnail set by video creator. + // Video decoders. + "OMX.ffmpeg.vp9.decoder", + "OMX.Intel.sw_vd.vp9", + "OMX.MTK.VIDEO.DECODER.SW.VP9", + "OMX.google.vp9.decoder", + "OMX.google.av1.decoder", + "OMX.sprd.av1.decoder", + "c2.android.av1.decoder", + "c2.android.av1-dav1d.decoder", + "c2.android.vp9.decoder", + "c2.mtk.sw.vp9.decoder", + // Analytics. + "searchR", + "browse-feed", + "FEwhat_to_watch", + "FEsubscriptions", + "search_vwc_description_transition_key", + "g-high-recZ", + // Text and litho components found in the buffer that belong to path filters. + "expandable_metadata.eml", + "thumbnail.eml", + "avatar.eml", + "overflow_button.eml", + "shorts-lockup-image", + "shorts-lockup.overlay-metadata.secondary-text", + "YouTubeSans-SemiBold", + "sans-serif" + }; + + /** + * Substrings that are always first in the identifier. + */ + private final StringFilterGroup startsWithFilter = new StringFilterGroup( + null, // Multiple settings are used and must be individually checked if active. + "video_lockup_with_attachment.eml", + "compact_video.eml", + "inline_shorts", + "shorts_video_cell", + "shorts_pivot_item.eml" + ); + + /** + * Substrings that are never at the start of the path. + */ + @SuppressWarnings("FieldCanBeLocal") + private final StringFilterGroup containsFilter = new StringFilterGroup( + null, + "modern_type_shelf_header_content.eml", + "shorts_lockup_cell.eml", // Part of 'shorts_shelf_carousel.eml' + "video_card.eml" // Shorts that appear in a horizontal shelf. + ); + + /** + * Path components to not filter. Cannot filter the buffer when these are present, + * otherwise text in UI controls can be filtered as a keyword (such as using "Playlist" as a keyword). + *

+ * This is also a small performance improvement since + * the buffer of the parent component was already searched and passed. + */ + private final StringTrieSearch exceptions = new StringTrieSearch( + "metadata.eml", + "thumbnail.eml", + "avatar.eml", + "overflow_button.eml" + ); + + /** + * Minimum keyword/phrase length to prevent excessively broad content filtering. + * Only applies when not using whole word syntax. + */ + private static final int MINIMUM_KEYWORD_LENGTH = 3; + + /** + * Threshold for {@link #filteredVideosPercentage} + * that indicates all or nearly all videos have been filtered. + * This should be close to 100% to reduce false positives. + */ + private static final float ALL_VIDEOS_FILTERED_THRESHOLD = 0.95f; + + private static final float ALL_VIDEOS_FILTERED_SAMPLE_SIZE = 50; + + private static final long ALL_VIDEOS_FILTERED_BACKOFF_MILLISECONDS = 60 * 1000; // 60 seconds + + private static final int UTF8_MAX_BYTE_COUNT = 4; + + /** + * Rolling average of how many videos were filtered by a keyword. + * Used to detect if a keyword passes the initial check against {@link #STRINGS_IN_EVERY_BUFFER} + * but a keyword is still hiding all videos. + *

+ * This check can still fail if some extra UI elements pass the keywords, + * such as the video chapter preview or any other elements. + *

+ * To test this, add a filter that appears in all videos (such as 'ovd='), + * and open the subscription feed. In practice this does not always identify problems + * in the home feed and search, because the home feed has a finite amount of content and + * search results have a lot of extra video junk that is not hidden and interferes with the detection. + */ + private volatile float filteredVideosPercentage; + + /** + * If filtering is temporarily turned off, the time to resume filtering. + * Field is zero if no timeout is in effect. + */ + private volatile long timeToResumeFiltering; + + private final StringFilterGroup commentsFilter; + + private final StringTrieSearch commentsFilterExceptions = new StringTrieSearch(); + + /** + * The last value of {@link Settings#HIDE_KEYWORD_CONTENT_PHRASES} + * parsed and loaded into {@link #bufferSearch}. + * Allows changing the keywords without restarting the app. + */ + private volatile String lastKeywordPhrasesParsed; + + private volatile ByteTrieSearch bufferSearch; + + private static void logNavigationState(String state) { + // Enable locally to debug filtering. Default off to reduce log spam. + final boolean LOG_NAVIGATION_STATE = false; + // noinspection ConstantValue + if (LOG_NAVIGATION_STATE) { + Logger.printDebug(() -> "Navigation state: " + state); + } + } + + /** + * Change first letter of the first word to use title case. + */ + private static String titleCaseFirstWordOnly(String sentence) { + if (sentence.isEmpty()) { + return sentence; + } + final int firstCodePoint = sentence.codePointAt(0); + // In some non English languages title case is different than uppercase. + return new StringBuilder() + .appendCodePoint(Character.toTitleCase(firstCodePoint)) + .append(sentence, Character.charCount(firstCodePoint), sentence.length()) + .toString(); + } + + /** + * Uppercase the first letter of each word. + */ + private static String capitalizeAllFirstLetters(String sentence) { + if (sentence.isEmpty()) { + return sentence; + } + + final int delimiter = ' '; + // Use code points and not characters to handle unicode surrogates. + int[] codePoints = sentence.codePoints().toArray(); + boolean capitalizeNext = true; + for (int i = 0, length = codePoints.length; i < length; i++) { + final int codePoint = codePoints[i]; + if (codePoint == delimiter) { + capitalizeNext = true; + } else if (capitalizeNext) { + codePoints[i] = Character.toUpperCase(codePoint); + capitalizeNext = false; + } + } + return new String(codePoints, 0, codePoints.length); + } + + /** + * @return If the string contains any characters from languages that do not use spaces between words. + */ + private static boolean isLanguageWithNoSpaces(String text) { + for (int i = 0, length = text.length(); i < length; ) { + final int codePoint = text.codePointAt(i); + + Character.UnicodeBlock block = Character.UnicodeBlock.of(codePoint); + if (block == CJK_UNIFIED_IDEOGRAPHS // Chinese and Kanji + || block == HIRAGANA // Japanese Hiragana + || block == KATAKANA // Japanese Katakana + || block == THAI + || block == LAO + || block == MYANMAR + || block == KHMER + || block == TIBETAN) { + return true; + } + + i += Character.charCount(codePoint); + } + + return false; + } + + + /** + * @return If the phrase will hide all videos. Not an exhaustive check. + */ + private static boolean phrasesWillHideAllVideos(@NonNull String[] phrases, boolean matchWholeWords) { + for (String phrase : phrases) { + for (String commonString : STRINGS_IN_EVERY_BUFFER) { + if (matchWholeWords) { + byte[] commonStringBytes = commonString.getBytes(StandardCharsets.UTF_8); + int matchIndex = 0; + while (true) { + matchIndex = commonString.indexOf(phrase, matchIndex); + if (matchIndex < 0) break; + + if (keywordMatchIsWholeWord(commonStringBytes, matchIndex, phrase.length())) { + return true; + } + + matchIndex++; + } + } else if (Utils.containsAny(commonString, phrases)) { + return true; + } + } + } + + return false; + } + + /** + * @return If the start and end indexes are not surrounded by other letters. + * If the indexes are surrounded by numbers/symbols/punctuation it is considered a whole word. + */ + private static boolean keywordMatchIsWholeWord(byte[] text, int keywordStartIndex, int keywordLength) { + final Integer codePointBefore = getUtf8CodePointBefore(text, keywordStartIndex); + if (codePointBefore != null && Character.isLetter(codePointBefore)) { + return false; + } + + final Integer codePointAfter = getUtf8CodePointAt(text, keywordStartIndex + keywordLength); + //noinspection RedundantIfStatement + if (codePointAfter != null && Character.isLetter(codePointAfter)) { + return false; + } + + return true; + } + + /** + * @return The UTF8 character point immediately before the index, + * or null if the bytes before the index is not a valid UTF8 character. + */ + @Nullable + private static Integer getUtf8CodePointBefore(byte[] data, int index) { + int characterByteCount = 0; + while (--index >= 0 && ++characterByteCount <= UTF8_MAX_BYTE_COUNT) { + if (isValidUtf8(data, index, characterByteCount)) { + return decodeUtf8ToCodePoint(data, index, characterByteCount); + } + } + + return null; + } + + /** + * @return The UTF8 character point at the index, + * or null if the index holds no valid UTF8 character. + */ + @Nullable + private static Integer getUtf8CodePointAt(byte[] data, int index) { + int characterByteCount = 0; + final int dataLength = data.length; + while (index + characterByteCount < dataLength && ++characterByteCount <= UTF8_MAX_BYTE_COUNT) { + if (isValidUtf8(data, index, characterByteCount)) { + return decodeUtf8ToCodePoint(data, index, characterByteCount); + } + } + + return null; + } + + public static boolean isValidUtf8(byte[] data, int startIndex, int numberOfBytes) { + switch (numberOfBytes) { + case 1 -> { // 0xxxxxxx (ASCII) + return (data[startIndex] & 0x80) == 0; + } + case 2 -> { // 110xxxxx, 10xxxxxx + return (data[startIndex] & 0xE0) == 0xC0 + && (data[startIndex + 1] & 0xC0) == 0x80; + } + case 3 -> { // 1110xxxx, 10xxxxxx, 10xxxxxx + return (data[startIndex] & 0xF0) == 0xE0 + && (data[startIndex + 1] & 0xC0) == 0x80 + && (data[startIndex + 2] & 0xC0) == 0x80; + } + case 4 -> { // 11110xxx, 10xxxxxx, 10xxxxxx, 10xxxxxx + return (data[startIndex] & 0xF8) == 0xF0 + && (data[startIndex + 1] & 0xC0) == 0x80 + && (data[startIndex + 2] & 0xC0) == 0x80 + && (data[startIndex + 3] & 0xC0) == 0x80; + } + } + + throw new IllegalArgumentException("numberOfBytes: " + numberOfBytes); + } + + public static int decodeUtf8ToCodePoint(byte[] data, int startIndex, int numberOfBytes) { + switch (numberOfBytes) { + case 1 -> { + return data[startIndex]; + } + case 2 -> { + return ((data[startIndex] & 0x1F) << 6) | + (data[startIndex + 1] & 0x3F); + } + case 3 -> { + return ((data[startIndex] & 0x0F) << 12) | + ((data[startIndex + 1] & 0x3F) << 6) | + (data[startIndex + 2] & 0x3F); + } + case 4 -> { + return ((data[startIndex] & 0x07) << 18) | + ((data[startIndex + 1] & 0x3F) << 12) | + ((data[startIndex + 2] & 0x3F) << 6) | + (data[startIndex + 3] & 0x3F); + } + } + throw new IllegalArgumentException("numberOfBytes: " + numberOfBytes); + } + + private static boolean phraseUsesWholeWordSyntax(String phrase) { + return phrase.startsWith("\"") && phrase.endsWith("\""); + } + + private static String stripWholeWordSyntax(String phrase) { + return phrase.substring(1, phrase.length() - 1); + } + + private synchronized void parseKeywords() { // Must be synchronized since Litho is multi-threaded. + String rawKeywords = Settings.HIDE_KEYWORD_CONTENT_PHRASES.get(); + + //noinspection StringEquality + if (rawKeywords == lastKeywordPhrasesParsed) { + Logger.printDebug(() -> "Using previously initialized search"); + return; // Another thread won the race, and search is already initialized. + } + + ByteTrieSearch search = new ByteTrieSearch(); + String[] split = rawKeywords.split("\n"); + if (split.length != 0) { + // Linked Set so log statement are more organized and easier to read. + // Map is: Phrase -> isWholeWord + Map keywords = new LinkedHashMap<>(10 * split.length); + + for (String phrase : split) { + // Remove any trailing spaces the user may have accidentally included. + phrase = phrase.stripTrailing(); + if (phrase.isBlank()) continue; + + final boolean wholeWordMatching; + if (phraseUsesWholeWordSyntax(phrase)) { + if (phrase.length() == 2) { + continue; // Empty "" phrase + } + phrase = stripWholeWordSyntax(phrase); + wholeWordMatching = true; + } else if (phrase.length() < MINIMUM_KEYWORD_LENGTH && !isLanguageWithNoSpaces(phrase)) { + // Allow phrases of 1 and 2 characters if using a + // language that does not use spaces between words. + + // Do not reset the setting. Keep the invalid keywords so the user can fix the mistake. + Utils.showToastLong(str("revanced_hide_keyword_toast_invalid_length", phrase, MINIMUM_KEYWORD_LENGTH)); + continue; + } else { + wholeWordMatching = false; + } + + // Common casing that might appear. + // + // This could be simplified by adding case insensitive search to the prefix search, + // which is very simple to add to StringTreSearch for Unicode and ByteTrieSearch for ASCII. + // + // But to support Unicode with ByteTrieSearch would require major changes because + // UTF-8 characters can be different byte lengths, which does + // not allow comparing two different byte arrays using simple plain array indexes. + // + // Instead use all common case variations of the words. + String[] phraseVariations = { + phrase, + phrase.toLowerCase(), + titleCaseFirstWordOnly(phrase), + capitalizeAllFirstLetters(phrase), + phrase.toUpperCase() + }; + if (phrasesWillHideAllVideos(phraseVariations, wholeWordMatching)) { + String toastMessage; + // If whole word matching is off, but would pass with on, then show a different toast. + if (!wholeWordMatching && !phrasesWillHideAllVideos(phraseVariations, true)) { + toastMessage = "revanced_hide_keyword_toast_invalid_common_whole_word_required"; + } else { + toastMessage = "revanced_hide_keyword_toast_invalid_common"; + } + + Utils.showToastLong(str(toastMessage, phrase)); + continue; + } + + for (String variation : phraseVariations) { + // Check if the same phrase is declared both with and without quotes. + Boolean existing = keywords.get(variation); + if (existing == null) { + keywords.put(variation, wholeWordMatching); + } else if (existing != wholeWordMatching) { + Utils.showToastLong(str("revanced_hide_keyword_toast_invalid_conflicting", phrase)); + break; + } + } + } + + for (Map.Entry entry : keywords.entrySet()) { + String keyword = entry.getKey(); + //noinspection ExtractMethodRecommender + final boolean isWholeWord = entry.getValue(); + TrieSearch.TriePatternMatchedCallback callback = + (textSearched, startIndex, matchLength, callbackParameter) -> { + if (isWholeWord && !keywordMatchIsWholeWord(textSearched, startIndex, matchLength)) { + return false; + } + + Logger.printDebug(() -> (isWholeWord ? "Matched whole keyword: '" + : "Matched keyword: '") + keyword + "'"); + // noinspection unchecked + ((MutableReference) callbackParameter).value = keyword; + return true; + }; + byte[] stringBytes = keyword.getBytes(StandardCharsets.UTF_8); + search.addPattern(stringBytes, callback); + } + + Logger.printDebug(() -> "Search using: (" + search.getEstimatedMemorySize() + " KB) keywords: " + keywords.keySet()); + } + + bufferSearch = search; + timeToResumeFiltering = 0; + filteredVideosPercentage = 0; + lastKeywordPhrasesParsed = rawKeywords; // Must set last. + } + + public KeywordContentFilter() { + commentsFilterExceptions.addPatterns("engagement_toolbar"); + + commentsFilter = new StringFilterGroup( + Settings.HIDE_KEYWORD_CONTENT_COMMENTS, + "comment_thread.eml" + ); + + // Keywords are parsed on first call to isFiltered() + addPathCallbacks(startsWithFilter, containsFilter, commentsFilter); + } + + private boolean hideKeywordSettingIsActive() { + if (timeToResumeFiltering != 0) { + if (System.currentTimeMillis() < timeToResumeFiltering) { + return false; + } + + timeToResumeFiltering = 0; + filteredVideosPercentage = 0; + Logger.printDebug(() -> "Resuming keyword filtering"); + } + + final boolean hideHome = Settings.HIDE_KEYWORD_CONTENT_HOME.get(); + final boolean hideSearch = Settings.HIDE_KEYWORD_CONTENT_SEARCH.get(); + final boolean hideSubscriptions = Settings.HIDE_KEYWORD_CONTENT_SUBSCRIPTIONS.get(); + + if (!hideHome && !hideSearch && !hideSubscriptions) { + return false; + } else if (hideHome && hideSearch && hideSubscriptions) { + return true; + } + + // Must check player type first, as search bar can be active behind the player. + if (RootView.isPlayerActive()) { + // For now, consider the under video results the same as the home feed. + return hideHome; + } + + // Must check second, as search can be from any tab. + if (RootView.isSearchBarActive()) { + return hideSearch; + } + + NavigationButton selectedNavButton = NavigationButton.getSelectedNavigationButton(); + if (selectedNavButton == null) { + return hideHome; // Unknown tab, treat the same as home. + } + if (selectedNavButton == NavigationButton.HOME) { + return hideHome; + } + if (selectedNavButton == NavigationButton.SUBSCRIPTIONS) { + return hideSubscriptions; + } + // User is in the Library or Notifications tab. + return false; + } + + private void updateStats(boolean videoWasHidden, @Nullable String keyword) { + float updatedAverage = filteredVideosPercentage + * ((ALL_VIDEOS_FILTERED_SAMPLE_SIZE - 1) / ALL_VIDEOS_FILTERED_SAMPLE_SIZE); + if (videoWasHidden) { + updatedAverage += 1 / ALL_VIDEOS_FILTERED_SAMPLE_SIZE; + } + + if (updatedAverage <= ALL_VIDEOS_FILTERED_THRESHOLD) { + filteredVideosPercentage = updatedAverage; + return; + } + + // A keyword is hiding everything. + // Inform the user, and temporarily turn off filtering. + timeToResumeFiltering = System.currentTimeMillis() + ALL_VIDEOS_FILTERED_BACKOFF_MILLISECONDS; + + Logger.printDebug(() -> "Temporarily turning off filtering due to excessively broad filter: " + keyword); + Utils.showToastLong(str("revanced_hide_keyword_toast_invalid_broad", keyword)); + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (contentIndex != 0 && matchedGroup == startsWithFilter) { + return false; + } + + // Do not filter if comments path includes an engagement toolbar (like, dislike...) + if (matchedGroup == commentsFilter && commentsFilterExceptions.matches(path)) { + return false; + } + + // Field is intentionally compared using reference equality. + //noinspection StringEquality + if (Settings.HIDE_KEYWORD_CONTENT_PHRASES.get() != lastKeywordPhrasesParsed) { + // User changed the keywords or whole word setting. + parseKeywords(); + } + + if (matchedGroup != commentsFilter && !hideKeywordSettingIsActive()) { + return false; + } + + if (exceptions.matches(path)) { + return false; // Do not update statistics. + } + + MutableReference matchRef = new MutableReference<>(); + if (bufferSearch.matches(protobufBufferArray, matchRef)) { + updateStats(true, matchRef.value); + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + + updateStats(false, null); + return false; + } +} + +/** + * Simple non-atomic wrapper since {@link AtomicReference#setPlain(Object)} is not available with Android 8.0. + */ +final class MutableReference { + T value; +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/LayoutComponentsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/LayoutComponentsFilter.java new file mode 100644 index 0000000000..f124060f01 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/LayoutComponentsFilter.java @@ -0,0 +1,38 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class LayoutComponentsFilter extends Filter { + private static final String ACCOUNT_HEADER_PATH = "account_header.eml"; + + public LayoutComponentsFilter() { + addIdentifierCallbacks( + new StringFilterGroup( + Settings.HIDE_GRAY_SEPARATOR, + "cell_divider" + ) + ); + + addPathCallbacks( + new StringFilterGroup( + Settings.HIDE_HANDLE, + "|CellType|ContainerType|ContainerType|ContainerType|TextType|" + ) + ); + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (contentType == FilterContentType.PATH && !path.startsWith(ACCOUNT_HEADER_PATH)) { + return false; + } + + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlaybackSpeedMenuFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlaybackSpeedMenuFilter.java new file mode 100644 index 0000000000..87a0472996 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlaybackSpeedMenuFilter.java @@ -0,0 +1,52 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.youtube.patches.video.CustomPlaybackSpeedPatch; +import app.revanced.extension.youtube.settings.Settings; + +/** + * Abuse LithoFilter for {@link CustomPlaybackSpeedPatch}. + */ +public final class PlaybackSpeedMenuFilter extends Filter { + /** + * Old litho based speed selection menu. + */ + public static volatile boolean isOldPlaybackSpeedMenuVisible; + + /** + * 0.05x speed selection menu. + */ + public static volatile boolean isPlaybackRateSelectorMenuVisible; + + private final StringFilterGroup oldPlaybackMenuGroup; + + public PlaybackSpeedMenuFilter() { + // 0.05x litho speed menu. + final StringFilterGroup playbackRateSelectorGroup = new StringFilterGroup( + Settings.ENABLE_CUSTOM_PLAYBACK_SPEED, + "playback_rate_selector_menu_sheet.eml-js" + ); + + // Old litho based speed menu. + oldPlaybackMenuGroup = new StringFilterGroup( + Settings.ENABLE_CUSTOM_PLAYBACK_SPEED, + "playback_speed_sheet_content.eml-js"); + + addPathCallbacks(playbackRateSelectorGroup, oldPlaybackMenuGroup); + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (matchedGroup == oldPlaybackMenuGroup) { + isOldPlaybackSpeedMenuVisible = true; + } else { + isPlaybackRateSelectorMenuVisible = true; + } + + return false; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerComponentsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerComponentsFilter.java new file mode 100644 index 0000000000..835b709d52 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerComponentsFilter.java @@ -0,0 +1,129 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.shared.patches.components.StringFilterGroupList; +import app.revanced.extension.shared.utils.StringTrieSearch; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.PlayerType; + +@SuppressWarnings("unused") +public final class PlayerComponentsFilter extends Filter { + private final StringFilterGroupList channelBarGroupList = new StringFilterGroupList(); + private final StringFilterGroup channelBar; + private final StringTrieSearch suggestedActionsException = new StringTrieSearch(); + private final StringFilterGroup suggestedActions; + + public PlayerComponentsFilter() { + suggestedActionsException.addPatterns( + "channel_bar", + "shorts" + ); + + // The player audio track button does the exact same function as the audio track flyout menu option. + // But if the copy url button is shown, these button clashes and the the audio button does not work. + // Previously this was a setting to show/hide the player button. + // But it was decided it's simpler to always hide this button because: + // - it doesn't work with copy video url feature + // - the button is rare + // - always hiding makes the ReVanced settings simpler and easier to understand + // - nobody is going to notice the redundant button is always hidden + final StringFilterGroup audioTrackButton = new StringFilterGroup( + null, + "multi_feed_icon_button" + ); + + channelBar = new StringFilterGroup( + null, + "channel_bar_inner" + ); + + final StringFilterGroup channelWaterMark = new StringFilterGroup( + Settings.HIDE_CHANNEL_WATERMARK, + "featured_channel_watermark_overlay.eml" + ); + + final StringFilterGroup infoCards = new StringFilterGroup( + Settings.HIDE_INFO_CARDS, + "info_card_teaser_overlay.eml" + ); + + final StringFilterGroup infoPanel = new StringFilterGroup( + Settings.HIDE_INFO_PANEL, + "compact_banner", + "publisher_transparency_panel", + "single_item_information_panel" + ); + + final StringFilterGroup liveChat = new StringFilterGroup( + Settings.HIDE_LIVE_CHAT_MESSAGES, + "live_chat_text_message", + "viewer_engagement_message" // message about poll, not poll itself + ); + + final StringFilterGroup medicalPanel = new StringFilterGroup( + Settings.HIDE_MEDICAL_PANEL, + "emergency_onebox", + "medical_panel" + ); + + suggestedActions = new StringFilterGroup( + Settings.HIDE_SUGGESTED_ACTION, + "|suggested_action.eml|" + ); + + final StringFilterGroup timedReactions = new StringFilterGroup( + Settings.HIDE_TIMED_REACTIONS, + "emoji_control_panel", + "timed_reaction" + ); + + addPathCallbacks( + audioTrackButton, + channelBar, + channelWaterMark, + infoCards, + infoPanel, + liveChat, + medicalPanel, + suggestedActions, + timedReactions + ); + + final StringFilterGroup joinMembership = new StringFilterGroup( + Settings.HIDE_JOIN_BUTTON, + "compact_sponsor_button", + "|ContainerType|button.eml|" + ); + + final StringFilterGroup startTrial = new StringFilterGroup( + Settings.HIDE_START_TRIAL_BUTTON, + "channel_purchase_button" + ); + + channelBarGroupList.addAll( + joinMembership, + startTrial + ); + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (matchedGroup == suggestedActions) { + // suggested actions button on shorts and the suggested actions button on video players use the same path builder. + // Check PlayerType to make each setting work independently. + if (suggestedActionsException.matches(path) || PlayerType.getCurrent().isNoneOrHidden()) { + return false; + } + } else if (matchedGroup == channelBar) { + if (!channelBarGroupList.check(path).isFiltered()) { + return false; + } + } + + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerFlyoutMenuFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerFlyoutMenuFilter.java new file mode 100644 index 0000000000..a3bbafdfd8 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerFlyoutMenuFilter.java @@ -0,0 +1,170 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup; +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroupList; +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.shared.utils.StringTrieSearch; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.PlayerType; + +@SuppressWarnings("unused") +public final class PlayerFlyoutMenuFilter extends Filter { + private final ByteArrayFilterGroupList flyoutFilterGroupList = new ByteArrayFilterGroupList(); + + private final ByteArrayFilterGroup byteArrayException; + private final StringTrieSearch pathBuilderException = new StringTrieSearch(); + private final StringTrieSearch playerFlyoutMenuFooter = new StringTrieSearch(); + private final StringFilterGroup playerFlyoutMenu; + private final StringFilterGroup qualityHeader; + + public PlayerFlyoutMenuFilter() { + byteArrayException = new ByteArrayFilterGroup( + null, + "quality_sheet" + ); + pathBuilderException.addPattern( + "bottom_sheet_list_option" + ); + playerFlyoutMenuFooter.addPatterns( + "captions_sheet_content.eml", + "quality_sheet_content.eml" + ); + + final StringFilterGroup captionsFooter = new StringFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_CAPTIONS_FOOTER, + "|ContainerType|ContainerType|ContainerType|TextType|", + "|divider.eml|" + ); + + final StringFilterGroup qualityFooter = new StringFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_QUALITY_FOOTER, + "quality_sheet_footer.eml", + "|divider.eml|" + ); + + qualityHeader = new StringFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_QUALITY_HEADER, + "quality_sheet_header.eml" + ); + + playerFlyoutMenu = new StringFilterGroup(null, "overflow_menu_item.eml|"); + + // Using pathFilterGroupList due to new flyout panel(A/B) + addPathCallbacks( + captionsFooter, + qualityFooter, + qualityHeader, + playerFlyoutMenu + ); + + flyoutFilterGroupList.addAll( + new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_AMBIENT, + "yt_outline_screen_light" + ), + new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_AUDIO_TRACK, + "yt_outline_person_radar" + ), + new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_CAPTIONS, + "closed_caption" + ), + new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_HELP, + "yt_outline_question_circle" + ), + new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_LOCK_SCREEN, + "yt_outline_lock" + ), + new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_LOOP, + "yt_outline_arrow_repeat_1_" + ), + new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_MORE, + "yt_outline_info_circle" + ), + new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_PIP, + "yt_fill_picture_in_picture", + "yt_outline_picture_in_picture" + ), + new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_PLAYBACK_SPEED, + "yt_outline_play_arrow_half_circle" + ), + new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_PREMIUM_CONTROLS, + "yt_outline_adjust" + ), + new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_ADDITIONAL_SETTINGS, + "yt_outline_gear" + ), + new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_REPORT, + "yt_outline_flag" + ), + new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_STABLE_VOLUME, + "volume_stable" + ), + new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_SLEEP_TIMER, + "yt_outline_moon_z_" + ), + new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_STATS_FOR_NERDS, + "yt_outline_statistics_graph" + ), + new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_WATCH_IN_VR, + "yt_outline_vr" + ), + new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_FLYOUT_MENU_YT_MUSIC, + "yt_outline_open_new" + ) + ); + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (matchedGroup == playerFlyoutMenu) { + // Overflow menu is always the start of the path. + if (contentIndex != 0) { + return false; + } + // Shorts also use this player flyout panel + if (PlayerType.getCurrent().isNoneOrHidden() || byteArrayException.check(protobufBufferArray).isFiltered()) { + return false; + } + if (flyoutFilterGroupList.check(protobufBufferArray).isFiltered()) { + // Super class handles logging. + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + } else if (matchedGroup == qualityHeader) { + // Quality header is always the start of the path. + if (contentIndex != 0) { + return false; + } + // Super class handles logging. + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } else { + // Components other than the footer separator are not filtered. + if (pathBuilderException.matches(path) || !playerFlyoutMenuFooter.matches(path)) { + return false; + } + // Super class handles logging. + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + + return false; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/QuickActionFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/QuickActionFilter.java new file mode 100644 index 0000000000..f86e2dfced --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/QuickActionFilter.java @@ -0,0 +1,117 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup; +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroupList; +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class QuickActionFilter extends Filter { + private static final String QUICK_ACTION_PATH = "quick_actions.eml"; + private final StringFilterGroup quickActionRule; + + private final StringFilterGroup bufferFilterPathRule; + private final ByteArrayFilterGroupList bufferButtonsGroupList = new ByteArrayFilterGroupList(); + + private final StringFilterGroup liveChatReplay; + + public QuickActionFilter() { + quickActionRule = new StringFilterGroup(null, QUICK_ACTION_PATH); + addIdentifierCallbacks(quickActionRule); + bufferFilterPathRule = new StringFilterGroup( + null, + "|ContainerType|button.eml|", + "|fullscreen_video_action_button.eml|" + ); + + liveChatReplay = new StringFilterGroup( + Settings.HIDE_LIVE_CHAT_REPLAY_BUTTON, + "live_chat_ep_entrypoint.eml" + ); + + addIdentifierCallbacks(liveChatReplay); + + addPathCallbacks( + new StringFilterGroup( + Settings.HIDE_QUICK_ACTIONS_LIKE_BUTTON, + "|like_button" + ), + new StringFilterGroup( + Settings.HIDE_QUICK_ACTIONS_DISLIKE_BUTTON, + "dislike_button" + ), + new StringFilterGroup( + Settings.HIDE_QUICK_ACTIONS_COMMENT_BUTTON, + "comments_entry_point_button" + ), + new StringFilterGroup( + Settings.HIDE_QUICK_ACTIONS_SAVE_TO_PLAYLIST_BUTTON, + "|save_to_playlist_button" + ), + new StringFilterGroup( + Settings.HIDE_QUICK_ACTIONS_MORE_BUTTON, + "|overflow_menu_button" + ), + new StringFilterGroup( + Settings.HIDE_RELATED_VIDEO_OVERLAY, + "fullscreen_related_videos" + ), + bufferFilterPathRule + ); + + bufferButtonsGroupList.addAll( + new ByteArrayFilterGroup( + Settings.HIDE_QUICK_ACTIONS_COMMENT_BUTTON, + "yt_outline_message_bubble_right" + ), + new ByteArrayFilterGroup( + Settings.HIDE_QUICK_ACTIONS_LIVE_CHAT_BUTTON, + "yt_outline_message_bubble_overlap" + ), + new ByteArrayFilterGroup( + Settings.HIDE_QUICK_ACTIONS_OPEN_MIX_PLAYLIST_BUTTON, + "yt_outline_youtube_mix" + ), + new ByteArrayFilterGroup( + Settings.HIDE_QUICK_ACTIONS_OPEN_PLAYLIST_BUTTON, + "yt_outline_list_play_arrow" + ), + new ByteArrayFilterGroup( + Settings.HIDE_QUICK_ACTIONS_SHARE_BUTTON, + "yt_outline_share" + ) + ); + } + + private boolean isEveryFilterGroupEnabled() { + for (StringFilterGroup group : pathCallbacks) + if (!group.isEnabled()) return false; + + for (ByteArrayFilterGroup group : bufferButtonsGroupList) + if (!group.isEnabled()) return false; + + return true; + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (matchedGroup == liveChatReplay) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + if (!path.startsWith(QUICK_ACTION_PATH)) { + return false; + } + if (matchedGroup == quickActionRule && !isEveryFilterGroupEnabled()) { + return false; + } + if (matchedGroup == bufferFilterPathRule) { + return bufferButtonsGroupList.check(protobufBufferArray).isFiltered(); + } + + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/RelatedVideoFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/RelatedVideoFilter.java new file mode 100644 index 0000000000..af9a2fc4c9 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/RelatedVideoFilter.java @@ -0,0 +1,55 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import java.util.concurrent.atomic.AtomicBoolean; + +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.shared.PlayerType; + +/** + * Here is an unintended behavior: + *

+ * 1. The user does not hide Shorts in the Subscriptions tab, but hides them otherwise. + * 2. Goes to the Subscriptions tab and scrolls to where Shorts is. + * 3. Opens a regular video. + * 4. Minimizes the video and turns off the screen. + * 5. Turns the screen on and maximizes the video. + * 6. Shorts belonging to related videos are not hidden. + *

+ * Here is an explanation of this special issue: + *

+ * When the user minimizes the video, turns off the screen, and then turns it back on, + * the components below the player are reloaded, and at this moment the PlayerType is [WATCH_WHILE_MINIMIZED]. + * (Shorts belonging to related videos are also reloaded) + * Since the PlayerType is [WATCH_WHILE_MINIMIZED] at this moment, the navigation tab is checked. + * (Even though PlayerType is [WATCH_WHILE_MINIMIZED], this is a Shorts belonging to a related video) + *

+ * As a workaround for this special issue, if a video actionbar is detected, which is one of the components below the player, + * it is treated as being in the same state as [WATCH_WHILE_MAXIMIZED]. + */ +public final class RelatedVideoFilter extends Filter { + // Must be volatile or synchronized, as litho filtering runs off main thread and this field is then access from the main thread. + public static final AtomicBoolean isActionBarVisible = new AtomicBoolean(false); + + public RelatedVideoFilter() { + addIdentifierCallbacks( + new StringFilterGroup( + null, + "video_action_bar.eml" + ) + ); + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (PlayerType.getCurrent() == PlayerType.WATCH_WHILE_MINIMIZED && + isActionBarVisible.compareAndSet(false, true)) + Utils.runOnMainThreadDelayed(() -> isActionBarVisible.compareAndSet(true, false), 750); + + return false; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ReturnYouTubeChannelNameFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ReturnYouTubeChannelNameFilterPatch.java new file mode 100644 index 0000000000..a78ba0a710 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ReturnYouTubeChannelNameFilterPatch.java @@ -0,0 +1,105 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import java.net.URLDecoder; + +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup; +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroupList; +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.youtube.patches.utils.ReturnYouTubeChannelNamePatch; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings({"unused", "CharsetObjectCanBeUsed"}) +public final class ReturnYouTubeChannelNameFilterPatch extends Filter { + private static final String DELIMITING_CHARACTER = "❙"; + private static final String CHANNEL_ID_IDENTIFIER_CHARACTER = "UC"; + private static final String CHANNEL_ID_IDENTIFIER_WITH_DELIMITING_CHARACTER = + DELIMITING_CHARACTER + CHANNEL_ID_IDENTIFIER_CHARACTER; + private static final String HANDLE_IDENTIFIER_CHARACTER = "@"; + private static final String HANDLE_IDENTIFIER_WITH_DELIMITING_CHARACTER = + HANDLE_IDENTIFIER_CHARACTER + CHANNEL_ID_IDENTIFIER_CHARACTER; + + private final ByteArrayFilterGroupList shortsChannelBarAvatarFilterGroup = new ByteArrayFilterGroupList(); + + public ReturnYouTubeChannelNameFilterPatch() { + addPathCallbacks( + new StringFilterGroup(Settings.REPLACE_CHANNEL_HANDLE, "|reel_channel_bar_inner.eml|") + ); + shortsChannelBarAvatarFilterGroup.addAll( + new ByteArrayFilterGroup(Settings.REPLACE_CHANNEL_HANDLE, "/@") + ); + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (shortsChannelBarAvatarFilterGroup.check(protobufBufferArray).isFiltered()) { + setLastShortsChannelId(protobufBufferArray); + } + + return false; + } + + private void setLastShortsChannelId(byte[] protobufBufferArray) { + try { + String[] splitArr; + final String bufferString = findAsciiStrings(protobufBufferArray); + splitArr = bufferString.split(CHANNEL_ID_IDENTIFIER_WITH_DELIMITING_CHARACTER); + if (splitArr.length < 2) { + return; + } + final String splitedBufferString = CHANNEL_ID_IDENTIFIER_CHARACTER + splitArr[1]; + splitArr = splitedBufferString.split(HANDLE_IDENTIFIER_WITH_DELIMITING_CHARACTER); + if (splitArr.length < 2) { + return; + } + splitArr = splitArr[1].split(DELIMITING_CHARACTER); + if (splitArr.length < 1) { + return; + } + final String cachedHandle = HANDLE_IDENTIFIER_CHARACTER + splitArr[0]; + splitArr = splitedBufferString.split(DELIMITING_CHARACTER); + if (splitArr.length < 1) { + return; + } + final String channelId = splitArr[0].replaceAll("\"", "").trim(); + final String handle = URLDecoder.decode(cachedHandle, "UTF-8").trim(); + + ReturnYouTubeChannelNamePatch.setLastShortsChannelId(handle, channelId); + } catch (Exception ex) { + Logger.printException(() -> "setLastShortsChannelId failed", ex); + } + } + + private String findAsciiStrings(byte[] buffer) { + StringBuilder builder = new StringBuilder(Math.max(100, buffer.length / 2)); + builder.append(""); + + // Valid ASCII values (ignore control characters). + final int minimumAscii = 32; // 32 = space character + final int maximumAscii = 126; // 127 = delete character + final int minimumAsciiStringLength = 4; // Minimum length of an ASCII string to include. + String delimitingCharacter = "❙"; // Non ascii character, to allow easier log filtering. + + final int length = buffer.length; + int start = 0; + int end = 0; + while (end < length) { + int value = buffer[end]; + if (value < minimumAscii || value > maximumAscii || end == length - 1) { + if (end - start >= minimumAsciiStringLength) { + for (int i = start; i < end; i++) { + builder.append((char) buffer[i]); + } + builder.append(delimitingCharacter); + } + start = end + 1; + } + end++; + } + return builder.toString(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java new file mode 100644 index 0000000000..cbebddb448 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java @@ -0,0 +1,171 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; + +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup; +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroupList; +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.FilterGroup; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.TrieSearch; +import app.revanced.extension.youtube.patches.utils.ReturnYouTubeDislikePatch; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.VideoInformation; + +/** + * @noinspection ALL + *

+ * Searches for video id's in the proto buffer of Shorts dislike. + *

+ * Because multiple litho dislike spans are created in the background + * (and also anytime litho refreshes the components, which is somewhat arbitrary), + * that makes the value of {@link VideoInformation#getVideoId()} and {@link VideoInformation#getPlayerResponseVideoId()} + * unreliable to determine which video id a Shorts litho span belongs to. + *

+ * But the correct video id does appear in the protobuffer just before a Shorts litho span is created. + *

+ * Once a way to asynchronously update litho text is found, this strategy will no longer be needed. + */ +public final class ReturnYouTubeDislikeFilterPatch extends Filter { + + /** + * Last unique video id's loaded. Value is ignored and Map is treated as a Set. + * Cannot use {@link LinkedHashSet} because it's missing #removeEldestEntry(). + */ + @GuardedBy("itself") + private static final Map lastVideoIds = new LinkedHashMap<>() { + /** + * Number of video id's to keep track of for searching thru the buffer. + * A minimum value of 3 should be sufficient, but check a few more just in case. + */ + private static final int NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK = 5; + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK; + } + }; + private final ByteArrayFilterGroupList videoIdFilterGroup = new ByteArrayFilterGroupList(); + + public ReturnYouTubeDislikeFilterPatch() { + // When a new Short is opened, the like buttons always seem to load before the dislike. + // But if swiping back to a previous video and liking/disliking, then only that single button reloads. + // So must check for both buttons. + addPathCallbacks( + new StringFilterGroup(null, "|shorts_like_button.eml"), + new StringFilterGroup(null, "|shorts_dislike_button.eml") + ); + + // After the likes icon name is some binary data and then the video id for that specific short. + videoIdFilterGroup.addAll( + // on_shadowed = Video was previously like/disliked before opening. + // off_shadowed = Video was not previously liked/disliked before opening. + new ByteArrayFilterGroup(null, "ic_right_like_on_shadowed"), + new ByteArrayFilterGroup(null, "ic_right_like_off_shadowed"), + + new ByteArrayFilterGroup(null, "ic_right_dislike_on_shadowed"), + new ByteArrayFilterGroup(null, "ic_right_dislike_off_shadowed") + ); + } + + private volatile static String shortsVideoId = ""; + + public static String getShortsVideoId() { + return shortsVideoId; + } + + /** + * Injection point. + */ + public static void newShortsVideoStarted(@NonNull String newlyLoadedChannelId, @NonNull String newlyLoadedChannelName, + @NonNull String newlyLoadedVideoId, @NonNull String newlyLoadedVideoTitle, + final long newlyLoadedVideoLength, boolean newlyLoadedLiveStreamValue) { + if (!Settings.RYD_SHORTS.get()) { + return; + } + if (shortsVideoId.equals(newlyLoadedVideoId)) { + return; + } + Logger.printDebug(() -> "newShortsVideoStarted: " + newlyLoadedVideoId); + shortsVideoId = newlyLoadedVideoId; + } + + /** + * Injection point. + */ + public static void newPlayerResponseVideoId(String videoId, boolean isShortAndOpeningOrPlaying) { + try { + if (!isShortAndOpeningOrPlaying || !Settings.RYD_ENABLED.get() || !Settings.RYD_SHORTS.get()) { + return; + } + synchronized (lastVideoIds) { + if (lastVideoIds.put(videoId, Boolean.TRUE) == null) { + Logger.printDebug(() -> "New Short video id: " + videoId); + } + } + } catch (Exception ex) { + Logger.printException(() -> "newPlayerResponseVideoId failure", ex); + } + } + + /** + * This could use {@link TrieSearch}, but since the patterns are constantly changing + * the overhead of updating the Trie might negate the search performance gain. + */ + private static boolean byteArrayContainsString(@NonNull byte[] array, @NonNull String text) { + for (int i = 0, lastArrayStartIndex = array.length - text.length(); i <= lastArrayStartIndex; i++) { + boolean found = true; + for (int j = 0, textLength = text.length(); j < textLength; j++) { + if (array[i + j] != (byte) text.charAt(j)) { + found = false; + break; + } + } + if (found) { + return true; + } + } + + return false; + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (!Settings.RYD_ENABLED.get() || !Settings.RYD_SHORTS.get()) { + return false; + } + + FilterGroup.FilterGroupResult result = videoIdFilterGroup.check(protobufBufferArray); + if (result.isFiltered()) { + String matchedVideoId = findVideoId(protobufBufferArray); + // Matched video will be null if in incognito mode. + // Must pass a null id to correctly clear out the current video data. + // Otherwise if a Short is opened in non-incognito, then incognito is enabled and another Short is opened, + // the new incognito Short will show the old prior data. + ReturnYouTubeDislikePatch.setLastLithoShortsVideoId(matchedVideoId); + } + + return false; + } + + @Nullable + private String findVideoId(byte[] protobufBufferArray) { + synchronized (lastVideoIds) { + for (String videoId : lastVideoIds.keySet()) { + if (byteArrayContainsString(protobufBufferArray, videoId)) { + return videoId; + } + } + + return null; + } + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShareSheetMenuFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShareSheetMenuFilter.java new file mode 100644 index 0000000000..316b0db39b --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShareSheetMenuFilter.java @@ -0,0 +1,33 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.youtube.patches.misc.ShareSheetPatch; +import app.revanced.extension.youtube.settings.Settings; + +/** + * Abuse LithoFilter for {@link ShareSheetPatch}. + */ +public final class ShareSheetMenuFilter extends Filter { + // Must be volatile or synchronized, as litho filtering runs off main thread and this field is then access from the main thread. + public static volatile boolean isShareSheetMenuVisible; + + public ShareSheetMenuFilter() { + addIdentifierCallbacks( + new StringFilterGroup( + Settings.CHANGE_SHARE_SHEET, + "share_sheet_container.eml" + ) + ); + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + isShareSheetMenuVisible = true; + + return false; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsButtonFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsButtonFilter.java new file mode 100644 index 0000000000..8ec39c4b52 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsButtonFilter.java @@ -0,0 +1,274 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import org.apache.commons.lang3.StringUtils; + +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup; +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroupList; +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class ShortsButtonFilter extends Filter { + private final static String REEL_CHANNEL_BAR_PATH = "reel_channel_bar.eml"; + private final static String REEL_LIVE_HEADER_PATH = "immersive_live_header.eml"; + /** + * For paid promotion label and subscribe button that appears in the channel bar. + */ + private final static String REEL_METAPANEL_PATH = "reel_metapanel.eml"; + + private final static String SHORTS_PAUSED_STATE_BUTTON_PATH = "|ScrollableContainerType|ContainerType|button.eml|"; + + private final StringFilterGroup subscribeButton; + private final StringFilterGroup joinButton; + private final StringFilterGroup pausedOverlayButtons; + private final StringFilterGroup metaPanelButton; + private final ByteArrayFilterGroupList pausedOverlayButtonsGroupList = new ByteArrayFilterGroupList(); + + private final StringFilterGroup suggestedAction; + private final ByteArrayFilterGroupList suggestedActionsGroupList = new ByteArrayFilterGroupList(); + + private final StringFilterGroup actionBar; + private final ByteArrayFilterGroupList videoActionButtonGroupList = new ByteArrayFilterGroupList(); + + private final ByteArrayFilterGroup useThisSoundButton = new ByteArrayFilterGroup( + Settings.HIDE_SHORTS_USE_THIS_SOUND_BUTTON, + "yt_outline_camera" + ); + + public ShortsButtonFilter() { + StringFilterGroup floatingButton = new StringFilterGroup( + Settings.HIDE_SHORTS_FLOATING_BUTTON, + "floating_action_button" + ); + + addIdentifierCallbacks(floatingButton); + + pausedOverlayButtons = new StringFilterGroup( + null, + "shorts_paused_state" + ); + + StringFilterGroup channelBar = new StringFilterGroup( + Settings.HIDE_SHORTS_CHANNEL_BAR, + REEL_CHANNEL_BAR_PATH + ); + + StringFilterGroup fullVideoLinkLabel = new StringFilterGroup( + Settings.HIDE_SHORTS_FULL_VIDEO_LINK_LABEL, + "reel_multi_format_link" + ); + + StringFilterGroup videoTitle = new StringFilterGroup( + Settings.HIDE_SHORTS_VIDEO_TITLE, + "shorts_video_title_item" + ); + + StringFilterGroup reelSoundMetadata = new StringFilterGroup( + Settings.HIDE_SHORTS_SOUND_METADATA_LABEL, + "reel_sound_metadata" + ); + + StringFilterGroup infoPanel = new StringFilterGroup( + Settings.HIDE_SHORTS_INFO_PANEL, + "shorts_info_panel_overview" + ); + + StringFilterGroup stickers = new StringFilterGroup( + Settings.HIDE_SHORTS_STICKERS, + "stickers_layer.eml" + ); + + StringFilterGroup liveHeader = new StringFilterGroup( + Settings.HIDE_SHORTS_LIVE_HEADER, + "immersive_live_header" + ); + + StringFilterGroup paidPromotionButton = new StringFilterGroup( + Settings.HIDE_SHORTS_PAID_PROMOTION_LABEL, + "reel_player_disclosure.eml" + ); + + metaPanelButton = new StringFilterGroup( + null, + "|ContainerType|button.eml|" + ); + + joinButton = new StringFilterGroup( + Settings.HIDE_SHORTS_JOIN_BUTTON, + "sponsor_button" + ); + + subscribeButton = new StringFilterGroup( + Settings.HIDE_SHORTS_SUBSCRIBE_BUTTON, + "subscribe_button" + ); + + actionBar = new StringFilterGroup( + null, + "shorts_action_bar" + ); + + suggestedAction = new StringFilterGroup( + null, + "|suggested_action_inner.eml|" + ); + + addPathCallbacks( + suggestedAction, actionBar, joinButton, subscribeButton, metaPanelButton, + paidPromotionButton, pausedOverlayButtons, channelBar, fullVideoLinkLabel, + videoTitle, reelSoundMetadata, infoPanel, liveHeader, stickers + ); + + // + // Action buttons + // + videoActionButtonGroupList.addAll( + // This also appears as the path item 'shorts_like_button.eml' + new ByteArrayFilterGroup( + Settings.HIDE_SHORTS_LIKE_BUTTON, + "reel_like_button", + "reel_like_toggled_button" + ), + // This also appears as the path item 'shorts_dislike_button.eml' + new ByteArrayFilterGroup( + Settings.HIDE_SHORTS_DISLIKE_BUTTON, + "reel_dislike_button", + "reel_dislike_toggled_button" + ), + new ByteArrayFilterGroup( + Settings.HIDE_SHORTS_COMMENTS_BUTTON, + "reel_comment_button" + ), + new ByteArrayFilterGroup( + Settings.HIDE_SHORTS_SHARE_BUTTON, + "reel_share_button" + ), + new ByteArrayFilterGroup( + Settings.HIDE_SHORTS_REMIX_BUTTON, + "reel_remix_button" + ), + new ByteArrayFilterGroup( + Settings.DISABLE_SHORTS_LIKE_BUTTON_FOUNTAIN_ANIMATION, + "shorts_like_fountain" + ) + ); + + // + // Paused overlay buttons. + // + pausedOverlayButtonsGroupList.addAll( + new ByteArrayFilterGroup( + Settings.HIDE_SHORTS_TRENDS_BUTTON, + "yt_outline_fire_" + ), + new ByteArrayFilterGroup( + Settings.HIDE_SHORTS_SHOPPING_BUTTON, + "yt_outline_bag_" + ) + ); + + // + // Suggested actions. + // + suggestedActionsGroupList.addAll( + new ByteArrayFilterGroup( + Settings.HIDE_SHORTS_TAGGED_PRODUCTS, + // Product buttons show pictures of the products, and does not have any unique icons to identify. + // Instead use a unique identifier found in the buffer. + "PAproduct_listZ" + ), + new ByteArrayFilterGroup( + Settings.HIDE_SHORTS_SHOP_BUTTON, + "yt_outline_bag_" + ), + new ByteArrayFilterGroup( + Settings.HIDE_SHORTS_LOCATION_BUTTON, + "yt_outline_location_point_" + ), + new ByteArrayFilterGroup( + Settings.HIDE_SHORTS_SAVE_MUSIC_BUTTON, + "yt_outline_list_add_", + "yt_outline_bookmark_" + ), + new ByteArrayFilterGroup( + Settings.HIDE_SHORTS_SEARCH_SUGGESTIONS_BUTTON, + "yt_outline_search_" + ), + new ByteArrayFilterGroup( + Settings.HIDE_SHORTS_SUPER_THANKS_BUTTON, + "yt_outline_dollar_sign_heart_" + ), + new ByteArrayFilterGroup( + Settings.HIDE_SHORTS_USE_TEMPLATE_BUTTON, + "yt_outline_template_add" + ), + new ByteArrayFilterGroup( + Settings.HIDE_SHORTS_GREEN_SCREEN_BUTTON, + "shorts_green_screen" + ), + useThisSoundButton + ); + } + + private boolean isEverySuggestedActionFilterEnabled() { + for (ByteArrayFilterGroup group : suggestedActionsGroupList) + if (!group.isEnabled()) return false; + + return true; + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (matchedGroup == subscribeButton || matchedGroup == joinButton) { + // Selectively filter to avoid false positive filtering of other subscribe/join buttons. + if (StringUtils.startsWithAny(path, REEL_CHANNEL_BAR_PATH, REEL_LIVE_HEADER_PATH, REEL_METAPANEL_PATH)) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + return false; + } + + if (matchedGroup == metaPanelButton) { + if (path.startsWith(REEL_METAPANEL_PATH) && useThisSoundButton.check(protobufBufferArray).isFiltered()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + return false; + } + + // Video action buttons (like, dislike, comment, share, remix) have the same path. + if (matchedGroup == actionBar) { + if (videoActionButtonGroupList.check(protobufBufferArray).isFiltered()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + return false; + } + + if (matchedGroup == suggestedAction) { + if (isEverySuggestedActionFilterEnabled()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + // Suggested actions can be at the start or in the middle of a path. + if (suggestedActionsGroupList.check(protobufBufferArray).isFiltered()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + return false; + } + + if (matchedGroup == pausedOverlayButtons) { + if (Settings.HIDE_SHORTS_PAUSED_OVERLAY_BUTTONS.get()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } else if (StringUtils.contains(path, SHORTS_PAUSED_STATE_BUTTON_PATH)) { + if (pausedOverlayButtonsGroupList.check(protobufBufferArray).isFiltered()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + } + return false; + } + + // Super class handles logging. + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsShelfFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsShelfFilter.java new file mode 100644 index 0000000000..261c646215 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsShelfFilter.java @@ -0,0 +1,188 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup; +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.StringTrieSearch; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.NavigationBar.NavigationButton; +import app.revanced.extension.youtube.shared.RootView; + +@SuppressWarnings("unused") +public final class ShortsShelfFilter extends Filter { + private static final String BROWSE_ID_HISTORY = "FEhistory"; + private static final String BROWSE_ID_LIBRARY = "FElibrary"; + private static final String BROWSE_ID_NOTIFICATION_INBOX = "FEnotifications_inbox"; + private static final String BROWSE_ID_SUBSCRIPTIONS = "FEsubscriptions"; + private static final String CONVERSATION_CONTEXT_FEED_IDENTIFIER = + "horizontalCollectionSwipeProtector=null"; + private static final String SHELF_HEADER_PATH = "shelf_header.eml"; + private final StringFilterGroup channelProfile; + private final StringFilterGroup compactFeedVideoPath; + private final ByteArrayFilterGroup compactFeedVideoBuffer; + private final StringFilterGroup shelfHeaderIdentifier; + private final StringFilterGroup shelfHeaderPath; + private static final StringTrieSearch feedGroup = new StringTrieSearch(); + private static final BooleanSetting hideShortsShelf = Settings.HIDE_SHORTS_SHELF; + private static final BooleanSetting hideChannel = Settings.HIDE_SHORTS_SHELF_CHANNEL; + private static final ByteArrayFilterGroup channelProfileShelfHeader = + new ByteArrayFilterGroup( + hideChannel, + "Shorts" + ); + + public ShortsShelfFilter() { + feedGroup.addPattern(CONVERSATION_CONTEXT_FEED_IDENTIFIER); + + channelProfile = new StringFilterGroup( + hideChannel, + "shorts_pivot_item" + ); + + final StringFilterGroup shortsIdentifiers = new StringFilterGroup( + hideShortsShelf, + "shorts_shelf", + "inline_shorts", + "shorts_grid", + "shorts_video_cell" + ); + + shelfHeaderIdentifier = new StringFilterGroup( + hideShortsShelf, + SHELF_HEADER_PATH + ); + + addIdentifierCallbacks(channelProfile, shortsIdentifiers, shelfHeaderIdentifier); + + compactFeedVideoPath = new StringFilterGroup( + hideShortsShelf, + // Shorts that appear in the feed/search when the device is using tablet layout. + "compact_video.eml", + // 'video_lockup_with_attachment.eml' is used instead of 'compact_video.eml' for some users. (A/B tests) + "video_lockup_with_attachment.eml", + // Search results that appear in a horizontal shelf. + "video_card.eml" + ); + + // Filter out items that use the 'frame0' thumbnail. + // This is a valid thumbnail for both regular videos and Shorts, + // but it appears these thumbnails are used only for Shorts. + compactFeedVideoBuffer = new ByteArrayFilterGroup( + hideShortsShelf, + "/frame0.jpg" + ); + + // Feed Shorts shelf header. + // Use a different filter group for this pattern, as it requires an additional check after matching. + shelfHeaderPath = new StringFilterGroup( + hideShortsShelf, + SHELF_HEADER_PATH + ); + + addPathCallbacks(compactFeedVideoPath, shelfHeaderPath); + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + final boolean playerActive = RootView.isPlayerActive(); + final boolean searchBarActive = RootView.isSearchBarActive(); + final NavigationButton navigationButton = NavigationButton.getSelectedNavigationButton(); + final String navigation = navigationButton == null ? "null" : navigationButton.name(); + final String browseId = RootView.getBrowseId(); + final boolean hideShelves = shouldHideShortsFeedItems(playerActive, searchBarActive, navigationButton, browseId); + Logger.printDebug(() -> "hideShelves: " + hideShelves + "\nplayerActive: " + playerActive + "\nsearchBarActive: " + searchBarActive + "\nbrowseId: " + browseId + "\nnavigation: " + navigation); + if (contentType == FilterContentType.PATH) { + if (matchedGroup == compactFeedVideoPath) { + if (hideShelves && compactFeedVideoBuffer.check(protobufBufferArray).isFiltered()) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + return false; + } else if (matchedGroup == shelfHeaderPath) { + // Because the header is used in watch history and possibly other places, check for the index, + // which is 0 when the shelf header is used for Shorts. + if (contentIndex != 0) { + return false; + } + if (!channelProfileShelfHeader.check(protobufBufferArray).isFiltered()) { + return false; + } + if (feedGroup.matches(allValue)) { + return false; + } + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + } else if (contentType == FilterContentType.IDENTIFIER) { + // Feed/search identifier components. + if (matchedGroup == shelfHeaderIdentifier) { + // Check ConversationContext to not hide shelf header in channel profile + // This value does not exist in the shelf header in the channel profile + if (!feedGroup.matches(allValue)) { + return false; + } + } else if (matchedGroup == channelProfile) { + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + if (!hideShelves) { + return false; + } + } + + // Super class handles logging. + return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + + private static boolean shouldHideShortsFeedItems(boolean playerActive, boolean searchBarActive, NavigationButton selectedNavButton, String browseId) { + final boolean hideHomeAndRelatedVideos = Settings.HIDE_SHORTS_SHELF_HOME_RELATED_VIDEOS.get(); + final boolean hideSubscriptions = Settings.HIDE_SHORTS_SHELF_SUBSCRIPTIONS.get(); + final boolean hideSearch = Settings.HIDE_SHORTS_SHELF_SEARCH.get(); + + if (hideHomeAndRelatedVideos && hideSubscriptions && hideSearch) { + // Shorts suggestions can load in the background if a video is opened and + // then immediately minimized before any suggestions are loaded. + // In this state the player type will show minimized, which makes it not possible to + // distinguish between Shorts suggestions loading in the player and between + // scrolling thru search/home/subscription tabs while a player is minimized. + // + // To avoid this situation for users that never want to show Shorts (all hide Shorts options are enabled) + // then hide all Shorts everywhere including the Library history and Library playlists. + return true; + } + + // Must check player type first, as search bar can be active behind the player. + if (playerActive) { + // For now, consider the under video results the same as the home feed. + return hideHomeAndRelatedVideos; + } + + // Must check second, as search can be from any tab. + if (searchBarActive) { + return hideSearch; + } + + // Avoid checking navigation button status if all other Shorts should show. + if (!hideHomeAndRelatedVideos && !hideSubscriptions) { + return false; + } + + if (selectedNavButton == null) { + return hideHomeAndRelatedVideos; // Unknown tab, treat the same as home. + } + + switch (browseId) { + case BROWSE_ID_HISTORY, BROWSE_ID_LIBRARY, BROWSE_ID_NOTIFICATION_INBOX -> { + return false; + } + case BROWSE_ID_SUBSCRIPTIONS -> { + return hideSubscriptions; + } + default -> { + return hideHomeAndRelatedVideos; + } + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/VideoQualityMenuFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/VideoQualityMenuFilter.java new file mode 100644 index 0000000000..812eabbc4a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/VideoQualityMenuFilter.java @@ -0,0 +1,33 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.patches.components.Filter; +import app.revanced.extension.shared.patches.components.StringFilterGroup; +import app.revanced.extension.youtube.patches.video.RestoreOldVideoQualityMenuPatch; +import app.revanced.extension.youtube.settings.Settings; + +/** + * Abuse LithoFilter for {@link RestoreOldVideoQualityMenuPatch}. + */ +public final class VideoQualityMenuFilter extends Filter { + // Must be volatile or synchronized, as litho filtering runs off main thread and this field is then access from the main thread. + public static volatile boolean isVideoQualityMenuVisible; + + public VideoQualityMenuFilter() { + addPathCallbacks( + new StringFilterGroup( + Settings.RESTORE_OLD_VIDEO_QUALITY_MENU, + "quick_quality_sheet_content.eml-js" + ) + ); + } + + @Override + public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + isVideoQualityMenuVisible = true; + + return false; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/feed/FeedPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/feed/FeedPatch.java new file mode 100644 index 0000000000..46a494a877 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/feed/FeedPatch.java @@ -0,0 +1,221 @@ +package app.revanced.extension.youtube.patches.feed; + +import static app.revanced.extension.shared.utils.Utils.hideViewBy0dpUnderCondition; +import static app.revanced.extension.shared.utils.Utils.hideViewUnderCondition; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class FeedPatch { + + // region [Hide feed components] patch + + public static int hideCategoryBarInFeed(final int height) { + return Settings.HIDE_CATEGORY_BAR_IN_FEED.get() ? 0 : height; + } + + public static void hideCategoryBarInRelatedVideos(final View chipView) { + Utils.hideViewBy0dpUnderCondition( + Settings.HIDE_CATEGORY_BAR_IN_RELATED_VIDEOS.get() || Settings.HIDE_RELATED_VIDEOS.get(), + chipView + ); + } + + public static int hideCategoryBarInSearch(final int height) { + return Settings.HIDE_CATEGORY_BAR_IN_SEARCH.get() ? 0 : height; + } + + /** + * Rather than simply hiding the channel tab view, completely removes channel tab from list. + * If a channel tab is removed from the list, users will not be able to open it by swiping. + * + * @param channelTabText Text to be assigned to channel tab, such as 'Shorts', 'Playlists', 'Community', 'Store'. + * This text is hardcoded, so it follows the user's language. + * @return Whether to remove the channel tab from the list. + */ + public static boolean hideChannelTab(String channelTabText) { + if (!Settings.HIDE_CHANNEL_TAB.get()) { + return false; + } + if (channelTabText == null || channelTabText.isEmpty()) { + return false; + } + + String[] blockList = Settings.HIDE_CHANNEL_TAB_FILTER_STRINGS.get().split("\\n"); + + for (String filter : blockList) { + if (!filter.isEmpty() && channelTabText.equals(filter)) { + return true; + } + } + + return false; + } + + public static void hideBreakingNewsShelf(View view) { + hideViewBy0dpUnderCondition( + Settings.HIDE_CAROUSEL_SHELF.get(), + view + ); + } + + public static View hideCaptionsButton(View view) { + return Settings.HIDE_FEED_CAPTIONS_BUTTON.get() ? null : view; + } + + public static void hideCaptionsButtonContainer(View view) { + hideViewUnderCondition( + Settings.HIDE_FEED_CAPTIONS_BUTTON, + view + ); + } + + public static boolean hideFloatingButton() { + return Settings.HIDE_FLOATING_BUTTON.get(); + } + + public static void hideLatestVideosButton(View view) { + hideViewUnderCondition(Settings.HIDE_LATEST_VIDEOS_BUTTON.get(), view); + } + + public static boolean hideSubscriptionsChannelSection() { + return Settings.HIDE_SUBSCRIPTIONS_CAROUSEL.get(); + } + + public static void hideSubscriptionsChannelSection(View view) { + hideViewUnderCondition(Settings.HIDE_SUBSCRIPTIONS_CAROUSEL, view); + } + + private static FrameLayout.LayoutParams layoutParams; + private static int minimumHeight = -1; + private static int paddingLeft = 12; + private static int paddingTop = 0; + private static int paddingRight = 12; + private static int paddingBottom = 0; + + /** + * expandButtonContainer is used in channel profiles as well as search results. + * We need to hide expandButtonContainer only in search results, not in channel profile. + *

+ * If we hide expandButtonContainer with setVisibility, the empty space occupied by expandButtonContainer will still be left. + * Therefore, we need to dynamically resize the View with LayoutParams. + *

+ * Unlike other Views, expandButtonContainer cannot make a View invisible using the normal {@link Utils#hideViewByLayoutParams} method. + * We should set the parent view's padding and MinimumHeight to 0 to completely hide the expandButtonContainer. + * + * @param parentView Parent view of expandButtonContainer. + */ + public static void hideShowMoreButton(View parentView) { + if (!Settings.HIDE_SHOW_MORE_BUTTON.get()) + return; + + if (!(parentView instanceof ViewGroup viewGroup)) + return; + + if (!(viewGroup.getChildAt(0) instanceof ViewGroup expandButtonContainer)) + return; + + if (layoutParams == null) { + // We need to get the original LayoutParams and paddings applied to expandButtonContainer. + // Theses are used to make the expandButtonContainer visible again. + if (expandButtonContainer.getLayoutParams() instanceof FrameLayout.LayoutParams lp) { + layoutParams = lp; + paddingLeft = parentView.getPaddingLeft(); + paddingTop = parentView.getPaddingTop(); + paddingRight = parentView.getPaddingRight(); + paddingBottom = parentView.getPaddingBottom(); + } + } + + // I'm not sure if 'Utils.runOnMainThreadDelayed' is absolutely necessary. + Utils.runOnMainThreadDelayed(() -> { + // MinimumHeight is also needed to make expandButtonContainer visible again. + // Get original MinimumHeight. + if (minimumHeight == -1) { + minimumHeight = parentView.getMinimumHeight(); + } + + // In the search results, the child view structure of expandButtonContainer is as follows: + // expandButtonContainer + // L TextView (first child view is SHOWN, 'Show more' text) + // L ImageView (second child view is shown, dropdown arrow icon) + + // In the channel profiles, the child view structure of expandButtonContainer is as follows: + // expandButtonContainer + // L TextView (first child view is HIDDEN, 'Show more' text) + // L ImageView (second child view is shown, dropdown arrow icon) + + if (expandButtonContainer.getChildAt(0).getVisibility() != View.VISIBLE && layoutParams != null) { + // If the first child view (TextView) is HIDDEN, the channel profile is open. + // Restore parent view's padding and MinimumHeight to make them visible. + parentView.setMinimumHeight(minimumHeight); + parentView.setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom); + expandButtonContainer.setLayoutParams(layoutParams); + } else { + // If the first child view (TextView) is SHOWN, the search results is open. + // Set the parent view's padding and MinimumHeight to 0 to completely hide the expandButtonContainer. + parentView.setMinimumHeight(0); + parentView.setPadding(0, 0, 0, 0); + expandButtonContainer.setLayoutParams(new FrameLayout.LayoutParams(0, 0)); + } + }, 0 + ); + } + + // endregion + + // region [Hide feed flyout menu] patch + + /** + * hide feed flyout menu for phone + * + * @param menuTitleCharSequence menu title + */ + @Nullable + public static CharSequence hideFlyoutMenu(@Nullable CharSequence menuTitleCharSequence) { + if (menuTitleCharSequence != null && Settings.HIDE_FEED_FLYOUT_MENU.get()) { + String[] blockList = Settings.HIDE_FEED_FLYOUT_MENU_FILTER_STRINGS.get().split("\\n"); + String menuTitleString = menuTitleCharSequence.toString(); + + for (String filter : blockList) { + if (menuTitleString.equals(filter) && !filter.isEmpty()) + return null; + } + } + + return menuTitleCharSequence; + } + + /** + * hide feed flyout panel for tablet + * + * @param menuTextView flyout text view + * @param menuTitleCharSequence raw text + */ + public static void hideFlyoutMenu(TextView menuTextView, CharSequence menuTitleCharSequence) { + if (menuTitleCharSequence == null || !Settings.HIDE_FEED_FLYOUT_MENU.get()) + return; + + if (!(menuTextView.getParent() instanceof View parentView)) + return; + + String[] blockList = Settings.HIDE_FEED_FLYOUT_MENU_FILTER_STRINGS.get().split("\\n"); + String menuTitleString = menuTitleCharSequence.toString(); + + for (String filter : blockList) { + if (menuTitleString.equals(filter) && !filter.isEmpty()) + Utils.hideViewByLayoutParams(parentView); + } + } + + // endregion + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/feed/RelatedVideoPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/feed/RelatedVideoPatch.java new file mode 100644 index 0000000000..ccc20a6311 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/feed/RelatedVideoPatch.java @@ -0,0 +1,49 @@ +package app.revanced.extension.youtube.patches.feed; + +import androidx.annotation.Nullable; + +import java.util.concurrent.atomic.AtomicBoolean; + +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.BottomSheetState; +import app.revanced.extension.youtube.shared.RootView; + +@SuppressWarnings("unused") +public final class RelatedVideoPatch { + private static final boolean HIDE_RELATED_VIDEOS = Settings.HIDE_RELATED_VIDEOS.get(); + + private static final int OFFSET = Settings.RELATED_VIDEOS_OFFSET.get(); + + // video title,channel bar, video action bar, comment + private static final int MAX_ITEM_COUNT = 4 + OFFSET; + + private static final AtomicBoolean engagementPanelOpen = new AtomicBoolean(false); + + public static void showEngagementPanel(@Nullable Object object) { + engagementPanelOpen.set(object != null); + } + + public static void hideEngagementPanel() { + engagementPanelOpen.compareAndSet(true, false); + } + + public static int overrideItemCounts(int itemCounts) { + if (!HIDE_RELATED_VIDEOS) { + return itemCounts; + } + if (itemCounts < MAX_ITEM_COUNT) { + return itemCounts; + } + if (!RootView.isPlayerActive()) { + return itemCounts; + } + if (BottomSheetState.getCurrent().isOpen()) { + return itemCounts; + } + if (engagementPanelOpen.get()) { + return itemCounts; + } + return MAX_ITEM_COUNT; + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/ChangeStartPagePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/ChangeStartPagePatch.java new file mode 100644 index 0000000000..272eac1dd4 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/ChangeStartPagePatch.java @@ -0,0 +1,134 @@ +package app.revanced.extension.youtube.patches.general; + +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; + +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; + +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class ChangeStartPagePatch { + + public enum StartPage { + /** + * Unmodified type, and same as un-patched. + */ + ORIGINAL("", null), + + /** + * Browse id. + */ + BROWSE("FEguide_builder", TRUE), + EXPLORE("FEexplore", TRUE), + HISTORY("FEhistory", TRUE), + LIBRARY("FElibrary", TRUE), + MOVIE("FEstorefront", TRUE), + SUBSCRIPTIONS("FEsubscriptions", TRUE), + TRENDING("FEtrending", TRUE), + + /** + * Channel id, this can be used as a browseId. + */ + GAMING("UCOpNcN46UbXVtpKMrmU4Abg", TRUE), + LIVE("UC4R8DWoMoI7CAwX8_LjQHig", TRUE), + MUSIC("UC-9-kyTW8ZkZNDHQJ6FgpwQ", TRUE), + SPORTS("UCEgdi0XIXXZ-qJOFPf4JSKw", TRUE), + + /** + * Playlist id, this can be used as a browseId. + */ + LIKED_VIDEO("VLLL", TRUE), + WATCH_LATER("VLWL", TRUE), + + /** + * Intent action. + */ + SEARCH("com.google.android.youtube.action.open.search", FALSE), + SHORTS("com.google.android.youtube.action.open.shorts", FALSE); + + @Nullable + final Boolean isBrowseId; + + @NonNull + final String id; + + StartPage(@NonNull String id, @Nullable Boolean isBrowseId) { + this.id = id; + this.isBrowseId = isBrowseId; + } + + private boolean isBrowseId() { + return BooleanUtils.isTrue(isBrowseId); + } + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private boolean isIntentAction() { + return BooleanUtils.isFalse(isBrowseId); + } + } + + /** + * Intent action when YouTube is cold started from the launcher. + *

+ * If you don't check this, the hooking will also apply in the following cases: + * Case 1. The user clicked Shorts button on the YouTube shortcut. + * Case 2. The user clicked Shorts button on the YouTube widget. + * In this case, instead of opening Shorts, the start page specified by the user is opened. + */ + private static final String ACTION_MAIN = "android.intent.action.MAIN"; + + private static final StartPage START_PAGE = Settings.CHANGE_START_PAGE.get(); + private static final boolean ALWAYS_CHANGE_START_PAGE = Settings.CHANGE_START_PAGE_TYPE.get(); + + /** + * There is an issue where the back button on the toolbar doesn't work properly. + * As a workaround for this issue, instead of overriding the browserId multiple times, just override it once. + */ + private static boolean appLaunched = false; + + public static String overrideBrowseId(@NonNull String original) { + if (!START_PAGE.isBrowseId()) { + return original; + } + if (!ALWAYS_CHANGE_START_PAGE && appLaunched) { + Logger.printDebug(() -> "Ignore override browseId as the app already launched"); + return original; + } + appLaunched = true; + + final String browseId = START_PAGE.id; + Logger.printDebug(() -> "Changing browseId to " + browseId); + return browseId; + } + + public static void overrideIntentAction(@NonNull Intent intent) { + if (!START_PAGE.isIntentAction()) { + return; + } + if (!StringUtils.equals(intent.getAction(), ACTION_MAIN)) { + Logger.printDebug(() -> "Ignore override intent action" + + " as the current activity is not the entry point of the application"); + return; + } + + final String intentAction = START_PAGE.id; + Logger.printDebug(() -> "Changing intent action to " + intentAction); + intent.setAction(intentAction); + } + + public static final class ChangeStartPageTypeAvailability implements Setting.Availability { + @Override + public boolean isAvailable() { + return Settings.CHANGE_START_PAGE.get() != StartPage.ORIGINAL; + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/DownloadActionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/DownloadActionsPatch.java new file mode 100644 index 0000000000..0c16075611 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/DownloadActionsPatch.java @@ -0,0 +1,98 @@ +package app.revanced.extension.youtube.patches.general; + +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.utils.VideoUtils; + +@SuppressWarnings("unused") +public final class DownloadActionsPatch extends VideoUtils { + + private static final BooleanSetting overrideVideoDownloadButton = + Settings.OVERRIDE_VIDEO_DOWNLOAD_BUTTON; + + private static final BooleanSetting overridePlaylistDownloadButton = + Settings.OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON; + + /** + * Injection point. + *

+ * Called from the in app download hook, + * for both the player action button (below the video) + * and the 'Download video' flyout option for feed videos. + *

+ * Appears to always be called from the main thread. + */ + public static boolean inAppVideoDownloadButtonOnClick(String videoId) { + try { + if (!overrideVideoDownloadButton.get()) { + return false; + } + if (videoId == null || videoId.isEmpty()) { + return false; + } + launchVideoExternalDownloader(videoId); + + return true; + } catch (Exception ex) { + Logger.printException(() -> "inAppVideoDownloadButtonOnClick failure", ex); + } + return false; + } + + /** + * Injection point. + *

+ * Called from the in app playlist download hook. + *

+ * Appears to always be called from the main thread. + */ + public static String inAppPlaylistDownloadButtonOnClick(String playlistId) { + try { + if (!overridePlaylistDownloadButton.get()) { + return playlistId; + } + if (playlistId == null || playlistId.isEmpty()) { + return playlistId; + } + launchPlaylistExternalDownloader(playlistId); + + return ""; + } catch (Exception ex) { + Logger.printException(() -> "inAppPlaylistDownloadButtonOnClick failure", ex); + } + return playlistId; + } + + /** + * Injection point. + *

+ * Called from the 'Download playlist' flyout option. + *

+ * Appears to always be called from the main thread. + */ + public static boolean inAppPlaylistDownloadMenuOnClick(String playlistId) { + try { + if (!overridePlaylistDownloadButton.get()) { + return false; + } + if (playlistId == null || playlistId.isEmpty()) { + return false; + } + launchPlaylistExternalDownloader(playlistId); + + return true; + } catch (Exception ex) { + Logger.printException(() -> "inAppPlaylistDownloadMenuOnClick failure", ex); + } + return false; + } + + /** + * Injection point. + */ + public static boolean overridePlaylistDownloadButtonVisibility() { + return overridePlaylistDownloadButton.get(); + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/GeneralPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/GeneralPatch.java new file mode 100644 index 0000000000..cccf47d41e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/GeneralPatch.java @@ -0,0 +1,589 @@ +package app.revanced.extension.youtube.patches.general; + +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.shared.utils.Utils.getChildView; +import static app.revanced.extension.shared.utils.Utils.hideViewByLayoutParams; +import static app.revanced.extension.shared.utils.Utils.hideViewGroupByMarginLayoutParams; +import static app.revanced.extension.shared.utils.Utils.hideViewUnderCondition; +import static app.revanced.extension.youtube.patches.utils.PatchStatus.ImageSearchButton; +import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.util.TypedValue; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; +import android.view.ViewGroup.MarginLayoutParams; +import android.view.Window; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import com.google.android.apps.youtube.app.application.Shell_SettingsActivity; +import com.google.android.apps.youtube.app.settings.SettingsActivity; +import com.google.android.apps.youtube.app.settings.videoquality.VideoQualitySettingsActivity; + +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.ResourceUtils; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.utils.ThemeUtils; + +@SuppressWarnings("unused") +public class GeneralPatch { + + // region [Disable auto audio tracks] patch + + private static final String DEFAULT_AUDIO_TRACKS_IDENTIFIER = "original"; + private static ArrayList formatStreamModelArray; + + /** + * Find the stream format containing the parameter {@link GeneralPatch#DEFAULT_AUDIO_TRACKS_IDENTIFIER}, and save to the array. + * + * @param formatStreamModel stream format model including audio tracks. + */ + public static void setFormatStreamModelArray(final Object formatStreamModel) { + if (!Settings.DISABLE_AUTO_AUDIO_TRACKS.get()) { + return; + } + + // Ignoring, as the stream format model array has already been added. + if (formatStreamModelArray != null) { + return; + } + + // Ignoring, as it is not an original audio track. + if (!formatStreamModel.toString().contains(DEFAULT_AUDIO_TRACKS_IDENTIFIER)) { + return; + } + + // For some reason, when YouTube handles formatStreamModelArray, + // it uses an array with duplicate values at the first and second indices. + formatStreamModelArray = new ArrayList<>(); + formatStreamModelArray.add(formatStreamModel); + formatStreamModelArray.add(formatStreamModel); + } + + /** + * Returns an array of stream format models containing the default audio tracks. + * + * @param localizedFormatStreamModelArray stream format model array consisting of audio tracks in the system's language. + * @return stream format model array consisting of original audio tracks. + */ + public static ArrayList getFormatStreamModelArray(final ArrayList localizedFormatStreamModelArray) { + if (!Settings.DISABLE_AUTO_AUDIO_TRACKS.get()) { + return localizedFormatStreamModelArray; + } + + // Ignoring, as the stream format model array is empty. + if (formatStreamModelArray == null || formatStreamModelArray.isEmpty()) { + return localizedFormatStreamModelArray; + } + + // Initialize the array before returning it. + ArrayList defaultFormatStreamModelArray = formatStreamModelArray; + formatStreamModelArray = null; + return defaultFormatStreamModelArray; + } + + // endregion + + // region [Disable splash animation] patch + + public static boolean disableSplashAnimation(boolean original) { + try { + return !Settings.DISABLE_SPLASH_ANIMATION.get() && original; + } catch (Exception ex) { + Logger.printException(() -> "Failed to load disableSplashAnimation", ex); + } + return original; + } + + // endregion + + // region [Enable gradient loading screen] patch + + public static boolean enableGradientLoadingScreen() { + return Settings.ENABLE_GRADIENT_LOADING_SCREEN.get(); + } + + // endregion + + // region [Hide layout components] patch + + private static String[] accountMenuBlockList; + + static { + accountMenuBlockList = Settings.HIDE_ACCOUNT_MENU_FILTER_STRINGS.get().split("\\n"); + // Some settings should not be hidden. + accountMenuBlockList = Arrays.stream(accountMenuBlockList) + .filter(item -> !Objects.equals(item, str("settings"))) + .toArray(String[]::new); + } + + /** + * hide account menu in you tab + * + * @param menuTitleCharSequence menu title + */ + public static void hideAccountList(View view, CharSequence menuTitleCharSequence) { + if (!Settings.HIDE_ACCOUNT_MENU.get()) + return; + if (menuTitleCharSequence == null) + return; + if (!(view.getParent().getParent().getParent() instanceof ViewGroup viewGroup)) + return; + + hideAccountMenu(viewGroup, menuTitleCharSequence.toString()); + } + + /** + * hide account menu for tablet and old clients + * + * @param menuTitleCharSequence menu title + */ + public static void hideAccountMenu(View view, CharSequence menuTitleCharSequence) { + if (!Settings.HIDE_ACCOUNT_MENU.get()) + return; + if (menuTitleCharSequence == null) + return; + if (!(view.getParent().getParent() instanceof ViewGroup viewGroup)) + return; + + hideAccountMenu(viewGroup, menuTitleCharSequence.toString()); + } + + private static void hideAccountMenu(ViewGroup viewGroup, String menuTitleString) { + for (String filter : accountMenuBlockList) { + if (!filter.isEmpty() && menuTitleString.equals(filter)) { + if (viewGroup.getLayoutParams() instanceof MarginLayoutParams) + hideViewGroupByMarginLayoutParams(viewGroup); + else + viewGroup.setLayoutParams(new LayoutParams(0, 0)); + } + } + } + + public static int hideHandle(int originalValue) { + return Settings.HIDE_HANDLE.get() ? 8 : originalValue; + } + + public static boolean hideFloatingMicrophone(boolean original) { + return Settings.HIDE_FLOATING_MICROPHONE.get() || original; + } + + public static boolean hideSnackBar() { + return Settings.HIDE_SNACK_BAR.get(); + } + + // endregion + + // region [Hide navigation bar components] patch + + private static final Map shouldHideMap = new EnumMap<>(NavigationButton.class) { + { + put(NavigationButton.HOME, Settings.HIDE_NAVIGATION_HOME_BUTTON.get()); + put(NavigationButton.SHORTS, Settings.HIDE_NAVIGATION_SHORTS_BUTTON.get()); + put(NavigationButton.SUBSCRIPTIONS, Settings.HIDE_NAVIGATION_SUBSCRIPTIONS_BUTTON.get()); + put(NavigationButton.CREATE, Settings.HIDE_NAVIGATION_CREATE_BUTTON.get()); + put(NavigationButton.NOTIFICATIONS, Settings.HIDE_NAVIGATION_NOTIFICATIONS_BUTTON.get()); + put(NavigationButton.LIBRARY, Settings.HIDE_NAVIGATION_LIBRARY_BUTTON.get()); + } + }; + + public static boolean enableNarrowNavigationButton(boolean original) { + return Settings.ENABLE_NARROW_NAVIGATION_BUTTONS.get() || original; + } + + public static boolean enableTranslucentNavigationBar() { + return Settings.ENABLE_TRANSLUCENT_NAVIGATION_BAR.get(); + } + + public static boolean switchCreateWithNotificationButton(boolean original) { + return Settings.SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON.get() || original; + } + + public static void navigationTabCreated(NavigationButton button, View tabView) { + if (BooleanUtils.isTrue(shouldHideMap.get(button))) { + tabView.setVisibility(View.GONE); + } + } + + public static void hideNavigationLabel(TextView view) { + hideViewUnderCondition(Settings.HIDE_NAVIGATION_LABEL.get(), view); + } + + public static void hideNavigationBar(View view) { + hideViewUnderCondition(Settings.HIDE_NAVIGATION_BAR.get(), view); + } + + // endregion + + // region [Remove viewer discretion dialog] patch + + /** + * Injection point. + *

+ * The {@link AlertDialog#getButton(int)} method must be used after {@link AlertDialog#show()} is called. + * Otherwise {@link AlertDialog#getButton(int)} method will always return null. + * + *

+ * That's why {@link AlertDialog#show()} is absolutely necessary. + * Instead, use two tricks to hide Alertdialog. + *

+ * 1. Change the size of AlertDialog to 0. + * 2. Disable AlertDialog's background dim. + *

+ * This way, AlertDialog will be completely hidden, + * and {@link AlertDialog#getButton(int)} method can be used without issue. + */ + public static void confirmDialog(final AlertDialog dialog) { + if (!Settings.REMOVE_VIEWER_DISCRETION_DIALOG.get()) { + return; + } + + // This method is called after AlertDialog#show(), + // So we need to hide the AlertDialog before pressing the possitive button. + final Window window = dialog.getWindow(); + final Button button = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + if (window != null && button != null) { + WindowManager.LayoutParams params = window.getAttributes(); + params.height = 0; + params.width = 0; + + // Change the size of AlertDialog to 0. + window.setAttributes(params); + + // Disable AlertDialog's background dim. + window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + Utils.clickView(button); + } + } + + public static void confirmDialogAgeVerified(final AlertDialog dialog) { + final Button button = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + if (!button.getText().toString().equals(str("og_continue"))) + return; + + confirmDialog(dialog); + } + + // endregion + + // region [Spoof app version] patch + + public static String getVersionOverride(String appVersion) { + return Settings.SPOOF_APP_VERSION.get() + ? Settings.SPOOF_APP_VERSION_TARGET.get() + : appVersion; + } + + // endregion + + // region [Toolbar components] patch + + private static final int generalHeaderAttributeId = ResourceUtils.getAttrIdentifier("ytWordmarkHeader"); + private static final int premiumHeaderAttributeId = ResourceUtils.getAttrIdentifier("ytPremiumWordmarkHeader"); + + public static void setDrawerNavigationHeader(View lithoView) { + final int headerAttributeId = getHeaderAttributeId(); + + lithoView.getViewTreeObserver().addOnDrawListener(() -> { + if (!(lithoView instanceof ViewGroup viewGroup)) + return; + if (!(viewGroup.getChildAt(0) instanceof ImageView imageView)) + return; + final Activity mActivity = Utils.getActivity(); + if (mActivity == null) + return; + imageView.setImageDrawable(getHeaderDrawable(mActivity, headerAttributeId)); + }); + } + + public static int getHeaderAttributeId() { + return Settings.CHANGE_YOUTUBE_HEADER.get() + ? premiumHeaderAttributeId + : generalHeaderAttributeId; + } + + public static boolean overridePremiumHeader() { + return Settings.CHANGE_YOUTUBE_HEADER.get(); + } + + private static Drawable getHeaderDrawable(Activity mActivity, int resourceId) { + // Rest of the implementation added by patch. + return ResourceUtils.getDrawable(""); + } + + private static final int searchBarId = ResourceUtils.getIdIdentifier("search_bar"); + private static final int youtubeTextId = ResourceUtils.getIdIdentifier("youtube_text"); + private static final int searchBoxId = ResourceUtils.getIdIdentifier("search_box"); + private static final int searchIconId = ResourceUtils.getIdIdentifier("search_icon"); + + private static final boolean wideSearchbarEnabled = Settings.ENABLE_WIDE_SEARCH_BAR.get(); + // Loads the search bar deprecated by Google. + private static final boolean wideSearchbarWithHeaderEnabled = Settings.ENABLE_WIDE_SEARCH_BAR_WITH_HEADER.get(); + private static final boolean wideSearchbarYouTabEnabled = Settings.ENABLE_WIDE_SEARCH_BAR_IN_YOU_TAB.get(); + + public static boolean enableWideSearchBar(boolean original) { + return wideSearchbarEnabled || original; + } + + /** + * Limitation: Premium header will not be applied for YouTube Premium users if the user uses the 'Wide search bar with header' option. + * This is because it forces the deprecated search bar to be loaded. + * As a solution to this limitation, 'Change YouTube header' patch is required. + */ + public static boolean enableWideSearchBarWithHeader(boolean original) { + if (!wideSearchbarEnabled) + return original; + else + return wideSearchbarWithHeaderEnabled || original; + } + + public static boolean enableWideSearchBarWithHeaderInverse(boolean original) { + if (!wideSearchbarEnabled) + return original; + else + return !wideSearchbarWithHeaderEnabled && original; + } + + public static boolean enableWideSearchBarInYouTab(boolean original) { + if (!wideSearchbarEnabled) + return original; + else + return !wideSearchbarYouTabEnabled && original; + } + + public static void setWideSearchBarLayout(View view) { + if (!wideSearchbarEnabled) + return; + if (!(view.findViewById(searchBarId) instanceof RelativeLayout searchBarView)) + return; + + // When the deprecated search bar is loaded, two search bars overlap. + // Manually hides another search bar. + if (wideSearchbarWithHeaderEnabled) { + final View searchIconView = searchBarView.findViewById(searchIconId); + final View searchBoxView = searchBarView.findViewById(searchBoxId); + final View textView = searchBarView.findViewById(youtubeTextId); + if (textView != null) { + RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(0, 0); + layoutParams.setMargins(0, 0, 0, 0); + textView.setLayoutParams(layoutParams); + } + // The search icon in the deprecated search bar is clickable, but onClickListener is not assigned. + // Assign onClickListener and disable the effect when clicked. + if (searchIconView != null && searchBoxView != null) { + searchIconView.setOnClickListener(view1 -> searchBoxView.callOnClick()); + searchIconView.getBackground().setAlpha(0); + } + } else { + // This is the legacy method - Wide search bar without YouTube header. + // Since the padding start is 0, it does not look good. + // Add a padding start of 8.0 dip. + final int paddingLeft = searchBarView.getPaddingLeft(); + final int paddingRight = searchBarView.getPaddingRight(); + final int paddingTop = searchBarView.getPaddingTop(); + final int paddingBottom = searchBarView.getPaddingBottom(); + final int paddingStart = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, Utils.getResources().getDisplayMetrics()); + + // In RelativeLayout, paddingStart cannot be assigned programmatically. + // Check RTL layout and set left padding or right padding. + if (Utils.isRightToLeftTextLayout()) { + searchBarView.setPadding(paddingLeft, paddingTop, paddingStart, paddingBottom); + } else { + searchBarView.setPadding(paddingStart, paddingTop, paddingRight, paddingBottom); + } + } + } + + public static boolean hideCastButton(boolean original) { + return !Settings.HIDE_TOOLBAR_CAST_BUTTON.get() && original; + } + + public static void hideCastButton(MenuItem menuItem) { + if (!Settings.HIDE_TOOLBAR_CAST_BUTTON.get()) + return; + + menuItem.setVisible(false); + menuItem.setEnabled(false); + } + + public static void hideCreateButton(String enumString, View view) { + if (!Settings.HIDE_TOOLBAR_CREATE_BUTTON.get()) + return; + + hideViewUnderCondition(isCreateButton(enumString), view); + } + + public static void hideNotificationButton(String enumString, View view) { + if (!Settings.HIDE_TOOLBAR_NOTIFICATION_BUTTON.get()) + return; + + hideViewUnderCondition(isNotificationButton(enumString), view); + } + + public static boolean hideSearchTermThumbnail() { + return Settings.HIDE_SEARCH_TERM_THUMBNAIL.get(); + } + + private static final boolean hideImageSearchButton = Settings.HIDE_IMAGE_SEARCH_BUTTON.get(); + private static final boolean hideVoiceSearchButton = Settings.HIDE_VOICE_SEARCH_BUTTON.get(); + + /** + * If the user does not hide the Image search button but only the Voice search button, + * {@link View#setVisibility(int)} cannot be used on the Voice search button. + * (This breaks the search bar layout.) + *

+ * In this case, {@link Utils#hideViewByLayoutParams(View)} should be used. + */ + private static final boolean showImageSearchButtonAndHideVoiceSearchButton = !hideImageSearchButton && hideVoiceSearchButton && ImageSearchButton(); + + public static boolean hideImageSearchButton(boolean original) { + return !hideImageSearchButton && original; + } + + public static void hideVoiceSearchButton(View view) { + if (showImageSearchButtonAndHideVoiceSearchButton) { + hideViewByLayoutParams(view); + } else { + hideViewUnderCondition(hideVoiceSearchButton, view); + } + } + + public static void hideVoiceSearchButton(View view, int visibility) { + if (showImageSearchButtonAndHideVoiceSearchButton) { + view.setVisibility(visibility); + hideViewByLayoutParams(view); + } else { + view.setVisibility( + hideVoiceSearchButton + ? View.GONE : visibility + ); + } + } + + /** + * In ReVanced, image files are replaced to change the header, + * Whereas in RVX, the header is changed programmatically. + * There is an issue where the header is not changed in RVX when YouTube Doodles are hidden. + * As a workaround, manually set the header when YouTube Doodles are hidden. + */ + public static void hideYouTubeDoodles(ImageView imageView, Drawable drawable) { + final Activity mActivity = Utils.getActivity(); + if (Settings.HIDE_YOUTUBE_DOODLES.get() && mActivity != null) { + drawable = getHeaderDrawable(mActivity, getHeaderAttributeId()); + } + imageView.setImageDrawable(drawable); + } + + private static final int settingsDrawableId = + ResourceUtils.getDrawableIdentifier("yt_outline_gear_black_24"); + + public static int getCreateButtonDrawableId(int original) { + return Settings.REPLACE_TOOLBAR_CREATE_BUTTON.get() && + settingsDrawableId != 0 + ? settingsDrawableId + : original; + } + + public static void replaceCreateButton(String enumString, View toolbarView) { + if (!Settings.REPLACE_TOOLBAR_CREATE_BUTTON.get()) + return; + // Check if the button is a create button. + if (!isCreateButton(enumString)) + return; + ImageView imageView = getChildView((ViewGroup) toolbarView, view -> view instanceof ImageView); + if (imageView == null) + return; + + // Overriding is possible only after OnClickListener is assigned to the create button. + Utils.runOnMainThreadDelayed(() -> { + if (Settings.REPLACE_TOOLBAR_CREATE_BUTTON_TYPE.get()) { + imageView.setOnClickListener(GeneralPatch::openRVXSettings); + imageView.setOnLongClickListener(button -> { + openYouTubeSettings(button); + return true; + }); + } else { + imageView.setOnClickListener(GeneralPatch::openYouTubeSettings); + imageView.setOnLongClickListener(button -> { + openRVXSettings(button); + return true; + }); + } + }, 0); + } + + private static void openYouTubeSettings(View view) { + Context context = view.getContext(); + Intent intent = new Intent(Intent.ACTION_MAIN); + intent.setPackage(context.getPackageName()); + intent.setClass(context, Shell_SettingsActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + context.startActivity(intent); + } + + private static void openRVXSettings(View view) { + Context context = view.getContext(); + Intent intent = new Intent(Intent.ACTION_MAIN); + intent.setPackage(context.getPackageName()); + intent.setData(Uri.parse("revanced_extended_settings_intent")); + intent.setClass(context, VideoQualitySettingsActivity.class); + context.startActivity(intent); + } + + /** + * The theme of {@link Shell_SettingsActivity} is dark theme. + * Since this theme is hardcoded, we should manually specify the theme for the activity. + *

+ * Since {@link Shell_SettingsActivity} only invokes {@link SettingsActivity}, finish activity after specifying a theme. + * + * @param base {@link Shell_SettingsActivity} + */ + public static void setShellActivityTheme(Activity base) { + if (!Settings.REPLACE_TOOLBAR_CREATE_BUTTON.get()) + return; + + base.setTheme(ThemeUtils.getThemeId()); + Utils.runOnMainThreadDelayed(base::finish, 0); + } + + + private static boolean isCreateButton(String enumString) { + return StringUtils.equalsAny( + enumString, + "CREATION_ENTRY", // Create button for Phone layout + "FAB_CAMERA" // Create button for Tablet layout + ); + } + + private static boolean isNotificationButton(String enumString) { + return StringUtils.equalsAny( + enumString, + "TAB_ACTIVITY", // Notification button + "TAB_ACTIVITY_CAIRO" // Notification button (new layout) + ); + } + + // endregion + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/LayoutSwitchPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/LayoutSwitchPatch.java new file mode 100644 index 0000000000..56d3430803 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/LayoutSwitchPatch.java @@ -0,0 +1,79 @@ +package app.revanced.extension.youtube.patches.general; + +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; + +import androidx.annotation.Nullable; + +import org.apache.commons.lang3.BooleanUtils; + +import java.util.Objects; + +import app.revanced.extension.shared.utils.PackageUtils; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class LayoutSwitchPatch { + + public enum FormFactor { + /** + * Unmodified type, and same as un-patched. + */ + ORIGINAL(null, null, null), + SMALL_FORM_FACTOR(1, null, TRUE), + SMALL_FORM_FACTOR_WIDTH_DP(1, 480, TRUE), + LARGE_FORM_FACTOR(2, null, FALSE), + LARGE_FORM_FACTOR_WIDTH_DP(2, 600, FALSE); + + @Nullable + final Integer formFactorType; + + @Nullable + final Integer widthDp; + + @Nullable + final Boolean setMinimumDp; + + FormFactor(@Nullable Integer formFactorType, @Nullable Integer widthDp, @Nullable Boolean setMinimumDp) { + this.formFactorType = formFactorType; + this.widthDp = widthDp; + this.setMinimumDp = setMinimumDp; + } + + private boolean setMinimumDp() { + return BooleanUtils.isTrue(setMinimumDp); + } + } + + private static final FormFactor FORM_FACTOR = Settings.CHANGE_LAYOUT.get(); + + public static int getFormFactor(int original) { + Integer formFactorType = FORM_FACTOR.formFactorType; + return formFactorType == null + ? original + : formFactorType; + } + + public static int getWidthDp(int original) { + Integer widthDp = FORM_FACTOR.widthDp; + if (widthDp == null) { + return original; + } + final int smallestScreenWidthDp = PackageUtils.getSmallestScreenWidthDp(); + if (smallestScreenWidthDp == 0) { + return original; + } + return FORM_FACTOR.setMinimumDp() + ? Math.min(smallestScreenWidthDp, widthDp) + : Math.max(smallestScreenWidthDp, widthDp); + } + + public static boolean phoneLayoutEnabled() { + return Objects.equals(FORM_FACTOR.formFactorType, 1); + } + + public static boolean tabletLayoutEnabled() { + return Objects.equals(FORM_FACTOR.formFactorType, 2); + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/MiniplayerPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/MiniplayerPatch.java new file mode 100644 index 0000000000..9c13d5a52e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/MiniplayerPatch.java @@ -0,0 +1,197 @@ +package app.revanced.extension.youtube.patches.general; + +import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType.MODERN_1; +import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType.MODERN_2; +import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType.MODERN_3; +import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType.ORIGINAL; +import static app.revanced.extension.youtube.utils.ExtendedUtils.validateValue; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.ResourceUtils; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class MiniplayerPatch { + + /** + * Mini player type. Null fields indicates to use the original un-patched value. + */ + public enum MiniplayerType { + /** + * Unmodified type, and same as un-patched. + */ + ORIGINAL(null, null), + PHONE(false, null), + TABLET(true, null), + MODERN_1(null, 1), + MODERN_2(null, 2), + MODERN_3(null, 3); + + /** + * Legacy tablet hook value. + */ + @Nullable + final Boolean legacyTabletOverride; + + /** + * Modern player type used by YT. + */ + @Nullable + final Integer modernPlayerType; + + MiniplayerType(@Nullable Boolean legacyTabletOverride, @Nullable Integer modernPlayerType) { + this.legacyTabletOverride = legacyTabletOverride; + this.modernPlayerType = modernPlayerType; + } + + public boolean isModern() { + return modernPlayerType != null; + } + } + + /** + * Modern subtitle overlay for {@link MiniplayerType#MODERN_2}. + * Resource is not present in older targets, and this field will be zero. + */ + private static final int MODERN_OVERLAY_SUBTITLE_TEXT + = ResourceUtils.getIdIdentifier("modern_miniplayer_subtitle_text"); + + private static final MiniplayerType CURRENT_TYPE = Settings.MINIPLAYER_TYPE.get(); + + private static final boolean DOUBLE_TAP_ACTION_ENABLED = + (CURRENT_TYPE == MODERN_1 || CURRENT_TYPE == MODERN_2 || CURRENT_TYPE == MODERN_3) && Settings.MINIPLAYER_DOUBLE_TAP_ACTION.get(); + + private static final boolean DRAG_AND_DROP_ENABLED = + CURRENT_TYPE == MODERN_1 && Settings.MINIPLAYER_DRAG_AND_DROP.get(); + + private static final boolean HIDE_EXPAND_CLOSE_AVAILABLE = + (CURRENT_TYPE == MODERN_1 || CURRENT_TYPE == MODERN_3) && + !DOUBLE_TAP_ACTION_ENABLED && + !DRAG_AND_DROP_ENABLED; + + private static final boolean HIDE_EXPAND_CLOSE_ENABLED = + HIDE_EXPAND_CLOSE_AVAILABLE && Settings.MINIPLAYER_HIDE_EXPAND_CLOSE.get(); + + private static final boolean HIDE_SUBTEXT_ENABLED = + (CURRENT_TYPE == MODERN_1 || CURRENT_TYPE == MODERN_3) && Settings.MINIPLAYER_HIDE_SUBTEXT.get(); + + private static final boolean HIDE_REWIND_FORWARD_ENABLED = + CURRENT_TYPE == MODERN_1 && Settings.MINIPLAYER_HIDE_REWIND_FORWARD.get(); + + private static final int OPACITY_LEVEL; + + static { + final int opacity = validateValue( + Settings.MINIPLAYER_OPACITY, + 0, + 100, + "revanced_miniplayer_opacity_invalid_toast" + ); + + OPACITY_LEVEL = (opacity * 255) / 100; + } + + /** + * Injection point. + */ + public static boolean getLegacyTabletMiniplayerOverride(boolean original) { + Boolean isTablet = CURRENT_TYPE.legacyTabletOverride; + return isTablet == null + ? original + : isTablet; + } + + /** + * Injection point. + */ + public static boolean getModernMiniplayerOverride(boolean original) { + return CURRENT_TYPE == ORIGINAL + ? original + : CURRENT_TYPE.isModern(); + } + + /** + * Injection point. + */ + public static int getModernMiniplayerOverrideType(int original) { + Integer modernValue = CURRENT_TYPE.modernPlayerType; + return modernValue == null + ? original + : modernValue; + } + + /** + * Injection point. + */ + public static void adjustMiniplayerOpacity(ImageView view) { + if (CURRENT_TYPE == MODERN_1) { + view.setImageAlpha(OPACITY_LEVEL); + } + } + + /** + * Injection point. + */ + public static boolean enableMiniplayerDoubleTapAction() { + return DOUBLE_TAP_ACTION_ENABLED; + } + + /** + * Injection point. + */ + public static boolean enableMiniplayerDragAndDrop() { + return DRAG_AND_DROP_ENABLED; + } + + /** + * Injection point. + */ + public static void hideMiniplayerExpandClose(ImageView view) { + Utils.hideViewByRemovingFromParentUnderCondition(HIDE_EXPAND_CLOSE_ENABLED, view); + } + + /** + * Injection point. + */ + public static void hideMiniplayerRewindForward(ImageView view) { + Utils.hideViewByRemovingFromParentUnderCondition(HIDE_REWIND_FORWARD_ENABLED, view); + } + + /** + * Injection point. + */ + public static boolean hideMiniplayerSubTexts(View view) { + // Different subviews are passed in, but only TextView and layouts are of interest here. + final boolean hideView = HIDE_SUBTEXT_ENABLED && (view instanceof TextView || view instanceof LinearLayout); + Utils.hideViewByRemovingFromParentUnderCondition(hideView, view); + return hideView || view == null; + } + + /** + * Injection point. + */ + public static void playerOverlayGroupCreated(View group) { + // Modern 2 has an half broken subtitle that is always present. + // Always hide it to make the miniplayer mostly usable. + if (CURRENT_TYPE == MODERN_2 && MODERN_OVERLAY_SUBTITLE_TEXT != 0) { + if (group instanceof ViewGroup viewGroup) { + View subtitleText = Utils.getChildView(viewGroup, true, + view -> view.getId() == MODERN_OVERLAY_SUBTITLE_TEXT); + + if (subtitleText != null) { + subtitleText.setVisibility(View.GONE); + Logger.printDebug(() -> "Modern overlay subtitle view set to hidden"); + } + } + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/SettingsMenuPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/SettingsMenuPatch.java new file mode 100644 index 0000000000..792fe46350 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/SettingsMenuPatch.java @@ -0,0 +1,52 @@ +package app.revanced.extension.youtube.patches.general; + +import androidx.preference.PreferenceScreen; + +import app.revanced.extension.shared.patches.BaseSettingsMenuPatch; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class SettingsMenuPatch extends BaseSettingsMenuPatch { + + public static void hideSettingsMenu(PreferenceScreen mPreferenceScreen) { + if (mPreferenceScreen == null) return; + for (SettingsMenuComponent component : SettingsMenuComponent.values()) + if (component.enabled) + removePreference(mPreferenceScreen, component.key); + } + + private enum SettingsMenuComponent { + YOUTUBE_TV("yt_unplugged_pref_key", Settings.HIDE_SETTINGS_MENU_YOUTUBE_TV.get()), + PARENT_TOOLS("parent_tools_key", Settings.HIDE_SETTINGS_MENU_PARENT_TOOLS.get()), + PRE_PURCHASE("yt_unlimited_pre_purchase_key", Settings.HIDE_SETTINGS_MENU_PRE_PURCHASE.get()), + GENERAL("general_key", Settings.HIDE_SETTINGS_MENU_GENERAL.get()), + ACCOUNT("account_switcher_key", Settings.HIDE_SETTINGS_MENU_ACCOUNT.get()), + DATA_SAVING("data_saving_settings_key", Settings.HIDE_SETTINGS_MENU_DATA_SAVING.get()), + AUTOPLAY("auto_play_key", Settings.HIDE_SETTINGS_MENU_AUTOPLAY.get()), + VIDEO_QUALITY_PREFERENCES("video_quality_settings_key", Settings.HIDE_SETTINGS_MENU_VIDEO_QUALITY_PREFERENCES.get()), + POST_PURCHASE("yt_unlimited_post_purchase_key", Settings.HIDE_SETTINGS_MENU_POST_PURCHASE.get()), + OFFLINE("offline_key", Settings.HIDE_SETTINGS_MENU_OFFLINE.get()), + WATCH_ON_TV("pair_with_tv_key", Settings.HIDE_SETTINGS_MENU_WATCH_ON_TV.get()), + MANAGE_ALL_HISTORY("history_key", Settings.HIDE_SETTINGS_MENU_MANAGE_ALL_HISTORY.get()), + YOUR_DATA_IN_YOUTUBE("your_data_key", Settings.HIDE_SETTINGS_MENU_YOUR_DATA_IN_YOUTUBE.get()), + PRIVACY("privacy_key", Settings.HIDE_SETTINGS_MENU_PRIVACY.get()), + TRY_EXPERIMENTAL_NEW_FEATURES("premium_early_access_browse_page_key", Settings.HIDE_SETTINGS_MENU_TRY_EXPERIMENTAL_NEW_FEATURES.get()), + PURCHASES_AND_MEMBERSHIPS("subscription_product_setting_key", Settings.HIDE_SETTINGS_MENU_PURCHASES_AND_MEMBERSHIPS.get()), + BILLING_AND_PAYMENTS("billing_and_payment_key", Settings.HIDE_SETTINGS_MENU_BILLING_AND_PAYMENTS.get()), + NOTIFICATIONS("notification_key", Settings.HIDE_SETTINGS_MENU_NOTIFICATIONS.get()), + THIRD_PARTY("third_party_key", Settings.HIDE_SETTINGS_MENU_THIRD_PARTY.get()), + CONNECTED_APPS("connected_accounts_browse_page_key", Settings.HIDE_SETTINGS_MENU_CONNECTED_APPS.get()), + LIVE_CHAT("live_chat_key", Settings.HIDE_SETTINGS_MENU_LIVE_CHAT.get()), + CAPTIONS("captions_key", Settings.HIDE_SETTINGS_MENU_CAPTIONS.get()), + ACCESSIBILITY("accessibility_settings_key", Settings.HIDE_SETTINGS_MENU_ACCESSIBILITY.get()), + ABOUT("about_key", Settings.HIDE_SETTINGS_MENU_ABOUT.get()); + + private final String key; + private final boolean enabled; + + SettingsMenuComponent(String key, boolean enabled) { + this.key = key; + this.enabled = enabled; + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/YouTubeMusicActionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/YouTubeMusicActionsPatch.java new file mode 100644 index 0000000000..52c0da246a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/YouTubeMusicActionsPatch.java @@ -0,0 +1,55 @@ +package app.revanced.extension.youtube.patches.general; + +import androidx.annotation.NonNull; + +import org.apache.commons.lang3.StringUtils; + +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.utils.ExtendedUtils; +import app.revanced.extension.youtube.utils.VideoUtils; + +@SuppressWarnings("unused") +public final class YouTubeMusicActionsPatch extends VideoUtils { + + private static final String PACKAGE_NAME_YOUTUBE_MUSIC = "com.google.android.apps.youtube.music"; + + private static final boolean isOverrideYouTubeMusicEnabled = + Settings.OVERRIDE_YOUTUBE_MUSIC_BUTTON.get(); + + private static final boolean overrideYouTubeMusicEnabled = + isOverrideYouTubeMusicEnabled && isYouTubeMusicEnabled(); + + public static String overridePackageName(@NonNull String packageName) { + if (!overrideYouTubeMusicEnabled) { + return packageName; + } + if (!StringUtils.equals(PACKAGE_NAME_YOUTUBE_MUSIC, packageName)) { + return packageName; + } + final String thirdPartyPackageName = Settings.THIRD_PARTY_YOUTUBE_MUSIC_PACKAGE_NAME.get(); + if (!ExtendedUtils.isPackageEnabled(thirdPartyPackageName)) { + return packageName; + } + return thirdPartyPackageName; + } + + private static boolean isYouTubeMusicEnabled() { + return ExtendedUtils.isPackageEnabled(PACKAGE_NAME_YOUTUBE_MUSIC); + } + + public static final class HookYouTubeMusicAvailability implements Setting.Availability { + @Override + public boolean isAvailable() { + return isYouTubeMusicEnabled(); + } + } + + public static final class HookYouTubeMusicPackageNameAvailability implements Setting.Availability { + @Override + public boolean isAvailable() { + return isOverrideYouTubeMusicEnabled && isYouTubeMusicEnabled(); + } + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/BackgroundPlaybackPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/BackgroundPlaybackPatch.java new file mode 100644 index 0000000000..4cf3456ee9 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/BackgroundPlaybackPatch.java @@ -0,0 +1,12 @@ +package app.revanced.extension.youtube.patches.misc; + +import app.revanced.extension.youtube.shared.ShortsPlayerState; + +@SuppressWarnings("unused") +public class BackgroundPlaybackPatch { + + public static boolean allowBackgroundPlayback(boolean original) { + return original || ShortsPlayerState.getCurrent().isClosed(); + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/ExternalBrowserPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/ExternalBrowserPatch.java new file mode 100644 index 0000000000..794fd93e0c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/ExternalBrowserPatch.java @@ -0,0 +1,14 @@ +package app.revanced.extension.youtube.patches.misc; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class ExternalBrowserPatch { + + public static String enableExternalBrowser(final String original) { + if (!Settings.ENABLE_EXTERNAL_BROWSER.get()) + return original; + + return ""; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/OpenLinksDirectlyPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/OpenLinksDirectlyPatch.java new file mode 100644 index 0000000000..a3e9b9658e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/OpenLinksDirectlyPatch.java @@ -0,0 +1,24 @@ +package app.revanced.extension.youtube.patches.misc; + +import android.net.Uri; + +import java.util.Objects; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class OpenLinksDirectlyPatch { + private static final String YOUTUBE_REDIRECT_PATH = "/redirect"; + + public static Uri enableBypassRedirect(String uri) { + final Uri parsed = Uri.parse(uri); + if (!Settings.ENABLE_OPEN_LINKS_DIRECTLY.get()) + return parsed; + + if (Objects.equals(parsed.getPath(), YOUTUBE_REDIRECT_PATH)) { + return Uri.parse(Uri.decode(parsed.getQueryParameter("q"))); + } + + return parsed; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/OpusCodecPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/OpusCodecPatch.java new file mode 100644 index 0000000000..32696151a0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/OpusCodecPatch.java @@ -0,0 +1,11 @@ +package app.revanced.extension.youtube.patches.misc; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class OpusCodecPatch { + + public static boolean enableOpusCodec() { + return Settings.ENABLE_OPUS_CODEC.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/QUICProtocolPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/QUICProtocolPatch.java new file mode 100644 index 0000000000..b8e099b915 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/QUICProtocolPatch.java @@ -0,0 +1,17 @@ +package app.revanced.extension.youtube.patches.misc; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class QUICProtocolPatch { + + public static boolean disableQUICProtocol(boolean original) { + try { + return !Settings.DISABLE_QUIC_PROTOCOL.get() && original; + } catch (Exception ex) { + Logger.printException(() -> "Failed to load disableQUICProtocol", ex); + } + return original; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/ShareSheetPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/ShareSheetPatch.java new file mode 100644 index 0000000000..a1236f4797 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/ShareSheetPatch.java @@ -0,0 +1,62 @@ +package app.revanced.extension.youtube.patches.misc; + +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.view.ViewGroup; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.components.ShareSheetMenuFilter; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class ShareSheetPatch { + private static final boolean changeShareSheetEnabled = Settings.CHANGE_SHARE_SHEET.get(); + + private static void clickSystemShareButton(final RecyclerView bottomSheetRecyclerView, + final RecyclerView appsContainerRecyclerView) { + if (appsContainerRecyclerView.getChildAt(appsContainerRecyclerView.getChildCount() - 1) instanceof ViewGroup parentView && + parentView.getChildAt(0) instanceof ViewGroup shareWithOtherAppsView) { + ShareSheetMenuFilter.isShareSheetMenuVisible = false; + + bottomSheetRecyclerView.setVisibility(View.GONE); + Utils.clickView(shareWithOtherAppsView); + } + } + + /** + * Injection point. + */ + public static void onShareSheetMenuCreate(final RecyclerView recyclerView) { + if (!changeShareSheetEnabled) + return; + + recyclerView.getViewTreeObserver().addOnDrawListener(() -> { + try { + if (!ShareSheetMenuFilter.isShareSheetMenuVisible) { + return; + } + if (!(recyclerView.getChildAt(0) instanceof ViewGroup parentView4th)) { + return; + } + if (parentView4th.getChildAt(0) instanceof ViewGroup parentView3rd && + parentView3rd.getChildAt(0) instanceof RecyclerView appsContainerRecyclerView) { + clickSystemShareButton(recyclerView, appsContainerRecyclerView); + } else if (parentView4th.getChildAt(1) instanceof ViewGroup parentView3rd && + parentView3rd.getChildAt(0) instanceof RecyclerView appsContainerRecyclerView) { + clickSystemShareButton(recyclerView, appsContainerRecyclerView); + } + } catch (Exception ex) { + Logger.printException(() -> "onShareSheetMenuCreate failure", ex); + } + }); + } + + /** + * Injection point. + */ + public static String overridePackageName(String original) { + return changeShareSheetEnabled ? "" : original; + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/SpoofStreamingDataPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/SpoofStreamingDataPatch.java new file mode 100644 index 0000000000..f2ed0c18b5 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/SpoofStreamingDataPatch.java @@ -0,0 +1,185 @@ +package app.revanced.extension.youtube.patches.misc; + +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.Nullable; + +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.Objects; + +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.misc.client.AppClient.ClientType; +import app.revanced.extension.youtube.patches.misc.requests.StreamingDataRequest; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class SpoofStreamingDataPatch { + private static final boolean SPOOF_STREAMING_DATA = Settings.SPOOF_STREAMING_DATA.get(); + + /** + * Any unreachable ip address. Used to intentionally fail requests. + */ + private static final String UNREACHABLE_HOST_URI_STRING = "https://127.0.0.0"; + private static final Uri UNREACHABLE_HOST_URI = Uri.parse(UNREACHABLE_HOST_URI_STRING); + + /** + * Injection point. + * Blocks /get_watch requests by returning an unreachable URI. + * + * @param playerRequestUri The URI of the player request. + * @return An unreachable URI if the request is a /get_watch request, otherwise the original URI. + */ + public static Uri blockGetWatchRequest(Uri playerRequestUri) { + if (SPOOF_STREAMING_DATA) { + try { + String path = playerRequestUri.getPath(); + + if (path != null && path.contains("get_watch")) { + Logger.printDebug(() -> "Blocking 'get_watch' by returning unreachable uri"); + + return UNREACHABLE_HOST_URI; + } + } catch (Exception ex) { + Logger.printException(() -> "blockGetWatchRequest failure", ex); + } + } + + return playerRequestUri; + } + + /** + * Injection point. + *

+ * Blocks /initplayback requests. + */ + public static String blockInitPlaybackRequest(String originalUrlString) { + if (SPOOF_STREAMING_DATA) { + try { + var originalUri = Uri.parse(originalUrlString); + String path = originalUri.getPath(); + + if (path != null && path.contains("initplayback")) { + Logger.printDebug(() -> "Blocking 'initplayback' by returning unreachable url"); + + return UNREACHABLE_HOST_URI_STRING; + } + } catch (Exception ex) { + Logger.printException(() -> "blockInitPlaybackRequest failure", ex); + } + } + + return originalUrlString; + } + + /** + * Injection point. + */ + public static boolean isSpoofingEnabled() { + return SPOOF_STREAMING_DATA; + } + + /** + * Injection point. + */ + public static void fetchStreams(String url, Map requestHeaders) { + if (SPOOF_STREAMING_DATA) { + try { + Uri uri = Uri.parse(url); + String path = uri.getPath(); + // 'heartbeat' has no video id and appears to be only after playback has started. + if (path != null && path.contains("player") && !path.contains("heartbeat")) { + String videoId = Objects.requireNonNull(uri.getQueryParameter("id")); + StreamingDataRequest.fetchRequest(videoId, requestHeaders); + } + } catch (Exception ex) { + Logger.printException(() -> "buildRequest failure", ex); + } + } + } + + /** + * Injection point. + * Fix playback by replace the streaming data. + * Called after {@link #fetchStreams(String, Map)} . + */ + @Nullable + public static ByteBuffer getStreamingData(String videoId) { + if (SPOOF_STREAMING_DATA) { + try { + StreamingDataRequest request = StreamingDataRequest.getRequestForVideoId(videoId); + if (request != null) { + // This hook is always called off the main thread, + // but this can later be called for the same video id from the main thread. + // This is not a concern, since the fetch will always be finished + // and never block the main thread. + // But if debugging, then still verify this is the situation. + if (Settings.ENABLE_DEBUG_LOGGING.get() && !request.fetchCompleted() && Utils.isCurrentlyOnMainThread()) { + Logger.printException(() -> "Error: Blocking main thread"); + } + var stream = request.getStream(); + if (stream != null) { + Logger.printDebug(() -> "Overriding video stream: " + videoId); + return stream; + } + } + + Logger.printDebug(() -> "Not overriding streaming data (video stream is null): " + videoId); + } catch (Exception ex) { + Logger.printException(() -> "getStreamingData failure", ex); + } + } + + return null; + } + + /** + * Injection point. + * Called after {@link #getStreamingData(String)}. + */ + @Nullable + public static byte[] removeVideoPlaybackPostBody(Uri uri, int method, byte[] postData) { + if (SPOOF_STREAMING_DATA) { + try { + final int methodPost = 2; + if (method == methodPost) { + String path = uri.getPath(); + if (path != null && path.contains("videoplayback")) { + return null; + } + } + } catch (Exception ex) { + Logger.printException(() -> "removeVideoPlaybackPostBody failure", ex); + } + } + + return postData; + } + + /** + * Injection point. + */ + public static String appendSpoofedClient(String videoFormat) { + try { + if (SPOOF_STREAMING_DATA && Settings.SPOOF_STREAMING_DATA_STATS_FOR_NERDS.get() + && !TextUtils.isEmpty(videoFormat)) { + // Force LTR layout, to match the same LTR video time/length layout YouTube uses for all languages + return "\u202D" + videoFormat + String.format("\u2009(%s)", StreamingDataRequest.getLastSpoofedClientName()); // u202D = left to right override + } + } catch (Exception ex) { + Logger.printException(() -> "appendSpoofedClient failure", ex); + } + + return videoFormat; + } + + public static final class iOSAvailability implements Setting.Availability { + @Override + public boolean isAvailable() { + return Settings.SPOOF_STREAMING_DATA.get() && Settings.SPOOF_STREAMING_DATA_TYPE.get() == ClientType.IOS; + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/WatchHistoryPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/WatchHistoryPatch.java new file mode 100644 index 0000000000..01a002be4d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/WatchHistoryPatch.java @@ -0,0 +1,37 @@ +package app.revanced.extension.youtube.patches.misc; + +import android.net.Uri; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class WatchHistoryPatch { + + public enum WatchHistoryType { + ORIGINAL, + REPLACE, + BLOCK + } + + private static final Uri UNREACHABLE_HOST_URI = Uri.parse("https://127.0.0.0"); + private static final String WWW_TRACKING_URL_AUTHORITY = "www.youtube.com"; + + public static Uri replaceTrackingUrl(Uri trackingUrl) { + final WatchHistoryType watchHistoryType = Settings.WATCH_HISTORY_TYPE.get(); + if (watchHistoryType != WatchHistoryType.ORIGINAL) { + try { + if (watchHistoryType == WatchHistoryType.REPLACE) { + return trackingUrl.buildUpon().authority(WWW_TRACKING_URL_AUTHORITY).build(); + } else if (watchHistoryType == WatchHistoryType.BLOCK) { + return UNREACHABLE_HOST_URI; + } + } catch (Exception ex) { + Logger.printException(() -> "replaceTrackingUrl failure", ex); + } + } + + return trackingUrl; + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/client/AppClient.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/client/AppClient.java new file mode 100644 index 0000000000..79f72f997a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/client/AppClient.java @@ -0,0 +1,221 @@ +package app.revanced.extension.youtube.patches.misc.client; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.os.Build; + +import androidx.annotation.Nullable; + +public class AppClient { + + // ANDROID + private static final String OS_NAME_ANDROID = "Android"; + + // IOS + /** + * The hardcoded client version of the iOS app used for InnerTube requests with this client. + * + *

+ * It can be extracted by getting the latest release version of the app on + * the App + * Store page of the YouTube app, in the {@code What’s New} section. + *

+ */ + private static final String CLIENT_VERSION_IOS = "19.47.7"; + private static final String DEVICE_MAKE_IOS = "Apple"; + /** + * The device machine id for the iPhone XS Max (iPhone11,4), used to get 60fps. + * The device machine id for the iPhone 16 Pro Max (iPhone17,2), used to get HDR with AV1 hardware decoding. + * + *

+ * See this GitHub Gist for more + * information. + *

+ */ + private static final String DEVICE_MODEL_IOS = DeviceHardwareSupport.allowAV1() + ? "iPhone17,2" + : "iPhone11,4"; + private static final String OS_NAME_IOS = "iOS"; + /** + * The minimum supported OS version for the iOS YouTube client is iOS 14.0. + * Using an invalid OS version will use the AVC codec. + */ + private static final String OS_VERSION_IOS = DeviceHardwareSupport.allowVP9() + ? "18.1.1.22B91" + : "13.7.17H35"; + private static final String USER_AGENT_VERSION_IOS = DeviceHardwareSupport.allowVP9() + ? "18_1_1" + : "13_7"; + private static final String USER_AGENT_IOS = "com.google.ios.youtube/" + + CLIENT_VERSION_IOS + + "(" + + DEVICE_MODEL_IOS + + "; U; CPU iOS " + + USER_AGENT_VERSION_IOS + + " like Mac OS X)"; + + // ANDROID VR + /** + * The hardcoded client version of the Android VR app used for InnerTube requests with this client. + * + *

+ * It can be extracted by getting the latest release version of the app on + * the App + * Store page of the YouTube app, in the {@code Additional details} section. + *

+ */ + private static final String CLIENT_VERSION_ANDROID_VR = "1.60.19"; + /** + * The device machine id for the Meta Quest 3, used to get opus codec with the Android VR client. + * + *

+ * See this GitLab for more + * information. + *

+ */ + private static final String DEVICE_MODEL_ANDROID_VR = "Quest 3"; + private static final String OS_VERSION_ANDROID_VR = "12"; + /** + * The SDK version for Android 12 is 31, + * but for some reason the build.props for the {@code Quest 3} state that the SDK version is 32. + */ + private static final int ANDROID_SDK_VERSION_ANDROID_VR = 32; + /** + * Package name for YouTube VR (Google DayDream): com.google.android.apps.youtube.vr (Deprecated) + * Package name for YouTube VR (Meta Quests): com.google.android.apps.youtube.vr.oculus + * Package name for YouTube VR (ByteDance Pico 4): com.google.android.apps.youtube.vr.pico + */ + private static final String USER_AGENT_ANDROID_VR = "com.google.android.apps.youtube.vr.oculus/" + + CLIENT_VERSION_ANDROID_VR + + " (Linux; U; Android " + + OS_VERSION_ANDROID_VR + + "; GB) gzip"; + + // ANDROID UNPLUGGED + private static final String CLIENT_VERSION_ANDROID_UNPLUGGED = "8.47.0"; + /** + * The device machine id for the Chromecast with Google TV 4K. + * + *

+ * See this GitLab for more + * information. + *

+ */ + private static final String DEVICE_MODEL_ANDROID_UNPLUGGED = "Google TV Streamer"; + private static final String OS_VERSION_ANDROID_UNPLUGGED = "14"; + private static final int ANDROID_SDK_VERSION_ANDROID_UNPLUGGED = 34; + private static final String USER_AGENT_ANDROID_UNPLUGGED = "com.google.android.apps.youtube.unplugged/" + + CLIENT_VERSION_ANDROID_UNPLUGGED + + " (Linux; U; Android " + + OS_VERSION_ANDROID_UNPLUGGED + + "; GB) gzip"; + + private AppClient() { + } + + public enum ClientType { + IOS(5, + DEVICE_MAKE_IOS, + DEVICE_MODEL_IOS, + CLIENT_VERSION_IOS, + OS_NAME_IOS, + OS_VERSION_IOS, + null, + USER_AGENT_IOS, + false + ), + ANDROID_VR(28, + null, + DEVICE_MODEL_ANDROID_VR, + CLIENT_VERSION_ANDROID_VR, + OS_NAME_ANDROID, + OS_VERSION_ANDROID_VR, + ANDROID_SDK_VERSION_ANDROID_VR, + USER_AGENT_ANDROID_VR, + true + ), + ANDROID_UNPLUGGED(29, + null, + DEVICE_MODEL_ANDROID_UNPLUGGED, + CLIENT_VERSION_ANDROID_UNPLUGGED, + OS_NAME_ANDROID, + OS_VERSION_ANDROID_UNPLUGGED, + ANDROID_SDK_VERSION_ANDROID_UNPLUGGED, + USER_AGENT_ANDROID_UNPLUGGED, + true + ); + + public final String friendlyName; + + /** + * YouTube + * client type + */ + public final int id; + + /** + * Device manufacturer. + */ + @Nullable + public final String make; + + /** + * Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model) + */ + public final String deviceModel; + + /** + * Device OS name. + */ + @Nullable + public final String osName; + + /** + * Device OS version. + */ + public final String osVersion; + + /** + * Player user-agent. + */ + public final String userAgent; + + /** + * Android SDK version, equivalent to {@link Build.VERSION#SDK} (System property: ro.build.version.sdk) + * Field is null if not applicable. + */ + public final Integer androidSdkVersion; + + /** + * App version. + */ + public final String clientVersion; + + /** + * If the client can access the API logged in. + */ + public final boolean canLogin; + + ClientType(int id, + @Nullable String make, + String deviceModel, + String clientVersion, + @Nullable String osName, + String osVersion, + Integer androidSdkVersion, + String userAgent, + boolean canLogin + ) { + this.friendlyName = str("revanced_spoof_streaming_data_type_entry_" + name().toLowerCase()); + this.id = id; + this.make = make; + this.deviceModel = deviceModel; + this.clientVersion = clientVersion; + this.osName = osName; + this.osVersion = osVersion; + this.androidSdkVersion = androidSdkVersion; + this.userAgent = userAgent; + this.canLogin = canLogin; + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/client/DeviceHardwareSupport.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/client/DeviceHardwareSupport.java new file mode 100644 index 0000000000..91ffd5aae5 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/client/DeviceHardwareSupport.java @@ -0,0 +1,54 @@ +package app.revanced.extension.youtube.patches.misc.client; + +import static app.revanced.extension.shared.utils.Utils.isSDKAbove; + +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.youtube.settings.Settings; + +public class DeviceHardwareSupport { + private static final boolean DEVICE_HAS_HARDWARE_DECODING_VP9; + private static final boolean DEVICE_HAS_HARDWARE_DECODING_AV1; + + static { + boolean vp9found = false; + boolean av1found = false; + MediaCodecList codecList = new MediaCodecList(MediaCodecList.ALL_CODECS); + final boolean deviceIsAndroidTenOrLater = isSDKAbove(29); + + for (MediaCodecInfo codecInfo : codecList.getCodecInfos()) { + final boolean isHardwareAccelerated = deviceIsAndroidTenOrLater + ? codecInfo.isHardwareAccelerated() + : !codecInfo.getName().startsWith("OMX.google"); // Software decoder. + if (isHardwareAccelerated && !codecInfo.isEncoder()) { + for (String type : codecInfo.getSupportedTypes()) { + if (type.equalsIgnoreCase("video/x-vnd.on2.vp9")) { + vp9found = true; + } else if (type.equalsIgnoreCase("video/av01")) { + av1found = true; + } + } + } + } + + DEVICE_HAS_HARDWARE_DECODING_VP9 = vp9found; + DEVICE_HAS_HARDWARE_DECODING_AV1 = av1found; + + Logger.printDebug(() -> DEVICE_HAS_HARDWARE_DECODING_AV1 + ? "Device supports AV1 hardware decoding\n" + : "Device does not support AV1 hardware decoding\n" + + (DEVICE_HAS_HARDWARE_DECODING_VP9 + ? "Device supports VP9 hardware decoding" + : "Device does not support VP9 hardware decoding")); + } + + public static boolean allowVP9() { + return DEVICE_HAS_HARDWARE_DECODING_VP9 && !Settings.SPOOF_STREAMING_DATA_IOS_FORCE_AVC.get(); + } + + public static boolean allowAV1() { + return allowVP9() && DEVICE_HAS_HARDWARE_DECODING_AV1; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/PlayerRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/PlayerRoutes.java new file mode 100644 index 0000000000..7459f41a5c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/PlayerRoutes.java @@ -0,0 +1,103 @@ +package app.revanced.extension.youtube.patches.misc.requests; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.util.Objects; + +import app.revanced.extension.shared.requests.Requester; +import app.revanced.extension.shared.requests.Route; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.misc.client.AppClient.ClientType; + +@SuppressWarnings("deprecation") +public final class PlayerRoutes { + /** + * The base URL of requests of non-web clients to the InnerTube internal API. + */ + private static final String YOUTUBEI_V1_GAPIS_URL = "https://youtubei.googleapis.com/youtubei/v1/"; + + static final Route.CompiledRoute GET_STREAMING_DATA = new Route( + Route.Method.POST, + "player" + + "?fields=streamingData" + + "&alt=proto" + ).compile(); + + static final Route.CompiledRoute GET_PLAYLIST_PAGE = new Route( + Route.Method.POST, + "next" + + "?fields=contents.singleColumnWatchNextResults.playlist.playlist" + ).compile(); + + /** + * TCP connection and HTTP read timeout + */ + private static final int CONNECTION_TIMEOUT_MILLISECONDS = 10 * 1000; // 10 Seconds. + + private PlayerRoutes() { + } + + static String createInnertubeBody(ClientType clientType, String videoId) { + return createInnertubeBody(clientType, videoId, null); + } + + static String createInnertubeBody(ClientType clientType, String videoId, String playlistId) { + JSONObject innerTubeBody = new JSONObject(); + + try { + JSONObject context = new JSONObject(); + + JSONObject client = new JSONObject(); + client.put("clientName", clientType.name()); + client.put("clientVersion", clientType.clientVersion); + client.put("deviceModel", clientType.deviceModel); + client.put("osVersion", clientType.osVersion); + if (clientType.make != null) { + client.put("deviceMake", clientType.make); + } + if (clientType.osName != null) { + client.put("osName", clientType.osName); + } + if (clientType.androidSdkVersion != null) { + client.put("androidSdkVersion", clientType.androidSdkVersion.toString()); + } + String languageCode = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale.getLanguage(); + client.put("hl", languageCode); + + context.put("client", client); + + innerTubeBody.put("context", context); + innerTubeBody.put("contentCheckOk", true); + innerTubeBody.put("racyCheckOk", true); + innerTubeBody.put("videoId", videoId); + if (playlistId != null) { + innerTubeBody.put("playlistId", playlistId); + } + } catch (JSONException e) { + Logger.printException(() -> "Failed to create innerTubeBody", e); + } + + return innerTubeBody.toString(); + } + + /** + * @noinspection SameParameterValue + */ + static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route, ClientType clientType) throws IOException { + var connection = Requester.getConnectionFromCompiledRoute(YOUTUBEI_V1_GAPIS_URL, route); + + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("User-Agent", clientType.userAgent); + + connection.setUseCaches(false); + connection.setDoOutput(true); + + connection.setConnectTimeout(CONNECTION_TIMEOUT_MILLISECONDS); + connection.setReadTimeout(CONNECTION_TIMEOUT_MILLISECONDS); + return connection; + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/PlaylistRequest.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/PlaylistRequest.java new file mode 100644 index 0000000000..370e23cfc1 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/PlaylistRequest.java @@ -0,0 +1,199 @@ +package app.revanced.extension.youtube.patches.misc.requests; + +import static app.revanced.extension.youtube.patches.misc.requests.PlayerRoutes.GET_PLAYLIST_PAGE; + +import android.annotation.SuppressLint; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import app.revanced.extension.shared.requests.Requester; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.misc.client.AppClient.ClientType; +import app.revanced.extension.youtube.shared.VideoInformation; + +public class PlaylistRequest { + + /** + * How long to keep fetches until they are expired. + */ + private static final long CACHE_RETENTION_TIME_MILLISECONDS = 60 * 1000; // 1 Minute + + private static final long MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000; // 20 seconds + + @GuardedBy("itself") + private static final Map cache = new HashMap<>(); + + @SuppressLint("ObsoleteSdkInt") + public static void fetchRequestIfNeeded(@Nullable String videoId) { + Objects.requireNonNull(videoId); + synchronized (cache) { + final long now = System.currentTimeMillis(); + + cache.values().removeIf(request -> { + final boolean expired = request.isExpired(now); + if (expired) Logger.printDebug(() -> "Removing expired stream: " + request.videoId); + return expired; + }); + + if (!cache.containsKey(videoId)) { + cache.put(videoId, new PlaylistRequest(videoId)); + } + } + } + + @Nullable + public static PlaylistRequest getRequestForVideoId(@Nullable String videoId) { + synchronized (cache) { + return cache.get(videoId); + } + } + + private static void handleConnectionError(String toastMessage, @Nullable Exception ex) { + Logger.printInfo(() -> toastMessage, ex); + } + + @Nullable + private static JSONObject send(ClientType clientType, String videoId) { + Objects.requireNonNull(clientType); + Objects.requireNonNull(videoId); + + final long startTime = System.currentTimeMillis(); + String clientTypeName = clientType.name(); + Logger.printDebug(() -> "Fetching playlist request for: " + videoId + " using client: " + clientTypeName); + + try { + HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_PLAYLIST_PAGE, clientType); + + String innerTubeBody = PlayerRoutes.createInnertubeBody( + clientType, + videoId, + "RD" + videoId + ); + byte[] requestBody = innerTubeBody.getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(requestBody.length); + connection.getOutputStream().write(requestBody); + + final int responseCode = connection.getResponseCode(); + if (responseCode == 200) return Requester.parseJSONObject(connection); + + handleConnectionError(clientTypeName + " not available with response code: " + + responseCode + " message: " + connection.getResponseMessage(), + null); + } catch (SocketTimeoutException ex) { + handleConnectionError("Connection timeout", ex); + } catch (IOException ex) { + handleConnectionError("Network error", ex); + } catch (Exception ex) { + Logger.printException(() -> "send failed", ex); + } finally { + Logger.printDebug(() -> "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms"); + } + + return null; + } + + private static Boolean fetch(@NonNull String videoId) { + final ClientType clientType = ClientType.ANDROID_VR; + final JSONObject playlistJson = send(clientType, videoId); + if (playlistJson != null) { + try { + final JSONObject singleColumnWatchNextResultsJsonObject = playlistJson + .getJSONObject("contents") + .getJSONObject("singleColumnWatchNextResults"); + + if (!singleColumnWatchNextResultsJsonObject.has("playlist")) { + return false; + } + + final JSONObject playlistJsonObject = singleColumnWatchNextResultsJsonObject + .getJSONObject("playlist") + .getJSONObject("playlist"); + + final Object currentStreamObject = playlistJsonObject + .getJSONArray("contents") + .get(0); + + if (!(currentStreamObject instanceof JSONObject currentStreamJsonObject)) { + return false; + } + + final JSONObject watchEndpointJsonObject = currentStreamJsonObject + .getJSONObject("playlistPanelVideoRenderer") + .getJSONObject("navigationEndpoint") + .getJSONObject("watchEndpoint"); + + Logger.printDebug(() -> "watchEndpoint: " + watchEndpointJsonObject); + + return watchEndpointJsonObject.has("playerParams") && + VideoInformation.isMixPlaylistsOpenedByUser(watchEndpointJsonObject.getString("playerParams")); + } catch (JSONException e) { + Logger.printDebug(() -> "Fetch failed while processing response data for response: " + playlistJson); + } + } + + return false; + } + + /** + * Time this instance and the fetch future was created. + */ + private final long timeFetched; + private final String videoId; + private final Future future; + + private PlaylistRequest(String videoId) { + this.timeFetched = System.currentTimeMillis(); + this.videoId = videoId; + this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId)); + } + + public boolean isExpired(long now) { + final long timeSinceCreation = now - timeFetched; + if (timeSinceCreation > CACHE_RETENTION_TIME_MILLISECONDS) { + return true; + } + + // Only expired if the fetch failed (API null response). + return (fetchCompleted() && getStream() == null); + } + + /** + * @return if the fetch call has completed. + */ + public boolean fetchCompleted() { + return future.isDone(); + } + + public Boolean getStream() { + try { + return future.get(MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS); + } catch (TimeoutException ex) { + Logger.printInfo(() -> "getStream timed out", ex); + } catch (InterruptedException ex) { + Logger.printException(() -> "getStream interrupted", ex); + Thread.currentThread().interrupt(); // Restore interrupt status flag. + } catch (ExecutionException ex) { + Logger.printException(() -> "getStream failure", ex); + } + + return null; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/StreamingDataRequest.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/StreamingDataRequest.java new file mode 100644 index 0000000000..6d09ccc057 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/StreamingDataRequest.java @@ -0,0 +1,242 @@ +package app.revanced.extension.youtube.patches.misc.requests; + +import static app.revanced.extension.youtube.patches.misc.requests.PlayerRoutes.GET_STREAMING_DATA; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.misc.client.AppClient.ClientType; +import app.revanced.extension.youtube.settings.Settings; + +public class StreamingDataRequest { + private static final ClientType[] ALL_CLIENT_TYPES = ClientType.values(); + private static final ClientType[] CLIENT_ORDER_TO_USE; + + static { + ClientType preferredClient = Settings.SPOOF_STREAMING_DATA_TYPE.get(); + CLIENT_ORDER_TO_USE = new ClientType[ALL_CLIENT_TYPES.length]; + + CLIENT_ORDER_TO_USE[0] = preferredClient; + + int i = 1; + for (ClientType c : ALL_CLIENT_TYPES) { + if (c != preferredClient) { + CLIENT_ORDER_TO_USE[i++] = c; + } + } + } + + private static ClientType lastSpoofedClientType; + + public static String getLastSpoofedClientName() { + return lastSpoofedClientType == null + ? "Unknown" + : lastSpoofedClientType.friendlyName; + } + + /** + * TCP connection and HTTP read timeout. + */ + private static final int HTTP_TIMEOUT_MILLISECONDS = 10 * 1000; + + /** + * Any arbitrarily large value, but must be at least twice {@link #HTTP_TIMEOUT_MILLISECONDS} + */ + private static final int MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000; + + @GuardedBy("itself") + private static final Map cache = Collections.synchronizedMap( + new LinkedHashMap<>(100) { + /** + * Cache limit must be greater than the maximum number of videos open at once, + * which theoretically is more than 4 (3 Shorts + one regular minimized video). + * But instead use a much larger value, to handle if a video viewed a while ago + * is somehow still referenced. Each stream is a small array of Strings + * so memory usage is not a concern. + */ + private static final int CACHE_LIMIT = 50; + + @Override + protected boolean removeEldestEntry(Entry eldest) { + return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit. + } + }); + + public static void fetchRequest(@NonNull String videoId, Map fetchHeaders) { + cache.put(videoId, new StreamingDataRequest(videoId, fetchHeaders)); + } + + @Nullable + public static StreamingDataRequest getRequestForVideoId(@Nullable String videoId) { + return cache.get(videoId); + } + + private static void handleConnectionError(String toastMessage, @Nullable Exception ex) { + Logger.printInfo(() -> toastMessage, ex); + } + + // Available only to logged in users. + private static final String AUTHORIZATION_HEADER = "Authorization"; + + private static final String[] REQUEST_HEADER_KEYS = { + AUTHORIZATION_HEADER, + "X-GOOG-API-FORMAT-VERSION", + "X-Goog-Visitor-Id" + }; + + private static void writeInnerTubeBody(HttpURLConnection connection, ClientType clientType, + String videoId, Map playerHeaders) { + try { + connection.setConnectTimeout(HTTP_TIMEOUT_MILLISECONDS); + connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS); + + if (playerHeaders != null) { + for (String key : REQUEST_HEADER_KEYS) { + if (!clientType.canLogin && key.equals(AUTHORIZATION_HEADER)) { + continue; + } + String value = playerHeaders.get(key); + if (value != null) { + connection.setRequestProperty(key, value); + } + } + } + + String innerTubeBody = PlayerRoutes.createInnertubeBody(clientType, videoId); + byte[] requestBody = innerTubeBody.getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(requestBody.length); + connection.getOutputStream().write(requestBody); + } catch (IOException ex) { + handleConnectionError("Network error", ex); + } + } + + @Nullable + private static HttpURLConnection send(ClientType clientType, String videoId, + Map playerHeaders) { + Objects.requireNonNull(clientType); + Objects.requireNonNull(videoId); + Objects.requireNonNull(playerHeaders); + + final long startTime = System.currentTimeMillis(); + String clientTypeName = clientType.name(); + Logger.printDebug(() -> "Fetching video streams for: " + videoId + " using client: " + clientType.name()); + + try { + HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_STREAMING_DATA, clientType); + writeInnerTubeBody(connection, clientType, videoId, playerHeaders); + + final int responseCode = connection.getResponseCode(); + if (responseCode == 200) return connection; + + handleConnectionError(clientTypeName + " not available with response code: " + + responseCode + " message: " + connection.getResponseMessage(), + null); + } catch (Exception ex) { + Logger.printException(() -> "send failed", ex); + } finally { + Logger.printDebug(() -> "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms"); + } + + return null; + } + + private static final ByteArrayFilterGroup liveStreams = + new ByteArrayFilterGroup( + Settings.SPOOF_STREAMING_DATA_IOS_SKIP_LIVESTREAM_PLAYBACK, + "yt_live_broadcast", + "yt_premiere_broadcast" + ); + + private static ByteBuffer fetch(@NonNull String videoId, Map playerHeaders) { + try { + lastSpoofedClientType = null; + + // Retry with different client if empty response body is received. + for (ClientType clientType : CLIENT_ORDER_TO_USE) { + HttpURLConnection connection = send(clientType, videoId, playerHeaders); + + // gzip encoding doesn't response with content length (-1), + // but empty response body does. + if (connection == null || connection.getContentLength() == 0) { + continue; + } + InputStream inputStream = new BufferedInputStream(connection.getInputStream()); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buffer = new byte[2048]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) >= 0) { + baos.write(buffer, 0, bytesRead); + } + inputStream.close(); + if (clientType == ClientType.IOS && liveStreams.check(buffer).isFiltered()) { + Logger.printDebug(() -> "Ignore IOS spoofing as it is a livestream (video: " + videoId + ")"); + continue; + } + lastSpoofedClientType = clientType; + + return ByteBuffer.wrap(baos.toByteArray()); + } + } catch (IOException ex) { + Logger.printException(() -> "Fetch failed while processing response data", ex); + } + + handleConnectionError("Could not fetch any client streams", null); + return null; + } + + private final String videoId; + private final Future future; + + private StreamingDataRequest(String videoId, Map playerHeaders) { + Objects.requireNonNull(playerHeaders); + this.videoId = videoId; + this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId, playerHeaders)); + } + + public boolean fetchCompleted() { + return future.isDone(); + } + + @Nullable + public ByteBuffer getStream() { + try { + return future.get(MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS); + } catch (TimeoutException ex) { + Logger.printInfo(() -> "getStream timed out", ex); + } catch (InterruptedException ex) { + Logger.printException(() -> "getStream interrupted", ex); + Thread.currentThread().interrupt(); // Restore interrupt status flag. + } catch (ExecutionException ex) { + Logger.printException(() -> "getStream failure", ex); + } + + return null; + } + + @NonNull + @Override + public String toString() { + return "StreamingDataRequest{" + "videoId='" + videoId + '\'' + '}'; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/AlwaysRepeat.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/AlwaysRepeat.java new file mode 100644 index 0000000000..777831a1e5 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/AlwaysRepeat.java @@ -0,0 +1,59 @@ +package app.revanced.extension.youtube.patches.overlaybutton; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class AlwaysRepeat extends BottomControlButton { + @Nullable + private static AlwaysRepeat instance; + + public AlwaysRepeat(ViewGroup bottomControlsViewGroup) { + super( + bottomControlsViewGroup, + "always_repeat_button", + Settings.OVERLAY_BUTTON_ALWAYS_REPEAT, + Settings.ALWAYS_REPEAT, + Settings.ALWAYS_REPEAT_PAUSE, + view -> { + if (instance != null) + instance.changeSelected(!view.isSelected()); + }, + view -> { + if (instance != null) + instance.changeColorFilter(); + return true; + } + ); + } + + /** + * Injection point. + */ + public static void initialize(View bottomControlsViewGroup) { + try { + if (bottomControlsViewGroup instanceof ViewGroup viewGroup) { + instance = new AlwaysRepeat(viewGroup); + } + } catch (Exception ex) { + Logger.printException(() -> "initialize failure", ex); + } + } + + /** + * Injection point. + */ + public static void changeVisibility(boolean showing, boolean animation) { + if (instance != null) instance.setVisibility(showing, animation); + } + + public static void changeVisibilityNegatedImmediate() { + if (instance != null) instance.setVisibilityNegatedImmediate(); + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/BottomControlButton.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/BottomControlButton.java new file mode 100644 index 0000000000..da4744f5a8 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/BottomControlButton.java @@ -0,0 +1,174 @@ +package app.revanced.extension.youtube.patches.overlaybutton; + +import static app.revanced.extension.shared.utils.ResourceUtils.getAnimation; +import static app.revanced.extension.shared.utils.ResourceUtils.getInteger; +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.shared.utils.Utils.getChildView; + +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.ref.WeakReference; +import java.util.Objects; + +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +public abstract class BottomControlButton { + private static final Animation fadeIn; + private static final Animation fadeOut; + private static final Animation fadeOutImmediate; + + private final ColorFilter cf = + new PorterDuffColorFilter(Color.parseColor("#fffffc79"), PorterDuff.Mode.SRC_ATOP); + + private final WeakReference buttonRef; + private final BooleanSetting setting; + private final BooleanSetting primaryInteractionSetting; + private final BooleanSetting secondaryInteractionSetting; + protected boolean isVisible; + + static { + fadeIn = getAnimation("fade_in"); + // android.R.integer.config_shortAnimTime, 200 + fadeIn.setDuration(getInteger("fade_duration_fast")); + + fadeOut = getAnimation("fade_out"); + // android.R.integer.config_mediumAnimTime, 400 + fadeOut.setDuration(getInteger("fade_overlay_fade_duration")); + + fadeOutImmediate = getAnimation("abc_fade_out"); + // android.R.integer.config_shortAnimTime, 200 + fadeOutImmediate.setDuration(getInteger("fade_duration_fast")); + } + + @NonNull + public static Animation getButtonFadeIn() { + return fadeIn; + } + + @NonNull + public static Animation getButtonFadeOut() { + return fadeOut; + } + + @NonNull + public static Animation getButtonFadeOutImmediate() { + return fadeOutImmediate; + } + + public BottomControlButton(@NonNull ViewGroup bottomControlsViewGroup, @NonNull String imageViewButtonId, @NonNull BooleanSetting booleanSetting, + @NonNull View.OnClickListener onClickListener, @Nullable View.OnLongClickListener longClickListener) { + this(bottomControlsViewGroup, imageViewButtonId, booleanSetting, null, null, onClickListener, longClickListener); + } + + @SuppressWarnings("unused") + public BottomControlButton(@NonNull ViewGroup bottomControlsViewGroup, @NonNull String imageViewButtonId, @NonNull BooleanSetting booleanSetting, @Nullable BooleanSetting primaryInteractionSetting, + @NonNull View.OnClickListener onClickListener, @Nullable View.OnLongClickListener longClickListener) { + this(bottomControlsViewGroup, imageViewButtonId, booleanSetting, primaryInteractionSetting, null, onClickListener, longClickListener); + } + + public BottomControlButton(@NonNull ViewGroup bottomControlsViewGroup, @NonNull String imageViewButtonId, @NonNull BooleanSetting booleanSetting, + @Nullable BooleanSetting primaryInteractionSetting, @Nullable BooleanSetting secondaryInteractionSetting, + @NonNull View.OnClickListener onClickListener, @Nullable View.OnLongClickListener longClickListener) { + Logger.printDebug(() -> "Initializing button: " + imageViewButtonId); + + setting = booleanSetting; + + // Create the button. + ImageView imageView = Objects.requireNonNull(getChildView(bottomControlsViewGroup, imageViewButtonId)); + imageView.setOnClickListener(onClickListener); + this.primaryInteractionSetting = primaryInteractionSetting; + this.secondaryInteractionSetting = secondaryInteractionSetting; + if (primaryInteractionSetting != null) { + imageView.setSelected(primaryInteractionSetting.get()); + } + if (secondaryInteractionSetting != null) { + setColorFilter(imageView, secondaryInteractionSetting.get()); + } + if (longClickListener != null) { + imageView.setOnLongClickListener(longClickListener); + } + imageView.setVisibility(View.GONE); + buttonRef = new WeakReference<>(imageView); + } + + public void changeActivated(boolean activated) { + ImageView imageView = buttonRef.get(); + if (imageView == null) + return; + imageView.setActivated(activated); + } + + public void changeSelected(boolean selected) { + ImageView imageView = buttonRef.get(); + if (imageView == null || primaryInteractionSetting == null) + return; + + if (imageView.getColorFilter() == cf) { + Utils.showToastShort(str("revanced_overlay_button_not_allowed_warning")); + return; + } + + imageView.setSelected(selected); + primaryInteractionSetting.save(selected); + } + + public void changeColorFilter() { + ImageView imageView = buttonRef.get(); + if (imageView == null) return; + if (primaryInteractionSetting == null || secondaryInteractionSetting == null) + return; + + imageView.setSelected(true); + primaryInteractionSetting.save(true); + + final boolean newValue = !secondaryInteractionSetting.get(); + secondaryInteractionSetting.save(newValue); + setColorFilter(imageView, newValue); + } + + public void setColorFilter(ImageView imageView, boolean selected) { + if (selected) + imageView.setColorFilter(cf); + else + imageView.clearColorFilter(); + } + + public void setVisibility(boolean visible, boolean animation) { + ImageView imageView = buttonRef.get(); + if (imageView == null || isVisible == visible) return; + isVisible = visible; + + imageView.clearAnimation(); + if (visible && setting.get()) { + imageView.setVisibility(View.VISIBLE); + if (animation) imageView.startAnimation(fadeIn); + return; + } + if (imageView.getVisibility() == View.VISIBLE) { + if (animation) imageView.startAnimation(fadeOut); + imageView.setVisibility(View.GONE); + } + } + + public void setVisibilityNegatedImmediate() { + ImageView imageView = buttonRef.get(); + if (imageView == null) return; + if (!setting.get()) return; + + imageView.clearAnimation(); + imageView.startAnimation(fadeOutImmediate); + imageView.setVisibility(View.GONE); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/CopyVideoUrl.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/CopyVideoUrl.java new file mode 100644 index 0000000000..33e7e88bbb --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/CopyVideoUrl.java @@ -0,0 +1,55 @@ +package app.revanced.extension.youtube.patches.overlaybutton; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.utils.VideoUtils; + +@SuppressWarnings("unused") +public class CopyVideoUrl extends BottomControlButton { + @Nullable + private static CopyVideoUrl instance; + + public CopyVideoUrl(ViewGroup bottomControlsViewGroup) { + super( + bottomControlsViewGroup, + "copy_video_url_button", + Settings.OVERLAY_BUTTON_COPY_VIDEO_URL, + view -> VideoUtils.copyUrl(false), + view -> { + VideoUtils.copyUrl(true); + return true; + } + ); + } + + /** + * Injection point. + */ + public static void initialize(View bottomControlsViewGroup) { + try { + if (bottomControlsViewGroup instanceof ViewGroup viewGroup) { + instance = new CopyVideoUrl(viewGroup); + } + } catch (Exception ex) { + Logger.printException(() -> "initialize failure", ex); + } + } + + /** + * Injection point. + */ + public static void changeVisibility(boolean showing, boolean animation) { + if (instance != null) instance.setVisibility(showing, animation); + } + + public static void changeVisibilityNegatedImmediate() { + if (instance != null) instance.setVisibilityNegatedImmediate(); + } + + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/CopyVideoUrlTimestamp.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/CopyVideoUrlTimestamp.java new file mode 100644 index 0000000000..bfda8216b2 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/CopyVideoUrlTimestamp.java @@ -0,0 +1,55 @@ +package app.revanced.extension.youtube.patches.overlaybutton; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.utils.VideoUtils; + +@SuppressWarnings("unused") +public class CopyVideoUrlTimestamp extends BottomControlButton { + @Nullable + private static CopyVideoUrlTimestamp instance; + + public CopyVideoUrlTimestamp(ViewGroup bottomControlsViewGroup) { + super( + bottomControlsViewGroup, + "copy_video_url_timestamp_button", + Settings.OVERLAY_BUTTON_COPY_VIDEO_URL_TIMESTAMP, + view -> VideoUtils.copyUrl(true), + view -> { + VideoUtils.copyTimeStamp(); + return true; + } + ); + } + + /** + * Injection point. + */ + public static void initialize(View bottomControlsViewGroup) { + try { + if (bottomControlsViewGroup instanceof ViewGroup viewGroup) { + instance = new CopyVideoUrlTimestamp(viewGroup); + } + } catch (Exception ex) { + Logger.printException(() -> "initialize failure", ex); + } + } + + /** + * Injection point. + */ + public static void changeVisibility(boolean showing, boolean animation) { + if (instance != null) instance.setVisibility(showing, animation); + } + + public static void changeVisibilityNegatedImmediate() { + if (instance != null) instance.setVisibilityNegatedImmediate(); + } + + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/ExternalDownload.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/ExternalDownload.java new file mode 100644 index 0000000000..e6a572af64 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/ExternalDownload.java @@ -0,0 +1,52 @@ +package app.revanced.extension.youtube.patches.overlaybutton; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.utils.VideoUtils; + +@SuppressWarnings("unused") +public class ExternalDownload extends BottomControlButton { + @Nullable + private static ExternalDownload instance; + + public ExternalDownload(ViewGroup bottomControlsViewGroup) { + super( + bottomControlsViewGroup, + "external_download_button", + Settings.OVERLAY_BUTTON_EXTERNAL_DOWNLOADER, + view -> VideoUtils.launchVideoExternalDownloader(), + null + ); + } + + /** + * Injection point. + */ + public static void initialize(View bottomControlsViewGroup) { + try { + if (bottomControlsViewGroup instanceof ViewGroup viewGroup) { + instance = new ExternalDownload(viewGroup); + } + } catch (Exception ex) { + Logger.printException(() -> "initialize failure", ex); + } + } + + /** + * Injection point. + */ + public static void changeVisibility(boolean showing, boolean animation) { + if (instance != null) instance.setVisibility(showing, animation); + } + + public static void changeVisibilityNegatedImmediate() { + if (instance != null) instance.setVisibilityNegatedImmediate(); + } + + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/MuteVolume.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/MuteVolume.java new file mode 100644 index 0000000000..532bc0a62e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/MuteVolume.java @@ -0,0 +1,76 @@ +package app.revanced.extension.youtube.patches.overlaybutton; + +import android.content.Context; +import android.media.AudioManager; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings({"deprecation", "unused"}) +public class MuteVolume extends BottomControlButton { + @Nullable + private static MuteVolume instance; + private static AudioManager audioManager; + private static final int stream = AudioManager.STREAM_MUSIC; + + public MuteVolume(ViewGroup bottomControlsViewGroup) { + super( + bottomControlsViewGroup, + "mute_volume_button", + Settings.OVERLAY_BUTTON_MUTE_VOLUME, + view -> { + if (instance != null && audioManager != null) { + boolean unMuted = !audioManager.isStreamMute(stream); + audioManager.setStreamMute(stream, unMuted); + instance.changeActivated(unMuted); + } + }, + null + ); + } + + /** + * Injection point. + */ + public static void initialize(View bottomControlsViewGroup) { + try { + if (bottomControlsViewGroup instanceof ViewGroup viewGroup) { + instance = new MuteVolume(viewGroup); + } + if (bottomControlsViewGroup.getContext().getSystemService(Context.AUDIO_SERVICE) instanceof AudioManager am) { + audioManager = am; + } + } catch (Exception ex) { + Logger.printException(() -> "initialize failure", ex); + } + } + + /** + * Injection point. + */ + public static void changeVisibility(boolean showing, boolean animation) { + if (instance != null) { + instance.setVisibility(showing, animation); + changeActivated(instance); + } + } + + public static void changeVisibilityNegatedImmediate() { + if (instance != null) { + instance.setVisibilityNegatedImmediate(); + changeActivated(instance); + } + } + + private static void changeActivated(MuteVolume instance) { + if (audioManager != null) { + boolean muted = audioManager.isStreamMute(stream); + instance.changeActivated(muted); + } + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/PlayAll.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/PlayAll.java new file mode 100644 index 0000000000..25df9ae4b3 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/PlayAll.java @@ -0,0 +1,55 @@ +package app.revanced.extension.youtube.patches.overlaybutton; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.utils.VideoUtils; + +@SuppressWarnings("unused") +public class PlayAll extends BottomControlButton { + + @Nullable + private static PlayAll instance; + + public PlayAll(ViewGroup bottomControlsViewGroup) { + super( + bottomControlsViewGroup, + "play_all_button", + Settings.OVERLAY_BUTTON_PLAY_ALL, + view -> VideoUtils.openVideo(Settings.OVERLAY_BUTTON_PLAY_ALL_TYPE.get()), + view -> { + VideoUtils.openVideo(); + return true; + } + ); + } + + /** + * Injection point. + */ + public static void initialize(View bottomControlsViewGroup) { + try { + if (bottomControlsViewGroup instanceof ViewGroup viewGroup) { + instance = new PlayAll(viewGroup); + } + } catch (Exception ex) { + Logger.printException(() -> "initialize failure", ex); + } + } + + /** + * Injection point. + */ + public static void changeVisibility(boolean showing, boolean animation) { + if (instance != null) instance.setVisibility(showing, animation); + } + + public static void changeVisibilityNegatedImmediate() { + if (instance != null) instance.setVisibilityNegatedImmediate(); + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/SpeedDialog.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/SpeedDialog.java new file mode 100644 index 0000000000..a091f36c62 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/SpeedDialog.java @@ -0,0 +1,68 @@ +package app.revanced.extension.youtube.patches.overlaybutton; + +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.shared.utils.Utils.showToastShort; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.VideoInformation; +import app.revanced.extension.youtube.utils.VideoUtils; + +@SuppressWarnings("unused") +public class SpeedDialog extends BottomControlButton { + @Nullable + private static SpeedDialog instance; + + public SpeedDialog(ViewGroup bottomControlsViewGroup) { + super( + bottomControlsViewGroup, + "speed_dialog_button", + Settings.OVERLAY_BUTTON_SPEED_DIALOG, + view -> VideoUtils.showPlaybackSpeedDialog(view.getContext()), + view -> { + if (!Settings.REMEMBER_PLAYBACK_SPEED_LAST_SELECTED.get() || + VideoInformation.getPlaybackSpeed() == Settings.DEFAULT_PLAYBACK_SPEED.get()) { + VideoInformation.overridePlaybackSpeed(1.0f); + showToastShort(str("revanced_overlay_button_speed_dialog_reset", "1.0")); + } else { + float defaultSpeed = Settings.DEFAULT_PLAYBACK_SPEED.get(); + VideoInformation.overridePlaybackSpeed(defaultSpeed); + showToastShort(str("revanced_overlay_button_speed_dialog_reset", defaultSpeed)); + } + + return true; + } + ); + } + + /** + * Injection point. + */ + public static void initialize(View bottomControlsViewGroup) { + try { + if (bottomControlsViewGroup instanceof ViewGroup viewGroup) { + instance = new SpeedDialog(viewGroup); + } + } catch (Exception ex) { + Logger.printException(() -> "initialize failure", ex); + } + } + + /** + * Injection point. + */ + public static void changeVisibility(boolean showing, boolean animation) { + if (instance != null) instance.setVisibility(showing, animation); + } + + public static void changeVisibilityNegatedImmediate() { + if (instance != null) instance.setVisibilityNegatedImmediate(); + } + + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/Whitelists.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/Whitelists.java new file mode 100644 index 0000000000..e88cacd001 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/Whitelists.java @@ -0,0 +1,55 @@ +package app.revanced.extension.youtube.patches.overlaybutton; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.settings.preference.WhitelistedChannelsPreference; +import app.revanced.extension.youtube.whitelist.Whitelist; + +@SuppressWarnings("unused") +public class Whitelists extends BottomControlButton { + @Nullable + private static Whitelists instance; + + public Whitelists(ViewGroup bottomControlsViewGroup) { + super( + bottomControlsViewGroup, + "whitelist_button", + Settings.OVERLAY_BUTTON_WHITELIST, + view -> Whitelist.showWhitelistDialog(view.getContext()), + view -> { + WhitelistedChannelsPreference.showWhitelistedChannelDialog(view.getContext()); + return true; + } + ); + } + + /** + * Injection point. + */ + public static void initialize(View bottomControlsViewGroup) { + try { + if (bottomControlsViewGroup instanceof ViewGroup viewGroup) { + instance = new Whitelists(viewGroup); + } + } catch (Exception ex) { + Logger.printException(() -> "initialize failure", ex); + } + } + + /** + * Injection point. + */ + public static void changeVisibility(boolean showing, boolean animation) { + if (instance != null) instance.setVisibility(showing, animation); + } + + public static void changeVisibilityNegatedImmediate() { + if (instance != null) instance.setVisibilityNegatedImmediate(); + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/player/PlayerPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/player/PlayerPatch.java new file mode 100644 index 0000000000..688a9901a2 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/player/PlayerPatch.java @@ -0,0 +1,730 @@ +package app.revanced.extension.youtube.patches.player; + +import static app.revanced.extension.shared.utils.Utils.hideViewBy0dpUnderCondition; +import static app.revanced.extension.shared.utils.Utils.hideViewByRemovingFromParentUnderCondition; +import static app.revanced.extension.shared.utils.Utils.hideViewUnderCondition; +import static app.revanced.extension.youtube.utils.ExtendedUtils.validateValue; + +import android.app.Activity; +import android.content.pm.ActivityInfo; +import android.graphics.Color; +import android.support.v7.widget.RecyclerView; +import android.util.TypedValue; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.coordinatorlayout.widget.CoordinatorLayout; + +import java.lang.ref.WeakReference; +import java.util.Objects; + +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.shared.settings.IntegerSetting; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.ResourceUtils; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.utils.InitializationPatch; +import app.revanced.extension.youtube.patches.utils.PatchStatus; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.PlayerType; +import app.revanced.extension.youtube.shared.RootView; +import app.revanced.extension.youtube.shared.ShortsPlayerState; +import app.revanced.extension.youtube.shared.VideoInformation; +import app.revanced.extension.youtube.utils.VideoUtils; + +@SuppressWarnings("unused") +public class PlayerPatch { + private static final IntegerSetting quickActionsMarginTopSetting = Settings.QUICK_ACTIONS_TOP_MARGIN; + + private static final int PLAYER_OVERLAY_OPACITY_LEVEL; + private static final int QUICK_ACTIONS_MARGIN_TOP; + private static final float SPEED_OVERLAY_VALUE; + + static { + final int opacity = validateValue( + Settings.CUSTOM_PLAYER_OVERLAY_OPACITY, + 0, + 100, + "revanced_custom_player_overlay_opacity_invalid_toast" + ); + PLAYER_OVERLAY_OPACITY_LEVEL = (opacity * 255) / 100; + + SPEED_OVERLAY_VALUE = validateValue( + Settings.SPEED_OVERLAY_VALUE, + 0.0f, + 8.0f, + "revanced_speed_overlay_value_invalid_toast" + ); + + final int topMargin = validateValue( + Settings.QUICK_ACTIONS_TOP_MARGIN, + 0, + 32, + "revanced_quick_actions_top_margin_invalid_toast" + ); + + QUICK_ACTIONS_MARGIN_TOP = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, (float) topMargin, Utils.getResources().getDisplayMetrics()); + } + + // region [Ambient mode control] patch + + public static boolean bypassAmbientModeRestrictions(boolean original) { + return (!Settings.BYPASS_AMBIENT_MODE_RESTRICTIONS.get() && original) || Settings.DISABLE_AMBIENT_MODE.get(); + } + + public static boolean disableAmbientModeInFullscreen() { + return !Settings.DISABLE_AMBIENT_MODE_IN_FULLSCREEN.get(); + } + + // endregion + + // region [Change player flyout menu toggles] patch + + public static boolean changeSwitchToggle(boolean original) { + return !Settings.CHANGE_PLAYER_FLYOUT_MENU_TOGGLE.get() && original; + } + + public static String getToggleString(String str) { + return ResourceUtils.getString(str); + } + + // endregion + + // region [Description components] patch + + public static boolean disableRollingNumberAnimations() { + return Settings.DISABLE_ROLLING_NUMBER_ANIMATIONS.get(); + } + + /** + * view id R.id.content + */ + private static final int contentId = ResourceUtils.getIdIdentifier("content"); + private static final boolean expandDescriptionEnabled = Settings.EXPAND_VIDEO_DESCRIPTION.get(); + private static final String descriptionString = Settings.EXPAND_VIDEO_DESCRIPTION_STRINGS.get(); + + private static boolean isDescriptionPanel = false; + + public static void setContentDescription(String contentDescription) { + if (!expandDescriptionEnabled) { + return; + } + if (contentDescription == null || contentDescription.isEmpty()) { + isDescriptionPanel = false; + return; + } + if (descriptionString.isEmpty()) { + isDescriptionPanel = false; + return; + } + isDescriptionPanel = descriptionString.equals(contentDescription); + } + + /** + * The last time the clickDescriptionView method was called. + */ + private static long lastTimeDescriptionViewInvoked; + + + public static void onVideoDescriptionCreate(RecyclerView recyclerView) { + if (!expandDescriptionEnabled) + return; + + recyclerView.getViewTreeObserver().addOnDrawListener(() -> { + try { + // Video description panel is only open when the player is active. + if (!RootView.isPlayerActive()) { + return; + } + // Video description's recyclerView is a child view of [contentId]. + if (!(recyclerView.getParent().getParent() instanceof View contentView)) { + return; + } + if (contentView.getId() != contentId) { + return; + } + // This method is invoked whenever the Engagement panel is opened. (Description, Chapters, Comments, etc.) + // Check the title of the Engagement panel to prevent unnecessary clicking. + if (!isDescriptionPanel) { + return; + } + // The first view group contains information such as the video's title, like count, and number of views. + if (!(recyclerView.getChildAt(0) instanceof ViewGroup primaryViewGroup)) { + return; + } + if (primaryViewGroup.getChildCount() < 2) { + return; + } + // Typically, descriptionView is placed as the second child of recyclerView. + if (recyclerView.getChildAt(1) instanceof ViewGroup viewGroup) { + clickDescriptionView(viewGroup); + } + // In some videos, descriptionView is placed as the third child of recyclerView. + if (recyclerView.getChildAt(2) instanceof ViewGroup viewGroup) { + clickDescriptionView(viewGroup); + } + // Even if both methods are performed, there is no major issue with the operation of the patch. + } catch (Exception ex) { + Logger.printException(() -> "onVideoDescriptionCreate failed.", ex); + } + }); + } + + private static void clickDescriptionView(@NonNull ViewGroup descriptionViewGroup) { + final View descriptionView = descriptionViewGroup.getChildAt(0); + if (descriptionView == null) { + return; + } + // This method is sometimes used multiple times. + // To prevent this, ignore method reuse within 1 second. + final long now = System.currentTimeMillis(); + if (now - lastTimeDescriptionViewInvoked < 1000) { + return; + } + lastTimeDescriptionViewInvoked = now; + + // The type of descriptionView can be either ViewGroup or TextView. (A/B tests) + // If the type of descriptionView is TextView, longer delay is required. + final long delayMillis = descriptionView instanceof TextView + ? 500 + : 100; + + Utils.runOnMainThreadDelayed(() -> Utils.clickView(descriptionView), delayMillis); + } + + /** + * This method is invoked only when the view type of descriptionView is {@link TextView}. (A/B tests) + * + * @param textView descriptionView. + * @param original Whether to apply {@link TextView#setTextIsSelectable}. + * Patch replaces the {@link TextView#setTextIsSelectable} method invoke. + */ + public static void disableVideoDescriptionInteraction(TextView textView, boolean original) { + if (textView != null) { + textView.setTextIsSelectable( + !Settings.DISABLE_VIDEO_DESCRIPTION_INTERACTION.get() && original + ); + } + } + + // endregion + + // region [Disable haptic feedback] patch + + public static boolean disableChapterVibrate() { + return Settings.DISABLE_HAPTIC_FEEDBACK_CHAPTERS.get(); + } + + + public static boolean disableSeekVibrate() { + return Settings.DISABLE_HAPTIC_FEEDBACK_SEEK.get(); + } + + public static boolean disableSeekUndoVibrate() { + return Settings.DISABLE_HAPTIC_FEEDBACK_SEEK_UNDO.get(); + } + + public static boolean disableScrubbingVibrate() { + return Settings.DISABLE_HAPTIC_FEEDBACK_SCRUBBING.get(); + } + + public static boolean disableZoomVibrate() { + return Settings.DISABLE_HAPTIC_FEEDBACK_ZOOM.get(); + } + + // endregion + + // region [Fullscreen components] patch + + public static void disableEngagementPanels(CoordinatorLayout coordinatorLayout) { + if (!Settings.DISABLE_ENGAGEMENT_PANEL.get()) return; + coordinatorLayout.setVisibility(View.GONE); + } + + public static void showVideoTitleSection(FrameLayout frameLayout, View view) { + final boolean isEnabled = Settings.SHOW_VIDEO_TITLE_SECTION.get() || !Settings.DISABLE_ENGAGEMENT_PANEL.get(); + + if (isEnabled) { + frameLayout.addView(view); + } + } + + public static boolean hideAutoPlayPreview() { + return Settings.HIDE_AUTOPLAY_PREVIEW.get(); + } + + public static boolean hideRelatedVideoOverlay() { + return Settings.HIDE_RELATED_VIDEO_OVERLAY.get(); + } + + public static void hideQuickActions(View view) { + final boolean isEnabled = Settings.DISABLE_ENGAGEMENT_PANEL.get() || Settings.HIDE_QUICK_ACTIONS.get(); + + Utils.hideViewBy0dpUnderCondition( + isEnabled, + view + ); + } + + public static void setQuickActionMargin(View view) { + int topMarginPx = getQuickActionsTopMargin(); + if (topMarginPx == 0) { + return; + } + + if (!(view.getLayoutParams() instanceof ViewGroup.MarginLayoutParams mlp)) + return; + + mlp.setMargins( + mlp.leftMargin, + topMarginPx, + mlp.rightMargin, + mlp.bottomMargin + ); + view.requestLayout(); + } + + public static boolean enableCompactControlsOverlay(boolean original) { + return Settings.ENABLE_COMPACT_CONTROLS_OVERLAY.get() || original; + } + + public static boolean disableLandScapeMode(boolean original) { + return Settings.DISABLE_LANDSCAPE_MODE.get() || original; + } + + private static volatile boolean isScreenOn; + + public static boolean keepFullscreen(boolean original) { + if (!Settings.KEEP_LANDSCAPE_MODE.get()) + return original; + + return isScreenOn; + } + + public static void setScreenOn() { + if (!Settings.KEEP_LANDSCAPE_MODE.get()) + return; + + isScreenOn = true; + Utils.runOnMainThreadDelayed(() -> isScreenOn = false, Settings.KEEP_LANDSCAPE_MODE_TIMEOUT.get()); + } + + private static WeakReference watchDescriptorActivityRef = new WeakReference<>(null); + private static volatile boolean isLandScapeVideo = true; + + public static void setWatchDescriptorActivity(Activity activity) { + watchDescriptorActivityRef = new WeakReference<>(activity); + } + + public static boolean forceFullscreen(boolean original) { + if (!Settings.FORCE_FULLSCREEN.get()) + return original; + + Utils.runOnMainThreadDelayed(PlayerPatch::setOrientation, 1000); + return true; + } + + private static void setOrientation() { + final Activity watchDescriptorActivity = watchDescriptorActivityRef.get(); + final int requestedOrientation = isLandScapeVideo + ? ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + : watchDescriptorActivity.getRequestedOrientation(); + + watchDescriptorActivity.setRequestedOrientation(requestedOrientation); + } + + public static void setVideoPortrait(int width, int height) { + if (!Settings.FORCE_FULLSCREEN.get()) + return; + + isLandScapeVideo = width > height; + } + + // endregion + + // region [Hide comments component] patch + + public static void changeEmojiPickerOpacity(ImageView imageView) { + if (!Settings.HIDE_COMMENT_TIMESTAMP_AND_EMOJI_BUTTONS.get()) + return; + + imageView.setImageAlpha(0); + } + + @Nullable + public static Object disableEmojiPickerOnClickListener(@Nullable Object object) { + return Settings.HIDE_COMMENT_TIMESTAMP_AND_EMOJI_BUTTONS.get() ? null : object; + } + + // endregion + + // region [Hide player buttons] patch + + public static boolean hideAutoPlayButton() { + return Settings.HIDE_PLAYER_AUTOPLAY_BUTTON.get(); + } + + public static boolean hideCaptionsButton(boolean original) { + return !Settings.HIDE_PLAYER_CAPTIONS_BUTTON.get() && original; + } + + public static int hideCastButton(int original) { + return Settings.HIDE_PLAYER_CAST_BUTTON.get() + ? View.GONE + : original; + } + + public static void hideCaptionsButton(View view) { + Utils.hideViewUnderCondition(Settings.HIDE_PLAYER_CAPTIONS_BUTTON, view); + } + + public static void hideCollapseButton(ImageView imageView) { + if (!Settings.HIDE_PLAYER_COLLAPSE_BUTTON.get()) + return; + + imageView.setImageResource(android.R.color.transparent); + imageView.setImageAlpha(0); + imageView.setEnabled(false); + + var layoutParams = imageView.getLayoutParams(); + if (layoutParams instanceof RelativeLayout.LayoutParams) { + RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(0, 0); + imageView.setLayoutParams(lp); + } else { + Logger.printDebug(() -> "Unknown collapse button layout params: " + layoutParams); + } + } + + public static void setTitleAnchorStartMargin(View titleAnchorView) { + if (!Settings.HIDE_PLAYER_COLLAPSE_BUTTON.get()) + return; + + var layoutParams = titleAnchorView.getLayoutParams(); + if (titleAnchorView.getLayoutParams() instanceof RelativeLayout.LayoutParams lp) { + lp.setMarginStart(0); + } else { + Logger.printDebug(() -> "Unknown title anchor layout params: " + layoutParams); + } + } + + public static ImageView hideFullscreenButton(ImageView imageView) { + final boolean hideView = Settings.HIDE_PLAYER_FULLSCREEN_BUTTON.get(); + + Utils.hideViewUnderCondition(hideView, imageView); + return hideView ? null : imageView; + } + + public static boolean hidePreviousNextButton(boolean previousOrNextButtonVisible) { + return !Settings.HIDE_PLAYER_PREVIOUS_NEXT_BUTTON.get() && previousOrNextButtonVisible; + } + + public static boolean hideMusicButton() { + return Settings.HIDE_PLAYER_YOUTUBE_MUSIC_BUTTON.get(); + } + + // endregion + + // region [Player components] patch + + public static void changeOpacity(ImageView imageView) { + imageView.setImageAlpha(PLAYER_OVERLAY_OPACITY_LEVEL); + } + + private static boolean isAutoPopupPanel; + + public static boolean disableAutoPlayerPopupPanels(boolean isLiveChatOrPlaylistPanel) { + if (!Settings.DISABLE_AUTO_PLAYER_POPUP_PANELS.get()) { + return false; + } + if (isLiveChatOrPlaylistPanel) { + return true; + } + return isAutoPopupPanel && ShortsPlayerState.getCurrent().isClosed(); + } + + public static void setInitVideoPanel(boolean initVideoPanel) { + isAutoPopupPanel = initVideoPanel; + } + + @NonNull + public static String videoId = ""; + + public static void disableAutoSwitchMixPlaylists(@NonNull String newlyLoadedChannelId, @NonNull String newlyLoadedChannelName, + @NonNull String newlyLoadedVideoId, @NonNull String newlyLoadedVideoTitle, + final long newlyLoadedVideoLength, boolean newlyLoadedLiveStreamValue) { + if (!Settings.DISABLE_AUTO_SWITCH_MIX_PLAYLISTS.get()) { + return; + } + if (PlayerType.getCurrent() == PlayerType.INLINE_MINIMAL) { + return; + } + if (Objects.equals(newlyLoadedVideoId, videoId)) { + return; + } + videoId = newlyLoadedVideoId; + + if (!VideoInformation.lastPlayerResponseIsAutoGeneratedMixPlaylist()) { + return; + } + VideoUtils.pauseMedia(); + VideoUtils.openVideo(videoId); + } + + public static boolean disableSpeedOverlay() { + return disableSpeedOverlay(true); + } + + public static boolean disableSpeedOverlay(boolean original) { + return !Settings.DISABLE_SPEED_OVERLAY.get() && original; + } + + public static double speedOverlayValue() { + return speedOverlayValue(2.0f); + } + + public static float speedOverlayValue(float original) { + return SPEED_OVERLAY_VALUE; + } + + public static boolean hideChannelWatermark(boolean original) { + return !Settings.HIDE_CHANNEL_WATERMARK.get() && original; + } + + public static void hideCrowdfundingBox(View view) { + hideViewBy0dpUnderCondition(Settings.HIDE_CROWDFUNDING_BOX.get(), view); + } + + public static void hideDoubleTapOverlayFilter(View view) { + hideViewByRemovingFromParentUnderCondition(Settings.HIDE_DOUBLE_TAP_OVERLAY_FILTER, view); + } + + public static void hideEndScreenCards(View view) { + if (Settings.HIDE_END_SCREEN_CARDS.get()) { + view.setVisibility(View.GONE); + } + } + + public static boolean hideFilmstripOverlay() { + return Settings.HIDE_FILMSTRIP_OVERLAY.get(); + } + + public static boolean hideInfoCard(boolean original) { + return !Settings.HIDE_INFO_CARDS.get() && original; + } + + public static boolean hideSeekMessage() { + return Settings.HIDE_SEEK_MESSAGE.get(); + } + + public static boolean hideSeekUndoMessage() { + return Settings.HIDE_SEEK_UNDO_MESSAGE.get(); + } + + public static void hideSuggestedActions(View view) { + hideViewUnderCondition(Settings.HIDE_SUGGESTED_ACTION.get(), view); + } + + public static boolean hideSuggestedVideoEndScreen() { + return Settings.HIDE_SUGGESTED_VIDEO_END_SCREEN.get(); + } + + public static void skipAutoPlayCountdown(View view) { + if (!hideSuggestedVideoEndScreen()) + return; + if (!Settings.SKIP_AUTOPLAY_COUNTDOWN.get()) + return; + + Utils.clickView(view); + } + + public static boolean hideZoomOverlay() { + return Settings.HIDE_ZOOM_OVERLAY.get(); + } + + // endregion + + // region [Hide player flyout menu] patch + + private static final String QUALITY_LABEL_PREMIUM = "1080p Premium"; + + public static String hidePlayerFlyoutMenuEnhancedBitrate(String qualityLabel) { + return Settings.HIDE_PLAYER_FLYOUT_MENU_ENHANCED_BITRATE.get() && + Objects.equals(QUALITY_LABEL_PREMIUM, qualityLabel) + ? null + : qualityLabel; + } + + public static void hidePlayerFlyoutMenuCaptionsFooter(View view) { + Utils.hideViewUnderCondition( + Settings.HIDE_PLAYER_FLYOUT_MENU_CAPTIONS_FOOTER.get(), + view + ); + } + + public static void hidePlayerFlyoutMenuQualityFooter(View view) { + Utils.hideViewUnderCondition( + Settings.HIDE_PLAYER_FLYOUT_MENU_QUALITY_FOOTER.get(), + view + ); + } + + public static View hidePlayerFlyoutMenuQualityHeader(View view) { + return Settings.HIDE_PLAYER_FLYOUT_MENU_QUALITY_HEADER.get() + ? new View(view.getContext()) // empty view + : view; + } + + /** + * Overriding this values is possible only after the litho component has been loaded. + * Otherwise, crash will occur. + * See {@link InitializationPatch#onCreate}. + * + * @param original original value. + * @return whether to enable PiP Mode in the player flyout menu. + */ + public static boolean hidePiPModeMenu(boolean original) { + if (!BaseSettings.SETTINGS_INITIALIZED.get()) { + return original; + } + + return !Settings.HIDE_PLAYER_FLYOUT_MENU_PIP.get(); + } + + // endregion + + // region [Seekbar components] patch + + public static final int ORIGINAL_SEEKBAR_COLOR = 0xFFFF0000; + + public static String appendTimeStampInformation(String original) { + if (!Settings.APPEND_TIME_STAMP_INFORMATION.get()) return original; + + String appendString = Settings.APPEND_TIME_STAMP_INFORMATION_TYPE.get() + ? VideoUtils.getFormattedQualityString(null) + : VideoUtils.getFormattedSpeedString(null); + + // Encapsulate the entire appendString with bidi control characters + appendString = "\u2066" + appendString + "\u2069"; + + // Format the original string with the appended timestamp information + return String.format( + "%s\u2009•\u2009%s", // Add the separator and the appended information + original, appendString + ); + } + + public static void setContainerClickListener(View view) { + if (!Settings.APPEND_TIME_STAMP_INFORMATION.get()) + return; + + if (!(view.getParent() instanceof View containerView)) + return; + + final BooleanSetting appendTypeSetting = Settings.APPEND_TIME_STAMP_INFORMATION_TYPE; + final boolean previousBoolean = appendTypeSetting.get(); + + containerView.setOnLongClickListener(timeStampContainerView -> { + appendTypeSetting.save(!previousBoolean); + return true; + } + ); + + if (Settings.REPLACE_TIME_STAMP_ACTION.get()) { + containerView.setOnClickListener(timeStampContainerView -> VideoUtils.showFlyoutMenu()); + } + } + + public static int getSeekbarClickedColorValue(final int colorValue) { + return colorValue == ORIGINAL_SEEKBAR_COLOR + ? overrideSeekbarColor(colorValue) + : colorValue; + } + + public static int resumedProgressBarColor(final int colorValue) { + return Settings.ENABLE_CUSTOM_SEEKBAR_COLOR.get() + ? getSeekbarClickedColorValue(colorValue) + : colorValue; + } + + /** + * Overrides all drawable color that use the YouTube seekbar color. + * Used only for the video thumbnails seekbar. + *

+ * If {@link Settings#HIDE_SEEKBAR_THUMBNAIL} is enabled, this returns a fully transparent color. + */ + public static int getColor(int colorValue) { + if (colorValue == ORIGINAL_SEEKBAR_COLOR) { + if (Settings.HIDE_SEEKBAR_THUMBNAIL.get()) { + return 0x00000000; + } + return overrideSeekbarColor(ORIGINAL_SEEKBAR_COLOR); + } + return colorValue; + } + + /** + * Points where errors occur when playing videos on the PlayStore (ROOT Build) + */ + public static int overrideSeekbarColor(final int colorValue) { + try { + return Settings.ENABLE_CUSTOM_SEEKBAR_COLOR.get() + ? Color.parseColor(Settings.ENABLE_CUSTOM_SEEKBAR_COLOR_VALUE.get()) + : colorValue; + } catch (Exception ignored) { + Settings.ENABLE_CUSTOM_SEEKBAR_COLOR_VALUE.resetToDefault(); + } + return colorValue; + } + + public static boolean enableSeekbarTapping() { + return Settings.ENABLE_SEEKBAR_TAPPING.get(); + } + + public static boolean enableHighQualityFullscreenThumbnails() { + return Settings.RESTORE_OLD_SEEKBAR_THUMBNAILS.get(); + } + + private static final int timeBarChapterViewId = + ResourceUtils.getIdIdentifier("time_bar_chapter_title"); + + public static boolean hideSeekbar() { + return Settings.HIDE_SEEKBAR.get(); + } + + public static boolean disableSeekbarChapters() { + return Settings.DISABLE_SEEKBAR_CHAPTERS.get(); + } + + public static boolean hideSeekbarChapterLabel(View view) { + return Settings.HIDE_SEEKBAR_CHAPTER_LABEL.get() && view.getId() == timeBarChapterViewId; + } + + public static boolean hideTimeStamp() { + return Settings.HIDE_TIME_STAMP.get(); + } + + public static boolean restoreOldSeekbarThumbnails() { + return !Settings.RESTORE_OLD_SEEKBAR_THUMBNAILS.get(); + } + + public static boolean enableCairoSeekbar() { + return Settings.ENABLE_CAIRO_SEEKBAR.get(); + } + + // endregion + + public static int getQuickActionsTopMargin() { + if (!PatchStatus.QuickActions()) { + return 0; + } + return QUICK_ACTIONS_MARGIN_TOP; + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/AnimationFeedbackPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/AnimationFeedbackPatch.java new file mode 100644 index 0000000000..e21d61a0bd --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/AnimationFeedbackPatch.java @@ -0,0 +1,84 @@ +package app.revanced.extension.youtube.patches.shorts; + +import static app.revanced.extension.shared.utils.ResourceUtils.getRawIdentifier; +import static app.revanced.extension.youtube.patches.shorts.AnimationFeedbackPatch.AnimationType.ORIGINAL; + +import androidx.annotation.Nullable; + +import com.airbnb.lottie.LottieAnimationView; + +import app.revanced.extension.shared.utils.ResourceUtils; +import app.revanced.extension.youtube.patches.utils.LottieAnimationViewPatch; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class AnimationFeedbackPatch { + + public enum AnimationType { + /** + * Unmodified type, and same as un-patched. + */ + ORIGINAL(null), + THUMBS_UP("like_tap_feedback"), + THUMBS_UP_CAIRO("like_tap_feedback_cairo"), + HEART("like_tap_feedback_heart"), + HEART_TINT("like_tap_feedback_heart_tint"), + HIDDEN("like_tap_feedback_hidden"); + + /** + * Animation id. + */ + final int rawRes; + + AnimationType(@Nullable String jsonName) { + this.rawRes = jsonName != null + ? getRawIdentifier(jsonName) + : 0; + } + } + + private static final AnimationType CURRENT_TYPE = Settings.ANIMATION_TYPE.get(); + + private static final boolean HIDE_PLAY_PAUSE_FEEDBACK = Settings.HIDE_SHORTS_PLAY_PAUSE_BUTTON_BACKGROUND.get(); + + private static final int PAUSE_TAP_FEEDBACK_HIDDEN + = ResourceUtils.getRawIdentifier("pause_tap_feedback_hidden"); + + private static final int PLAY_TAP_FEEDBACK_HIDDEN + = ResourceUtils.getRawIdentifier("play_tap_feedback_hidden"); + + + /** + * Injection point. + */ + public static void setShortsLikeFeedback(LottieAnimationView lottieAnimationView) { + if (CURRENT_TYPE == ORIGINAL) { + return; + } + + LottieAnimationViewPatch.setLottieAnimationRawResources(lottieAnimationView, CURRENT_TYPE.rawRes); + } + + /** + * Injection point. + */ + public static void setShortsPauseFeedback(LottieAnimationView lottieAnimationView) { + if (!HIDE_PLAY_PAUSE_FEEDBACK) { + return; + } + + LottieAnimationViewPatch.setLottieAnimationRawResources(lottieAnimationView, PAUSE_TAP_FEEDBACK_HIDDEN); + } + + /** + * Injection point. + */ + public static void setShortsPlayFeedback(LottieAnimationView lottieAnimationView) { + if (!HIDE_PLAY_PAUSE_FEEDBACK) { + return; + } + + LottieAnimationViewPatch.setLottieAnimationRawResources(lottieAnimationView, PLAY_TAP_FEEDBACK_HIDDEN); + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/ShortsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/ShortsPatch.java new file mode 100644 index 0000000000..1bf0f5bc5d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/ShortsPatch.java @@ -0,0 +1,224 @@ +package app.revanced.extension.youtube.patches.shorts; + +import static app.revanced.extension.shared.utils.Utils.hideViewUnderCondition; +import static app.revanced.extension.youtube.utils.ExtendedUtils.validateValue; + +import android.util.TypedValue; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import com.google.android.libraries.youtube.rendering.ui.pivotbar.PivotBar; + +import java.lang.ref.WeakReference; + +import app.revanced.extension.shared.utils.ResourceUtils; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.ShortsPlayerState; +import app.revanced.extension.youtube.utils.VideoUtils; + +@SuppressWarnings("unused") +public class ShortsPatch { + private static final boolean ENABLE_TIME_STAMP = Settings.ENABLE_TIME_STAMP.get(); + public static final boolean HIDE_SHORTS_NAVIGATION_BAR = Settings.HIDE_SHORTS_NAVIGATION_BAR.get(); + + private static final int META_PANEL_BOTTOM_MARGIN; + private static final double NAVIGATION_BAR_HEIGHT_PERCENTAGE; + + static { + if (HIDE_SHORTS_NAVIGATION_BAR) { + ShortsPlayerState.getOnChange().addObserver((ShortsPlayerState state) -> { + setNavigationBarLayoutParams(state); + return null; + }); + } + final int bottomMargin = validateValue( + Settings.META_PANEL_BOTTOM_MARGIN, + 0, + 64, + "revanced_shorts_meta_panel_bottom_margin_invalid_toast" + ); + + META_PANEL_BOTTOM_MARGIN = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, (float) bottomMargin, Utils.getResources().getDisplayMetrics()); + + final int heightPercentage = validateValue( + Settings.SHORTS_NAVIGATION_BAR_HEIGHT_PERCENTAGE, + 0, + 100, + "revanced_shorts_navigation_bar_height_percentage_invalid_toast" + ); + + NAVIGATION_BAR_HEIGHT_PERCENTAGE = heightPercentage / 100d; + } + + public static Enum repeat; + public static Enum singlePlay; + public static Enum endScreen; + + public static Enum changeShortsRepeatState(Enum currentState) { + switch (Settings.CHANGE_SHORTS_REPEAT_STATE.get()) { + case 1 -> currentState = repeat; + case 2 -> currentState = singlePlay; + case 3 -> currentState = endScreen; + } + + return currentState; + } + + public static boolean disableResumingStartupShortsPlayer() { + return Settings.DISABLE_RESUMING_SHORTS_PLAYER.get(); + } + + public static boolean enableShortsTimeStamp(boolean original) { + return ENABLE_TIME_STAMP || original; + } + + public static int enableShortsTimeStamp(int original) { + return ENABLE_TIME_STAMP ? 10010 : original; + } + + public static void setShortsMetaPanelBottomMargin(View view) { + if (!ENABLE_TIME_STAMP) + return; + + if (!(view.getLayoutParams() instanceof RelativeLayout.LayoutParams lp)) + return; + + lp.setMargins(0, 0, 0, META_PANEL_BOTTOM_MARGIN); + lp.setMarginEnd(ResourceUtils.getDimension("reel_player_right_dyn_bar_width")); + } + + public static void setShortsTimeStampChangeRepeatState(View view) { + if (!ENABLE_TIME_STAMP) + return; + if (!Settings.TIME_STAMP_CHANGE_REPEAT_STATE.get()) + return; + if (view == null) + return; + + view.setLongClickable(true); + view.setOnLongClickListener(view1 -> { + VideoUtils.showShortsRepeatDialog(view1.getContext()); + return true; + }); + } + + public static void hideShortsCommentsButton(View view) { + hideViewUnderCondition(Settings.HIDE_SHORTS_COMMENTS_BUTTON.get(), view); + } + + public static boolean hideShortsDislikeButton() { + return Settings.HIDE_SHORTS_DISLIKE_BUTTON.get(); + } + + public static ViewGroup hideShortsInfoPanel(ViewGroup viewGroup) { + return Settings.HIDE_SHORTS_INFO_PANEL.get() ? null : viewGroup; + } + + public static boolean hideShortsLikeButton() { + return Settings.HIDE_SHORTS_LIKE_BUTTON.get(); + } + + public static boolean hideShortsPaidPromotionLabel() { + return Settings.HIDE_SHORTS_PAID_PROMOTION_LABEL.get(); + } + + public static void hideShortsPaidPromotionLabel(TextView textView) { + hideViewUnderCondition(Settings.HIDE_SHORTS_PAID_PROMOTION_LABEL.get(), textView); + } + + public static void hideShortsRemixButton(View view) { + hideViewUnderCondition(Settings.HIDE_SHORTS_REMIX_BUTTON.get(), view); + } + + public static void hideShortsShareButton(View view) { + hideViewUnderCondition(Settings.HIDE_SHORTS_SHARE_BUTTON.get(), view); + } + + public static boolean hideShortsSoundButton() { + return Settings.HIDE_SHORTS_SOUND_BUTTON.get(); + } + + private static final int zeroPaddingDimenId = + ResourceUtils.getDimenIdentifier("revanced_zero_padding"); + + public static int getShortsSoundButtonDimenId(int dimenId) { + return Settings.HIDE_SHORTS_SOUND_BUTTON.get() + ? zeroPaddingDimenId + : dimenId; + } + + public static int hideShortsSubscribeButton(int original) { + return Settings.HIDE_SHORTS_SUBSCRIBE_BUTTON.get() ? 0 : original; + } + + // YouTube 18.29.38 ~ YouTube 19.28.42 + public static boolean hideShortsPausedHeader() { + return Settings.HIDE_SHORTS_PAUSED_HEADER.get(); + } + + // YouTube 19.29.42 ~ + public static boolean hideShortsPausedHeader(boolean original) { + return Settings.HIDE_SHORTS_PAUSED_HEADER.get() || original; + } + + public static boolean hideShortsToolBar(boolean original) { + return !Settings.HIDE_SHORTS_TOOLBAR.get() && original; + } + + /** + * BottomBarContainer is the parent view of {@link PivotBar}, + * And can be hidden using {@link View#setVisibility} only when it is initialized. + *

+ * If it was not hidden with {@link View#setVisibility} when it was initialized, + * it should be hidden with {@link FrameLayout.LayoutParams}. + *

+ * When Shorts is opened, {@link FrameLayout.LayoutParams} should be changed to 0dp, + * When Shorts is closed, {@link FrameLayout.LayoutParams} should be changed to the original. + */ + private static WeakReference bottomBarContainerRef = new WeakReference<>(null); + + private static FrameLayout.LayoutParams originalLayoutParams; + private static final FrameLayout.LayoutParams zeroLayoutParams = + new FrameLayout.LayoutParams(0, 0); + + public static void setNavigationBar(View view) { + if (!HIDE_SHORTS_NAVIGATION_BAR) { + return; + } + bottomBarContainerRef = new WeakReference<>(view); + if (!(view.getLayoutParams() instanceof FrameLayout.LayoutParams lp)) { + return; + } + if (originalLayoutParams == null) { + originalLayoutParams = lp; + } + } + + public static int setNavigationBarHeight(int original) { + return HIDE_SHORTS_NAVIGATION_BAR + ? (int) Math.round(original * NAVIGATION_BAR_HEIGHT_PERCENTAGE) + : original; + } + + private static void setNavigationBarLayoutParams(@NonNull ShortsPlayerState shortsPlayerState) { + final View navigationBar = bottomBarContainerRef.get(); + if (navigationBar == null) { + return; + } + if (!(navigationBar.getLayoutParams() instanceof FrameLayout.LayoutParams lp)) { + return; + } + navigationBar.setLayoutParams( + shortsPlayerState.isClosed() + ? originalLayoutParams + : zeroLayoutParams + ); + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spans/SanitizeVideoSubtitleFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spans/SanitizeVideoSubtitleFilter.java new file mode 100644 index 0000000000..fc4daf1775 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spans/SanitizeVideoSubtitleFilter.java @@ -0,0 +1,36 @@ +package app.revanced.extension.youtube.patches.spans; + +import android.text.SpannableString; + +import app.revanced.extension.shared.patches.spans.Filter; +import app.revanced.extension.shared.patches.spans.SpanType; +import app.revanced.extension.shared.patches.spans.StringFilterGroup; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings({"unused", "ConstantValue", "FieldCanBeLocal"}) +public final class SanitizeVideoSubtitleFilter extends Filter { + + public SanitizeVideoSubtitleFilter() { + addCallbacks( + new StringFilterGroup( + Settings.SANITIZE_VIDEO_SUBTITLE, + "|video_subtitle.eml|" + ) + ); + } + + @Override + public boolean skip(String conversionContext, SpannableString spannableString, Object span, + int start, int end, int flags, boolean isWord, SpanType spanType, StringFilterGroup matchedGroup) { + if (isWord) { + if (spanType == SpanType.IMAGE) { + hideImageSpan(spannableString, start, end, flags); + return super.skip(conversionContext, spannableString, span, start, end, flags, isWord, spanType, matchedGroup); + } else if (spanType == SpanType.CUSTOM_CHARACTER_STYLE) { + hideSpan(spannableString, start, end, flags); + return super.skip(conversionContext, spannableString, span, start, end, flags, isWord, spanType, matchedGroup); + } + } + return false; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spans/SearchLinksFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spans/SearchLinksFilter.java new file mode 100644 index 0000000000..2e6babc822 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spans/SearchLinksFilter.java @@ -0,0 +1,52 @@ +package app.revanced.extension.youtube.patches.spans; + +import android.text.SpannableString; + +import app.revanced.extension.shared.patches.spans.Filter; +import app.revanced.extension.shared.patches.spans.SpanType; +import app.revanced.extension.shared.patches.spans.StringFilterGroup; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings({"unused", "ConstantValue", "FieldCanBeLocal"}) +public final class SearchLinksFilter extends Filter { + /** + * Located in front of the search icon. + */ + private final String WORD_JOINER_CHARACTER = "\u2060"; + + public SearchLinksFilter() { + addCallbacks( + new StringFilterGroup( + Settings.HIDE_COMMENT_HIGHLIGHTED_SEARCH_LINKS, + "|comment." + ) + ); + } + + /** + * @return Whether the word contains a search icon or not. + */ + private boolean isSearchLinks(SpannableString original, int end) { + String originalString = original.toString(); + int wordJoinerIndex = originalString.indexOf(WORD_JOINER_CHARACTER); + // There may be more than one highlight keyword in the comment. + // Check the index of all highlight keywords. + while (wordJoinerIndex != -1) { + if (end - wordJoinerIndex == 2) return true; + wordJoinerIndex = originalString.indexOf(WORD_JOINER_CHARACTER, wordJoinerIndex + 1); + } + return false; + } + + @Override + public boolean skip(String conversionContext, SpannableString spannableString, Object span, + int start, int end, int flags, boolean isWord, SpanType spanType, StringFilterGroup matchedGroup) { + if (isWord && isSearchLinks(spannableString, end)) { + if (spanType == SpanType.IMAGE) { + hideSpan(spannableString, start, end, flags); + } + return super.skip(conversionContext, spannableString, span, start, end, flags, isWord, spanType, matchedGroup); + } + return false; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/swipe/SwipeControlsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/swipe/SwipeControlsPatch.java new file mode 100644 index 0000000000..24ee3f4a39 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/swipe/SwipeControlsPatch.java @@ -0,0 +1,48 @@ +package app.revanced.extension.youtube.patches.swipe; + +import android.view.View; + +import java.lang.ref.WeakReference; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings({"unused", "deprecation"}) +public class SwipeControlsPatch { + private static WeakReference fullscreenEngagementOverlayViewRef = new WeakReference<>(null); + + /** + * Injection point. + */ + public static boolean disableHDRAutoBrightness() { + return Settings.DISABLE_HDR_AUTO_BRIGHTNESS.get(); + } + + /** + * Injection point. + */ + public static boolean enableSwipeToSwitchVideo() { + return Settings.ENABLE_SWIPE_TO_SWITCH_VIDEO.get(); + } + + /** + * Injection point. + */ + public static boolean enableWatchPanelGestures() { + return Settings.ENABLE_WATCH_PANEL_GESTURES.get(); + } + + /** + * Injection point. + * + * @param fullscreenEngagementOverlayView R.layout.fullscreen_engagement_overlay + */ + public static void setFullscreenEngagementOverlayView(View fullscreenEngagementOverlayView) { + fullscreenEngagementOverlayViewRef = new WeakReference<>(fullscreenEngagementOverlayView); + } + + public static boolean isEngagementOverlayVisible() { + final View engagementOverlayView = fullscreenEngagementOverlayViewRef.get(); + return engagementOverlayView != null && engagementOverlayView.getVisibility() == View.VISIBLE; + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/AlwaysRepeatPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/AlwaysRepeatPatch.java new file mode 100644 index 0000000000..41b8ea4d9b --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/AlwaysRepeatPatch.java @@ -0,0 +1,29 @@ +package app.revanced.extension.youtube.patches.utils; + +import static app.revanced.extension.youtube.utils.VideoUtils.pauseMedia; + +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.VideoInformation; + +@SuppressWarnings("unused") +public class AlwaysRepeatPatch extends Utils { + + /** + * Injection point. + * + * @return video is repeated. + */ + public static boolean alwaysRepeat() { + return alwaysRepeatEnabled() && VideoInformation.overrideVideoTime(0); + } + + public static boolean alwaysRepeatEnabled() { + final boolean alwaysRepeat = Settings.ALWAYS_REPEAT.get(); + final boolean alwaysRepeatPause = Settings.ALWAYS_REPEAT_PAUSE.get(); + + if (alwaysRepeat && alwaysRepeatPause) pauseMedia(); + return alwaysRepeat; + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/BottomSheetHookPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/BottomSheetHookPatch.java new file mode 100644 index 0000000000..b7c5c1c084 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/BottomSheetHookPatch.java @@ -0,0 +1,21 @@ +package app.revanced.extension.youtube.patches.utils; + +import app.revanced.extension.youtube.shared.BottomSheetState; + +@SuppressWarnings("unused") +public class BottomSheetHookPatch { + /** + * Injection point. + */ + public static void onAttachedToWindow() { + BottomSheetState.set(BottomSheetState.OPEN); + } + + /** + * Injection point. + */ + public static void onDetachedFromWindow() { + BottomSheetState.set(BottomSheetState.CLOSED); + } +} + diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/CastButtonPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/CastButtonPatch.java new file mode 100644 index 0000000000..bd206dcc96 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/CastButtonPatch.java @@ -0,0 +1,25 @@ +package app.revanced.extension.youtube.patches.utils; + +import android.view.View; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class CastButtonPatch { + + /** + * The [Hide cast button] setting is separated into the [Hide cast button in player] setting and the [Hide cast button in toolbar] setting. + * Always hide the cast button when both settings are true. + *

+ * These two settings belong to different patches, and since the default value for this setting is true, + * it is essential to ensure that each patch is included to ensure independent operation. + */ + public static int hideCastButton(int original) { + return Settings.HIDE_TOOLBAR_CAST_BUTTON.get() + && PatchStatus.ToolBarComponents() + && Settings.HIDE_PLAYER_CAST_BUTTON.get() + && PatchStatus.PlayerButtons() + ? View.GONE + : original; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/DoubleBackToClosePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/DoubleBackToClosePatch.java new file mode 100644 index 0000000000..fdf4ba1630 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/DoubleBackToClosePatch.java @@ -0,0 +1,66 @@ +package app.revanced.extension.youtube.patches.utils; + +import android.app.Activity; + +import androidx.annotation.NonNull; + +import app.revanced.extension.youtube.settings.Settings; + +/** + * @noinspection ALL + */ +public class DoubleBackToClosePatch { + /** + * Time between two back button presses + */ + private static final long PRESSED_TIMEOUT_MILLISECONDS = Settings.DOUBLE_BACK_TO_CLOSE_TIMEOUT.get(); + + /** + * Last time back button was pressed + */ + private static long lastTimeBackPressed = 0; + + /** + * State whether scroll position reaches the top + */ + private static boolean isScrollTop = false; + + /** + * Detect event when back button is pressed + * + * @param activity is used when closing the app + */ + public static void closeActivityOnBackPressed(@NonNull Activity activity) { + // Check scroll position reaches the top in home feed + if (!isScrollTop) + return; + + final long currentTime = System.currentTimeMillis(); + + // If the time between two back button presses does not reach PRESSED_TIMEOUT_MILLISECONDS, + // set lastTimeBackPressed to the current time. + if (currentTime - lastTimeBackPressed < PRESSED_TIMEOUT_MILLISECONDS || + PRESSED_TIMEOUT_MILLISECONDS == 0) + activity.finish(); + else + lastTimeBackPressed = currentTime; + } + + /** + * Detect event when ScrollView is created by RecyclerView + *

+ * start of ScrollView + */ + public static void onStartScrollView() { + isScrollTop = false; + } + + /** + * Detect event when the scroll position reaches the top by the back button + *

+ * stop of ScrollView + */ + public static void onStopScrollView() { + isScrollTop = true; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/DrawableColorPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/DrawableColorPatch.java new file mode 100644 index 0000000000..853779b373 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/DrawableColorPatch.java @@ -0,0 +1,50 @@ +package app.revanced.extension.youtube.patches.utils; + +import app.revanced.extension.shared.utils.ResourceUtils; + +@SuppressWarnings("unused") +public class DrawableColorPatch { + private static final int[] WHITE_VALUES = { + -1, // comments chip background + -394759, // music related results panel background + -83886081 // video chapters list background + }; + + private static final int[] DARK_VALUES = { + -14145496, // drawer content view background + -14606047, // comments chip background + -15198184, // music related results panel background + -15790321, // comments chip background (new layout) + -98492127 // video chapters list background + }; + + // background colors + private static int whiteColor = 0; + private static int blackColor = 0; + + public static int getColor(int originalValue) { + if (anyEquals(originalValue, DARK_VALUES)) { + return getBlackColor(); + } else if (anyEquals(originalValue, WHITE_VALUES)) { + return getWhiteColor(); + } + return originalValue; + } + + private static int getBlackColor() { + if (blackColor == 0) blackColor = ResourceUtils.getColor("yt_black1"); + return blackColor; + } + + private static int getWhiteColor() { + if (whiteColor == 0) whiteColor = ResourceUtils.getColor("yt_white1"); + return whiteColor; + } + + private static boolean anyEquals(int value, int... of) { + for (int v : of) if (value == v) return true; + return false; + } +} + + diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/InitializationPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/InitializationPatch.java new file mode 100644 index 0000000000..4dd5f08215 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/InitializationPatch.java @@ -0,0 +1,39 @@ +package app.revanced.extension.youtube.patches.utils; + +import static app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment.showRestartDialog; +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.shared.utils.Utils.runOnMainThreadDelayed; + +import android.app.Activity; + +import androidx.annotation.NonNull; + +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.youtube.utils.ExtendedUtils; + +@SuppressWarnings("unused") +public class InitializationPatch { + private static final BooleanSetting SETTINGS_INITIALIZED = BaseSettings.SETTINGS_INITIALIZED; + + /** + * Some layouts that depend on litho do not load when the app is first installed. + * (Also reproduced on unPatched YouTube) + *

+ * To fix this, show the restart dialog when the app is installed for the first time. + */ + public static void onCreate(@NonNull Activity mActivity) { + if (SETTINGS_INITIALIZED.get()) { + return; + } + runOnMainThreadDelayed(() -> showRestartDialog(mActivity, str("revanced_extended_restart_first_run"), 3500), 500); + runOnMainThreadDelayed(() -> SETTINGS_INITIALIZED.save(true), 1000); + } + + public static void setExtendedUtils(@NonNull Activity mActivity) { + ExtendedUtils.setApplicationLabel(); + ExtendedUtils.setSmallestScreenWidthDp(); + ExtendedUtils.setVersionName(); + ExtendedUtils.setPlayerFlyoutMenuAdditionalSettings(); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/LockModeStateHookPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/LockModeStateHookPatch.java new file mode 100644 index 0000000000..96baec1a18 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/LockModeStateHookPatch.java @@ -0,0 +1,18 @@ +package app.revanced.extension.youtube.patches.utils; + +import androidx.annotation.Nullable; + +import app.revanced.extension.youtube.shared.LockModeState; + +@SuppressWarnings("unused") +public class LockModeStateHookPatch { + /** + * Injection point. + */ + public static void setLockModeState(@Nullable Enum lockModeState) { + if (lockModeState == null) return; + + LockModeState.setFromString(lockModeState.name()); + } +} + diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/LottieAnimationViewPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/LottieAnimationViewPatch.java new file mode 100644 index 0000000000..68323f843d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/LottieAnimationViewPatch.java @@ -0,0 +1,25 @@ +package app.revanced.extension.youtube.patches.utils; + +import com.airbnb.lottie.LottieAnimationView; + +import app.revanced.extension.shared.utils.Logger; + +public class LottieAnimationViewPatch { + + public static void setLottieAnimationRawResources(LottieAnimationView lottieAnimationView, int rawRes) { + if (lottieAnimationView == null) { + Logger.printDebug(() -> "View is null"); + return; + } + if (rawRes == 0) { + Logger.printDebug(() -> "Resource is not found"); + return; + } + setAnimation(lottieAnimationView, rawRes); + } + + @SuppressWarnings("unused") + private static void setAnimation(LottieAnimationView lottieAnimationView, int rawRes) { + // Rest of the implementation added by patch. + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PatchStatus.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PatchStatus.java new file mode 100644 index 0000000000..309415c0d6 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PatchStatus.java @@ -0,0 +1,50 @@ +package app.revanced.extension.youtube.patches.utils; + +public class PatchStatus { + + public static boolean ImageSearchButton() { + // Replace this with true if the Hide image search buttons patch succeeds + return false; + } + + public static boolean MinimalHeader() { + // Replace this with true If the Custom header patch succeeds and the patch option was `youtube_minimal_header` + return false; + } + + public static boolean PlayerButtons() { + // Replace this with true if the Hide player buttons patch succeeds + return false; + } + + public static boolean QuickActions() { + // Replace this with true if the Fullscreen components patch succeeds + return false; + } + + public static boolean RememberPlaybackSpeed() { + // Replace this with true if the Video playback patch succeeds + return false; + } + + public static boolean SponsorBlock() { + // Replace this with true if the SponsorBlock patch succeeds + return false; + } + + public static boolean ToolBarComponents() { + // Replace this with true if the Toolbar components patch succeeds + return false; + } + + // Modified by a patch. Do not touch. + public static String RVXMusicPackageName() { + return "com.google.android.apps.youtube.music"; + } + + // Modified by a patch. Do not touch. + public static boolean OldSeekbarThumbnailsDefaultBoolean() { + return false; + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerControlsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerControlsPatch.java new file mode 100644 index 0000000000..0fb6115e68 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerControlsPatch.java @@ -0,0 +1,122 @@ +package app.revanced.extension.youtube.patches.utils; + +import static app.revanced.extension.shared.utils.ResourceUtils.getIdIdentifier; + +import android.view.View; + +import androidx.annotation.NonNull; + +import java.lang.ref.WeakReference; + +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.shared.PlayerControlsVisibility; + +/** + * @noinspection ALL + */ +public class PlayerControlsPatch { + private static WeakReference playerOverflowButtonViewRef = new WeakReference<>(null); + private static final int playerOverflowButtonId = + getIdIdentifier("player_overflow_button"); + + /** + * Injection point. + */ + public static void initializeBottomControlButton(View bottomControlsViewGroup) { + // AlwaysRepeat.initialize(bottomControlsViewGroup); + // CopyVideoUrl.initialize(bottomControlsViewGroup); + // CopyVideoUrlTimestamp.initialize(bottomControlsViewGroup); + // MuteVolume.initialize(bottomControlsViewGroup); + // ExternalDownload.initialize(bottomControlsViewGroup); + // SpeedDialog.initialize(bottomControlsViewGroup); + // TimeOrderedPlaylist.initialize(bottomControlsViewGroup); + // Whitelists.initialize(bottomControlsViewGroup); + } + + /** + * Injection point. + */ + public static void initializeTopControlButton(View youtubeControlsLayout) { + // CreateSegmentButtonController.initialize(youtubeControlsLayout); + // VotingButtonController.initialize(youtubeControlsLayout); + } + + /** + * Injection point. + * Legacy method. + *

+ * Player overflow button view does not attach to windows immediately after cold start. + * Player overflow button view is not attached to the windows until the user touches the player at least once, and the overlay buttons are hidden until then. + * To prevent this, uses the legacy method to show the overlay button until the player overflow button view is attached to the windows. + */ + public static void changeVisibility(boolean showing) { + if (playerOverflowButtonViewRef.get() != null) { + return; + } + changeVisibility(showing, false); + } + + private static void changeVisibility(boolean showing, boolean animation) { + // AlwaysRepeat.changeVisibility(showing, animation); + // CopyVideoUrl.changeVisibility(showing, animation); + // CopyVideoUrlTimestamp.changeVisibility(showing, animation); + // MuteVolume.changeVisibility(showing, animation); + // ExternalDownload.changeVisibility(showing, animation); + // SpeedDialog.changeVisibility(showing, animation); + // TimeOrderedPlaylist.changeVisibility(showing, animation); + // Whitelists.changeVisibility(showing, animation); + + // CreateSegmentButtonController.changeVisibility(showing, animation); + // VotingButtonController.changeVisibility(showing, animation); + } + + /** + * Injection point. + * New method. + *

+ * Show or hide the overlay button when the player overflow button view is visible and hidden, respectively. + *

+ * Inject the current view into {@link PlayerControlsPatch#playerOverflowButtonView} to check that the player overflow button view is attached to the window. + * From this point on, the legacy method is deprecated. + */ + public static void changeVisibility(boolean showing, boolean animation, @NonNull View view) { + if (view.getId() != playerOverflowButtonId) { + return; + } + if (playerOverflowButtonViewRef.get() == null) { + Utils.runOnMainThreadDelayed(() -> playerOverflowButtonViewRef = new WeakReference<>(view), 1400); + } + changeVisibility(showing, animation); + } + + /** + * Injection point. + *

+ * Called whenever a motion event occurs on the player controller. + *

+ * When the user touches the player overlay (motion event occurs), the player overlay disappears immediately. + * In this case, the overlay buttons should also disappear immediately. + *

+ * In other words, this method detects when the player overlay disappears immediately upon the user's touch, + * and quickly fades out all overlay buttons. + */ + public static void changeVisibilityNegatedImmediate() { + if (PlayerControlsVisibility.getCurrent() == PlayerControlsVisibility.PLAYER_CONTROLS_VISIBILITY_HIDDEN) { + changeVisibilityNegatedImmediately(); + } + } + + private static void changeVisibilityNegatedImmediately() { + // AlwaysRepeat.changeVisibilityNegatedImmediate(); + // CopyVideoUrl.changeVisibilityNegatedImmediate(); + // CopyVideoUrlTimestamp.changeVisibilityNegatedImmediate(); + // MuteVolume.changeVisibilityNegatedImmediate(); + // ExternalDownload.changeVisibilityNegatedImmediate(); + // SpeedDialog.changeVisibilityNegatedImmediate(); + // TimeOrderedPlaylist.changeVisibilityNegatedImmediate(); + // Whitelists.changeVisibilityNegatedImmediate(); + + // CreateSegmentButtonController.changeVisibilityNegatedImmediate(); + // VotingButtonController.changeVisibilityNegatedImmediate(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerControlsVisibilityHookPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerControlsVisibilityHookPatch.java new file mode 100644 index 0000000000..b710594492 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerControlsVisibilityHookPatch.java @@ -0,0 +1,18 @@ +package app.revanced.extension.youtube.patches.utils; + +import androidx.annotation.Nullable; + +import app.revanced.extension.youtube.shared.PlayerControlsVisibility; + +@SuppressWarnings("unused") +public class PlayerControlsVisibilityHookPatch { + /** + * Injection point. + */ + public static void setPlayerControlsVisibility(@Nullable Enum youTubePlayerControlsVisibility) { + if (youTubePlayerControlsVisibility == null) return; + + PlayerControlsVisibility.setFromString(youTubePlayerControlsVisibility.name()); + } +} + diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerTypeHookPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerTypeHookPatch.java new file mode 100644 index 0000000000..ea9bd114c6 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerTypeHookPatch.java @@ -0,0 +1,52 @@ +package app.revanced.extension.youtube.patches.utils; + +import android.view.View; + +import androidx.annotation.Nullable; + +import app.revanced.extension.youtube.shared.PlayerType; +import app.revanced.extension.youtube.shared.ShortsPlayerState; +import app.revanced.extension.youtube.shared.VideoState; + +@SuppressWarnings("unused") +public class PlayerTypeHookPatch { + /** + * Injection point. + */ + public static void setPlayerType(@Nullable Enum youTubePlayerType) { + if (youTubePlayerType == null) return; + + PlayerType.setFromString(youTubePlayerType.name()); + } + + /** + * Injection point. + */ + public static void setVideoState(@Nullable Enum youTubeVideoState) { + if (youTubeVideoState == null) return; + + VideoState.setFromString(youTubeVideoState.name()); + } + + /** + * Injection point. + *

+ * Add a listener to the shorts player overlay View. + * Triggered when a shorts player is attached or detached to Windows. + * + * @param view shorts player overlay (R.id.reel_watch_player). + */ + public static void onShortsCreate(View view) { + view.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { + @Override + public void onViewAttachedToWindow(@Nullable View v) { + ShortsPlayerState.set(ShortsPlayerState.OPEN); + } + @Override + public void onViewDetachedFromWindow(@Nullable View v) { + ShortsPlayerState.set(ShortsPlayerState.CLOSED); + } + }); + } +} + diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ProgressBarDrawable.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ProgressBarDrawable.java new file mode 100644 index 0000000000..3cb20d6179 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ProgressBarDrawable.java @@ -0,0 +1,46 @@ +package app.revanced.extension.youtube.patches.utils; + +import static app.revanced.extension.youtube.patches.player.PlayerPatch.ORIGINAL_SEEKBAR_COLOR; +import static app.revanced.extension.youtube.patches.player.PlayerPatch.resumedProgressBarColor; + +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class ProgressBarDrawable extends Drawable { + + private final Paint paint = new Paint(); + + @Override + public void draw(@NonNull Canvas canvas) { + if (Settings.HIDE_SEEKBAR_THUMBNAIL.get()) { + return; + } + paint.setColor(resumedProgressBarColor(ORIGINAL_SEEKBAR_COLOR)); + canvas.drawRect(getBounds(), paint); + } + + @Override + public void setAlpha(int alpha) { + paint.setAlpha(alpha); + } + + @Override + public void setColorFilter(@Nullable ColorFilter colorFilter) { + paint.setColorFilter(colorFilter); + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ReturnYouTubeChannelNamePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ReturnYouTubeChannelNamePatch.java new file mode 100644 index 0000000000..fca94b6b03 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ReturnYouTubeChannelNamePatch.java @@ -0,0 +1,130 @@ +package app.revanced.extension.youtube.patches.utils; + +import androidx.annotation.NonNull; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class ReturnYouTubeChannelNamePatch { + + private static final boolean REPLACE_CHANNEL_HANDLE = Settings.REPLACE_CHANNEL_HANDLE.get(); + /** + * The last character of some handles is an official channel certification mark. + * This was in the form of nonBreakSpaceCharacter before SpannableString was made. + */ + private static final String NON_BREAK_SPACE_CHARACTER = "\u00A0"; + private volatile static String channelName = ""; + + /** + * Key: channelId, Value: channelName. + */ + private static final Map channelIdMap = Collections.synchronizedMap( + new LinkedHashMap<>(20) { + private static final int CACHE_LIMIT = 10; + + @Override + protected boolean removeEldestEntry(Entry eldest) { + return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit. + } + }); + + /** + * Key: handle, Value: channelName. + */ + private static final Map channelHandleMap = Collections.synchronizedMap( + new LinkedHashMap<>(20) { + private static final int CACHE_LIMIT = 10; + + @Override + protected boolean removeEldestEntry(Entry eldest) { + return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit. + } + }); + + /** + * This method is only invoked on Shorts and is updated whenever the user swipes up or down on the Shorts. + */ + public static void newShortsVideoStarted(@NonNull String newlyLoadedChannelId, @NonNull String newlyLoadedChannelName, + @NonNull String newlyLoadedVideoId, @NonNull String newlyLoadedVideoTitle, + final long newlyLoadedVideoLength, boolean newlyLoadedLiveStreamValue) { + if (!REPLACE_CHANNEL_HANDLE) { + return; + } + if (channelIdMap.get(newlyLoadedChannelId) != null) { + return; + } + if (channelIdMap.put(newlyLoadedChannelId, newlyLoadedChannelName) == null) { + channelName = newlyLoadedChannelName; + Logger.printDebug(() -> "New video started, ChannelId " + newlyLoadedChannelId + ", Channel Name: " + newlyLoadedChannelName); + } + } + + /** + * Injection point. + */ + public static CharSequence onCharSequenceLoaded(@NonNull Object conversionContext, + @NonNull CharSequence charSequence) { + try { + if (!REPLACE_CHANNEL_HANDLE) { + return charSequence; + } + final String conversionContextString = conversionContext.toString(); + if (!conversionContextString.contains("|reel_channel_bar_inner.eml|")) { + return charSequence; + } + final String originalString = charSequence.toString(); + if (!originalString.startsWith("@")) { + return charSequence; + } + return getChannelName(originalString); + } catch (Exception ex) { + Logger.printException(() -> "onCharSequenceLoaded failed", ex); + } + return charSequence; + } + + private static CharSequence getChannelName(@NonNull String handle) { + final String trimmedHandle = handle.replaceAll(NON_BREAK_SPACE_CHARACTER, ""); + + String cachedChannelName = channelHandleMap.get(trimmedHandle); + if (cachedChannelName == null) { + if (!channelName.isEmpty() && channelHandleMap.put(handle, channelName) == null) { + Logger.printDebug(() -> "Set Handle from last fetched Channel Name, Handle: " + handle + ", Channel Name: " + channelName); + cachedChannelName = channelName; + } else { + Logger.printDebug(() -> "Channel handle is not found: " + trimmedHandle); + return handle; + } + } + + if (handle.contains(NON_BREAK_SPACE_CHARACTER)) { + cachedChannelName += NON_BREAK_SPACE_CHARACTER; + } + String replacedChannelName = cachedChannelName; + Logger.printDebug(() -> "Replace Handle " + handle + " to " + replacedChannelName); + return replacedChannelName; + } + + public synchronized static void setLastShortsChannelId(@NonNull String handle, @NonNull String channelId) { + try { + if (channelHandleMap.get(handle) != null) { + return; + } + final String channelName = channelIdMap.get(channelId); + if (channelName == null) { + Logger.printDebug(() -> "Channel name is not found!"); + return; + } + if (channelHandleMap.put(handle, channelName) == null) { + Logger.printDebug(() -> "Set Handle from Shorts, Handle: " + handle + ", Channel Name: " + channelName); + } + } catch (Exception ex) { + Logger.printException(() -> "setLastShortsChannelId failure ", ex); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ReturnYouTubeDislikePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ReturnYouTubeDislikePatch.java new file mode 100644 index 0000000000..8e29174e5c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ReturnYouTubeDislikePatch.java @@ -0,0 +1,690 @@ +package app.revanced.extension.youtube.patches.utils; + +import static app.revanced.extension.shared.returnyoutubedislike.ReturnYouTubeDislike.Vote; +import static app.revanced.extension.youtube.utils.ExtendedUtils.isSpoofingToLessThan; + +import android.graphics.Rect; +import android.graphics.drawable.ShapeDrawable; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.Spanned; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import app.revanced.extension.shared.returnyoutubedislike.requests.ReturnYouTubeDislikeApi; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.components.ReturnYouTubeDislikeFilterPatch; +import app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.PlayerType; +import app.revanced.extension.youtube.shared.VideoInformation; + +/** + * Handles all interaction of UI patch components. + *

+ * Known limitation: + * The implementation of Shorts litho requires blocking the loading the first Short until RYD has completed. + * This is because it modifies the dislikes text synchronously, and if the RYD fetch has + * not completed yet then the UI will be temporarily frozen. + *

+ * A (yet to be implemented) solution that fixes this problem. Any one of: + * - Modify patch to hook onto the Shorts Litho TextView, and update the dislikes text asynchronously. + * - Find a way to force Litho to rebuild it's component tree, + * and use that hook to force the shorts dislikes to update after the fetch is completed. + * - Hook into the dislikes button image view, and replace the dislikes thumb down image with a + * generated image of the number of dislikes, then update the image asynchronously. This Could + * also be used for the regular video player to give a better UI layout and completely remove + * the need for the Rolling Number patches. + */ +@SuppressWarnings("unused") +public class ReturnYouTubeDislikePatch { + + public static final boolean IS_SPOOFING_TO_NON_LITHO_SHORTS_PLAYER = + isSpoofingToLessThan("18.34.00"); + + /** + * RYD data for the current video on screen. + */ + @Nullable + private static volatile ReturnYouTubeDislike currentVideoData; + + /** + * The last litho based Shorts loaded. + * May be the same value as {@link #currentVideoData}, but usually is the next short to swipe to. + */ + @Nullable + private static volatile ReturnYouTubeDislike lastLithoShortsVideoData; + + /** + * Because the litho Shorts spans are created after {@link ReturnYouTubeDislikeFilterPatch} + * detects the video ids, after the user votes the litho will update + * but {@link #lastLithoShortsVideoData} is not the correct data to use. + * If this is true, then instead use {@link #currentVideoData}. + */ + private static volatile boolean lithoShortsShouldUseCurrentData; + + /** + * Last video id prefetched. Field is to prevent prefetching the same video id multiple times in a row. + */ + @Nullable + private static volatile String lastPrefetchedVideoId; + + public static void onRYDStatusChange() { + ReturnYouTubeDislikeApi.resetRateLimits(); + // Must remove all values to protect against using stale data + // if the user enables RYD while a video is on screen. + clearData(); + } + + private static void clearData() { + currentVideoData = null; + lastLithoShortsVideoData = null; + lithoShortsShouldUseCurrentData = false; + // Rolling number text should not be cleared, + // as it's used if incognito Short is opened/closed + // while a regular video is on screen. + } + + // + // Litho player for both regular videos and Shorts. + // + + /** + * Injection point. + *

+ * For Litho segmented buttons and Litho Shorts player. + */ + @NonNull + public static CharSequence onLithoTextLoaded(@NonNull Object conversionContext, + @NonNull CharSequence original) { + return onLithoTextLoaded(conversionContext, original, false); + } + + /** + * Injection point. + *

+ * Called when a litho text component is initially created, + * and also when a Span is later reused again (such as scrolling off/on screen). + *

+ * This method is sometimes called on the main thread, but it usually is called _off_ the main thread. + * This method can be called multiple times for the same UI element (including after dislikes was added). + * + * @param original Original char sequence was created or reused by Litho. + * @param isRollingNumber If the span is for a Rolling Number. + * @return The original char sequence (if nothing should change), or a replacement char sequence that contains dislikes. + */ + @NonNull + private static CharSequence onLithoTextLoaded(@NonNull Object conversionContext, + @NonNull CharSequence original, + boolean isRollingNumber) { + try { + if (!Settings.RYD_ENABLED.get()) { + return original; + } + + String conversionContextString = conversionContext.toString(); + + if (isRollingNumber && !conversionContextString.contains("video_action_bar.eml")) { + return original; + } + + if (conversionContextString.contains("segmented_like_dislike_button.eml")) { + // Regular video. + ReturnYouTubeDislike videoData = currentVideoData; + if (videoData == null) { + return original; // User enabled RYD while a video was on screen. + } + if (!(original instanceof Spanned)) { + original = new SpannableString(original); + } + return videoData.getDislikesSpanForRegularVideo((Spanned) original, + true, isRollingNumber); + } + + if (isRollingNumber) { + return original; // No need to check for Shorts in the context. + } + + if (conversionContextString.contains("|shorts_dislike_button.eml")) { + return getShortsSpan(original, true); + } + + if (conversionContextString.contains("|shorts_like_button.eml") + && !Utils.containsNumber(original)) { + Logger.printDebug(() -> "Replacing hidden likes count"); + return getShortsSpan(original, false); + } + } catch (Exception ex) { + Logger.printException(() -> "onLithoTextLoaded failure", ex); + } + return original; + } + + // + // Litho Shorts player in the incognito mode / live stream. + // + + /** + * Injection point. + *

+ * This method is used in the following situations. + *

+ * 1. When the dislike counts are fetched in the Incognito mode. + * 2. When the dislike counts are fetched in the live stream. + * + * @param original Original span that was created or reused by Litho. + * @return The original span (if nothing should change), or a replacement span that contains dislikes. + */ + public static CharSequence onCharSequenceLoaded(@NonNull Object conversionContext, + @NonNull CharSequence original) { + try { + String conversionContextString = conversionContext.toString(); + if (!Settings.RYD_ENABLED.get()) { + return original; + } + if (!Settings.RYD_SHORTS.get()) { + return original; + } + + final boolean fetchDislikeLiveStream = + conversionContextString.contains("immersive_live_video_action_bar.eml") + && conversionContextString.contains("|dislike_button.eml|"); + + if (!fetchDislikeLiveStream) { + return original; + } + + ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(ReturnYouTubeDislikeFilterPatch.getShortsVideoId()); + videoData.setVideoIdIsShort(true); + lastLithoShortsVideoData = videoData; + lithoShortsShouldUseCurrentData = false; + + return videoData.getDislikeSpanForShort(SHORTS_LOADING_SPAN); + } catch (Exception ex) { + Logger.printException(() -> "onCharSequenceLoaded failure", ex); + } + return original; + } + + + private static CharSequence getShortsSpan(@NonNull CharSequence original, boolean isDislikesSpan) { + // Litho Shorts player. + if (!Settings.RYD_SHORTS.get() || (isDislikesSpan && Settings.HIDE_SHORTS_DISLIKE_BUTTON.get()) + || (!isDislikesSpan && Settings.HIDE_SHORTS_LIKE_BUTTON.get())) { + return original; + } + + ReturnYouTubeDislike videoData = lastLithoShortsVideoData; + if (videoData == null) { + // The Shorts litho video id filter did not detect the video id. + // This is normal in incognito mode, but otherwise is abnormal. + Logger.printDebug(() -> "Cannot modify Shorts litho span, data is null"); + return original; + } + + // Use the correct dislikes data after voting. + if (lithoShortsShouldUseCurrentData) { + if (isDislikesSpan) { + lithoShortsShouldUseCurrentData = false; + } + videoData = currentVideoData; + if (videoData == null) { + Logger.printException(() -> "currentVideoData is null"); // Should never happen + return original; + } + Logger.printDebug(() -> "Using current video data for litho span"); + } + + return isDislikesSpan + ? videoData.getDislikeSpanForShort((Spanned) original) + : videoData.getLikeSpanForShort((Spanned) original); + } + + // + // Rolling Number + // + + /** + * Current regular video rolling number text, if rolling number is in use. + * This is saved to a field as it's used in every draw() call. + */ + @Nullable + private static volatile CharSequence rollingNumberSpan; + + /** + * Injection point. + */ + public static String onRollingNumberLoaded(@NonNull Object conversionContext, + @NonNull String original) { + try { + CharSequence replacement = onLithoTextLoaded(conversionContext, original, true); + + String replacementString = replacement.toString(); + if (!replacementString.equals(original)) { + rollingNumberSpan = replacement; + return replacementString; + } // Else, the text was not a likes count but instead the view count or something else. + } catch (Exception ex) { + Logger.printException(() -> "onRollingNumberLoaded failure", ex); + } + return original; + } + + /** + * Injection point. + *

+ * Called for all usage of Rolling Number. + * Modifies the measured String text width to include the left separator and padding, if needed. + */ + public static float onRollingNumberMeasured(String text, float measuredTextWidth) { + try { + if (Settings.RYD_ENABLED.get()) { + if (ReturnYouTubeDislike.isPreviouslyCreatedSegmentedSpan(text)) { + // +1 pixel is needed for some foreign languages that measure + // the text different from what is used for layout (Greek in particular). + // Probably a bug in Android, but who knows. + // Single line mode is also used as an additional fix for this issue. + if (Settings.RYD_COMPACT_LAYOUT.get()) { + return measuredTextWidth + 1; + } + + return measuredTextWidth + 1 + + ReturnYouTubeDislike.leftSeparatorBounds.right + + ReturnYouTubeDislike.leftSeparatorShapePaddingPixels; + } + } + } catch (Exception ex) { + Logger.printException(() -> "onRollingNumberMeasured failure", ex); + } + + return measuredTextWidth; + } + + /** + * Add Rolling Number text view modifications. + */ + private static void addRollingNumberPatchChanges(TextView view) { + // YouTube Rolling Numbers do not use compound drawables or drawable padding. + if (view.getCompoundDrawablePadding() == 0) { + Logger.printDebug(() -> "Adding rolling number TextView changes"); + view.setCompoundDrawablePadding(ReturnYouTubeDislike.leftSeparatorShapePaddingPixels); + ShapeDrawable separator = ReturnYouTubeDislike.getLeftSeparatorDrawable(); + if (Utils.isRightToLeftTextLayout()) { + view.setCompoundDrawables(null, null, separator, null); + } else { + view.setCompoundDrawables(separator, null, null, null); + } + + // Disliking can cause the span to grow in size, which is ok and is laid out correctly, + // but if the user then removes their dislike the layout will not adjust to the new shorter width. + // Use a center alignment to take up any extra space. + view.setTextAlignment(View.TEXT_ALIGNMENT_CENTER); + + // Single line mode does not clip words if the span is larger than the view bounds. + // The styled span applied to the view should always have the same bounds, + // but use this feature just in case the measurements are somehow off by a few pixels. + view.setSingleLine(true); + } + } + + /** + * Remove Rolling Number text view modifications made by this patch. + * Required as it appears text views can be reused for other rolling numbers (view count, upload time, etc). + */ + private static void removeRollingNumberPatchChanges(TextView view) { + if (view.getCompoundDrawablePadding() != 0) { + Logger.printDebug(() -> "Removing rolling number TextView changes"); + view.setCompoundDrawablePadding(0); + view.setCompoundDrawables(null, null, null, null); + view.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY); // Default alignment + view.setSingleLine(false); + } + } + + /** + * Injection point. + */ + public static CharSequence updateRollingNumber(TextView view, CharSequence original) { + try { + if (!Settings.RYD_ENABLED.get()) { + removeRollingNumberPatchChanges(view); + return original; + } + final boolean isDescriptionPanel = view.getParent() instanceof ViewGroup viewGroupParent + && viewGroupParent.getChildCount() < 2; + // Called for all instances of RollingNumber, so must check if text is for a dislikes. + // Text will already have the correct content but it's missing the drawable separators. + if (!ReturnYouTubeDislike.isPreviouslyCreatedSegmentedSpan(original.toString()) || isDescriptionPanel) { + // The text is the video view count, upload time, or some other text. + removeRollingNumberPatchChanges(view); + return original; + } + + CharSequence replacement = rollingNumberSpan; + if (replacement == null) { + // User enabled RYD while a video was open, + // or user opened/closed a Short while a regular video was opened. + Logger.printDebug(() -> "Cannot update rolling number (field is null)"); + removeRollingNumberPatchChanges(view); + return original; + } + + if (Settings.RYD_COMPACT_LAYOUT.get()) { + removeRollingNumberPatchChanges(view); + } else { + addRollingNumberPatchChanges(view); + } + + // Remove any padding set by Rolling Number. + view.setPadding(0, 0, 0, 0); + + // When displaying dislikes, the rolling animation is not visually correct + // and the dislikes always animate (even though the dislike count has not changed). + // The animation is caused by an image span attached to the span, + // and using only the modified segmented span prevents the animation from showing. + return replacement; + } catch (Exception ex) { + Logger.printException(() -> "updateRollingNumber failure", ex); + return original; + } + } + + // + // Non litho Shorts player. + // + + /** + * Replacement text to use for "Dislikes" while RYD is fetching. + */ + private static final Spannable SHORTS_LOADING_SPAN = new SpannableString("-"); + + /** + * Dislikes TextViews used by Shorts. + *

+ * Multiple TextViews are loaded at once (for the prior and next videos to swipe to). + * Keep track of all of them, and later pick out the correct one based on their on screen position. + */ + private static final List> shortsTextViewRefs = new ArrayList<>(); + + private static void clearRemovedShortsTextViews() { + shortsTextViewRefs.removeIf(ref -> ref.get() == null); + } + + /** + * Injection point. Called when a Shorts dislike is updated. Always on main thread. + * Handles update asynchronously, otherwise Shorts video will be frozen while the UI thread is blocked. + * + * @return if RYD is enabled and the TextView was updated. + */ + public static boolean setShortsDislikes(@NonNull View likeDislikeView) { + try { + if (!Settings.RYD_ENABLED.get()) { + return false; + } + if (!Settings.RYD_SHORTS.get() || Settings.HIDE_SHORTS_DISLIKE_BUTTON.get()) { + // Must clear the data here, in case a new video was loaded while PlayerType + // suggested the video was not a short (can happen when spoofing to an old app version). + clearData(); + return false; + } + Logger.printDebug(() -> "setShortsDislikes"); + + TextView textView = (TextView) likeDislikeView; + textView.setText(SHORTS_LOADING_SPAN); // Change 'Dislike' text to the loading text. + shortsTextViewRefs.add(new WeakReference<>(textView)); + + if (likeDislikeView.isSelected() && isShortTextViewOnScreen(textView)) { + Logger.printDebug(() -> "Shorts dislike is already selected"); + ReturnYouTubeDislike videoData = currentVideoData; + if (videoData != null) videoData.setUserVote(Vote.DISLIKE); + } + + // For the first short played, the Shorts dislike hook is called after the video id hook. + // But for most other times this hook is called before the video id (which is not ideal). + // Must update the TextViews here, and also after the videoId changes. + updateOnScreenShortsTextViews(false); + + return true; + } catch (Exception ex) { + Logger.printException(() -> "setShortsDislikes failure", ex); + return false; + } + } + + /** + * @param forceUpdate if false, then only update the 'loading text views. + * If true, update all on screen text views. + */ + private static void updateOnScreenShortsTextViews(boolean forceUpdate) { + try { + clearRemovedShortsTextViews(); + if (shortsTextViewRefs.isEmpty()) { + return; + } + ReturnYouTubeDislike videoData = currentVideoData; + if (videoData == null) { + return; + } + + Logger.printDebug(() -> "updateShortsTextViews"); + + Runnable update = () -> { + Spanned shortsDislikesSpan = videoData.getDislikeSpanForShort(SHORTS_LOADING_SPAN); + Utils.runOnMainThreadNowOrLater(() -> { + String videoId = videoData.getVideoId(); + if (!videoId.equals(VideoInformation.getVideoId())) { + // User swiped to new video before fetch completed + Logger.printDebug(() -> "Ignoring stale dislikes data for short: " + videoId); + return; + } + + // Update text views that appear to be visible on screen. + // Only 1 will be the actual textview for the current Short, + // but discarded and not yet garbage collected views can remain. + // So must set the dislike span on all views that match. + for (WeakReference textViewRef : shortsTextViewRefs) { + TextView textView = textViewRef.get(); + if (textView == null) { + continue; + } + if (isShortTextViewOnScreen(textView) + && (forceUpdate || textView.getText().toString().equals(SHORTS_LOADING_SPAN.toString()))) { + Logger.printDebug(() -> "Setting Shorts TextView to: " + shortsDislikesSpan); + textView.setText(shortsDislikesSpan); + } + } + }); + }; + if (videoData.fetchCompleted()) { + update.run(); // Network call is completed, no need to wait on background thread. + } else { + Utils.runOnBackgroundThread(update); + } + } catch (Exception ex) { + Logger.printException(() -> "updateOnScreenShortsTextViews failure", ex); + } + } + + /** + * Check if a view is within the screen bounds. + */ + private static boolean isShortTextViewOnScreen(@NonNull View view) { + final int[] location = new int[2]; + view.getLocationInWindow(location); + if (location[0] <= 0 && location[1] <= 0) { // Lower bound + return false; + } + Rect windowRect = new Rect(); + view.getWindowVisibleDisplayFrame(windowRect); // Upper bound + return location[0] < windowRect.width() && location[1] < windowRect.height(); + } + + + // + // Video Id and voting hooks (all players). + // + + private static volatile boolean lastPlayerResponseWasShort; + + /** + * Injection point. Uses 'playback response' video id hook to preload RYD. + */ + public static void preloadVideoId(@NonNull String videoId, boolean isShortAndOpeningOrPlaying) { + try { + if (!Settings.RYD_ENABLED.get()) { + return; + } + if (videoId.equals(lastPrefetchedVideoId)) { + return; + } + + final boolean videoIdIsShort = VideoInformation.lastPlayerResponseIsShort(); + // Shorts shelf in home and subscription feed causes player response hook to be called, + // and the 'is opening/playing' parameter will be false. + // This hook will be called again when the Short is actually opened. + if (videoIdIsShort && (!isShortAndOpeningOrPlaying || !Settings.RYD_SHORTS.get())) { + return; + } + final boolean waitForFetchToComplete = !IS_SPOOFING_TO_NON_LITHO_SHORTS_PLAYER + && videoIdIsShort && !lastPlayerResponseWasShort; + + Logger.printDebug(() -> "Prefetching RYD for video: " + videoId); + ReturnYouTubeDislike fetch = ReturnYouTubeDislike.getFetchForVideoId(videoId); + if (waitForFetchToComplete && !fetch.fetchCompleted()) { + // This call is off the main thread, so wait until the RYD fetch completely finishes, + // otherwise if this returns before the fetch completes then the UI can + // become frozen when the main thread tries to modify the litho Shorts dislikes and + // it must wait for the fetch. + // Only need to do this for the first Short opened, as the next Short to swipe to + // are preloaded in the background. + // + // If an asynchronous litho Shorts solution is found, then this blocking call should be removed. + Logger.printDebug(() -> "Waiting for prefetch to complete: " + videoId); + fetch.getFetchData(20000); // Any arbitrarily large max wait time. + } + + // Set the fields after the fetch completes, so any concurrent calls will also wait. + lastPlayerResponseWasShort = videoIdIsShort; + lastPrefetchedVideoId = videoId; + } catch (Exception ex) { + Logger.printException(() -> "preloadVideoId failure", ex); + } + } + + /** + * Injection point. Uses 'current playing' video id hook. Always called on main thread. + */ + public static void newVideoLoaded(@NonNull String videoId) { + try { + if (!Settings.RYD_ENABLED.get()) { + return; + } + Objects.requireNonNull(videoId); + + final PlayerType currentPlayerType = PlayerType.getCurrent(); + final boolean isNoneHiddenOrSlidingMinimized = currentPlayerType.isNoneHiddenOrSlidingMinimized(); + if (isNoneHiddenOrSlidingMinimized && !Settings.RYD_SHORTS.get()) { + // Must clear here, otherwise the wrong data can be used for a minimized regular video. + clearData(); + return; + } + + if (videoIdIsSame(currentVideoData, videoId)) { + return; + } + Logger.printDebug(() -> "New video id: " + videoId + " playerType: " + currentPlayerType); + + ReturnYouTubeDislike data = ReturnYouTubeDislike.getFetchForVideoId(videoId); + // Pre-emptively set the data to short status. + // Required to prevent Shorts data from being used on a minimized video in incognito mode. + if (isNoneHiddenOrSlidingMinimized) { + data.setVideoIdIsShort(true); + } + currentVideoData = data; + + // Current video id hook can be called out of order with the non litho Shorts text view hook. + // Must manually update again here. + if (isNoneHiddenOrSlidingMinimized) { + updateOnScreenShortsTextViews(true); + } + } catch (Exception ex) { + Logger.printException(() -> "newVideoLoaded failure", ex); + } + } + + public static void setLastLithoShortsVideoId(@Nullable String videoId) { + if (videoIdIsSame(lastLithoShortsVideoData, videoId)) { + return; + } + + if (videoId == null) { + // Litho filter did not detect the video id. App is in incognito mode, + // or the proto buffer structure was changed and the video id is no longer present. + // Must clear both currently playing and last litho data otherwise the + // next regular video may use the wrong data. + Logger.printDebug(() -> "Litho filter did not find any video ids"); + clearData(); + return; + } + + Logger.printDebug(() -> "New litho Shorts video id: " + videoId); + ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(videoId); + videoData.setVideoIdIsShort(true); + lastLithoShortsVideoData = videoData; + lithoShortsShouldUseCurrentData = false; + } + + private static boolean videoIdIsSame(@Nullable ReturnYouTubeDislike fetch, @Nullable String videoId) { + return (fetch == null && videoId == null) + || (fetch != null && fetch.getVideoId().equals(videoId)); + } + + /** + * Injection point. + *

+ * Called when the user likes or dislikes. + * + * @param vote int that matches {@link Vote#value} + */ + public static void sendVote(int vote) { + try { + if (!Settings.RYD_ENABLED.get()) { + return; + } + final boolean isNoneHiddenOrMinimized = PlayerType.getCurrent().isNoneHiddenOrMinimized(); + if (isNoneHiddenOrMinimized && !Settings.RYD_SHORTS.get()) { + return; + } + ReturnYouTubeDislike videoData = currentVideoData; + if (videoData == null) { + Logger.printDebug(() -> "Cannot send vote, as current video data is null"); + return; // User enabled RYD while a regular video was minimized. + } + + for (Vote v : Vote.values()) { + if (v.value == vote) { + videoData.sendVote(v); + + if (isNoneHiddenOrMinimized && lastLithoShortsVideoData != null) { + lithoShortsShouldUseCurrentData = true; + } + + return; + } + } + Logger.printException(() -> "Unknown vote type: " + vote); + } catch (Exception ex) { + Logger.printException(() -> "sendVote failure", ex); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ToolBarPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ToolBarPatch.java new file mode 100644 index 0000000000..2d681133f2 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ToolBarPatch.java @@ -0,0 +1,28 @@ +package app.revanced.extension.youtube.patches.utils; + +import android.view.View; +import android.widget.ImageView; + +import app.revanced.extension.shared.utils.Logger; + +@SuppressWarnings("unused") +public class ToolBarPatch { + + public static void hookToolBar(Enum buttonEnum, ImageView imageView) { + final String enumString = buttonEnum.name(); + if (enumString.isEmpty() || + imageView == null || + !(imageView.getParent() instanceof View view)) { + return; + } + + Logger.printDebug(() -> "enumString: " + enumString); + + hookToolBar(enumString, view); + } + + private static void hookToolBar(String enumString, View parentView) { + } +} + + diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/AV1CodecPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/AV1CodecPatch.java new file mode 100644 index 0000000000..71f5dd9d65 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/AV1CodecPatch.java @@ -0,0 +1,53 @@ +package app.revanced.extension.youtube.patches.video; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class AV1CodecPatch { + private static final int LITERAL_VALUE_AV01 = 1635135811; + private static final int LITERAL_VALUE_DOLBY_VISION = 1685485123; + private static final String VP9_CODEC = "video/x-vnd.on2.vp9"; + private static long lastTimeResponse = 0; + + /** + * Replace the SW AV01 codec to VP9 codec. + * May not be valid on some clients. + * + * @param original hardcoded value - "video/av01" + */ + public static String replaceCodec(String original) { + return Settings.REPLACE_AV1_CODEC.get() ? VP9_CODEC : original; + } + + /** + * Replace the SW AV01 codec request with a Dolby Vision codec request. + * This request is invalid, so it falls back to codecs other than AV01. + *

+ * Limitation: Fallback process causes about 15-20 seconds of buffering. + * + * @param literalValue literal value of the codec + */ + public static int rejectResponse(int literalValue) { + if (!Settings.REJECT_AV1_CODEC.get()) + return literalValue; + + Logger.printDebug(() -> "Response: " + literalValue); + + if (literalValue != LITERAL_VALUE_AV01) + return literalValue; + + final long currentTime = System.currentTimeMillis(); + + // Ignore the invoke within 20 seconds. + if (currentTime - lastTimeResponse > 20000) { + lastTimeResponse = currentTime; + Utils.showToastShort(str("revanced_reject_av1_codec_toast")); + } + + return LITERAL_VALUE_DOLBY_VISION; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/CustomPlaybackSpeedPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/CustomPlaybackSpeedPatch.java new file mode 100644 index 0000000000..d546921536 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/CustomPlaybackSpeedPatch.java @@ -0,0 +1,266 @@ +package app.revanced.extension.youtube.patches.video; + +import static app.revanced.extension.shared.utils.ResourceUtils.getString; +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.content.Context; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; + +import java.util.Arrays; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.components.PlaybackSpeedMenuFilter; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.utils.VideoUtils; + +@SuppressWarnings("unused") +public class CustomPlaybackSpeedPatch { + /** + * Maximum playback speed, exclusive value. Custom speeds must be less than this value. + *

+ * Going over 8x does not increase the actual playback speed any higher, + * and the UI selector starts flickering and acting weird. + * Over 10x and the speeds show up out of order in the UI selector. + */ + public static final float MAXIMUM_PLAYBACK_SPEED = 8; + private static final String[] defaultSpeedEntries; + private static final String[] defaultSpeedEntryValues; + /** + * Custom playback speeds. + */ + private static float[] playbackSpeeds; + private static String[] customSpeedEntries; + private static String[] customSpeedEntryValues; + + private static String[] playbackSpeedEntries; + private static String[] playbackSpeedEntryValues; + + /** + * The last time the old playback menu was forcefully called. + */ + private static long lastTimeOldPlaybackMenuInvoked; + + static { + defaultSpeedEntries = new String[]{getString("quality_auto"), "0.25x", "0.5x", "0.75x", getString("revanced_playback_speed_normal"), "1.25x", "1.5x", "1.75x", "2.0x"}; + defaultSpeedEntryValues = new String[]{"-2.0", "0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0"}; + + loadCustomSpeeds(); + } + + /** + * Injection point. + */ + public static float[] getArray(float[] original) { + return isCustomPlaybackSpeedEnabled() ? playbackSpeeds : original; + } + + /** + * Injection point. + */ + public static int getLength(int original) { + return isCustomPlaybackSpeedEnabled() ? playbackSpeeds.length : original; + } + + /** + * Injection point. + */ + public static int getSize(int original) { + return isCustomPlaybackSpeedEnabled() ? 0 : original; + } + + public static String[] getListEntries() { + return isCustomPlaybackSpeedEnabled() + ? customSpeedEntries + : defaultSpeedEntries; + } + + public static String[] getListEntryValues() { + return isCustomPlaybackSpeedEnabled() + ? customSpeedEntryValues + : defaultSpeedEntryValues; + } + + public static String[] getTrimmedListEntries() { + if (playbackSpeedEntries == null) { + final String[] playbackSpeedWithAutoEntries = getListEntries(); + playbackSpeedEntries = Arrays.copyOfRange(playbackSpeedWithAutoEntries, 1, playbackSpeedWithAutoEntries.length); + } + + return playbackSpeedEntries; + } + + public static String[] getTrimmedListEntryValues() { + if (playbackSpeedEntryValues == null) { + final String[] playbackSpeedWithAutoEntryValues = getListEntryValues(); + playbackSpeedEntryValues = Arrays.copyOfRange(playbackSpeedWithAutoEntryValues, 1, playbackSpeedWithAutoEntryValues.length); + } + + return playbackSpeedEntryValues; + } + + private static void resetCustomSpeeds(@NonNull String toastMessage) { + Utils.showToastLong(toastMessage); + Utils.showToastShort(str("revanced_extended_reset_to_default_toast")); + Settings.CUSTOM_PLAYBACK_SPEEDS.resetToDefault(); + } + + private static void loadCustomSpeeds() { + try { + if (!Settings.ENABLE_CUSTOM_PLAYBACK_SPEED.get()) { + return; + } + + String[] speedStrings = Settings.CUSTOM_PLAYBACK_SPEEDS.get().split("\\s+"); + Arrays.sort(speedStrings); + if (speedStrings.length == 0) { + throw new IllegalArgumentException(); + } + playbackSpeeds = new float[speedStrings.length]; + int i = 0; + for (String speedString : speedStrings) { + final float speedFloat = Float.parseFloat(speedString); + if (speedFloat <= 0 || arrayContains(playbackSpeeds, speedFloat)) { + throw new IllegalArgumentException(); + } + + if (speedFloat > MAXIMUM_PLAYBACK_SPEED) { + resetCustomSpeeds(str("revanced_custom_playback_speeds_invalid", MAXIMUM_PLAYBACK_SPEED)); + loadCustomSpeeds(); + return; + } + + playbackSpeeds[i] = speedFloat; + i++; + } + + if (customSpeedEntries != null) return; + + customSpeedEntries = new String[playbackSpeeds.length + 1]; + customSpeedEntryValues = new String[playbackSpeeds.length + 1]; + customSpeedEntries[0] = getString("quality_auto"); + customSpeedEntryValues[0] = "-2.0"; + + i = 1; + for (float speed : playbackSpeeds) { + String speedString = String.valueOf(speed); + customSpeedEntries[i] = speed != 1.0f + ? speedString + "x" + : getString("revanced_playback_speed_normal"); + customSpeedEntryValues[i] = speedString; + i++; + } + } catch (Exception ex) { + Logger.printInfo(() -> "parse error", ex); + resetCustomSpeeds(str("revanced_custom_playback_speeds_parse_exception")); + loadCustomSpeeds(); + } + } + + private static boolean arrayContains(float[] array, float value) { + for (float arrayValue : array) { + if (arrayValue == value) return true; + } + return false; + } + + private static boolean isCustomPlaybackSpeedEnabled() { + return Settings.ENABLE_CUSTOM_PLAYBACK_SPEED.get() && playbackSpeeds != null; + } + + /** + * Injection point. + */ + public static void onFlyoutMenuCreate(RecyclerView recyclerView) { + if (!Settings.ENABLE_CUSTOM_PLAYBACK_SPEED.get()) { + return; + } + + recyclerView.getViewTreeObserver().addOnDrawListener(() -> { + try { + if (PlaybackSpeedMenuFilter.isOldPlaybackSpeedMenuVisible) { + if (hideLithoMenuAndShowOldSpeedMenu(recyclerView, 8)) { + PlaybackSpeedMenuFilter.isOldPlaybackSpeedMenuVisible = false; + } + return; + } + } catch (Exception ex) { + Logger.printException(() -> "isOldPlaybackSpeedMenuVisible failure", ex); + } + + try { + if (PlaybackSpeedMenuFilter.isPlaybackRateSelectorMenuVisible) { + if (hideLithoMenuAndShowOldSpeedMenu(recyclerView, 5)) { + PlaybackSpeedMenuFilter.isPlaybackRateSelectorMenuVisible = false; + } + } + } catch (Exception ex) { + Logger.printException(() -> "isPlaybackRateSelectorMenuVisible failure", ex); + } + }); + } + + private static boolean hideLithoMenuAndShowOldSpeedMenu(RecyclerView recyclerView, int expectedChildCount) { + if (recyclerView.getChildCount() == 0) { + return false; + } + + if (!(recyclerView.getChildAt(0) instanceof ViewGroup PlaybackSpeedParentView)) { + return false; + } + + if (PlaybackSpeedParentView.getChildCount() != expectedChildCount) { + return false; + } + + if (!(Utils.getParentView(recyclerView, 3) instanceof ViewGroup parentView3rd)) { + return false; + } + + if (!(parentView3rd.getParent() instanceof ViewGroup parentView4th)) { + return false; + } + + // Dismiss View [R.id.touch_outside] is the 1st ChildView of the 4th ParentView. + // This only shows in phone layout. + Utils.clickView(parentView4th.getChildAt(0)); + + // In tablet layout there is no Dismiss View, instead we just hide all two parent views. + parentView3rd.setVisibility(View.GONE); + parentView4th.setVisibility(View.GONE); + + // Show old playback speed menu. + showCustomPlaybackSpeedMenu(recyclerView.getContext()); + + return true; + } + + /** + * This method is sometimes used multiple times + * To prevent this, ignore method reuse within 1 second. + * + * @param context Context for [playbackSpeedDialogListener] + */ + private static void showCustomPlaybackSpeedMenu(@NonNull Context context) { + // This method is sometimes used multiple times. + // To prevent this, ignore method reuse within 1 second. + final long now = System.currentTimeMillis(); + if (now - lastTimeOldPlaybackMenuInvoked < 1000) { + return; + } + lastTimeOldPlaybackMenuInvoked = now; + + if (Settings.CUSTOM_PLAYBACK_SPEED_MENU_TYPE.get()) { + // Open playback speed dialog + VideoUtils.showPlaybackSpeedDialog(context); + } else { + // Open old style flyout menu + VideoUtils.showPlaybackSpeedFlyoutMenu(); + } + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/HDRVideoPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/HDRVideoPatch.java new file mode 100644 index 0000000000..0ad3758c38 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/HDRVideoPatch.java @@ -0,0 +1,11 @@ +package app.revanced.extension.youtube.patches.video; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class HDRVideoPatch { + + public static boolean disableHDRVideo() { + return !Settings.DISABLE_HDR_VIDEO.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/PlaybackSpeedPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/PlaybackSpeedPatch.java new file mode 100644 index 0000000000..e4a2417fa0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/PlaybackSpeedPatch.java @@ -0,0 +1,139 @@ +package app.revanced.extension.youtube.patches.video; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.apache.commons.lang3.BooleanUtils; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.misc.requests.PlaylistRequest; +import app.revanced.extension.youtube.patches.utils.PatchStatus; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.VideoInformation; +import app.revanced.extension.youtube.whitelist.Whitelist; + +@SuppressWarnings("unused") +public class PlaybackSpeedPatch { + private static final long TOAST_DELAY_MILLISECONDS = 750; + private static long lastTimeSpeedChanged; + private static boolean isLiveStream; + + /** + * Injection point. + */ + public static void newVideoStarted(@NonNull String newlyLoadedChannelId, @NonNull String newlyLoadedChannelName, + @NonNull String newlyLoadedVideoId, @NonNull String newlyLoadedVideoTitle, + final long newlyLoadedVideoLength, boolean newlyLoadedLiveStreamValue) { + isLiveStream = newlyLoadedLiveStreamValue; + Logger.printDebug(() -> "newVideoStarted: " + newlyLoadedVideoId); + + final float defaultPlaybackSpeed = getDefaultPlaybackSpeed(newlyLoadedChannelId, newlyLoadedVideoId); + Logger.printDebug(() -> "overridePlaybackSpeed: " + defaultPlaybackSpeed); + + VideoInformation.overridePlaybackSpeed(defaultPlaybackSpeed); + } + + /** + * Injection point. + */ + public static void fetchPlaylistData(@NonNull String videoId, boolean isShortAndOpeningOrPlaying) { + if (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC.get()) { + try { + final boolean videoIdIsShort = VideoInformation.lastPlayerResponseIsShort(); + // Shorts shelf in home and subscription feed causes player response hook to be called, + // and the 'is opening/playing' parameter will be false. + // This hook will be called again when the Short is actually opened. + if (videoIdIsShort && !isShortAndOpeningOrPlaying) { + return; + } + + PlaylistRequest.fetchRequestIfNeeded(videoId); + } catch (Exception ex) { + Logger.printException(() -> "fetchPlaylistData failure", ex); + } + } + } + + /** + * Injection point. + */ + public static float getPlaybackSpeedInShorts(final float playbackSpeed) { + if (!VideoInformation.lastPlayerResponseIsShort()) + return playbackSpeed; + if (!Settings.ENABLE_DEFAULT_PLAYBACK_SPEED_SHORTS.get()) + return playbackSpeed; + + float defaultPlaybackSpeed = getDefaultPlaybackSpeed(VideoInformation.getChannelId(), null); + Logger.printDebug(() -> "overridePlaybackSpeed in Shorts: " + defaultPlaybackSpeed); + + return defaultPlaybackSpeed; + } + + /** + * Injection point. + * Called when user selects a playback speed. + * + * @param playbackSpeed The playback speed the user selected + */ + public static void userSelectedPlaybackSpeed(float playbackSpeed) { + if (PatchStatus.RememberPlaybackSpeed() && + Settings.REMEMBER_PLAYBACK_SPEED_LAST_SELECTED.get()) { + // With the 0.05x menu, if the speed is set by integrations to higher than 2.0x + // then the menu will allow increasing without bounds but the max speed is + // still capped to under 8.0x. + playbackSpeed = Math.min(playbackSpeed, CustomPlaybackSpeedPatch.MAXIMUM_PLAYBACK_SPEED - 0.05f); + + // Prevent toast spamming if using the 0.05x adjustments. + // Show exactly one toast after the user stops interacting with the speed menu. + final long now = System.currentTimeMillis(); + lastTimeSpeedChanged = now; + + final float finalPlaybackSpeed = playbackSpeed; + Utils.runOnMainThreadDelayed(() -> { + if (lastTimeSpeedChanged != now) { + // The user made additional speed adjustments and this call is outdated. + return; + } + + if (Settings.DEFAULT_PLAYBACK_SPEED.get() == finalPlaybackSpeed) { + // User changed to a different speed and immediately changed back. + // Or the user is going past 8.0x in the glitched out 0.05x menu. + return; + } + Settings.DEFAULT_PLAYBACK_SPEED.save(finalPlaybackSpeed); + + if (!Settings.REMEMBER_PLAYBACK_SPEED_LAST_SELECTED_TOAST.get()) { + return; + } + Utils.showToastShort(str("revanced_remember_playback_speed_toast", (finalPlaybackSpeed + "x"))); + }, TOAST_DELAY_MILLISECONDS); + } + } + + private static float getDefaultPlaybackSpeed(@NonNull String channelId, @Nullable String videoId) { + return (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_LIVE.get() && isLiveStream) || + Whitelist.isChannelWhitelistedPlaybackSpeed(channelId) || + getPlaylistData(videoId) + ? 1.0f + : Settings.DEFAULT_PLAYBACK_SPEED.get(); + } + + private static boolean getPlaylistData(@Nullable String videoId) { + if (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC.get() && videoId != null) { + try { + PlaylistRequest request = PlaylistRequest.getRequestForVideoId(videoId); + final boolean isPlaylist = request != null && BooleanUtils.toBoolean(request.getStream()); + Logger.printDebug(() -> "isPlaylist: " + isPlaylist); + + return isPlaylist; + } catch (Exception ex) { + Logger.printException(() -> "getPlaylistData failure", ex); + } + } + + return false; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/ReloadVideoPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/ReloadVideoPatch.java new file mode 100644 index 0000000000..b2516e7ce6 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/ReloadVideoPatch.java @@ -0,0 +1,53 @@ +package app.revanced.extension.youtube.patches.video; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import androidx.annotation.NonNull; + +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.PlayerType; +import app.revanced.extension.youtube.shared.VideoInformation; + +@SuppressWarnings("unused") +public class ReloadVideoPatch { + private static final long RELOAD_VIDEO_TIME_MILLISECONDS = 15000L; + + @NonNull + public static String videoId = ""; + + /** + * Injection point. + */ + public static void newVideoStarted(@NonNull String newlyLoadedChannelId, @NonNull String newlyLoadedChannelName, + @NonNull String newlyLoadedVideoId, @NonNull String newlyLoadedVideoTitle, + final long newlyLoadedVideoLength, boolean newlyLoadedLiveStreamValue) { + if (!Settings.SKIP_PRELOADED_BUFFER.get()) + return; + if (PlayerType.getCurrent() == PlayerType.INLINE_MINIMAL) + return; + if (videoId.equals(newlyLoadedVideoId)) + return; + videoId = newlyLoadedVideoId; + + if (newlyLoadedVideoLength < RELOAD_VIDEO_TIME_MILLISECONDS || newlyLoadedLiveStreamValue) + return; + + final long seekTime = Math.max(RELOAD_VIDEO_TIME_MILLISECONDS, (long) (newlyLoadedVideoLength * 0.5)); + + Utils.runOnMainThreadDelayed(() -> reloadVideo(seekTime), 250); + } + + private static void reloadVideo(final long videoLength) { + final long lastVideoTime = VideoInformation.getVideoTime(); + final float playbackSpeed = VideoInformation.getPlaybackSpeed(); + final long speedAdjustedTimeThreshold = (long) (playbackSpeed * 300); + VideoInformation.overrideVideoTime(videoLength); + VideoInformation.overrideVideoTime(lastVideoTime + speedAdjustedTimeThreshold); + + if (!Settings.SKIP_PRELOADED_BUFFER_TOAST.get()) + return; + + Utils.showToastShort(str("revanced_skipped_preloaded_buffer")); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/RestoreOldVideoQualityMenuPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/RestoreOldVideoQualityMenuPatch.java new file mode 100644 index 0000000000..b1ac0c9794 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/RestoreOldVideoQualityMenuPatch.java @@ -0,0 +1,73 @@ +package app.revanced.extension.youtube.patches.video; + +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ListView; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.components.VideoQualityMenuFilter; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class RestoreOldVideoQualityMenuPatch { + + public static boolean restoreOldVideoQualityMenu() { + return Settings.RESTORE_OLD_VIDEO_QUALITY_MENU.get(); + } + + public static void restoreOldVideoQualityMenu(ListView listView) { + if (!Settings.RESTORE_OLD_VIDEO_QUALITY_MENU.get()) + return; + + listView.setVisibility(View.GONE); + + Utils.runOnMainThreadDelayed(() -> { + listView.setSoundEffectsEnabled(false); + listView.performItemClick(null, 2, 0); + }, + 1 + ); + } + + public static void onFlyoutMenuCreate(final RecyclerView recyclerView) { + if (!Settings.RESTORE_OLD_VIDEO_QUALITY_MENU.get()) + return; + + recyclerView.getViewTreeObserver().addOnDrawListener(() -> { + try { + // Check if the current view is the quality menu. + if (!VideoQualityMenuFilter.isVideoQualityMenuVisible || recyclerView.getChildCount() == 0) { + return; + } + + if (!(Utils.getParentView(recyclerView, 3) instanceof ViewGroup quickQualityViewParent)) { + return; + } + + if (!(recyclerView.getChildAt(0) instanceof ViewGroup advancedQualityParentView)) { + return; + } + + if (advancedQualityParentView.getChildCount() < 4) { + return; + } + + View advancedQualityView = advancedQualityParentView.getChildAt(3); + if (advancedQualityView == null) { + return; + } + + quickQualityViewParent.setVisibility(View.GONE); + + // Click the "Advanced" quality menu to show the "old" quality menu. + advancedQualityView.callOnClick(); + + VideoQualityMenuFilter.isVideoQualityMenuVisible = false; + } catch (Exception ex) { + Logger.printException(() -> "onFlyoutMenuCreate failure", ex); + } + }); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/SpoofDeviceDimensionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/SpoofDeviceDimensionsPatch.java new file mode 100644 index 0000000000..d5e4e28013 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/SpoofDeviceDimensionsPatch.java @@ -0,0 +1,16 @@ +package app.revanced.extension.youtube.patches.video; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class SpoofDeviceDimensionsPatch { + private static final boolean SPOOF = Settings.SPOOF_DEVICE_DIMENSIONS.get(); + + public static int getMinHeightOrWidth(int minHeightOrWidth) { + return SPOOF ? 64 : minHeightOrWidth; + } + + public static int getMaxHeightOrWidth(int maxHeightOrWidth) { + return SPOOF ? 4096 : maxHeightOrWidth; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/VP9CodecPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/VP9CodecPatch.java new file mode 100644 index 0000000000..6052c55ef0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/VP9CodecPatch.java @@ -0,0 +1,11 @@ +package app.revanced.extension.youtube.patches.video; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class VP9CodecPatch { + + public static boolean disableVP9Codec() { + return !Settings.DISABLE_VP9_CODEC.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/VideoQualityPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/VideoQualityPatch.java new file mode 100644 index 0000000000..22b51c3342 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/VideoQualityPatch.java @@ -0,0 +1,91 @@ +package app.revanced.extension.youtube.patches.video; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import androidx.annotation.NonNull; + +import app.revanced.extension.shared.settings.IntegerSetting; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.PlayerType; +import app.revanced.extension.youtube.shared.VideoInformation; + +@SuppressWarnings("unused") +public class VideoQualityPatch { + private static final int DEFAULT_YOUTUBE_VIDEO_QUALITY = -2; + private static final IntegerSetting mobileQualitySetting = Settings.DEFAULT_VIDEO_QUALITY_MOBILE; + private static final IntegerSetting wifiQualitySetting = Settings.DEFAULT_VIDEO_QUALITY_WIFI; + + @NonNull + public static String videoId = ""; + + /** + * Injection point. + */ + public static void newVideoStarted() { + setVideoQuality(0); + } + + /** + * Injection point. + */ + public static void newVideoStarted(@NonNull String newlyLoadedChannelId, @NonNull String newlyLoadedChannelName, + @NonNull String newlyLoadedVideoId, @NonNull String newlyLoadedVideoTitle, + final long newlyLoadedVideoLength, boolean newlyLoadedLiveStreamValue) { + if (PlayerType.getCurrent() == PlayerType.INLINE_MINIMAL) + return; + if (videoId.equals(newlyLoadedVideoId)) + return; + videoId = newlyLoadedVideoId; + setVideoQuality(Settings.SKIP_PRELOADED_BUFFER.get() ? 250 : 500); + } + + /** + * Injection point. + */ + public static void userSelectedVideoQuality() { + Utils.runOnMainThreadDelayed(() -> + userSelectedVideoQuality(VideoInformation.getVideoQuality()), + 300 + ); + } + + private static void setVideoQuality(final long delayMillis) { + final int defaultQuality = Utils.getNetworkType() == Utils.NetworkType.MOBILE + ? mobileQualitySetting.get() + : wifiQualitySetting.get(); + + if (defaultQuality == DEFAULT_YOUTUBE_VIDEO_QUALITY) + return; + + Utils.runOnMainThreadDelayed(() -> + VideoInformation.overrideVideoQuality( + VideoInformation.getAvailableVideoQuality(defaultQuality) + ), + delayMillis + ); + } + + private static void userSelectedVideoQuality(final int defaultQuality) { + if (!Settings.REMEMBER_VIDEO_QUALITY_LAST_SELECTED.get()) + return; + if (defaultQuality == DEFAULT_YOUTUBE_VIDEO_QUALITY) + return; + + final Utils.NetworkType networkType = Utils.getNetworkType(); + + switch (networkType) { + case NONE -> { + Utils.showToastShort(str("revanced_remember_video_quality_none")); + return; + } + case MOBILE -> mobileQualitySetting.save(defaultQuality); + default -> wifiQualitySetting.save(defaultQuality); + } + + if (!Settings.REMEMBER_VIDEO_QUALITY_LAST_SELECTED_TOAST.get()) + return; + + Utils.showToastShort(str("revanced_remember_video_quality_" + networkType.getName(), defaultQuality + "p")); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/ReturnYouTubeDislike.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/ReturnYouTubeDislike.java new file mode 100644 index 0000000000..dd478f4f02 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/ReturnYouTubeDislike.java @@ -0,0 +1,776 @@ +package app.revanced.extension.youtube.returnyoutubedislike; + +import static app.revanced.extension.shared.returnyoutubedislike.ReturnYouTubeDislike.Vote; +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.shared.utils.Utils.isSDKAbove; +import static app.revanced.extension.youtube.utils.ExtendedUtils.isSpoofingToLessThan; + +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.OvalShape; +import android.graphics.drawable.shapes.RectShape; +import android.icu.text.CompactDecimalFormat; +import android.icu.text.DecimalFormat; +import android.icu.text.DecimalFormatSymbols; +import android.icu.text.NumberFormat; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ForegroundColorSpan; +import android.text.style.ImageSpan; +import android.text.style.ReplacementSpan; +import android.util.DisplayMetrics; +import android.util.TypedValue; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import app.revanced.extension.shared.returnyoutubedislike.requests.RYDVoteData; +import app.revanced.extension.shared.returnyoutubedislike.requests.ReturnYouTubeDislikeApi; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.PlayerType; +import app.revanced.extension.youtube.utils.ThemeUtils; + +/** + * Handles fetching and creation/replacing of RYD dislike text spans. + *

+ * Because Litho creates spans using multiple threads, this entire class supports multithreading as well. + */ +public class ReturnYouTubeDislike { + + /** + * Maximum amount of time to block the UI from updates while waiting for network call to complete. + *

+ * Must be less than 5 seconds, as per: + * ... + */ + private static final long MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH = 4000; + + /** + * How long to retain successful RYD fetches. + */ + private static final long CACHE_TIMEOUT_SUCCESS_MILLISECONDS = 7 * 60 * 1000; // 7 Minutes + + /** + * How long to retain unsuccessful RYD fetches, + * and also the minimum time before retrying again. + */ + private static final long CACHE_TIMEOUT_FAILURE_MILLISECONDS = 3 * 60 * 1000; // 3 Minutes + + /** + * Unique placeholder character, used to detect if a segmented span already has dislikes added to it. + * Must be something YouTube is unlikely to use, as it's searched for in all usage of Rolling Number. + */ + private static final char MIDDLE_SEPARATOR_CHARACTER = '◎'; // 'bullseye' + + public static final boolean IS_SPOOFING_TO_OLD_SEPARATOR_COLOR = + isSpoofingToLessThan("18.10.00"); + + /** + * Cached lookup of all video ids. + */ + @GuardedBy("itself") + private static final Map fetchCache = new HashMap<>(); + + /** + * Used to send votes, one by one, in the same order the user created them. + */ + private static final ExecutorService voteSerialExecutor = Executors.newSingleThreadExecutor(); + + /** + * For formatting dislikes as number. + */ + @GuardedBy("ReturnYouTubeDislike.class") // not thread safe + private static CompactDecimalFormat dislikeCountFormatter; + + /** + * For formatting dislikes as percentage. + */ + @GuardedBy("ReturnYouTubeDislike.class") + private static NumberFormat dislikePercentageFormatter; + + // Used for segmented dislike spans in Litho regular player. + public static final Rect leftSeparatorBounds; + private static final Rect middleSeparatorBounds; + + /** + * Left separator horizontal padding for Rolling Number layout. + */ + public static final int leftSeparatorShapePaddingPixels; + private static final ShapeDrawable leftSeparatorShape; + public static final Locale locale; + + static { + final Resources resources = Utils.getResources(); + DisplayMetrics dp = resources.getDisplayMetrics(); + + leftSeparatorBounds = new Rect(0, 0, + (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1.2f, dp), + (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14, dp)); + final int middleSeparatorSize = + (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3.7f, dp); + middleSeparatorBounds = new Rect(0, 0, middleSeparatorSize, middleSeparatorSize); + + leftSeparatorShapePaddingPixels = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10.0f, dp); + + leftSeparatorShape = new ShapeDrawable(new RectShape()); + leftSeparatorShape.setBounds(leftSeparatorBounds); + locale = resources.getConfiguration().getLocales().get(0); + + ReturnYouTubeDislikeApi.toastOnConnectionError = Settings.RYD_TOAST_ON_CONNECTION_ERROR.get(); + } + + private final String videoId; + + /** + * Stores the results of the vote api fetch, and used as a barrier to wait until fetch completes. + * Absolutely cannot be holding any lock during calls to {@link Future#get()}. + */ + private final Future future; + + /** + * Time this instance and the fetch future was created. + */ + private final long timeFetched; + + /** + * If this instance was previously used for a Short. + */ + @GuardedBy("this") + private boolean isShort; + + /** + * Optional current vote status of the UI. Used to apply a user vote that was done on a previous video viewing. + */ + @Nullable + @GuardedBy("this") + private Vote userVote; + + /** + * Original dislike span, before modifications. + */ + @Nullable + @GuardedBy("this") + private Spanned originalDislikeSpan; + + /** + * Replacement like/dislike span that includes formatted dislikes. + * Used to prevent recreating the same span multiple times. + */ + @Nullable + @GuardedBy("this") + private SpannableString replacementLikeDislikeSpan; + + /** + * Color of the left and middle separator, based on the color of the right separator. + * It's unknown where YT gets the color from, and the values here are approximated by hand. + * Ideally, this would be the actual color YT uses at runtime. + *

+ * Older versions before the 'Me' library tab use a slightly different color. + * If spoofing was previously used and is now turned off, + * or an old version was recently upgraded then the old colors are sometimes still used. + */ + private static int getSeparatorColor() { + if (IS_SPOOFING_TO_OLD_SEPARATOR_COLOR) { + return ThemeUtils.isDarkTheme() + ? 0x29AAAAAA // transparent dark gray + : 0xFFD9D9D9; // light gray + } + + return ThemeUtils.isDarkTheme() + ? 0x33FFFFFF + : 0xFFD9D9D9; + } + + public static ShapeDrawable getLeftSeparatorDrawable() { + leftSeparatorShape.getPaint().setColor(getSeparatorColor()); + return leftSeparatorShape; + } + + /** + * @param isSegmentedButton If UI is using the segmented single UI component for both like and dislike. + */ + @NonNull + private static SpannableString createDislikeSpan(@NonNull Spanned oldSpannable, + boolean isSegmentedButton, + boolean isRollingNumber, + @NonNull RYDVoteData voteData) { + if (!isSegmentedButton) { + // Simple replacement of 'dislike' with a number/percentage. + return newSpannableWithDislikes(oldSpannable, voteData); + } + + // Note: Some locales use right to left layout (Arabic, Hebrew, etc). + // If making changes to this code, change device settings to a RTL language and verify layout is correct. + CharSequence oldLikes = oldSpannable; + + // YouTube creators can hide the like count on a video, + // and the like count appears as a device language specific string that says 'Like'. + // Check if the string contains any numbers. + if (!Utils.containsNumber(oldLikes)) { + if (Settings.RYD_ESTIMATED_LIKE.get()) { + // Likes are hidden by video creator + // + // RYD does not directly provide like data, but can use an estimated likes + // using the same scale factor RYD applied to the raw dislikes. + // + // example video: https://www.youtube.com/watch?v=UnrU5vxCHxw + // RYD data: https://returnyoutubedislikeapi.com/votes?videoId=UnrU5vxCHxw + Logger.printDebug(() -> "Using estimated likes"); + oldLikes = formatDislikeCount(voteData.getLikeCount()); + } else { + // Change the "Likes" string to show that likes and dislikes are hidden. + String hiddenMessageString = str("revanced_ryd_video_likes_hidden_by_video_owner"); + return newSpanUsingStylingOfAnotherSpan(oldSpannable, hiddenMessageString); + } + } + + SpannableStringBuilder builder = new SpannableStringBuilder(); + final boolean compactLayout = Settings.RYD_COMPACT_LAYOUT.get(); + + if (!compactLayout) { + String leftSeparatorString = getTextDirectionString(); + final Spannable leftSeparatorSpan; + if (isRollingNumber) { + leftSeparatorSpan = new SpannableString(leftSeparatorString); + } else { + leftSeparatorString += " "; + leftSeparatorSpan = new SpannableString(leftSeparatorString); + // Styling spans cannot overwrite RTL or LTR character. + leftSeparatorSpan.setSpan( + new VerticallyCenteredImageSpan(getLeftSeparatorDrawable(), false), + 1, 2, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + leftSeparatorSpan.setSpan( + new FixedWidthEmptySpan(leftSeparatorShapePaddingPixels), + 2, 3, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + } + builder.append(leftSeparatorSpan); + } + + // likes + builder.append(newSpanUsingStylingOfAnotherSpan(oldSpannable, oldLikes)); + + // middle separator + String middleSeparatorString = compactLayout + ? " " + MIDDLE_SEPARATOR_CHARACTER + " " + : " \u2009" + MIDDLE_SEPARATOR_CHARACTER + "\u2009 "; // u2009 = 'narrow space' character + final int shapeInsertionIndex = middleSeparatorString.length() / 2; + Spannable middleSeparatorSpan = new SpannableString(middleSeparatorString); + ShapeDrawable shapeDrawable = new ShapeDrawable(new OvalShape()); + shapeDrawable.getPaint().setColor(getSeparatorColor()); + shapeDrawable.setBounds(middleSeparatorBounds); + // Use original text width if using Rolling Number, + // to ensure the replacement styled span has the same width as the measured String, + // otherwise layout can be broken (especially on devices with small system font sizes). + middleSeparatorSpan.setSpan( + new VerticallyCenteredImageSpan(shapeDrawable, isRollingNumber), + shapeInsertionIndex, shapeInsertionIndex + 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + builder.append(middleSeparatorSpan); + + // dislikes + builder.append(newSpannableWithDislikes(oldSpannable, voteData)); + + return new SpannableString(builder); + } + + private static @NonNull String getTextDirectionString() { + return Utils.isRightToLeftTextLayout() + ? "\u200F" // u200F = right to left character + : "\u200E"; // u200E = left to right character + } + + /** + * @return If the text is likely for a previously created likes/dislikes segmented span. + */ + public static boolean isPreviouslyCreatedSegmentedSpan(@NonNull String text) { + return text.indexOf(MIDDLE_SEPARATOR_CHARACTER) >= 0; + } + + private static boolean spansHaveEqualTextAndColor(@NonNull Spanned one, @NonNull Spanned two) { + // Cannot use equals on the span, because many of the inner styling spans do not implement equals. + // Instead, compare the underlying text and the text color to handle when dark mode is changed. + // Cannot compare the status of device dark mode, as Litho components are updated just before dark mode status changes. + if (!one.toString().equals(two.toString())) { + return false; + } + ForegroundColorSpan[] oneColors = one.getSpans(0, one.length(), ForegroundColorSpan.class); + ForegroundColorSpan[] twoColors = two.getSpans(0, two.length(), ForegroundColorSpan.class); + final int oneLength = oneColors.length; + if (oneLength != twoColors.length) { + return false; + } + for (int i = 0; i < oneLength; i++) { + if (oneColors[i].getForegroundColor() != twoColors[i].getForegroundColor()) { + return false; + } + } + return true; + } + + private static SpannableString newSpannableWithLikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) { + return newSpanUsingStylingOfAnotherSpan(sourceStyling, formatDislikeCount(voteData.getLikeCount())); + } + + private static SpannableString newSpannableWithDislikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) { + return newSpanUsingStylingOfAnotherSpan(sourceStyling, + Settings.RYD_DISLIKE_PERCENTAGE.get() + ? formatDislikePercentage(voteData.getDislikePercentage()) + : formatDislikeCount(voteData.getDislikeCount())); + } + + private static SpannableString newSpanUsingStylingOfAnotherSpan(@NonNull Spanned sourceStyle, @NonNull CharSequence newSpanText) { + if (sourceStyle == newSpanText && sourceStyle instanceof SpannableString spannableString) { + return spannableString; // Nothing to do. + } + + SpannableString destination = new SpannableString(newSpanText); + Object[] spans = sourceStyle.getSpans(0, sourceStyle.length(), Object.class); + for (Object span : spans) { + destination.setSpan(span, 0, destination.length(), sourceStyle.getSpanFlags(span)); + } + + return destination; + } + + private static String formatDislikeCount(long dislikeCount) { + if (isSDKAbove(24)) { + synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize + if (dislikeCountFormatter == null) { + Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().getLocales().get(0); + dislikeCountFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT); + + // YouTube disregards locale specific number characters + // and instead shows english number characters everywhere. + // To use the same behavior, override the digit characters to use English + // so languages such as Arabic will show "1.234" instead of the native "۱,۲۳٤" + if (isSDKAbove(28)) { + DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale); + symbols.setDigitStrings(DecimalFormatSymbols.getInstance(Locale.ENGLISH).getDigitStrings()); + dislikeCountFormatter.setDecimalFormatSymbols(symbols); + } + } + return dislikeCountFormatter.format(dislikeCount); + } + } + + // Will never be reached, as the oldest supported YouTube app requires Android N or greater. + return String.valueOf(dislikeCount); + } + + private static String formatDislikePercentage(float dislikePercentage) { + if (isSDKAbove(24)) { + synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize + if (dislikePercentageFormatter == null) { + Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().getLocales().get(0); + dislikePercentageFormatter = NumberFormat.getPercentInstance(locale); + + // Want to set the digit strings, and the simplest way is to cast to the implementation NumberFormat returns. + if (isSDKAbove(28) && dislikePercentageFormatter instanceof DecimalFormat decimalFormat) { + DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale); + symbols.setDigitStrings(DecimalFormatSymbols.getInstance(Locale.ENGLISH).getDigitStrings()); + decimalFormat.setDecimalFormatSymbols(symbols); + } + } + if (dislikePercentage >= 0.01) { // at least 1% + dislikePercentageFormatter.setMaximumFractionDigits(0); // show only whole percentage points + } else { + dislikePercentageFormatter.setMaximumFractionDigits(1); // show up to 1 digit precision + } + return dislikePercentageFormatter.format(dislikePercentage); + } + } + + // Will never be reached, as the oldest supported YouTube app requires Android N or greater. + return String.valueOf((int) (dislikePercentage * 100)); + } + + @NonNull + public static ReturnYouTubeDislike getFetchForVideoId(@Nullable String videoId) { + Objects.requireNonNull(videoId); + synchronized (fetchCache) { + // Remove any expired entries. + final long now = System.currentTimeMillis(); + if (isSDKAbove(24)) { + fetchCache.values().removeIf(value -> { + final boolean expired = value.isExpired(now); + if (expired) + Logger.printDebug(() -> "Removing expired fetch: " + value.videoId); + return expired; + }); + } else { + final Iterator> itr = fetchCache.entrySet().iterator(); + while (itr.hasNext()) { + final Map.Entry entry = itr.next(); + if (entry.getValue().isExpired(now)) { + Logger.printDebug(() -> "Removing expired fetch: " + entry.getValue().videoId); + itr.remove(); + } + } + } + + ReturnYouTubeDislike fetch = fetchCache.get(videoId); + if (fetch == null) { + fetch = new ReturnYouTubeDislike(videoId); + fetchCache.put(videoId, fetch); + } + return fetch; + } + } + + /** + * Should be called if the user changes dislikes appearance settings. + */ + public static void clearAllUICaches() { + synchronized (fetchCache) { + for (ReturnYouTubeDislike fetch : fetchCache.values()) { + fetch.clearUICache(); + } + } + } + + private ReturnYouTubeDislike(@NonNull String videoId) { + this.videoId = Objects.requireNonNull(videoId); + this.timeFetched = System.currentTimeMillis(); + this.future = Utils.submitOnBackgroundThread(() -> ReturnYouTubeDislikeApi.fetchVotes(videoId)); + } + + private boolean isExpired(long now) { + final long timeSinceCreation = now - timeFetched; + if (timeSinceCreation < CACHE_TIMEOUT_FAILURE_MILLISECONDS) { + return false; // Not expired, even if the API call failed. + } + if (timeSinceCreation > CACHE_TIMEOUT_SUCCESS_MILLISECONDS) { + return true; // Always expired. + } + // Only expired if the fetch failed (API null response). + return (!fetchCompleted() || getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH) == null); + } + + @Nullable + public RYDVoteData getFetchData(long maxTimeToWait) { + try { + return future.get(maxTimeToWait, TimeUnit.MILLISECONDS); + } catch (TimeoutException ex) { + Logger.printDebug(() -> "Waited but future was not complete after: " + maxTimeToWait + "ms"); + } catch (ExecutionException | InterruptedException ex) { + Logger.printException(() -> "Future failure ", ex); // will never happen + } + return null; + } + + /** + * @return if the RYD fetch call has completed. + */ + public boolean fetchCompleted() { + return future.isDone(); + } + + private synchronized void clearUICache() { + if (replacementLikeDislikeSpan != null) { + Logger.printDebug(() -> "Clearing replacement span for: " + videoId); + } + replacementLikeDislikeSpan = null; + } + + /** + * Must call off main thread, as this will make a network call if user is not yet registered. + * + * @return ReturnYouTubeDislike user ID. If user registration has never happened + * and the network call fails, this returns NULL. + */ + @Nullable + private static String getUserId() { + Utils.verifyOffMainThread(); + + String userId = Settings.RYD_USER_ID.get(); + if (!userId.isEmpty()) { + return userId; + } + + userId = ReturnYouTubeDislikeApi.registerAsNewUser(); + if (userId != null) { + Settings.RYD_USER_ID.save(userId); + } + return userId; + } + + @NonNull + public String getVideoId() { + return videoId; + } + + /** + * Pre-emptively set this as a Short. + */ + public synchronized void setVideoIdIsShort(boolean isShort) { + this.isShort = isShort; + } + + /** + * @return the replacement span containing dislikes, or the original span if RYD is not available. + */ + @NonNull + public synchronized Spanned getDislikesSpanForRegularVideo(@NonNull Spanned original, + boolean isSegmentedButton, + boolean isRollingNumber) { + return waitForFetchAndUpdateReplacementSpan(original, isSegmentedButton, + isRollingNumber, false, false); + } + + /** + * Called when a Shorts like Spannable is created. + */ + @NonNull + public synchronized Spanned getLikeSpanForShort(@NonNull Spanned original) { + return waitForFetchAndUpdateReplacementSpan(original, false, + false, true, true); + } + + /** + * Called when a Shorts dislike Spannable is created. + */ + @NonNull + public synchronized Spanned getDislikeSpanForShort(@NonNull Spanned original) { + return waitForFetchAndUpdateReplacementSpan(original, false, + false, true, false); + } + + @NonNull + private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original, + boolean isSegmentedButton, + boolean isRollingNumber, + boolean spanIsForShort, + boolean spanIsForLikes) { + try { + RYDVoteData votingData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH); + if (votingData == null) { + Logger.printDebug(() -> "Cannot add dislike to UI (RYD data not available)"); + return original; + } + + synchronized (this) { + if (spanIsForShort) { + // Cannot set this to false if span is not for a Short. + // When spoofing to an old version and a Short is opened while a regular video + // is on screen, this instance can be loaded for the minimized regular video. + // But this Shorts data won't be displayed for that call + // and when it is un-minimized it will reload again and the load will be ignored. + isShort = true; + } else if (isShort) { + // user: + // 1, opened a video + // 2. opened a short (without closing the regular video) + // 3. closed the short + // 4. regular video is now present, but the videoId and RYD data is still for the short + Logger.printDebug(() -> "Ignoring regular video dislike span," + + " as data loaded was previously used for a Short: " + videoId); + return original; + } + + // prevents reproducible bugs with the following steps: + // (user is using YouTube with RollingNumber applied) + // 1. opened a video + // 2. switched to fullscreen + // 3. click video's title to open the video description + // 4. dislike count may be replaced in the like count area or view count area of the video description + if (PlayerType.getCurrent().isFullScreenOrSlidingFullScreen()) { + Logger.printDebug(() -> "Ignoring fullscreen video description panel: " + videoId); + return original; + } + + if (spanIsForLikes) { + // Scrolling Shorts does not cause the Spans to be reloaded, + // so there is no need to cache the likes for this situations. + Logger.printDebug(() -> "Creating likes span for: " + votingData.videoId); + return newSpannableWithLikes(original, votingData); + } + + if (originalDislikeSpan != null && replacementLikeDislikeSpan != null + && spansHaveEqualTextAndColor(original, originalDislikeSpan)) { + Logger.printDebug(() -> "Replacing span with previously created dislike span of data: " + videoId); + return replacementLikeDislikeSpan; + } + + // No replacement span exist, create it now. + + if (userVote != null) { + votingData.updateUsingVote(userVote); + } + originalDislikeSpan = original; + replacementLikeDislikeSpan = createDislikeSpan(original, isSegmentedButton, isRollingNumber, votingData); + Logger.printDebug(() -> "Replaced: '" + originalDislikeSpan + "' with: '" + + replacementLikeDislikeSpan + "'" + " using video: " + videoId); + + return replacementLikeDislikeSpan; + } + } catch (Exception ex) { + Logger.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", ex); + } + + return original; + } + + public void sendVote(@NonNull Vote vote) { + Utils.verifyOnMainThread(); + Objects.requireNonNull(vote); + try { + if (isShort != PlayerType.getCurrent().isNoneOrHidden()) { + // Shorts was loaded with regular video present, then Shorts was closed. + // and then user voted on the now visible original video. + // Cannot send a vote, because this instance is for the wrong video. + Utils.showToastLong(str("revanced_ryd_failure_ryd_enabled_while_playing_video_then_user_voted")); + return; + } + + setUserVote(vote); + + voteSerialExecutor.execute(() -> { + try { // Must wrap in try/catch to properly log exceptions. + ReturnYouTubeDislikeApi.sendVote(getUserId(), videoId, vote); + } catch (Exception ex) { + Logger.printException(() -> "Failed to send vote", ex); + } + }); + } catch (Exception ex) { + Logger.printException(() -> "Error trying to send vote", ex); + } + } + + /** + * Sets the current user vote value, and does not send the vote to the RYD API. + *

+ * Only used to set value if thumbs up/down is already selected on video load. + */ + public void setUserVote(@NonNull Vote vote) { + Objects.requireNonNull(vote); + try { + Logger.printDebug(() -> "setUserVote: " + vote); + + synchronized (this) { + userVote = vote; + clearUICache(); + } + + if (future.isDone()) { + // Update the fetched vote data. + RYDVoteData voteData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH); + if (voteData == null) { + // RYD fetch failed. + Logger.printDebug(() -> "Cannot update UI (vote data not available)"); + return; + } + voteData.updateUsingVote(vote); + } // Else, vote will be applied after fetch completes. + + } catch (Exception ex) { + Logger.printException(() -> "setUserVote failure", ex); + } + } +} + +/** + * Styles a Spannable with an empty fixed width. + */ +class FixedWidthEmptySpan extends ReplacementSpan { + final int fixedWidth; + + /** + * @param fixedWith Fixed width in screen pixels. + */ + FixedWidthEmptySpan(int fixedWith) { + this.fixedWidth = fixedWith; + if (fixedWith < 0) throw new IllegalArgumentException(); + } + + @Override + public int getSize(@NonNull Paint paint, @NonNull CharSequence text, + int start, int end, @Nullable Paint.FontMetricsInt fontMetrics) { + return fixedWidth; + } + + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, + float x, int top, int y, int bottom, @NonNull Paint paint) { + // Nothing to draw. + } +} + +/** + * Vertically centers a Spanned Drawable. + */ +class VerticallyCenteredImageSpan extends ImageSpan { + final boolean useOriginalWidth; + + /** + * @param useOriginalWidth Use the original layout width of the text this span is applied to, + * and not the bounds of the Drawable. Drawable is always displayed using it's own bounds, + * and this setting only affects the layout width of the entire span. + */ + public VerticallyCenteredImageSpan(Drawable drawable, boolean useOriginalWidth) { + super(drawable); + this.useOriginalWidth = useOriginalWidth; + } + + @Override + public int getSize(@NonNull Paint paint, @NonNull CharSequence text, + int start, int end, @Nullable Paint.FontMetricsInt fontMetrics) { + Drawable drawable = getDrawable(); + Rect bounds = drawable.getBounds(); + if (fontMetrics != null) { + Paint.FontMetricsInt paintMetrics = paint.getFontMetricsInt(); + final int fontHeight = paintMetrics.descent - paintMetrics.ascent; + final int drawHeight = bounds.bottom - bounds.top; + final int halfDrawHeight = drawHeight / 2; + final int yCenter = paintMetrics.ascent + fontHeight / 2; + + fontMetrics.ascent = yCenter - halfDrawHeight; + fontMetrics.top = fontMetrics.ascent; + fontMetrics.bottom = yCenter + halfDrawHeight; + fontMetrics.descent = fontMetrics.bottom; + } + if (useOriginalWidth) { + return (int) paint.measureText(text, start, end); + } + return bounds.right; + } + + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, + float x, int top, int y, int bottom, @NonNull Paint paint) { + Drawable drawable = getDrawable(); + canvas.save(); + Paint.FontMetricsInt paintMetrics = paint.getFontMetricsInt(); + final int fontHeight = paintMetrics.descent - paintMetrics.ascent; + final int yCenter = y + paintMetrics.descent - fontHeight / 2; + final Rect drawBounds = drawable.getBounds(); + float translateX = x; + if (useOriginalWidth) { + // Horizontally center the drawable in the same space as the original text. + translateX += (paint.measureText(text, start, end) - (drawBounds.right - drawBounds.left)) / 2; + } + final int translateY = yCenter - (drawBounds.bottom - drawBounds.top) / 2; + canvas.translate(translateX, translateY); + drawable.draw(canvas); + canvas.restore(); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java new file mode 100644 index 0000000000..bdb78aaa51 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java @@ -0,0 +1,654 @@ +package app.revanced.extension.youtube.settings; + +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; +import static app.revanced.extension.shared.settings.Setting.migrateFromOldPreferences; +import static app.revanced.extension.shared.settings.Setting.parent; +import static app.revanced.extension.shared.settings.Setting.parentsAny; +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType; +import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType.MODERN_1; +import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType.MODERN_2; +import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType.MODERN_3; +import static app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour.MANUAL_SKIP; +import static app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour.SKIP_AUTOMATICALLY; +import static app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour.SKIP_AUTOMATICALLY_ONCE; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.shared.settings.EnumSetting; +import app.revanced.extension.shared.settings.FloatSetting; +import app.revanced.extension.shared.settings.IntegerSetting; +import app.revanced.extension.shared.settings.LongSetting; +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.settings.StringSetting; +import app.revanced.extension.shared.settings.preference.SharedPrefCategory; +import app.revanced.extension.youtube.patches.alternativethumbnails.AlternativeThumbnailsPatch.DeArrowAvailability; +import app.revanced.extension.youtube.patches.alternativethumbnails.AlternativeThumbnailsPatch.StillImagesAvailability; +import app.revanced.extension.youtube.patches.alternativethumbnails.AlternativeThumbnailsPatch.ThumbnailOption; +import app.revanced.extension.youtube.patches.alternativethumbnails.AlternativeThumbnailsPatch.ThumbnailStillTime; +import app.revanced.extension.youtube.patches.general.ChangeStartPagePatch; +import app.revanced.extension.youtube.patches.general.ChangeStartPagePatch.StartPage; +import app.revanced.extension.youtube.patches.general.LayoutSwitchPatch.FormFactor; +import app.revanced.extension.youtube.patches.general.YouTubeMusicActionsPatch; +import app.revanced.extension.youtube.patches.misc.SpoofStreamingDataPatch; +import app.revanced.extension.youtube.patches.misc.WatchHistoryPatch.WatchHistoryType; +import app.revanced.extension.youtube.patches.misc.client.AppClient.ClientType; +import app.revanced.extension.youtube.patches.shorts.AnimationFeedbackPatch.AnimationType; +import app.revanced.extension.youtube.patches.utils.PatchStatus; +import app.revanced.extension.youtube.shared.PlaylistIdPrefix; +import app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings; + +@SuppressWarnings("unused") +public class Settings extends BaseSettings { + // PreferenceScreen: Ads + public static final BooleanSetting HIDE_GENERAL_ADS = new BooleanSetting("revanced_hide_general_ads", TRUE); + public static final BooleanSetting HIDE_GET_PREMIUM = new BooleanSetting("revanced_hide_get_premium", TRUE, true); + public static final BooleanSetting HIDE_MERCHANDISE_SHELF = new BooleanSetting("revanced_hide_merchandise_shelf", TRUE); + public static final BooleanSetting HIDE_PLAYER_STORE_SHELF = new BooleanSetting("revanced_hide_player_store_shelf", TRUE); + public static final BooleanSetting HIDE_PAID_PROMOTION_LABEL = new BooleanSetting("revanced_hide_paid_promotion_label", TRUE); + public static final BooleanSetting HIDE_SELF_SPONSOR_CARDS = new BooleanSetting("revanced_hide_self_sponsor_cards", TRUE); + public static final BooleanSetting HIDE_VIDEO_ADS = new BooleanSetting("revanced_hide_video_ads", TRUE, true); + public static final BooleanSetting HIDE_VIEW_PRODUCTS = new BooleanSetting("revanced_hide_view_products", TRUE); + public static final BooleanSetting HIDE_WEB_SEARCH_RESULTS = new BooleanSetting("revanced_hide_web_search_results", TRUE); + + + // PreferenceScreen: Alternative Thumbnails + public static final EnumSetting ALT_THUMBNAIL_HOME = new EnumSetting<>("revanced_alt_thumbnail_home", ThumbnailOption.ORIGINAL); + public static final EnumSetting ALT_THUMBNAIL_SUBSCRIPTIONS = new EnumSetting<>("revanced_alt_thumbnail_subscriptions", ThumbnailOption.ORIGINAL); + public static final EnumSetting ALT_THUMBNAIL_LIBRARY = new EnumSetting<>("revanced_alt_thumbnail_library", ThumbnailOption.ORIGINAL); + public static final EnumSetting ALT_THUMBNAIL_PLAYER = new EnumSetting<>("revanced_alt_thumbnail_player", ThumbnailOption.ORIGINAL); + public static final EnumSetting ALT_THUMBNAIL_SEARCH = new EnumSetting<>("revanced_alt_thumbnail_search", ThumbnailOption.ORIGINAL); + public static final StringSetting ALT_THUMBNAIL_DEARROW_API_URL = new StringSetting("revanced_alt_thumbnail_dearrow_api_url", + "https://dearrow-thumb.ajay.app/api/v1/getThumbnail", true, new DeArrowAvailability()); + public static final BooleanSetting ALT_THUMBNAIL_DEARROW_CONNECTION_TOAST = new BooleanSetting("revanced_alt_thumbnail_dearrow_connection_toast", FALSE, new DeArrowAvailability()); + public static final EnumSetting ALT_THUMBNAIL_STILLS_TIME = new EnumSetting<>("revanced_alt_thumbnail_stills_time", ThumbnailStillTime.MIDDLE, new StillImagesAvailability()); + public static final BooleanSetting ALT_THUMBNAIL_STILLS_FAST = new BooleanSetting("revanced_alt_thumbnail_stills_fast", FALSE, new StillImagesAvailability()); + + + // PreferenceScreen: Feed + public static final BooleanSetting HIDE_ALBUM_CARDS = new BooleanSetting("revanced_hide_album_card", TRUE); + public static final BooleanSetting HIDE_CAROUSEL_SHELF = new BooleanSetting("revanced_hide_carousel_shelf", FALSE, true); + public static final BooleanSetting HIDE_CHIPS_SHELF = new BooleanSetting("revanced_hide_chips_shelf", TRUE); + public static final BooleanSetting HIDE_EXPANDABLE_CHIP = new BooleanSetting("revanced_hide_expandable_chip", TRUE); + public static final BooleanSetting HIDE_EXPANDABLE_SHELF = new BooleanSetting("revanced_hide_expandable_shelf", TRUE); + public static final BooleanSetting HIDE_FEED_CAPTIONS_BUTTON = new BooleanSetting("revanced_hide_feed_captions_button", FALSE, true); + public static final BooleanSetting HIDE_FEED_SEARCH_BAR = new BooleanSetting("revanced_hide_feed_search_bar", FALSE); + public static final BooleanSetting HIDE_FEED_SURVEY = new BooleanSetting("revanced_hide_feed_survey", TRUE); + public static final BooleanSetting HIDE_FLOATING_BUTTON = new BooleanSetting("revanced_hide_floating_button", FALSE, true); + public static final BooleanSetting HIDE_IMAGE_SHELF = new BooleanSetting("revanced_hide_image_shelf", TRUE); + public static final BooleanSetting HIDE_LATEST_POSTS = new BooleanSetting("revanced_hide_latest_posts", TRUE); + public static final BooleanSetting HIDE_LATEST_VIDEOS_BUTTON = new BooleanSetting("revanced_hide_latest_videos_button", TRUE); + public static final BooleanSetting HIDE_MIX_PLAYLISTS = new BooleanSetting("revanced_hide_mix_playlists", FALSE); + public static final BooleanSetting HIDE_MOVIE_SHELF = new BooleanSetting("revanced_hide_movie_shelf", FALSE); + public static final BooleanSetting HIDE_NOTIFY_ME_BUTTON = new BooleanSetting("revanced_hide_notify_me_button", FALSE); + public static final BooleanSetting HIDE_PLAYABLES = new BooleanSetting("revanced_hide_playables", TRUE); + public static final BooleanSetting HIDE_SHOW_MORE_BUTTON = new BooleanSetting("revanced_hide_show_more_button", TRUE, true); + public static final BooleanSetting HIDE_SUBSCRIPTIONS_CAROUSEL = new BooleanSetting("revanced_hide_subscriptions_carousel", FALSE, true); + public static final BooleanSetting HIDE_TICKET_SHELF = new BooleanSetting("revanced_hide_ticket_shelf", TRUE); + + + // PreferenceScreen: Feed - Category bar + public static final BooleanSetting HIDE_CATEGORY_BAR_IN_FEED = new BooleanSetting("revanced_hide_category_bar_in_feed", FALSE, true); + public static final BooleanSetting HIDE_CATEGORY_BAR_IN_SEARCH = new BooleanSetting("revanced_hide_category_bar_in_search", FALSE, true); + public static final BooleanSetting HIDE_CATEGORY_BAR_IN_RELATED_VIDEOS = new BooleanSetting("revanced_hide_category_bar_in_related_videos", FALSE, true); + + // PreferenceScreen: Feed - Channel profile + public static final BooleanSetting HIDE_CHANNEL_TAB = new BooleanSetting("revanced_hide_channel_tab", FALSE); + public static final StringSetting HIDE_CHANNEL_TAB_FILTER_STRINGS = new StringSetting("revanced_hide_channel_tab_filter_strings", "", true, parent(HIDE_CHANNEL_TAB)); + public static final BooleanSetting HIDE_BROWSE_STORE_BUTTON = new BooleanSetting("revanced_hide_browse_store_button", TRUE); + public static final BooleanSetting HIDE_CHANNEL_MEMBER_SHELF = new BooleanSetting("revanced_hide_channel_member_shelf", TRUE); + public static final BooleanSetting HIDE_CHANNEL_PROFILE_LINKS = new BooleanSetting("revanced_hide_channel_profile_links", TRUE); + public static final BooleanSetting HIDE_FOR_YOU_SHELF = new BooleanSetting("revanced_hide_for_you_shelf", TRUE); + + // PreferenceScreen: Feed - Community posts + public static final BooleanSetting HIDE_COMMUNITY_POSTS_CHANNEL = new BooleanSetting("revanced_hide_community_posts_channel", FALSE); + public static final BooleanSetting HIDE_COMMUNITY_POSTS_HOME_RELATED_VIDEOS = new BooleanSetting("revanced_hide_community_posts_home_related_videos", TRUE); + public static final BooleanSetting HIDE_COMMUNITY_POSTS_SUBSCRIPTIONS = new BooleanSetting("revanced_hide_community_posts_subscriptions", FALSE); + + // PreferenceScreen: Feed - Flyout menu + public static final BooleanSetting HIDE_FEED_FLYOUT_MENU = new BooleanSetting("revanced_hide_feed_flyout_menu", FALSE); + public static final StringSetting HIDE_FEED_FLYOUT_MENU_FILTER_STRINGS = new StringSetting("revanced_hide_feed_flyout_menu_filter_strings", "", true, parent(HIDE_FEED_FLYOUT_MENU)); + + // PreferenceScreen: Feed - Video filter + public static final BooleanSetting HIDE_KEYWORD_CONTENT_HOME = new BooleanSetting("revanced_hide_keyword_content_home", FALSE); + public static final BooleanSetting HIDE_KEYWORD_CONTENT_SEARCH = new BooleanSetting("revanced_hide_keyword_content_search", FALSE); + public static final BooleanSetting HIDE_KEYWORD_CONTENT_SUBSCRIPTIONS = new BooleanSetting("revanced_hide_keyword_content_subscriptions", FALSE); + public static final BooleanSetting HIDE_KEYWORD_CONTENT_COMMENTS = new BooleanSetting("revanced_hide_keyword_content_comments", FALSE); + public static final StringSetting HIDE_KEYWORD_CONTENT_PHRASES = new StringSetting("revanced_hide_keyword_content_phrases", "", + parentsAny(HIDE_KEYWORD_CONTENT_HOME, HIDE_KEYWORD_CONTENT_SEARCH, HIDE_KEYWORD_CONTENT_SUBSCRIPTIONS, HIDE_KEYWORD_CONTENT_COMMENTS)); + + public static final BooleanSetting HIDE_RECOMMENDED_VIDEO = new BooleanSetting("revanced_hide_recommended_video", FALSE); + public static final BooleanSetting HIDE_LOW_VIEWS_VIDEO = new BooleanSetting("revanced_hide_low_views_video", TRUE); + + public static final BooleanSetting HIDE_VIDEO_BY_VIEW_COUNTS_HOME = new BooleanSetting("revanced_hide_video_by_view_counts_home", FALSE); + public static final BooleanSetting HIDE_VIDEO_BY_VIEW_COUNTS_SEARCH = new BooleanSetting("revanced_hide_video_by_view_counts_search", FALSE); + public static final BooleanSetting HIDE_VIDEO_BY_VIEW_COUNTS_SUBSCRIPTIONS = new BooleanSetting("revanced_hide_video_by_view_counts_subscriptions", FALSE); + public static final LongSetting HIDE_VIDEO_VIEW_COUNTS_LESS_THAN = new LongSetting("revanced_hide_video_view_counts_less_than", 1000L, + parentsAny(HIDE_VIDEO_BY_VIEW_COUNTS_HOME, HIDE_VIDEO_BY_VIEW_COUNTS_SEARCH, HIDE_VIDEO_BY_VIEW_COUNTS_SUBSCRIPTIONS)); + public static final LongSetting HIDE_VIDEO_VIEW_COUNTS_GREATER_THAN = new LongSetting("revanced_hide_video_view_counts_greater_than", 1_000_000_000_000L, + parentsAny(HIDE_VIDEO_BY_VIEW_COUNTS_HOME, HIDE_VIDEO_BY_VIEW_COUNTS_SEARCH, HIDE_VIDEO_BY_VIEW_COUNTS_SUBSCRIPTIONS)); + public static final StringSetting HIDE_VIDEO_VIEW_COUNTS_MULTIPLIER = new StringSetting("revanced_hide_video_view_counts_multiplier", str("revanced_hide_video_view_counts_multiplier_default_value"), true, + parentsAny(HIDE_VIDEO_BY_VIEW_COUNTS_HOME, HIDE_VIDEO_BY_VIEW_COUNTS_SEARCH, HIDE_VIDEO_BY_VIEW_COUNTS_SUBSCRIPTIONS)); + + // Experimental Flags + public static final BooleanSetting HIDE_RELATED_VIDEOS = new BooleanSetting("revanced_hide_related_videos", FALSE, true, "revanced_hide_related_videos_user_dialog_message"); + public static final IntegerSetting RELATED_VIDEOS_OFFSET = new IntegerSetting("revanced_related_videos_offset", 2, true, parent(HIDE_RELATED_VIDEOS)); + + + // PreferenceScreen: General + public static final EnumSetting CHANGE_START_PAGE = new EnumSetting<>("revanced_change_start_page", StartPage.ORIGINAL, true); + public static final BooleanSetting CHANGE_START_PAGE_TYPE = new BooleanSetting("revanced_change_start_page_type", FALSE, true, + new ChangeStartPagePatch.ChangeStartPageTypeAvailability()); + public static final BooleanSetting DISABLE_AUTO_AUDIO_TRACKS = new BooleanSetting("revanced_disable_auto_audio_tracks", FALSE); + public static final BooleanSetting DISABLE_SPLASH_ANIMATION = new BooleanSetting("revanced_disable_splash_animation", FALSE, true); + public static final BooleanSetting ENABLE_GRADIENT_LOADING_SCREEN = new BooleanSetting("revanced_enable_gradient_loading_screen", FALSE, true); + public static final BooleanSetting HIDE_FLOATING_MICROPHONE = new BooleanSetting("revanced_hide_floating_microphone", TRUE, true); + public static final BooleanSetting HIDE_GRAY_SEPARATOR = new BooleanSetting("revanced_hide_gray_separator", TRUE); + public static final BooleanSetting HIDE_SNACK_BAR = new BooleanSetting("revanced_hide_snack_bar", FALSE); + public static final BooleanSetting REMOVE_VIEWER_DISCRETION_DIALOG = new BooleanSetting("revanced_remove_viewer_discretion_dialog", FALSE); + + public static final EnumSetting CHANGE_LAYOUT = new EnumSetting<>("revanced_change_layout", FormFactor.ORIGINAL, true); + public static final BooleanSetting SPOOF_APP_VERSION = new BooleanSetting("revanced_spoof_app_version", false, true, "revanced_spoof_app_version_user_dialog_message"); + public static final StringSetting SPOOF_APP_VERSION_TARGET = new StringSetting("revanced_spoof_app_version_target", "18.17.43", true, parent(SPOOF_APP_VERSION)); + + // PreferenceScreen: General - Account menu + public static final BooleanSetting HIDE_ACCOUNT_MENU = new BooleanSetting("revanced_hide_account_menu", FALSE); + public static final StringSetting HIDE_ACCOUNT_MENU_FILTER_STRINGS = new StringSetting("revanced_hide_account_menu_filter_strings", "", true, parent(HIDE_ACCOUNT_MENU)); + public static final BooleanSetting HIDE_HANDLE = new BooleanSetting("revanced_hide_handle", TRUE, true); + + // PreferenceScreen: General - Custom filter + public static final BooleanSetting CUSTOM_FILTER = new BooleanSetting("revanced_custom_filter", FALSE); + public static final StringSetting CUSTOM_FILTER_STRINGS = new StringSetting("revanced_custom_filter_strings", "", true, parent(CUSTOM_FILTER)); + + // PreferenceScreen: General - Miniplayer + public static final EnumSetting MINIPLAYER_TYPE = new EnumSetting<>("revanced_miniplayer_type", MiniplayerType.ORIGINAL, true); + public static final BooleanSetting MINIPLAYER_DOUBLE_TAP_ACTION = new BooleanSetting("revanced_miniplayer_enable_double_tap_action", FALSE, true, MINIPLAYER_TYPE.availability(MODERN_1, MODERN_2, MODERN_3)); + public static final BooleanSetting MINIPLAYER_DRAG_AND_DROP = new BooleanSetting("revanced_miniplayer_enable_drag_and_drop", FALSE, true, MINIPLAYER_TYPE.availability(MODERN_1)); + public static final BooleanSetting MINIPLAYER_HIDE_EXPAND_CLOSE = new BooleanSetting("revanced_miniplayer_hide_expand_close", FALSE, true); + public static final BooleanSetting MINIPLAYER_HIDE_SUBTEXT = new BooleanSetting("revanced_miniplayer_hide_subtext", FALSE, true, MINIPLAYER_TYPE.availability(MODERN_1, MODERN_3)); + public static final BooleanSetting MINIPLAYER_HIDE_REWIND_FORWARD = new BooleanSetting("revanced_miniplayer_hide_rewind_forward", FALSE, true, MINIPLAYER_TYPE.availability(MODERN_1)); + public static final IntegerSetting MINIPLAYER_OPACITY = new IntegerSetting("revanced_miniplayer_opacity", 100, true, MINIPLAYER_TYPE.availability(MODERN_1)); + + // PreferenceScreen: General - Navigation bar + public static final BooleanSetting ENABLE_NARROW_NAVIGATION_BUTTONS = new BooleanSetting("revanced_enable_narrow_navigation_buttons", FALSE, true); + public static final BooleanSetting HIDE_NAVIGATION_CREATE_BUTTON = new BooleanSetting("revanced_hide_navigation_create_button", TRUE, true); + public static final BooleanSetting HIDE_NAVIGATION_HOME_BUTTON = new BooleanSetting("revanced_hide_navigation_home_button", FALSE, true); + public static final BooleanSetting HIDE_NAVIGATION_LIBRARY_BUTTON = new BooleanSetting("revanced_hide_navigation_library_button", FALSE, true); + public static final BooleanSetting HIDE_NAVIGATION_NOTIFICATIONS_BUTTON = new BooleanSetting("revanced_hide_navigation_notifications_button", FALSE, true); + public static final BooleanSetting HIDE_NAVIGATION_SHORTS_BUTTON = new BooleanSetting("revanced_hide_navigation_shorts_button", FALSE, true); + public static final BooleanSetting HIDE_NAVIGATION_SUBSCRIPTIONS_BUTTON = new BooleanSetting("revanced_hide_navigation_subscriptions_button", FALSE, true); + public static final BooleanSetting HIDE_NAVIGATION_LABEL = new BooleanSetting("revanced_hide_navigation_label", FALSE, true); + public static final BooleanSetting SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON = new BooleanSetting("revanced_switch_create_with_notifications_button", TRUE, true, "revanced_switch_create_with_notifications_button_user_dialog_message"); + public static final BooleanSetting ENABLE_TRANSLUCENT_NAVIGATION_BAR = new BooleanSetting("revanced_enable_translucent_navigation_bar", FALSE, true); + public static final BooleanSetting HIDE_NAVIGATION_BAR = new BooleanSetting("revanced_hide_navigation_bar", FALSE, true); + + // PreferenceScreen: General - Override buttons + public static final BooleanSetting OVERRIDE_VIDEO_DOWNLOAD_BUTTON = new BooleanSetting("revanced_override_video_download_button", FALSE); + public static final BooleanSetting OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON = new BooleanSetting("revanced_override_playlist_download_button", FALSE); + public static final StringSetting EXTERNAL_DOWNLOADER_PACKAGE_NAME_VIDEO = new StringSetting("revanced_external_downloader_package_name_video", "com.deniscerri.ytdl"); + public static final StringSetting EXTERNAL_DOWNLOADER_PACKAGE_NAME_PLAYLIST = new StringSetting("revanced_external_downloader_package_name_playlist", "com.deniscerri.ytdl"); + public static final BooleanSetting OVERRIDE_YOUTUBE_MUSIC_BUTTON = new BooleanSetting("revanced_override_youtube_music_button", FALSE, true + , new YouTubeMusicActionsPatch.HookYouTubeMusicAvailability()); + public static final StringSetting THIRD_PARTY_YOUTUBE_MUSIC_PACKAGE_NAME = new StringSetting("revanced_third_party_youtube_music_package_name", PatchStatus.RVXMusicPackageName(), true + , new YouTubeMusicActionsPatch.HookYouTubeMusicPackageNameAvailability()); + + // PreferenceScreen: General - Settings menu + public static final BooleanSetting HIDE_SETTINGS_MENU_PARENT_TOOLS = new BooleanSetting("revanced_hide_settings_menu_parent_tools", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_GENERAL = new BooleanSetting("revanced_hide_settings_menu_general", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_ACCOUNT = new BooleanSetting("revanced_hide_settings_menu_account", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_DATA_SAVING = new BooleanSetting("revanced_hide_settings_menu_data_saving", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_AUTOPLAY = new BooleanSetting("revanced_hide_settings_menu_auto_play", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_VIDEO_QUALITY_PREFERENCES = new BooleanSetting("revanced_hide_settings_menu_video_quality", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_OFFLINE = new BooleanSetting("revanced_hide_settings_menu_offline", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_WATCH_ON_TV = new BooleanSetting("revanced_hide_settings_menu_pair_with_tv", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_MANAGE_ALL_HISTORY = new BooleanSetting("revanced_hide_settings_menu_history", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_YOUR_DATA_IN_YOUTUBE = new BooleanSetting("revanced_hide_settings_menu_your_data", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_PRIVACY = new BooleanSetting("revanced_hide_settings_menu_privacy", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_TRY_EXPERIMENTAL_NEW_FEATURES = new BooleanSetting("revanced_hide_settings_menu_premium_early_access", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_PURCHASES_AND_MEMBERSHIPS = new BooleanSetting("revanced_hide_settings_menu_subscription_product", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_BILLING_AND_PAYMENTS = new BooleanSetting("revanced_hide_settings_menu_billing_and_payment", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_NOTIFICATIONS = new BooleanSetting("revanced_hide_settings_menu_notification", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_CONNECTED_APPS = new BooleanSetting("revanced_hide_settings_menu_connected_accounts", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_LIVE_CHAT = new BooleanSetting("revanced_hide_settings_menu_live_chat", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_CAPTIONS = new BooleanSetting("revanced_hide_settings_menu_captions", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_ACCESSIBILITY = new BooleanSetting("revanced_hide_settings_menu_accessibility", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_ABOUT = new BooleanSetting("revanced_hide_settings_menu_about", FALSE, true); + // dummy data + public static final BooleanSetting HIDE_SETTINGS_MENU_YOUTUBE_TV = new BooleanSetting("revanced_hide_settings_menu_youtube_tv", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_PRE_PURCHASE = new BooleanSetting("revanced_hide_settings_menu_pre_purchase", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_POST_PURCHASE = new BooleanSetting("revanced_hide_settings_menu_post_purchase", FALSE, true); + public static final BooleanSetting HIDE_SETTINGS_MENU_THIRD_PARTY = new BooleanSetting("revanced_hide_settings_menu_third_party", FALSE, true); + + // PreferenceScreen: General - Toolbar + public static final BooleanSetting CHANGE_YOUTUBE_HEADER = new BooleanSetting("revanced_change_youtube_header", TRUE, true); + public static final BooleanSetting ENABLE_WIDE_SEARCH_BAR = new BooleanSetting("revanced_enable_wide_search_bar", FALSE, true); + public static final BooleanSetting ENABLE_WIDE_SEARCH_BAR_WITH_HEADER = new BooleanSetting("revanced_enable_wide_search_bar_with_header", TRUE, true); + public static final BooleanSetting ENABLE_WIDE_SEARCH_BAR_IN_YOU_TAB = new BooleanSetting("revanced_enable_wide_search_bar_in_you_tab", FALSE, true); + public static final BooleanSetting HIDE_TOOLBAR_CAST_BUTTON = new BooleanSetting("revanced_hide_toolbar_cast_button", TRUE, true); + public static final BooleanSetting HIDE_TOOLBAR_CREATE_BUTTON = new BooleanSetting("revanced_hide_toolbar_create_button", FALSE, true); + public static final BooleanSetting HIDE_TOOLBAR_NOTIFICATION_BUTTON = new BooleanSetting("revanced_hide_toolbar_notification_button", FALSE, true); + public static final BooleanSetting HIDE_SEARCH_TERM_THUMBNAIL = new BooleanSetting("revanced_hide_search_term_thumbnail", FALSE); + public static final BooleanSetting HIDE_IMAGE_SEARCH_BUTTON = new BooleanSetting("revanced_hide_image_search_button", FALSE, true); + public static final BooleanSetting HIDE_VOICE_SEARCH_BUTTON = new BooleanSetting("revanced_hide_voice_search_button", FALSE, true); + public static final BooleanSetting HIDE_YOUTUBE_DOODLES = new BooleanSetting("revanced_hide_youtube_doodles", FALSE, true, "revanced_hide_youtube_doodles_user_dialog_message"); + public static final BooleanSetting REPLACE_TOOLBAR_CREATE_BUTTON = new BooleanSetting("revanced_replace_toolbar_create_button", FALSE, true); + public static final BooleanSetting REPLACE_TOOLBAR_CREATE_BUTTON_TYPE = new BooleanSetting("revanced_replace_toolbar_create_button_type", FALSE, true); + + + // PreferenceScreen: Player + public static final IntegerSetting CUSTOM_PLAYER_OVERLAY_OPACITY = new IntegerSetting("revanced_custom_player_overlay_opacity", 100, true); + public static final BooleanSetting DISABLE_AUTO_PLAYER_POPUP_PANELS = new BooleanSetting("revanced_disable_auto_player_popup_panels", TRUE, true); + public static final BooleanSetting DISABLE_AUTO_SWITCH_MIX_PLAYLISTS = new BooleanSetting("revanced_disable_auto_switch_mix_playlists", FALSE, true, "revanced_disable_auto_switch_mix_playlists_user_dialog_message"); + public static final BooleanSetting DISABLE_SPEED_OVERLAY = new BooleanSetting("revanced_disable_speed_overlay", FALSE, true); + public static final FloatSetting SPEED_OVERLAY_VALUE = new FloatSetting("revanced_speed_overlay_value", 2.0f, true); + public static final BooleanSetting HIDE_CHANNEL_WATERMARK = new BooleanSetting("revanced_hide_channel_watermark", TRUE); + public static final BooleanSetting HIDE_CROWDFUNDING_BOX = new BooleanSetting("revanced_hide_crowdfunding_box", TRUE, true); + public static final BooleanSetting HIDE_DOUBLE_TAP_OVERLAY_FILTER = new BooleanSetting("revanced_hide_double_tap_overlay_filter", FALSE, true); + public static final BooleanSetting HIDE_END_SCREEN_CARDS = new BooleanSetting("revanced_hide_end_screen_cards", FALSE, true); + public static final BooleanSetting HIDE_FILMSTRIP_OVERLAY = new BooleanSetting("revanced_hide_filmstrip_overlay", FALSE, true); + public static final BooleanSetting HIDE_INFO_CARDS = new BooleanSetting("revanced_hide_info_cards", FALSE, true); + public static final BooleanSetting HIDE_INFO_PANEL = new BooleanSetting("revanced_hide_info_panel", TRUE); + public static final BooleanSetting HIDE_LIVE_CHAT_MESSAGES = new BooleanSetting("revanced_hide_live_chat_messages", FALSE); + public static final BooleanSetting HIDE_MEDICAL_PANEL = new BooleanSetting("revanced_hide_medical_panel", TRUE); + public static final BooleanSetting HIDE_SEEK_MESSAGE = new BooleanSetting("revanced_hide_seek_message", FALSE, true); + public static final BooleanSetting HIDE_SEEK_UNDO_MESSAGE = new BooleanSetting("revanced_hide_seek_undo_message", FALSE, true); + public static final BooleanSetting HIDE_SUGGESTED_ACTION = new BooleanSetting("revanced_hide_suggested_actions", TRUE, true); + public static final BooleanSetting HIDE_TIMED_REACTIONS = new BooleanSetting("revanced_hide_timed_reactions", TRUE); + public static final BooleanSetting HIDE_SUGGESTED_VIDEO_END_SCREEN = new BooleanSetting("revanced_hide_suggested_video_end_screen", TRUE, true); + public static final BooleanSetting SKIP_AUTOPLAY_COUNTDOWN = new BooleanSetting("revanced_skip_autoplay_countdown", FALSE, true, parent(HIDE_SUGGESTED_VIDEO_END_SCREEN)); + public static final BooleanSetting HIDE_ZOOM_OVERLAY = new BooleanSetting("revanced_hide_zoom_overlay", FALSE, true); + public static final BooleanSetting SANITIZE_VIDEO_SUBTITLE = new BooleanSetting("revanced_sanitize_video_subtitle", FALSE); + + + // PreferenceScreen: Player - Action buttons + public static final BooleanSetting DISABLE_LIKE_DISLIKE_GLOW = new BooleanSetting("revanced_disable_like_dislike_glow", FALSE); + public static final BooleanSetting HIDE_CLIP_BUTTON = new BooleanSetting("revanced_hide_clip_button", FALSE); + public static final BooleanSetting HIDE_DOWNLOAD_BUTTON = new BooleanSetting("revanced_hide_download_button", FALSE); + public static final BooleanSetting HIDE_LIKE_DISLIKE_BUTTON = new BooleanSetting("revanced_hide_like_dislike_button", FALSE); + public static final BooleanSetting HIDE_PLAYLIST_BUTTON = new BooleanSetting("revanced_hide_playlist_button", FALSE); + public static final BooleanSetting HIDE_REMIX_BUTTON = new BooleanSetting("revanced_hide_remix_button", FALSE); + public static final BooleanSetting HIDE_REWARDS_BUTTON = new BooleanSetting("revanced_hide_rewards_button", FALSE); + public static final BooleanSetting HIDE_REPORT_BUTTON = new BooleanSetting("revanced_hide_report_button", FALSE); + public static final BooleanSetting HIDE_SHARE_BUTTON = new BooleanSetting("revanced_hide_share_button", FALSE); + public static final BooleanSetting HIDE_SHOP_BUTTON = new BooleanSetting("revanced_hide_shop_button", FALSE); + public static final BooleanSetting HIDE_THANKS_BUTTON = new BooleanSetting("revanced_hide_thanks_button", FALSE); + + // PreferenceScreen: Player - Ambient mode + public static final BooleanSetting BYPASS_AMBIENT_MODE_RESTRICTIONS = new BooleanSetting("revanced_bypass_ambient_mode_restrictions", FALSE); + public static final BooleanSetting DISABLE_AMBIENT_MODE = new BooleanSetting("revanced_disable_ambient_mode", FALSE, true); + public static final BooleanSetting DISABLE_AMBIENT_MODE_IN_FULLSCREEN = new BooleanSetting("revanced_disable_ambient_mode_in_fullscreen", FALSE, true); + + // PreferenceScreen: Player - Channel bar + public static final BooleanSetting HIDE_JOIN_BUTTON = new BooleanSetting("revanced_hide_join_button", TRUE); + public static final BooleanSetting HIDE_START_TRIAL_BUTTON = new BooleanSetting("revanced_hide_start_trial_button", TRUE); + + // PreferenceScreen: Player - Comments + public static final BooleanSetting HIDE_CHANNEL_GUIDELINES = new BooleanSetting("revanced_hide_channel_guidelines", TRUE); + public static final BooleanSetting HIDE_COMMENTS_BY_MEMBERS = new BooleanSetting("revanced_hide_comments_by_members", FALSE); + public static final BooleanSetting HIDE_COMMENT_HIGHLIGHTED_SEARCH_LINKS = new BooleanSetting("revanced_hide_comment_highlighted_search_links", FALSE, true); + public static final BooleanSetting HIDE_COMMENTS_SECTION = new BooleanSetting("revanced_hide_comments_section", FALSE); + public static final BooleanSetting HIDE_COMMENTS_SECTION_IN_HOME_FEED = new BooleanSetting("revanced_hide_comments_section_in_home_feed", FALSE); + public static final BooleanSetting HIDE_PREVIEW_COMMENT = new BooleanSetting("revanced_hide_preview_comment", FALSE); + public static final BooleanSetting HIDE_PREVIEW_COMMENT_TYPE = new BooleanSetting("revanced_hide_preview_comment_type", FALSE); + public static final BooleanSetting HIDE_PREVIEW_COMMENT_OLD_METHOD = new BooleanSetting("revanced_hide_preview_comment_old_method", FALSE); + public static final BooleanSetting HIDE_PREVIEW_COMMENT_NEW_METHOD = new BooleanSetting("revanced_hide_preview_comment_new_method", FALSE); + public static final BooleanSetting HIDE_COMMENT_CREATE_SHORTS_BUTTON = new BooleanSetting("revanced_hide_comment_create_shorts_button", FALSE); + public static final BooleanSetting HIDE_COMMENT_THANKS_BUTTON = new BooleanSetting("revanced_hide_comment_thanks_button", FALSE, true); + public static final BooleanSetting HIDE_COMMENT_TIMESTAMP_AND_EMOJI_BUTTONS = new BooleanSetting("revanced_hide_comment_timestamp_and_emoji_buttons", FALSE); + + // PreferenceScreen: Player - Flyout menu + public static final BooleanSetting CHANGE_PLAYER_FLYOUT_MENU_TOGGLE = new BooleanSetting("revanced_change_player_flyout_menu_toggle", FALSE, true); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_ENHANCED_BITRATE = new BooleanSetting("revanced_hide_player_flyout_menu_enhanced_bitrate", TRUE, true); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_AUDIO_TRACK = new BooleanSetting("revanced_hide_player_flyout_menu_audio_track", FALSE); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_CAPTIONS = new BooleanSetting("revanced_hide_player_flyout_menu_captions", FALSE); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_CAPTIONS_FOOTER = new BooleanSetting("revanced_hide_player_flyout_menu_captions_footer", TRUE, true); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_LOCK_SCREEN = new BooleanSetting("revanced_hide_player_flyout_menu_lock_screen", FALSE); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_MORE = new BooleanSetting("revanced_hide_player_flyout_menu_more_info", FALSE); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_PLAYBACK_SPEED = new BooleanSetting("revanced_hide_player_flyout_menu_playback_speed", FALSE); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_QUALITY_HEADER = new BooleanSetting("revanced_hide_player_flyout_menu_quality_header", FALSE); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_QUALITY_FOOTER = new BooleanSetting("revanced_hide_player_flyout_menu_quality_footer", TRUE, true); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_REPORT = new BooleanSetting("revanced_hide_player_flyout_menu_report", TRUE); + + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_ADDITIONAL_SETTINGS = new BooleanSetting("revanced_hide_player_flyout_menu_additional_settings", FALSE); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_AMBIENT = new BooleanSetting("revanced_hide_player_flyout_menu_ambient_mode", FALSE); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_HELP = new BooleanSetting("revanced_hide_player_flyout_menu_help", TRUE); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_LOOP = new BooleanSetting("revanced_hide_player_flyout_menu_loop_video", FALSE); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_PIP = new BooleanSetting("revanced_hide_player_flyout_menu_pip", TRUE, true); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_PREMIUM_CONTROLS = new BooleanSetting("revanced_hide_player_flyout_menu_premium_controls", TRUE); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_SLEEP_TIMER = new BooleanSetting("revanced_hide_player_flyout_menu_sleep_timer", TRUE); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_STABLE_VOLUME = new BooleanSetting("revanced_hide_player_flyout_menu_stable_volume", FALSE); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_STATS_FOR_NERDS = new BooleanSetting("revanced_hide_player_flyout_menu_stats_for_nerds", FALSE); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_WATCH_IN_VR = new BooleanSetting("revanced_hide_player_flyout_menu_watch_in_vr", TRUE); + public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_YT_MUSIC = new BooleanSetting("revanced_hide_player_flyout_menu_listen_with_youtube_music", TRUE); + + // PreferenceScreen: Player - Fullscreen + public static final BooleanSetting DISABLE_ENGAGEMENT_PANEL = new BooleanSetting("revanced_disable_engagement_panel", FALSE, true); + public static final BooleanSetting SHOW_VIDEO_TITLE_SECTION = new BooleanSetting("revanced_show_video_title_section", TRUE, true, parent(DISABLE_ENGAGEMENT_PANEL)); + public static final BooleanSetting HIDE_AUTOPLAY_PREVIEW = new BooleanSetting("revanced_hide_autoplay_preview", FALSE, true); + public static final BooleanSetting HIDE_LIVE_CHAT_REPLAY_BUTTON = new BooleanSetting("revanced_hide_live_chat_replay_button", FALSE); + public static final BooleanSetting HIDE_RELATED_VIDEO_OVERLAY = new BooleanSetting("revanced_hide_related_video_overlay", FALSE, true); + + public static final BooleanSetting HIDE_QUICK_ACTIONS = new BooleanSetting("revanced_hide_quick_actions", FALSE, true); + public static final BooleanSetting HIDE_QUICK_ACTIONS_COMMENT_BUTTON = new BooleanSetting("revanced_hide_quick_actions_comment_button", FALSE); + public static final BooleanSetting HIDE_QUICK_ACTIONS_DISLIKE_BUTTON = new BooleanSetting("revanced_hide_quick_actions_dislike_button", FALSE); + public static final BooleanSetting HIDE_QUICK_ACTIONS_LIKE_BUTTON = new BooleanSetting("revanced_hide_quick_actions_like_button", FALSE); + public static final BooleanSetting HIDE_QUICK_ACTIONS_LIVE_CHAT_BUTTON = new BooleanSetting("revanced_hide_quick_actions_live_chat_button", FALSE); + public static final BooleanSetting HIDE_QUICK_ACTIONS_MORE_BUTTON = new BooleanSetting("revanced_hide_quick_actions_more_button", FALSE); + public static final BooleanSetting HIDE_QUICK_ACTIONS_OPEN_MIX_PLAYLIST_BUTTON = new BooleanSetting("revanced_hide_quick_actions_open_mix_playlist_button", FALSE); + public static final BooleanSetting HIDE_QUICK_ACTIONS_OPEN_PLAYLIST_BUTTON = new BooleanSetting("revanced_hide_quick_actions_open_playlist_button", FALSE); + public static final BooleanSetting HIDE_QUICK_ACTIONS_SAVE_TO_PLAYLIST_BUTTON = new BooleanSetting("revanced_hide_quick_actions_save_to_playlist_button", FALSE); + public static final BooleanSetting HIDE_QUICK_ACTIONS_SHARE_BUTTON = new BooleanSetting("revanced_hide_quick_actions_share_button", FALSE); + public static final IntegerSetting QUICK_ACTIONS_TOP_MARGIN = new IntegerSetting("revanced_quick_actions_top_margin", 0, true); + + public static final BooleanSetting DISABLE_LANDSCAPE_MODE = new BooleanSetting("revanced_disable_landscape_mode", FALSE, true); + public static final BooleanSetting ENABLE_COMPACT_CONTROLS_OVERLAY = new BooleanSetting("revanced_enable_compact_controls_overlay", FALSE, true); + public static final BooleanSetting FORCE_FULLSCREEN = new BooleanSetting("revanced_force_fullscreen", FALSE, true); + public static final BooleanSetting KEEP_LANDSCAPE_MODE = new BooleanSetting("revanced_keep_landscape_mode", FALSE, true); + public static final LongSetting KEEP_LANDSCAPE_MODE_TIMEOUT = new LongSetting("revanced_keep_landscape_mode_timeout", 3000L, true); + + // PreferenceScreen: Player - Haptic feedback + public static final BooleanSetting DISABLE_HAPTIC_FEEDBACK_CHAPTERS = new BooleanSetting("revanced_disable_haptic_feedback_chapters", FALSE); + public static final BooleanSetting DISABLE_HAPTIC_FEEDBACK_SCRUBBING = new BooleanSetting("revanced_disable_haptic_feedback_scrubbing", FALSE); + public static final BooleanSetting DISABLE_HAPTIC_FEEDBACK_SEEK = new BooleanSetting("revanced_disable_haptic_feedback_seek", FALSE); + public static final BooleanSetting DISABLE_HAPTIC_FEEDBACK_SEEK_UNDO = new BooleanSetting("revanced_disable_haptic_feedback_seek_undo", FALSE); + public static final BooleanSetting DISABLE_HAPTIC_FEEDBACK_ZOOM = new BooleanSetting("revanced_disable_haptic_feedback_zoom", FALSE); + + // PreferenceScreen: Player - Player buttons + public static final BooleanSetting HIDE_PLAYER_AUTOPLAY_BUTTON = new BooleanSetting("revanced_hide_player_autoplay_button", TRUE, true); + public static final BooleanSetting HIDE_PLAYER_CAPTIONS_BUTTON = new BooleanSetting("revanced_hide_player_captions_button", FALSE, true); + public static final BooleanSetting HIDE_PLAYER_CAST_BUTTON = new BooleanSetting("revanced_hide_player_cast_button", TRUE, true); + public static final BooleanSetting HIDE_PLAYER_COLLAPSE_BUTTON = new BooleanSetting("revanced_hide_player_collapse_button", FALSE, true); + public static final BooleanSetting HIDE_PLAYER_FULLSCREEN_BUTTON = new BooleanSetting("revanced_hide_player_fullscreen_button", FALSE, true); + public static final BooleanSetting HIDE_PLAYER_PREVIOUS_NEXT_BUTTON = new BooleanSetting("revanced_hide_player_previous_next_button", FALSE, true); + public static final BooleanSetting HIDE_PLAYER_YOUTUBE_MUSIC_BUTTON = new BooleanSetting("revanced_hide_player_youtube_music_button", FALSE); + + public static final BooleanSetting ALWAYS_REPEAT = new BooleanSetting("revanced_always_repeat", FALSE); + public static final BooleanSetting ALWAYS_REPEAT_PAUSE = new BooleanSetting("revanced_always_repeat_pause", FALSE); + public static final BooleanSetting OVERLAY_BUTTON_ALWAYS_REPEAT = new BooleanSetting("revanced_overlay_button_always_repeat", FALSE); + public static final BooleanSetting OVERLAY_BUTTON_COPY_VIDEO_URL = new BooleanSetting("revanced_overlay_button_copy_video_url", FALSE); + public static final BooleanSetting OVERLAY_BUTTON_COPY_VIDEO_URL_TIMESTAMP = new BooleanSetting("revanced_overlay_button_copy_video_url_timestamp", FALSE); + public static final BooleanSetting OVERLAY_BUTTON_MUTE_VOLUME = new BooleanSetting("revanced_overlay_button_mute_volume", FALSE); + public static final BooleanSetting OVERLAY_BUTTON_EXTERNAL_DOWNLOADER = new BooleanSetting("revanced_overlay_button_external_downloader", FALSE); + public static final BooleanSetting OVERLAY_BUTTON_SPEED_DIALOG = new BooleanSetting("revanced_overlay_button_speed_dialog", FALSE); + public static final BooleanSetting OVERLAY_BUTTON_PLAY_ALL = new BooleanSetting("revanced_overlay_button_play_all", FALSE); + public static final EnumSetting OVERLAY_BUTTON_PLAY_ALL_TYPE = new EnumSetting<>("revanced_overlay_button_play_all_type", PlaylistIdPrefix.ALL_CONTENTS_WITH_TIME_DESCENDING); + public static final BooleanSetting OVERLAY_BUTTON_WHITELIST = new BooleanSetting("revanced_overlay_button_whitelist", FALSE); + + // PreferenceScreen: Player - Seekbar + public static final BooleanSetting APPEND_TIME_STAMP_INFORMATION = new BooleanSetting("revanced_append_time_stamp_information", TRUE, true); + public static final BooleanSetting APPEND_TIME_STAMP_INFORMATION_TYPE = new BooleanSetting("revanced_append_time_stamp_information_type", TRUE, parent(APPEND_TIME_STAMP_INFORMATION)); + public static final BooleanSetting REPLACE_TIME_STAMP_ACTION = new BooleanSetting("revanced_replace_time_stamp_action", TRUE, true, parent(APPEND_TIME_STAMP_INFORMATION)); + public static final BooleanSetting ENABLE_CUSTOM_SEEKBAR_COLOR = new BooleanSetting("revanced_enable_custom_seekbar_color", FALSE, true); + public static final StringSetting ENABLE_CUSTOM_SEEKBAR_COLOR_VALUE = new StringSetting("revanced_custom_seekbar_color_value", "#FF0000", true, parent(ENABLE_CUSTOM_SEEKBAR_COLOR)); + public static final BooleanSetting ENABLE_SEEKBAR_TAPPING = new BooleanSetting("revanced_enable_seekbar_tapping", TRUE); + public static final BooleanSetting HIDE_SEEKBAR = new BooleanSetting("revanced_hide_seekbar", FALSE, true); + public static final BooleanSetting HIDE_SEEKBAR_THUMBNAIL = new BooleanSetting("revanced_hide_seekbar_thumbnail", FALSE); + public static final BooleanSetting DISABLE_SEEKBAR_CHAPTERS = new BooleanSetting("revanced_disable_seekbar_chapters", FALSE, true); + public static final BooleanSetting HIDE_SEEKBAR_CHAPTER_LABEL = new BooleanSetting("revanced_hide_seekbar_chapter_label", FALSE, true); + public static final BooleanSetting HIDE_TIME_STAMP = new BooleanSetting("revanced_hide_time_stamp", FALSE, true); + public static final BooleanSetting RESTORE_OLD_SEEKBAR_THUMBNAILS = new BooleanSetting("revanced_restore_old_seekbar_thumbnails", + PatchStatus.OldSeekbarThumbnailsDefaultBoolean(), true); + public static final BooleanSetting ENABLE_SEEKBAR_THUMBNAILS_HIGH_QUALITY = new BooleanSetting("revanced_enable_seekbar_thumbnails_high_quality", FALSE, true, "revanced_enable_seekbar_thumbnails_high_quality_dialog_message"); + public static final BooleanSetting ENABLE_CAIRO_SEEKBAR = new BooleanSetting("revanced_enable_cairo_seekbar", FALSE, true); + + // PreferenceScreen: Player - Video description + public static final BooleanSetting DISABLE_ROLLING_NUMBER_ANIMATIONS = new BooleanSetting("revanced_disable_rolling_number_animations", FALSE); + public static final BooleanSetting HIDE_AI_GENERATED_VIDEO_SUMMARY_SECTION = new BooleanSetting("revanced_hide_ai_generated_video_summary_section", FALSE); + public static final BooleanSetting HIDE_ATTRIBUTES_SECTION = new BooleanSetting("revanced_hide_attributes_section", FALSE); + public static final BooleanSetting HIDE_CHAPTERS_SECTION = new BooleanSetting("revanced_hide_chapters_section", FALSE); + public static final BooleanSetting HIDE_CONTENTS_SECTION = new BooleanSetting("revanced_hide_contents_section", FALSE); + public static final BooleanSetting HIDE_INFO_CARDS_SECTION = new BooleanSetting("revanced_hide_info_cards_section", FALSE); + public static final BooleanSetting HIDE_KEY_CONCEPTS_SECTION = new BooleanSetting("revanced_hide_key_concepts_section", FALSE); + public static final BooleanSetting HIDE_PODCAST_SECTION = new BooleanSetting("revanced_hide_podcast_section", FALSE); + public static final BooleanSetting HIDE_SHOPPING_LINKS = new BooleanSetting("revanced_hide_shopping_links", TRUE); + public static final BooleanSetting HIDE_TRANSCRIPT_SECTION = new BooleanSetting("revanced_hide_transcript_section", FALSE); + public static final BooleanSetting DISABLE_VIDEO_DESCRIPTION_INTERACTION = new BooleanSetting("revanced_disable_video_description_interaction", FALSE, true); + public static final BooleanSetting EXPAND_VIDEO_DESCRIPTION = new BooleanSetting("revanced_expand_video_description", FALSE, true); + public static final StringSetting EXPAND_VIDEO_DESCRIPTION_STRINGS = new StringSetting("revanced_expand_video_description_strings", str("revanced_expand_video_description_strings_default_value"), true, parent(EXPAND_VIDEO_DESCRIPTION)); + + + // PreferenceScreen: Shorts + public static final BooleanSetting DISABLE_RESUMING_SHORTS_PLAYER = new BooleanSetting("revanced_disable_resuming_shorts_player", TRUE); + public static final BooleanSetting HIDE_SHORTS_FLOATING_BUTTON = new BooleanSetting("revanced_hide_shorts_floating_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_SHELF = new BooleanSetting("revanced_hide_shorts_shelf", TRUE, true); + public static final BooleanSetting HIDE_SHORTS_SHELF_CHANNEL = new BooleanSetting("revanced_hide_shorts_shelf_channel", FALSE); + public static final BooleanSetting HIDE_SHORTS_SHELF_HOME_RELATED_VIDEOS = new BooleanSetting("revanced_hide_shorts_shelf_home_related_videos", TRUE); + public static final BooleanSetting HIDE_SHORTS_SHELF_SUBSCRIPTIONS = new BooleanSetting("revanced_hide_shorts_shelf_subscriptions", TRUE); + public static final BooleanSetting HIDE_SHORTS_SHELF_SEARCH = new BooleanSetting("revanced_hide_shorts_shelf_search", TRUE); + public static final BooleanSetting HIDE_SHORTS_SHELF_HISTORY = new BooleanSetting("revanced_hide_shorts_shelf_history", FALSE); + public static final IntegerSetting CHANGE_SHORTS_REPEAT_STATE = new IntegerSetting("revanced_change_shorts_repeat_state", 0); + + // PreferenceScreen: Shorts - Shorts player components + public static final BooleanSetting HIDE_SHORTS_JOIN_BUTTON = new BooleanSetting("revanced_hide_shorts_join_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_SUBSCRIBE_BUTTON = new BooleanSetting("revanced_hide_shorts_subscribe_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_PAUSED_HEADER = new BooleanSetting("revanced_hide_shorts_paused_header", FALSE, true); + public static final BooleanSetting HIDE_SHORTS_PAUSED_OVERLAY_BUTTONS = new BooleanSetting("revanced_hide_shorts_paused_overlay_buttons", FALSE); + public static final BooleanSetting HIDE_SHORTS_TRENDS_BUTTON = new BooleanSetting("revanced_hide_shorts_trends_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_SHOPPING_BUTTON = new BooleanSetting("revanced_hide_shorts_shopping_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_STICKERS = new BooleanSetting("revanced_hide_shorts_stickers", TRUE); + public static final BooleanSetting HIDE_SHORTS_PAID_PROMOTION_LABEL = new BooleanSetting("revanced_hide_shorts_paid_promotion_label", TRUE, true); + public static final BooleanSetting HIDE_SHORTS_INFO_PANEL = new BooleanSetting("revanced_hide_shorts_info_panel", TRUE); + public static final BooleanSetting HIDE_SHORTS_LIVE_HEADER = new BooleanSetting("revanced_hide_shorts_live_header", FALSE); + public static final BooleanSetting HIDE_SHORTS_CHANNEL_BAR = new BooleanSetting("revanced_hide_shorts_channel_bar", FALSE); + public static final BooleanSetting HIDE_SHORTS_VIDEO_TITLE = new BooleanSetting("revanced_hide_shorts_video_title", FALSE); + public static final BooleanSetting HIDE_SHORTS_SOUND_METADATA_LABEL = new BooleanSetting("revanced_hide_shorts_sound_metadata_label", TRUE); + public static final BooleanSetting HIDE_SHORTS_FULL_VIDEO_LINK_LABEL = new BooleanSetting("revanced_hide_shorts_full_video_link_label", TRUE); + + // PreferenceScreen: Shorts - Shorts player components - Suggested actions + public static final BooleanSetting HIDE_SHORTS_GREEN_SCREEN_BUTTON = new BooleanSetting("revanced_hide_shorts_green_screen_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_SAVE_MUSIC_BUTTON = new BooleanSetting("revanced_hide_shorts_save_music_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_SHOP_BUTTON = new BooleanSetting("revanced_hide_shorts_shop_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_SUPER_THANKS_BUTTON = new BooleanSetting("revanced_hide_shorts_super_thanks_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_USE_THIS_SOUND_BUTTON = new BooleanSetting("revanced_hide_shorts_use_this_sound_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_USE_TEMPLATE_BUTTON = new BooleanSetting("revanced_hide_shorts_use_template_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_LOCATION_BUTTON = new BooleanSetting("revanced_hide_shorts_location_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_SEARCH_SUGGESTIONS_BUTTON = new BooleanSetting("revanced_hide_shorts_search_suggestions_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_TAGGED_PRODUCTS = new BooleanSetting("revanced_hide_shorts_tagged_products", TRUE); + + // PreferenceScreen: Shorts - Shorts player components - Action buttons + public static final BooleanSetting HIDE_SHORTS_LIKE_BUTTON = new BooleanSetting("revanced_hide_shorts_like_button", FALSE); + public static final BooleanSetting HIDE_SHORTS_DISLIKE_BUTTON = new BooleanSetting("revanced_hide_shorts_dislike_button", FALSE); + public static final BooleanSetting HIDE_SHORTS_COMMENTS_BUTTON = new BooleanSetting("revanced_hide_shorts_comments_button", FALSE); + public static final BooleanSetting HIDE_SHORTS_REMIX_BUTTON = new BooleanSetting("revanced_hide_shorts_remix_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_SHARE_BUTTON = new BooleanSetting("revanced_hide_shorts_share_button", FALSE); + public static final BooleanSetting HIDE_SHORTS_SOUND_BUTTON = new BooleanSetting("revanced_hide_shorts_sound_button", TRUE); + + public static final BooleanSetting DISABLE_SHORTS_LIKE_BUTTON_FOUNTAIN_ANIMATION = new BooleanSetting("revanced_disable_shorts_like_button_fountain_animation", FALSE); + public static final BooleanSetting HIDE_SHORTS_PLAY_PAUSE_BUTTON_BACKGROUND = new BooleanSetting("revanced_hide_shorts_play_pause_button_background", FALSE, true); + public static final EnumSetting ANIMATION_TYPE = new EnumSetting<>("revanced_shorts_double_tap_to_like_animation", AnimationType.ORIGINAL, true); + + + // Experimental Flags + public static final BooleanSetting ENABLE_TIME_STAMP = new BooleanSetting("revanced_enable_shorts_time_stamp", FALSE, true); + public static final BooleanSetting TIME_STAMP_CHANGE_REPEAT_STATE = new BooleanSetting("revanced_shorts_time_stamp_change_repeat_state", TRUE, true, parent(ENABLE_TIME_STAMP)); + public static final IntegerSetting META_PANEL_BOTTOM_MARGIN = new IntegerSetting("revanced_shorts_meta_panel_bottom_margin", 32, true, parent(ENABLE_TIME_STAMP)); + public static final BooleanSetting HIDE_SHORTS_TOOLBAR = new BooleanSetting("revanced_hide_shorts_toolbar", FALSE, true); + public static final BooleanSetting HIDE_SHORTS_NAVIGATION_BAR = new BooleanSetting("revanced_hide_shorts_navigation_bar", FALSE, true); + public static final IntegerSetting SHORTS_NAVIGATION_BAR_HEIGHT_PERCENTAGE = new IntegerSetting("revanced_shorts_navigation_bar_height_percentage", 45, true, parent(HIDE_SHORTS_NAVIGATION_BAR)); + public static final BooleanSetting REPLACE_CHANNEL_HANDLE = new BooleanSetting("revanced_replace_channel_handle", FALSE, true); + + // PreferenceScreen: Swipe controls + public static final BooleanSetting ENABLE_SWIPE_BRIGHTNESS = new BooleanSetting("revanced_enable_swipe_brightness", TRUE, true); + public static final BooleanSetting ENABLE_SWIPE_VOLUME = new BooleanSetting("revanced_enable_swipe_volume", TRUE, true); + public static final BooleanSetting ENABLE_SWIPE_LOWEST_VALUE_AUTO_BRIGHTNESS = new BooleanSetting("revanced_enable_swipe_lowest_value_auto_brightness", TRUE, parent(ENABLE_SWIPE_BRIGHTNESS)); + public static final BooleanSetting ENABLE_SAVE_AND_RESTORE_BRIGHTNESS = new BooleanSetting("revanced_enable_save_and_restore_brightness", TRUE, true, parent(ENABLE_SWIPE_BRIGHTNESS)); + public static final BooleanSetting ENABLE_SWIPE_PRESS_TO_ENGAGE = new BooleanSetting("revanced_enable_swipe_press_to_engage", FALSE, true, parentsAny(ENABLE_SWIPE_BRIGHTNESS, ENABLE_SWIPE_VOLUME)); + public static final BooleanSetting ENABLE_SWIPE_HAPTIC_FEEDBACK = new BooleanSetting("revanced_enable_swipe_haptic_feedback", TRUE, true, parentsAny(ENABLE_SWIPE_BRIGHTNESS, ENABLE_SWIPE_VOLUME)); + public static final BooleanSetting SWIPE_LOCK_MODE = new BooleanSetting("revanced_swipe_gestures_lock_mode", FALSE, true, parentsAny(ENABLE_SWIPE_BRIGHTNESS, ENABLE_SWIPE_VOLUME)); + public static final IntegerSetting SWIPE_MAGNITUDE_THRESHOLD = new IntegerSetting("revanced_swipe_magnitude_threshold", 0, true, parentsAny(ENABLE_SWIPE_BRIGHTNESS, ENABLE_SWIPE_VOLUME)); + public static final IntegerSetting SWIPE_OVERLAY_BACKGROUND_ALPHA = new IntegerSetting("revanced_swipe_overlay_background_alpha", 127, true, parentsAny(ENABLE_SWIPE_BRIGHTNESS, ENABLE_SWIPE_VOLUME)); + public static final IntegerSetting SWIPE_OVERLAY_TEXT_SIZE = new IntegerSetting("revanced_swipe_overlay_text_size", 20, true, parentsAny(ENABLE_SWIPE_BRIGHTNESS, ENABLE_SWIPE_VOLUME)); + public static final IntegerSetting SWIPE_OVERLAY_RECT_SIZE = new IntegerSetting("revanced_swipe_overlay_rect_size", 20, true, parentsAny(ENABLE_SWIPE_BRIGHTNESS, ENABLE_SWIPE_VOLUME)); + public static final LongSetting SWIPE_OVERLAY_TIMEOUT = new LongSetting("revanced_swipe_overlay_timeout", 500L, true, parentsAny(ENABLE_SWIPE_BRIGHTNESS, ENABLE_SWIPE_VOLUME)); + + public static final IntegerSetting SWIPE_BRIGHTNESS_SENSITIVITY = new IntegerSetting("revanced_swipe_brightness_sensitivity", 100, true, parent(ENABLE_SWIPE_BRIGHTNESS)); + public static final IntegerSetting SWIPE_VOLUME_SENSITIVITY = new IntegerSetting("revanced_swipe_volume_sensitivity", 100, true, parent(ENABLE_SWIPE_VOLUME)); + /** + * @noinspection DeprecatedIsStillUsed + */ + @Deprecated // Patch is obsolete and no longer works with 19.09+ + public static final BooleanSetting DISABLE_HDR_AUTO_BRIGHTNESS = new BooleanSetting("revanced_disable_hdr_auto_brightness", TRUE, true, parent(ENABLE_SWIPE_BRIGHTNESS)); + public static final BooleanSetting ENABLE_SWIPE_TO_SWITCH_VIDEO = new BooleanSetting("revanced_enable_swipe_to_switch_video", FALSE, true); + public static final BooleanSetting ENABLE_WATCH_PANEL_GESTURES = new BooleanSetting("revanced_enable_watch_panel_gestures", FALSE, true); + public static final BooleanSetting SWIPE_BRIGHTNESS_AUTO = new BooleanSetting("revanced_swipe_brightness_auto", TRUE, false, false); + public static final FloatSetting SWIPE_BRIGHTNESS_VALUE = new FloatSetting("revanced_swipe_brightness_value", -1.0f, false, false); + + + // PreferenceScreen: Video + public static final FloatSetting DEFAULT_PLAYBACK_SPEED = new FloatSetting("revanced_default_playback_speed", -2.0f); + public static final IntegerSetting DEFAULT_VIDEO_QUALITY_MOBILE = new IntegerSetting("revanced_default_video_quality_mobile", -2); + public static final IntegerSetting DEFAULT_VIDEO_QUALITY_WIFI = new IntegerSetting("revanced_default_video_quality_wifi", -2); + public static final BooleanSetting DISABLE_HDR_VIDEO = new BooleanSetting("revanced_disable_hdr_video", FALSE, true); + public static final BooleanSetting DISABLE_DEFAULT_PLAYBACK_SPEED_LIVE = new BooleanSetting("revanced_disable_default_playback_speed_live", TRUE); + public static final BooleanSetting ENABLE_CUSTOM_PLAYBACK_SPEED = new BooleanSetting("revanced_enable_custom_playback_speed", FALSE, true); + public static final BooleanSetting CUSTOM_PLAYBACK_SPEED_MENU_TYPE = new BooleanSetting("revanced_custom_playback_speed_menu_type", FALSE, parent(ENABLE_CUSTOM_PLAYBACK_SPEED)); + public static final StringSetting CUSTOM_PLAYBACK_SPEEDS = new StringSetting("revanced_custom_playback_speeds", "0.25\n0.5\n0.75\n1.0\n1.25\n1.5\n1.75\n2.0\n2.25\n2.5", true, parent(ENABLE_CUSTOM_PLAYBACK_SPEED)); + public static final BooleanSetting REMEMBER_PLAYBACK_SPEED_LAST_SELECTED = new BooleanSetting("revanced_remember_playback_speed_last_selected", TRUE); + public static final BooleanSetting REMEMBER_PLAYBACK_SPEED_LAST_SELECTED_TOAST = new BooleanSetting("revanced_remember_playback_speed_last_selected_toast", TRUE, parent(REMEMBER_PLAYBACK_SPEED_LAST_SELECTED)); + public static final BooleanSetting REMEMBER_VIDEO_QUALITY_LAST_SELECTED = new BooleanSetting("revanced_remember_video_quality_last_selected", TRUE); + public static final BooleanSetting REMEMBER_VIDEO_QUALITY_LAST_SELECTED_TOAST = new BooleanSetting("revanced_remember_video_quality_last_selected_toast", TRUE, parent(REMEMBER_VIDEO_QUALITY_LAST_SELECTED)); + public static final BooleanSetting RESTORE_OLD_VIDEO_QUALITY_MENU = new BooleanSetting("revanced_restore_old_video_quality_menu", TRUE, true); + // Experimental Flags + public static final BooleanSetting DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC = new BooleanSetting("revanced_disable_default_playback_speed_music", FALSE, true); + public static final BooleanSetting ENABLE_DEFAULT_PLAYBACK_SPEED_SHORTS = new BooleanSetting("revanced_enable_default_playback_speed_shorts", FALSE); + public static final BooleanSetting SKIP_PRELOADED_BUFFER = new BooleanSetting("revanced_skip_preloaded_buffer", FALSE, true, "revanced_skip_preloaded_buffer_user_dialog_message"); + public static final BooleanSetting SKIP_PRELOADED_BUFFER_TOAST = new BooleanSetting("revanced_skip_preloaded_buffer_toast", TRUE); + public static final BooleanSetting SPOOF_DEVICE_DIMENSIONS = new BooleanSetting("revanced_spoof_device_dimensions", FALSE, true); + public static final BooleanSetting DISABLE_VP9_CODEC = new BooleanSetting("revanced_disable_vp9_codec", FALSE, true); + public static final BooleanSetting REPLACE_AV1_CODEC = new BooleanSetting("revanced_replace_av1_codec", FALSE, true); + public static final BooleanSetting REJECT_AV1_CODEC = new BooleanSetting("revanced_reject_av1_codec", FALSE, true); + + + // PreferenceScreen: Miscellaneous + public static final BooleanSetting ENABLE_EXTERNAL_BROWSER = new BooleanSetting("revanced_enable_external_browser", TRUE, true); + public static final BooleanSetting ENABLE_OPEN_LINKS_DIRECTLY = new BooleanSetting("revanced_enable_open_links_directly", TRUE); + public static final BooleanSetting DISABLE_QUIC_PROTOCOL = new BooleanSetting("revanced_disable_quic_protocol", FALSE, true); + + // Experimental Flags + public static final BooleanSetting CHANGE_SHARE_SHEET = new BooleanSetting("revanced_change_share_sheet", FALSE, true); + public static final BooleanSetting ENABLE_OPUS_CODEC = new BooleanSetting("revanced_enable_opus_codec", FALSE, true); + + /** + * @noinspection DeprecatedIsStillUsed + */ + @Deprecated + public static final LongSetting DOUBLE_BACK_TO_CLOSE_TIMEOUT = new LongSetting("revanced_double_back_to_close_timeout", 2000L); + + // PreferenceScreen: Miscellaneous - Watch history + public static final EnumSetting WATCH_HISTORY_TYPE = new EnumSetting<>("revanced_watch_history_type", WatchHistoryType.REPLACE); + + // PreferenceScreen: Miscellaneous - Spoof streaming data + // The order of the settings should not be changed otherwise the app may crash + public static final BooleanSetting SPOOF_STREAMING_DATA = new BooleanSetting("revanced_spoof_streaming_data", TRUE, true, "revanced_spoof_streaming_data_user_dialog_message"); + public static final BooleanSetting SPOOF_STREAMING_DATA_IOS_FORCE_AVC = new BooleanSetting("revanced_spoof_streaming_data_ios_force_avc", FALSE, true, + "revanced_spoof_streaming_data_ios_force_avc_user_dialog_message", new SpoofStreamingDataPatch.iOSAvailability()); + public static final BooleanSetting SPOOF_STREAMING_DATA_IOS_SKIP_LIVESTREAM_PLAYBACK = new BooleanSetting("revanced_spoof_streaming_data_ios_skip_livestream_playback", TRUE, true, new SpoofStreamingDataPatch.iOSAvailability()); + public static final EnumSetting SPOOF_STREAMING_DATA_TYPE = new EnumSetting<>("revanced_spoof_streaming_data_type", ClientType.IOS, true, parent(SPOOF_STREAMING_DATA)); + public static final BooleanSetting SPOOF_STREAMING_DATA_STATS_FOR_NERDS = new BooleanSetting("revanced_spoof_streaming_data_stats_for_nerds", TRUE, parent(SPOOF_STREAMING_DATA)); + + // PreferenceScreen: Return YouTube Dislike + public static final BooleanSetting RYD_ENABLED = new BooleanSetting("ryd_enabled", TRUE); + public static final StringSetting RYD_USER_ID = new StringSetting("ryd_user_id", ""); + public static final BooleanSetting RYD_SHORTS = new BooleanSetting("ryd_shorts", TRUE, parent(RYD_ENABLED)); + public static final BooleanSetting RYD_DISLIKE_PERCENTAGE = new BooleanSetting("ryd_dislike_percentage", FALSE, parent(RYD_ENABLED)); + public static final BooleanSetting RYD_COMPACT_LAYOUT = new BooleanSetting("ryd_compact_layout", FALSE, parent(RYD_ENABLED)); + public static final BooleanSetting RYD_ESTIMATED_LIKE = new BooleanSetting("ryd_estimated_like", FALSE, true, parent(RYD_ENABLED)); + public static final BooleanSetting RYD_TOAST_ON_CONNECTION_ERROR = new BooleanSetting("ryd_toast_on_connection_error", FALSE, parent(RYD_ENABLED)); + + + // PreferenceScreen: SponsorBlock + public static final BooleanSetting SB_ENABLED = new BooleanSetting("sb_enabled", TRUE); + /** + * Do not use directly, instead use {@link SponsorBlockSettings} + */ + public static final StringSetting SB_PRIVATE_USER_ID = new StringSetting("sb_private_user_id_Do_Not_Share", "", parent(SB_ENABLED)); + public static final IntegerSetting SB_CREATE_NEW_SEGMENT_STEP = new IntegerSetting("sb_create_new_segment_step", 150, parent(SB_ENABLED)); + public static final BooleanSetting SB_VOTING_BUTTON = new BooleanSetting("sb_voting_button", FALSE, parent(SB_ENABLED)); + public static final BooleanSetting SB_CREATE_NEW_SEGMENT = new BooleanSetting("sb_create_new_segment", FALSE, parent(SB_ENABLED)); + public static final BooleanSetting SB_COMPACT_SKIP_BUTTON = new BooleanSetting("sb_compact_skip_button", FALSE, parent(SB_ENABLED)); + public static final BooleanSetting SB_AUTO_HIDE_SKIP_BUTTON = new BooleanSetting("sb_auto_hide_skip_button", TRUE, parent(SB_ENABLED)); + public static final BooleanSetting SB_TOAST_ON_SKIP = new BooleanSetting("sb_toast_on_skip", TRUE, parent(SB_ENABLED)); + public static final BooleanSetting SB_TOAST_ON_CONNECTION_ERROR = new BooleanSetting("sb_toast_on_connection_error", FALSE, parent(SB_ENABLED)); + public static final BooleanSetting SB_TRACK_SKIP_COUNT = new BooleanSetting("sb_track_skip_count", TRUE, parent(SB_ENABLED)); + public static final FloatSetting SB_SEGMENT_MIN_DURATION = new FloatSetting("sb_min_segment_duration", 0F, parent(SB_ENABLED)); + public static final BooleanSetting SB_VIDEO_LENGTH_WITHOUT_SEGMENTS = new BooleanSetting("sb_video_length_without_segments", FALSE, parent(SB_ENABLED)); + public static final StringSetting SB_API_URL = new StringSetting("sb_api_url", "https://sponsor.ajay.app", parent(SB_ENABLED)); + public static final BooleanSetting SB_USER_IS_VIP = new BooleanSetting("sb_user_is_vip", FALSE); + public static final IntegerSetting SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS = new IntegerSetting("sb_local_time_saved_number_segments", 0); + public static final LongSetting SB_LOCAL_TIME_SAVED_MILLISECONDS = new LongSetting("sb_local_time_saved_milliseconds", 0L); + + public static final StringSetting SB_CATEGORY_SPONSOR = new StringSetting("sb_sponsor", SKIP_AUTOMATICALLY.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_SPONSOR_COLOR = new StringSetting("sb_sponsor_color", "#00D400"); + public static final StringSetting SB_CATEGORY_SELF_PROMO = new StringSetting("sb_selfpromo", SKIP_AUTOMATICALLY.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_SELF_PROMO_COLOR = new StringSetting("sb_selfpromo_color", "#FFFF00"); + public static final StringSetting SB_CATEGORY_INTERACTION = new StringSetting("sb_interaction", SKIP_AUTOMATICALLY_ONCE.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_INTERACTION_COLOR = new StringSetting("sb_interaction_color", "#CC00FF"); + public static final StringSetting SB_CATEGORY_HIGHLIGHT = new StringSetting("sb_highlight", MANUAL_SKIP.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_HIGHLIGHT_COLOR = new StringSetting("sb_highlight_color", "#FF1684"); + public static final StringSetting SB_CATEGORY_INTRO = new StringSetting("sb_intro", SKIP_AUTOMATICALLY_ONCE.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_INTRO_COLOR = new StringSetting("sb_intro_color", "#00FFFF"); + public static final StringSetting SB_CATEGORY_OUTRO = new StringSetting("sb_outro", SKIP_AUTOMATICALLY_ONCE.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_OUTRO_COLOR = new StringSetting("sb_outro_color", "#0202ED"); + public static final StringSetting SB_CATEGORY_PREVIEW = new StringSetting("sb_preview", SKIP_AUTOMATICALLY_ONCE.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_PREVIEW_COLOR = new StringSetting("sb_preview_color", "#008FD6"); + public static final StringSetting SB_CATEGORY_FILLER = new StringSetting("sb_filler", SKIP_AUTOMATICALLY_ONCE.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_FILLER_COLOR = new StringSetting("sb_filler_color", "#7300FF"); + public static final StringSetting SB_CATEGORY_MUSIC_OFFTOPIC = new StringSetting("sb_music_offtopic", MANUAL_SKIP.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_MUSIC_OFFTOPIC_COLOR = new StringSetting("sb_music_offtopic_color", "#FF9900"); + public static final StringSetting SB_CATEGORY_UNSUBMITTED = new StringSetting("sb_unsubmitted", SKIP_AUTOMATICALLY.reVancedKeyValue); + public static final StringSetting SB_CATEGORY_UNSUBMITTED_COLOR = new StringSetting("sb_unsubmitted_color", "#FFFFFF"); + + // SB Setting not exported + public static final LongSetting SB_LAST_VIP_CHECK = new LongSetting("sb_last_vip_check", 0L, false, false); + public static final BooleanSetting SB_HIDE_EXPORT_WARNING = new BooleanSetting("sb_hide_export_warning", FALSE, false, false); + public static final BooleanSetting SB_SEEN_GUIDELINES = new BooleanSetting("sb_seen_guidelines", FALSE, false, false); + + static { + // region Migration initialized + // Categories were previously saved without a 'sb_' key prefix, so they need an additional adjustment. + Set> sbCategories = new HashSet<>(Arrays.asList( + SB_CATEGORY_SPONSOR, + SB_CATEGORY_SPONSOR_COLOR, + SB_CATEGORY_SELF_PROMO, + SB_CATEGORY_SELF_PROMO_COLOR, + SB_CATEGORY_INTERACTION, + SB_CATEGORY_INTERACTION_COLOR, + SB_CATEGORY_HIGHLIGHT, + SB_CATEGORY_HIGHLIGHT_COLOR, + SB_CATEGORY_INTRO, + SB_CATEGORY_INTRO_COLOR, + SB_CATEGORY_OUTRO, + SB_CATEGORY_OUTRO_COLOR, + SB_CATEGORY_PREVIEW, + SB_CATEGORY_PREVIEW_COLOR, + SB_CATEGORY_FILLER, + SB_CATEGORY_FILLER_COLOR, + SB_CATEGORY_MUSIC_OFFTOPIC, + SB_CATEGORY_MUSIC_OFFTOPIC_COLOR, + SB_CATEGORY_UNSUBMITTED, + SB_CATEGORY_UNSUBMITTED_COLOR)); + + SharedPrefCategory ytPrefs = new SharedPrefCategory("youtube"); + SharedPrefCategory rydPrefs = new SharedPrefCategory("ryd"); + SharedPrefCategory sbPrefs = new SharedPrefCategory("sponsor-block"); + for (Setting setting : Setting.allLoadedSettings()) { + String key = setting.key; + if (setting.key.startsWith("sb_")) { + if (sbCategories.contains(setting)) { + key = key.substring(3); // Remove the "sb_" prefix, as old categories are saved without it. + } + migrateFromOldPreferences(sbPrefs, setting, key); + } else if (setting.key.startsWith("ryd_")) { + migrateFromOldPreferences(rydPrefs, setting, key); + } else { + migrateFromOldPreferences(ytPrefs, setting, key); + } + } + // endregion + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/AboutYouTubeDataAPIPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/AboutYouTubeDataAPIPreference.java new file mode 100644 index 0000000000..c8e8bd0d51 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/AboutYouTubeDataAPIPreference.java @@ -0,0 +1,46 @@ +package app.revanced.extension.youtube.settings.preference; + +import android.app.Activity; +import android.content.Context; +import android.preference.Preference; +import android.util.AttributeSet; + +import app.revanced.extension.shared.settings.preference.YouTubeDataAPIDialogBuilder; + +@SuppressWarnings({"unused", "deprecation"}) +public class AboutYouTubeDataAPIPreference extends Preference implements Preference.OnPreferenceClickListener { + + private void init() { + setSelectable(true); + setOnPreferenceClickListener(this); + } + + public AboutYouTubeDataAPIPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + public AboutYouTubeDataAPIPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public AboutYouTubeDataAPIPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public AboutYouTubeDataAPIPreference(Context context) { + super(context); + init(); + } + + @Override + public boolean onPreferenceClick(Preference preference) { + if (getContext() instanceof Activity mActivity) { + YouTubeDataAPIDialogBuilder.showDialog(mActivity); + } + + return true; + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/AlternativeThumbnailsAboutDeArrowPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/AlternativeThumbnailsAboutDeArrowPreference.java new file mode 100644 index 0000000000..e979e9acad --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/AlternativeThumbnailsAboutDeArrowPreference.java @@ -0,0 +1,38 @@ +package app.revanced.extension.youtube.settings.preference; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.preference.Preference; +import android.util.AttributeSet; + +/** + * Allows tapping the DeArrow about preference to open the DeArrow website. + */ +@SuppressWarnings({"unused", "deprecation"}) +public class AlternativeThumbnailsAboutDeArrowPreference extends Preference { + { + setOnPreferenceClickListener(pref -> { + Intent i = new Intent(Intent.ACTION_VIEW); + i.setData(Uri.parse("https://dearrow.ajay.app")); + pref.getContext().startActivity(i); + return false; + }); + } + + public AlternativeThumbnailsAboutDeArrowPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public AlternativeThumbnailsAboutDeArrowPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public AlternativeThumbnailsAboutDeArrowPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public AlternativeThumbnailsAboutDeArrowPreference(Context context) { + super(context); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ExternalDownloaderPlaylistPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ExternalDownloaderPlaylistPreference.java new file mode 100644 index 0000000000..547eb3e34e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ExternalDownloaderPlaylistPreference.java @@ -0,0 +1,175 @@ +package app.revanced.extension.youtube.settings.preference; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.preference.Preference; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TableLayout; +import android.widget.TableRow; + +import java.util.Arrays; + +import app.revanced.extension.shared.settings.StringSetting; +import app.revanced.extension.shared.utils.ResourceUtils; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.utils.ExtendedUtils; + +@SuppressWarnings({"unused", "deprecation"}) +public class ExternalDownloaderPlaylistPreference extends Preference implements Preference.OnPreferenceClickListener { + + private static final StringSetting settings = Settings.EXTERNAL_DOWNLOADER_PACKAGE_NAME_PLAYLIST; + private static final String[] mEntries = ResourceUtils.getStringArray("revanced_external_downloader_playlist_label"); + private static final String[] mEntryValues = ResourceUtils.getStringArray("revanced_external_downloader_playlist_package_name"); + private static final String[] mWebsiteEntries = ResourceUtils.getStringArray("revanced_external_downloader_playlist_website"); + + @SuppressLint("StaticFieldLeak") + private static EditText mEditText; + private static String packageName; + private static int mClickedDialogEntryIndex; + + private final TextWatcher textWatcher = new TextWatcher() { + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + public void afterTextChanged(Editable s) { + packageName = s.toString(); + mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName); + } + }; + + private void init() { + setSelectable(true); + setOnPreferenceClickListener(this); + } + + public ExternalDownloaderPlaylistPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + public ExternalDownloaderPlaylistPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public ExternalDownloaderPlaylistPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ExternalDownloaderPlaylistPreference(Context context) { + super(context); + init(); + } + + @Override + public boolean onPreferenceClick(Preference preference) { + packageName = settings.get(); + mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName); + + final Context context = getContext(); + AlertDialog.Builder builder = Utils.getEditTextDialogBuilder(context); + + TableLayout table = new TableLayout(context); + table.setOrientation(LinearLayout.HORIZONTAL); + table.setPadding(15, 0, 15, 0); + + TableRow row = new TableRow(context); + + mEditText = new EditText(context); + mEditText.setHint(settings.defaultValue); + mEditText.setText(packageName); + mEditText.addTextChangedListener(textWatcher); + mEditText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 9); + mEditText.setLayoutParams(new TableRow.LayoutParams(TableRow.LayoutParams.MATCH_PARENT, TableRow.LayoutParams.WRAP_CONTENT, 1f)); + row.addView(mEditText); + + table.addView(row); + builder.setView(table); + + builder.setTitle(str("revanced_external_downloader_dialog_title")); + builder.setSingleChoiceItems(mEntries, mClickedDialogEntryIndex, (dialog, which) -> { + mClickedDialogEntryIndex = which; + mEditText.setText(mEntryValues[which]); + }); + builder.setPositiveButton(android.R.string.ok, (dialog, which) -> { + final String packageName = mEditText.getText().toString().trim(); + settings.save(packageName); + checkPackageIsValid(context, packageName); + dialog.dismiss(); + }); + builder.setNeutralButton(str("revanced_extended_settings_reset"), (dialog, which) -> settings.resetToDefault()); + builder.setNegativeButton(android.R.string.cancel, null); + + builder.show(); + + return true; + } + + private static boolean checkPackageIsValid(Context context, String packageName) { + String appName = ""; + String website = ""; + + if (mClickedDialogEntryIndex >= 0) { + appName = mEntries[mClickedDialogEntryIndex]; + website = mWebsiteEntries[mClickedDialogEntryIndex]; + } + + return showToastOrOpenWebsites(context, appName, packageName, website); + } + + private static boolean showToastOrOpenWebsites(Context context, String appName, String packageName, String website) { + if (ExtendedUtils.isPackageEnabled(packageName)) + return true; + + if (website.isEmpty()) { + Utils.showToastShort(str("revanced_external_downloader_not_installed_warning", packageName)); + return false; + } + + new AlertDialog.Builder(context) + .setTitle(str("revanced_external_downloader_not_installed_dialog_title")) + .setMessage(str("revanced_external_downloader_not_installed_dialog_message", appName, appName)) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(website)); + context.startActivity(i); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + + return false; + } + + public static boolean checkPackageIsDisabled() { + final Context context = Utils.getActivity(); + packageName = settings.get(); + mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName); + return !checkPackageIsValid(context, packageName); + } + + public static String getExternalDownloaderPackageName() { + String downloaderPackageName = settings.get().trim(); + + if (downloaderPackageName.isEmpty()) { + settings.resetToDefault(); + downloaderPackageName = settings.defaultValue; + } + + return downloaderPackageName; + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ExternalDownloaderVideoPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ExternalDownloaderVideoPreference.java new file mode 100644 index 0000000000..26a83143f0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ExternalDownloaderVideoPreference.java @@ -0,0 +1,175 @@ +package app.revanced.extension.youtube.settings.preference; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.preference.Preference; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TableLayout; +import android.widget.TableRow; + +import java.util.Arrays; + +import app.revanced.extension.shared.settings.StringSetting; +import app.revanced.extension.shared.utils.ResourceUtils; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.utils.ExtendedUtils; + +@SuppressWarnings({"unused", "deprecation"}) +public class ExternalDownloaderVideoPreference extends Preference implements Preference.OnPreferenceClickListener { + + private static final StringSetting settings = Settings.EXTERNAL_DOWNLOADER_PACKAGE_NAME_VIDEO; + private static final String[] mEntries = ResourceUtils.getStringArray("revanced_external_downloader_video_label"); + private static final String[] mEntryValues = ResourceUtils.getStringArray("revanced_external_downloader_video_package_name"); + private static final String[] mWebsiteEntries = ResourceUtils.getStringArray("revanced_external_downloader_video_website"); + + @SuppressLint("StaticFieldLeak") + private static EditText mEditText; + private static String packageName; + private static int mClickedDialogEntryIndex; + + private final TextWatcher textWatcher = new TextWatcher() { + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + public void afterTextChanged(Editable s) { + packageName = s.toString(); + mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName); + } + }; + + private void init() { + setSelectable(true); + setOnPreferenceClickListener(this); + } + + public ExternalDownloaderVideoPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + public ExternalDownloaderVideoPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public ExternalDownloaderVideoPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ExternalDownloaderVideoPreference(Context context) { + super(context); + init(); + } + + @Override + public boolean onPreferenceClick(Preference preference) { + packageName = settings.get(); + mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName); + + final Context context = getContext(); + AlertDialog.Builder builder = Utils.getEditTextDialogBuilder(context); + + TableLayout table = new TableLayout(context); + table.setOrientation(LinearLayout.HORIZONTAL); + table.setPadding(15, 0, 15, 0); + + TableRow row = new TableRow(context); + + mEditText = new EditText(context); + mEditText.setHint(settings.defaultValue); + mEditText.setText(packageName); + mEditText.addTextChangedListener(textWatcher); + mEditText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 9); + mEditText.setLayoutParams(new TableRow.LayoutParams(TableRow.LayoutParams.MATCH_PARENT, TableRow.LayoutParams.WRAP_CONTENT, 1f)); + row.addView(mEditText); + + table.addView(row); + builder.setView(table); + + builder.setTitle(str("revanced_external_downloader_dialog_title")); + builder.setSingleChoiceItems(mEntries, mClickedDialogEntryIndex, (dialog, which) -> { + mClickedDialogEntryIndex = which; + mEditText.setText(mEntryValues[which]); + }); + builder.setPositiveButton(android.R.string.ok, (dialog, which) -> { + final String packageName = mEditText.getText().toString().trim(); + settings.save(packageName); + checkPackageIsValid(context, packageName); + dialog.dismiss(); + }); + builder.setNeutralButton(str("revanced_extended_settings_reset"), (dialog, which) -> settings.resetToDefault()); + builder.setNegativeButton(android.R.string.cancel, null); + + builder.show(); + + return true; + } + + private static boolean checkPackageIsValid(Context context, String packageName) { + String appName = ""; + String website = ""; + + if (mClickedDialogEntryIndex >= 0) { + appName = mEntries[mClickedDialogEntryIndex]; + website = mWebsiteEntries[mClickedDialogEntryIndex]; + } + + return showToastOrOpenWebsites(context, appName, packageName, website); + } + + private static boolean showToastOrOpenWebsites(Context context, String appName, String packageName, String website) { + if (ExtendedUtils.isPackageEnabled(packageName)) + return true; + + if (website.isEmpty()) { + Utils.showToastShort(str("revanced_external_downloader_not_installed_warning", packageName)); + return false; + } + + new AlertDialog.Builder(context) + .setTitle(str("revanced_external_downloader_not_installed_dialog_title")) + .setMessage(str("revanced_external_downloader_not_installed_dialog_message", appName, appName)) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(website)); + context.startActivity(i); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + + return false; + } + + public static boolean checkPackageIsDisabled() { + final Context context = Utils.getActivity(); + packageName = settings.get(); + mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName); + return !checkPackageIsValid(context, packageName); + } + + public static String getExternalDownloaderPackageName() { + String downloaderPackageName = settings.get().trim(); + + if (downloaderPackageName.isEmpty()) { + settings.resetToDefault(); + downloaderPackageName = settings.defaultValue; + } + + return downloaderPackageName; + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ImportExportPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ImportExportPreference.java new file mode 100644 index 0000000000..77c1c6b75c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ImportExportPreference.java @@ -0,0 +1,102 @@ +package app.revanced.extension.youtube.settings.preference; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.annotation.TargetApi; +import android.app.AlertDialog; +import android.content.Context; +import android.preference.EditTextPreference; +import android.preference.Preference; +import android.text.InputType; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.widget.EditText; + +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +@SuppressWarnings({"unused", "deprecation"}) +public class ImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener { + + private String existingSettings; + + @TargetApi(26) + private void init() { + setSelectable(true); + + EditText editText = getEditText(); + editText.setTextIsSelectable(true); + editText.setAutofillHints((String) null); + editText.setInputType(editText.getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + editText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 8); // Use a smaller font to reduce text wrap. + + setOnPreferenceClickListener(this); + } + + public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public ImportExportPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ImportExportPreference(Context context) { + super(context); + init(); + } + + @Override + public boolean onPreferenceClick(Preference preference) { + try { + // Must set text before preparing dialog, otherwise text is non selectable if this preference is later reopened. + existingSettings = Setting.exportToJson(null); + getEditText().setText(existingSettings); + } catch (Exception ex) { + Logger.printException(() -> "showDialog failure", ex); + } + return true; + } + + @Override + protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { + try { + Utils.setEditTextDialogTheme(builder, true); + super.onPrepareDialogBuilder(builder); + // Show the user the settings in JSON format. + builder.setNeutralButton(str("revanced_extended_settings_import_copy"), (dialog, which) -> + Utils.setClipboard(getEditText().getText().toString(), str("revanced_share_copy_settings_success"))) + .setPositiveButton(str("revanced_extended_settings_import"), (dialog, which) -> + importSettings(getEditText().getText().toString())); + } catch (Exception ex) { + Logger.printException(() -> "onPrepareDialogBuilder failure", ex); + } + } + + private void importSettings(String replacementSettings) { + try { + if (replacementSettings.equals(existingSettings)) { + return; + } + ReVancedPreferenceFragment.settingImportInProgress = true; + final boolean rebootNeeded = Setting.importFromJSON(replacementSettings, true); + if (rebootNeeded) { + AbstractPreferenceFragment.showRestartDialog(getContext()); + } + } catch (Exception ex) { + Logger.printException(() -> "importSettings failure", ex); + } finally { + ReVancedPreferenceFragment.settingImportInProgress = false; + } + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/OpenDefaultAppSettingsPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/OpenDefaultAppSettingsPreference.java new file mode 100644 index 0000000000..169458053f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/OpenDefaultAppSettingsPreference.java @@ -0,0 +1,47 @@ +package app.revanced.extension.youtube.settings.preference; + +import static app.revanced.extension.shared.utils.Utils.isSDKAbove; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.preference.Preference; +import android.util.AttributeSet; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +@SuppressWarnings({"unused", "deprecation"}) +public class OpenDefaultAppSettingsPreference extends Preference { + { + setOnPreferenceClickListener(pref -> { + try { + Context context = Utils.getActivity(); + final Uri uri = Uri.parse("package:" + context.getPackageName()); + final Intent intent = isSDKAbove(31) + ? new Intent(android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS, uri) + : new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, uri); + context.startActivity(intent); + } catch (Exception exception) { + Logger.printException(() -> "OpenDefaultAppSettings Failed"); + } + return false; + }); + } + + public OpenDefaultAppSettingsPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public OpenDefaultAppSettingsPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public OpenDefaultAppSettingsPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public OpenDefaultAppSettingsPreference(Context context) { + super(context); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedPreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedPreferenceFragment.java new file mode 100644 index 0000000000..086243f9a6 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedPreferenceFragment.java @@ -0,0 +1,689 @@ +package app.revanced.extension.youtube.settings.preference; + +import static app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment.showRestartDialog; +import static app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment.updateListPreferenceSummary; +import static app.revanced.extension.shared.utils.ResourceUtils.getXmlIdentifier; +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.shared.utils.Utils.getChildView; +import static app.revanced.extension.shared.utils.Utils.isSDKAbove; +import static app.revanced.extension.shared.utils.Utils.showToastShort; +import static app.revanced.extension.youtube.settings.Settings.DEFAULT_PLAYBACK_SPEED; +import static app.revanced.extension.youtube.settings.Settings.HIDE_PREVIEW_COMMENT; +import static app.revanced.extension.youtube.settings.Settings.HIDE_PREVIEW_COMMENT_TYPE; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.preference.EditTextPreference; +import android.preference.ListPreference; +import android.preference.Preference; +import android.preference.PreferenceCategory; +import android.preference.PreferenceFragment; +import android.preference.PreferenceGroup; +import android.preference.PreferenceManager; +import android.preference.PreferenceScreen; +import android.preference.SwitchPreference; +import android.util.TypedValue; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toolbar; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; + +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.ResourceUtils; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.video.CustomPlaybackSpeedPatch; +import app.revanced.extension.youtube.utils.ExtendedUtils; +import app.revanced.extension.youtube.utils.ThemeUtils; + +@SuppressWarnings("deprecation") +public class ReVancedPreferenceFragment extends PreferenceFragment { + private static final int READ_REQUEST_CODE = 42; + private static final int WRITE_REQUEST_CODE = 43; + static boolean settingImportInProgress = false; + static boolean showingUserDialogMessage; + + @SuppressLint("SuspiciousIndentation") + private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> { + try { + if (str == null) return; + Setting setting = Setting.getSettingFromPath(str); + + if (setting == null) return; + + Preference mPreference = findPreference(str); + + if (mPreference == null) return; + + if (mPreference instanceof SwitchPreference switchPreference) { + BooleanSetting boolSetting = (BooleanSetting) setting; + if (settingImportInProgress) { + switchPreference.setChecked(boolSetting.get()); + } else { + BooleanSetting.privateSetValue(boolSetting, switchPreference.isChecked()); + } + + if (ExtendedUtils.anyMatchSetting(setting)) { + ExtendedUtils.setPlayerFlyoutMenuAdditionalSettings(); + } else if (setting.equals(HIDE_PREVIEW_COMMENT) || setting.equals(HIDE_PREVIEW_COMMENT_TYPE)) { + ExtendedUtils.setCommentPreviewSettings(); + } + } else if (mPreference instanceof EditTextPreference editTextPreference) { + if (settingImportInProgress) { + editTextPreference.setText(setting.get().toString()); + } else { + Setting.privateSetValueFromString(setting, editTextPreference.getText()); + } + } else if (mPreference instanceof ListPreference listPreference) { + if (settingImportInProgress) { + listPreference.setValue(setting.get().toString()); + } else { + Setting.privateSetValueFromString(setting, listPreference.getValue()); + } + if (setting.equals(DEFAULT_PLAYBACK_SPEED)) { + listPreference.setEntries(CustomPlaybackSpeedPatch.getListEntries()); + listPreference.setEntryValues(CustomPlaybackSpeedPatch.getListEntryValues()); + } + if (!(mPreference instanceof app.revanced.extension.youtube.settings.preference.SegmentCategoryListPreference)) { + updateListPreferenceSummary(listPreference, setting); + } + } else { + Logger.printException(() -> "Setting cannot be handled: " + mPreference.getClass() + " " + mPreference); + return; + } + + ReVancedSettingsPreference.initializeReVancedSettings(); + + if (settingImportInProgress) { + return; + } + + if (!showingUserDialogMessage) { + final Context context = getActivity(); + + if (setting.userDialogMessage != null + && mPreference instanceof SwitchPreference switchPreference + && setting.defaultValue instanceof Boolean defaultValue + && switchPreference.isChecked() != defaultValue) { + showSettingUserDialogConfirmation(context, switchPreference, (BooleanSetting) setting); + } else if (setting.rebootApp) { + showRestartDialog(context); + } + } + } catch (Exception ex) { + Logger.printException(() -> "OnSharedPreferenceChangeListener failure", ex); + } + }; + + private void showSettingUserDialogConfirmation(Context context, SwitchPreference switchPreference, BooleanSetting setting) { + Utils.verifyOnMainThread(); + + showingUserDialogMessage = true; + assert setting.userDialogMessage != null; + new AlertDialog.Builder(context) + .setTitle(str("revanced_extended_confirm_user_dialog_title")) + .setMessage(setting.userDialogMessage.toString()) + .setPositiveButton(android.R.string.ok, (dialog, id) -> { + if (setting.rebootApp) { + showRestartDialog(context); + } + }) + .setNegativeButton(android.R.string.cancel, (dialog, id) -> { + switchPreference.setChecked(setting.defaultValue); // Recursive call that resets the Setting value. + }) + .setOnDismissListener(dialog -> showingUserDialogMessage = false) + .setCancelable(false) + .show(); + } + + static PreferenceManager mPreferenceManager; + private SharedPreferences mSharedPreferences; + + private PreferenceScreen originalPreferenceScreen; + + public ReVancedPreferenceFragment() { + // Required empty public constructor + } + + private void putPreferenceScreenMap(SortedMap preferenceScreenMap, PreferenceGroup preferenceGroup) { + if (preferenceGroup instanceof PreferenceScreen mPreferenceScreen) { + preferenceScreenMap.put(mPreferenceScreen.getKey(), mPreferenceScreen); + } + } + + private void setPreferenceScreenToolbar() { + SortedMap preferenceScreenMap = new TreeMap<>(); + + PreferenceScreen rootPreferenceScreen = getPreferenceScreen(); + for (Preference preference : getAllPreferencesBy(rootPreferenceScreen)) { + if (!(preference instanceof PreferenceGroup preferenceGroup)) continue; + putPreferenceScreenMap(preferenceScreenMap, preferenceGroup); + for (Preference childPreference : getAllPreferencesBy(preferenceGroup)) { + if (!(childPreference instanceof PreferenceGroup nestedPreferenceGroup)) continue; + putPreferenceScreenMap(preferenceScreenMap, nestedPreferenceGroup); + for (Preference nestedPreference : getAllPreferencesBy(nestedPreferenceGroup)) { + if (!(nestedPreference instanceof PreferenceGroup childPreferenceGroup)) + continue; + putPreferenceScreenMap(preferenceScreenMap, childPreferenceGroup); + } + } + } + + for (PreferenceScreen mPreferenceScreen : preferenceScreenMap.values()) { + mPreferenceScreen.setOnPreferenceClickListener( + preferenceScreen -> { + Dialog preferenceScreenDialog = mPreferenceScreen.getDialog(); + ViewGroup rootView = (ViewGroup) preferenceScreenDialog + .findViewById(android.R.id.content) + .getParent(); + + Toolbar toolbar = new Toolbar(preferenceScreen.getContext()); + + toolbar.setTitle(preferenceScreen.getTitle()); + toolbar.setNavigationIcon(ThemeUtils.getBackButtonDrawable()); + toolbar.setNavigationOnClickListener(view -> preferenceScreenDialog.dismiss()); + + int margin = (int) TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 16, getResources().getDisplayMetrics() + ); + + toolbar.setTitleMargin(margin, 0, margin, 0); + + TextView toolbarTextView = getChildView(toolbar, TextView.class::isInstance); + if (toolbarTextView != null) { + toolbarTextView.setTextColor(ThemeUtils.getForegroundColor()); + } + + rootView.addView(toolbar, 0); + return false; + } + ); + } + } + + // Map to store dependencies: key is the preference key, value is a list of dependent preferences + private final Map> dependencyMap = new HashMap<>(); + // Set to track already added preferences to avoid duplicates + private final Set addedPreferences = new HashSet<>(); + // Map to store preferences grouped by their parent PreferenceGroup + private final Map> groupedPreferences = new LinkedHashMap<>(); + + @SuppressLint("ResourceType") + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + try { + mPreferenceManager = getPreferenceManager(); + mPreferenceManager.setSharedPreferencesName(Setting.preferences.name); + mSharedPreferences = mPreferenceManager.getSharedPreferences(); + addPreferencesFromResource(getXmlIdentifier("revanced_prefs")); + + // Initialize toolbars and other UI elements + setPreferenceScreenToolbar(); + + // Initialize ReVanced settings + ReVancedSettingsPreference.initializeReVancedSettings(); + SponsorBlockSettingsPreference.init(getActivity()); + + // Import/export + setBackupRestorePreference(); + + // Store all preferences and their dependencies + storeAllPreferences(getPreferenceScreen()); + + // Load and set initial preferences states + for (Setting setting : Setting.allLoadedSettings()) { + final Preference preference = mPreferenceManager.findPreference(setting.key); + if (preference != null && isSDKAbove(26)) { + preference.setSingleLineTitle(false); + } + + if (preference instanceof SwitchPreference switchPreference) { + BooleanSetting boolSetting = (BooleanSetting) setting; + switchPreference.setChecked(boolSetting.get()); + } else if (preference instanceof EditTextPreference editTextPreference) { + editTextPreference.setText(setting.get().toString()); + } else if (preference instanceof ListPreference listPreference) { + if (setting.equals(DEFAULT_PLAYBACK_SPEED)) { + listPreference.setEntries(CustomPlaybackSpeedPatch.getListEntries()); + listPreference.setEntryValues(CustomPlaybackSpeedPatch.getListEntryValues()); + } + if (!(preference instanceof app.revanced.extension.youtube.settings.preference.SegmentCategoryListPreference)) { + updateListPreferenceSummary(listPreference, setting); + } + } + } + + // Register preference change listener + mSharedPreferences.registerOnSharedPreferenceChangeListener(listener); + + originalPreferenceScreen = getPreferenceManager().createPreferenceScreen(getActivity()); + copyPreferences(getPreferenceScreen(), originalPreferenceScreen); + } catch (Exception th) { + Logger.printException(() -> "Error during onCreate()", th); + } + } + + private void copyPreferences(PreferenceScreen source, PreferenceScreen destination) { + for (Preference preference : getAllPreferencesBy(source)) { + destination.addPreference(preference); + } + } + + @Override + public void onDestroy() { + mSharedPreferences.unregisterOnSharedPreferenceChangeListener(listener); + super.onDestroy(); + } + + /** + * Recursively stores all preferences and their dependencies grouped by their parent PreferenceGroup. + * + * @param preferenceGroup The preference group to scan. + */ + private void storeAllPreferences(PreferenceGroup preferenceGroup) { + // Check if this is the root PreferenceScreen + boolean isRootScreen = preferenceGroup == getPreferenceScreen(); + + // Use the special top-level group only for the root PreferenceScreen + PreferenceGroup groupKey = isRootScreen + ? new PreferenceCategory(preferenceGroup.getContext()) + : preferenceGroup; + + if (isRootScreen) { + groupKey.setTitle(ResourceUtils.getString("revanced_extended_settings_title")); + } + + // Initialize a list to hold preferences of the current group + List currentGroupPreferences = groupedPreferences.computeIfAbsent(groupKey, k -> new ArrayList<>()); + + for (int i = 0; i < preferenceGroup.getPreferenceCount(); i++) { + Preference preference = preferenceGroup.getPreference(i); + + // Add preference to the current group if not already added + if (!currentGroupPreferences.contains(preference)) { + currentGroupPreferences.add(preference); + } + + // Store dependencies + if (preference.getDependency() != null) { + String dependencyKey = preference.getDependency(); + dependencyMap.computeIfAbsent(dependencyKey, k -> new ArrayList<>()).add(preference); + } + + // Recursively handle nested PreferenceGroups + if (preference instanceof PreferenceGroup nestedGroup) { + storeAllPreferences(nestedGroup); + } + } + } + + /** + * Filters preferences based on the search query, displaying grouped results with group titles. + * + * @param query The search query. + */ + public void filterPreferences(String query) { + // If the query is null or empty, reset preferences to their default state + if (query == null || query.isEmpty()) { + resetPreferences(); + return; + } + + // Convert the query to lowercase for case-insensitive search + query = query.toLowerCase(); + + // Get the preference screen to modify + PreferenceScreen preferenceScreen = getPreferenceScreen(); + // Remove all current preferences from the screen + preferenceScreen.removeAll(); + // Clear the list of added preferences to start fresh + addedPreferences.clear(); + + // Create a map to store matched preferences for each group + Map> matchedGroupPreferences = new LinkedHashMap<>(); + + // Create a set to store all keys that should be included + Set keysToInclude = new HashSet<>(); + + // First pass: identify all preferences that match the query and their dependencies + for (Map.Entry> entry : groupedPreferences.entrySet()) { + List preferences = entry.getValue(); + for (Preference preference : preferences) { + if (preferenceMatches(preference, query)) { + addPreferenceAndDependencies(preference, keysToInclude); + } + } + } + + // Second pass: add all identified preferences to matchedGroupPreferences + for (Map.Entry> entry : groupedPreferences.entrySet()) { + PreferenceGroup group = entry.getKey(); + List preferences = entry.getValue(); + List matchedPreferences = new ArrayList<>(); + + for (Preference preference : preferences) { + if (keysToInclude.contains(preference.getKey())) { + matchedPreferences.add(preference); + } + } + + if (!matchedPreferences.isEmpty()) { + matchedGroupPreferences.put(group, matchedPreferences); + } + } + + // Add matched preferences to the screen, maintaining the original order + for (Map.Entry> entry : matchedGroupPreferences.entrySet()) { + PreferenceGroup group = entry.getKey(); + List matchedPreferences = entry.getValue(); + + // Add the category for this group + PreferenceCategory category = new PreferenceCategory(preferenceScreen.getContext()); + category.setTitle(group.getTitle()); + preferenceScreen.addPreference(category); + + // Add matched preferences for this group + for (Preference preference : matchedPreferences) { + if (preference.isSelectable()) { + addPreferenceWithDependencies(category, preference); + } else { + // For non-selectable preferences, just add them directly + category.addPreference(preference); + } + } + } + } + + /** + * Checks if a preference matches the given query. + * + * @param preference The preference to check. + * @param query The search query. + * @return True if the preference matches the query, false otherwise. + */ + private boolean preferenceMatches(Preference preference, String query) { + // Check if the title contains the query string + if (preference.getTitle().toString().toLowerCase().contains(query)) { + return true; + } + + // Check if the summary contains the query string + if (preference.getSummary() != null && preference.getSummary().toString().toLowerCase().contains(query)) { + return true; + } + + // Additional checks for SwitchPreference + if (preference instanceof SwitchPreference switchPreference) { + CharSequence summaryOn = switchPreference.getSummaryOn(); + CharSequence summaryOff = switchPreference.getSummaryOff(); + + if ((summaryOn != null && summaryOn.toString().toLowerCase().contains(query)) || + (summaryOff != null && summaryOff.toString().toLowerCase().contains(query))) { + return true; + } + } + + // Additional checks for ListPreference + if (preference instanceof ListPreference listPreference) { + CharSequence[] entries = listPreference.getEntries(); + if (entries != null) { + for (CharSequence entry : entries) { + if (entry.toString().toLowerCase().contains(query)) { + return true; + } + } + } + + CharSequence[] entryValues = listPreference.getEntryValues(); + if (entryValues != null) { + for (CharSequence entryValue : entryValues) { + if (entryValue.toString().toLowerCase().contains(query)) { + return true; + } + } + } + } + + return false; + } + + /** + * Recursively adds a preference and its dependencies to the set of keys to include. + * + * @param preference The preference to add. + * @param keysToInclude The set of keys to include. + */ + private void addPreferenceAndDependencies(Preference preference, Set keysToInclude) { + String key = preference.getKey(); + if (key != null && !keysToInclude.contains(key)) { + keysToInclude.add(key); + + // Add the preference this one depends on + String dependencyKey = preference.getDependency(); + if (dependencyKey != null) { + Preference dependency = findPreferenceInAllGroups(dependencyKey); + if (dependency != null) { + addPreferenceAndDependencies(dependency, keysToInclude); + } + } + + // Add preferences that depend on this one + if (dependencyMap.containsKey(key)) { + for (Preference dependentPreference : Objects.requireNonNull(dependencyMap.get(key))) { + addPreferenceAndDependencies(dependentPreference, keysToInclude); + } + } + } + } + + /** + * Recursively adds a preference along with its dependencies + * (android:dependency attribute in XML). + * + * @param preferenceGroup The preference group to add to. + * @param preference The preference to add. + */ + private void addPreferenceWithDependencies(PreferenceGroup preferenceGroup, Preference preference) { + String key = preference.getKey(); + + // Instead of just using preference keys, we combine the category and key to ensure uniqueness + if (key != null && !addedPreferences.contains(preferenceGroup.getTitle() + ":" + key)) { + // Add dependencies first + if (preference.getDependency() != null) { + String dependencyKey = preference.getDependency(); + Preference dependency = findPreferenceInAllGroups(dependencyKey); + if (dependency != null) { + addPreferenceWithDependencies(preferenceGroup, dependency); + } else { + return; + } + } + + // Add the preference using a combination of the category and the key + preferenceGroup.addPreference(preference); + addedPreferences.add(preferenceGroup.getTitle() + ":" + key); // Track based on both category and key + + // Handle dependent preferences + if (dependencyMap.containsKey(key)) { + for (Preference dependentPreference : Objects.requireNonNull(dependencyMap.get(key))) { + addPreferenceWithDependencies(preferenceGroup, dependentPreference); + } + } + } + } + + /** + * Finds a preference in all groups based on its key. + * + * @param key The key of the preference to find. + * @return The found preference, or null if not found. + */ + private Preference findPreferenceInAllGroups(String key) { + for (List preferences : groupedPreferences.values()) { + for (Preference preference : preferences) { + if (preference.getKey() != null && preference.getKey().equals(key)) { + return preference; + } + } + } + return null; + } + + /** + * Resets the preference screen to its original state. + */ + private void resetPreferences() { + PreferenceScreen preferenceScreen = getPreferenceScreen(); + preferenceScreen.removeAll(); + for (Preference preference : getAllPreferencesBy(originalPreferenceScreen)) + preferenceScreen.addPreference(preference); + } + + private List getAllPreferencesBy(PreferenceGroup preferenceGroup) { + List preferences = new ArrayList<>(); + for (int i = 0; i < preferenceGroup.getPreferenceCount(); i++) + preferences.add(preferenceGroup.getPreference(i)); + return preferences; + } + + /** + * Add Preference to Import/Export settings submenu + */ + private void setBackupRestorePreference() { + findPreference("revanced_extended_settings_import").setOnPreferenceClickListener(pref -> { + importActivity(); + return false; + }); + + findPreference("revanced_extended_settings_export").setOnPreferenceClickListener(pref -> { + exportActivity(); + return false; + }); + } + + /** + * Invoke the SAF(Storage Access Framework) to export settings + */ + private void exportActivity() { + @SuppressLint("SimpleDateFormat") final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); + + final String appName = ExtendedUtils.getApplicationLabel(); + final String versionName = ExtendedUtils.getVersionName(); + final String formatDate = dateFormat.format(new Date(System.currentTimeMillis())); + final String fileName = String.format("%s_v%s_%s.txt", appName, versionName, formatDate); + + final Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_TITLE, fileName); + startActivityForResult(intent, WRITE_REQUEST_CODE); + } + + /** + * Invoke the SAF(Storage Access Framework) to import settings + */ + private void importActivity() { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType(isSDKAbove(29) ? "text/plain" : "*/*"); + startActivityForResult(intent, READ_REQUEST_CODE); + } + + /** + * Activity should be done within the lifecycle of PreferenceFragment + */ + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == WRITE_REQUEST_CODE && resultCode == Activity.RESULT_OK) { + exportText(data.getData()); + } else if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK && data != null) { + importText(data.getData()); + } + } + + private void exportText(Uri uri) { + final Context context = this.getActivity(); + + try { + @SuppressLint("Recycle") + FileWriter jsonFileWriter = + new FileWriter( + Objects.requireNonNull(context.getApplicationContext() + .getContentResolver() + .openFileDescriptor(uri, "w")) + .getFileDescriptor() + ); + PrintWriter printWriter = new PrintWriter(jsonFileWriter); + printWriter.write(Setting.exportToJson(context)); + printWriter.close(); + jsonFileWriter.close(); + + showToastShort(str("revanced_extended_settings_export_success")); + } catch (IOException e) { + showToastShort(str("revanced_extended_settings_export_failed")); + } + } + + private void importText(Uri uri) { + final Context context = this.getActivity(); + StringBuilder sb = new StringBuilder(); + String line; + + try { + settingImportInProgress = true; + + @SuppressLint("Recycle") + FileReader fileReader = + new FileReader( + Objects.requireNonNull(context.getApplicationContext() + .getContentResolver() + .openFileDescriptor(uri, "r")) + .getFileDescriptor() + ); + BufferedReader bufferedReader = new BufferedReader(fileReader); + while ((line = bufferedReader.readLine()) != null) { + sb.append(line).append("\n"); + } + bufferedReader.close(); + fileReader.close(); + + final boolean restartNeeded = Setting.importFromJSON(sb.toString(), true); + if (restartNeeded) { + showRestartDialog(getActivity()); + } + } catch (IOException e) { + showToastShort(str("revanced_extended_settings_import_failed")); + throw new RuntimeException(e); + } finally { + settingImportInProgress = false; + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedSettingsPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedSettingsPreference.java new file mode 100644 index 0000000000..e902d2ab8e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedSettingsPreference.java @@ -0,0 +1,277 @@ +package app.revanced.extension.youtube.settings.preference; + +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.shared.utils.Utils.isSDKAbove; +import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType.MODERN_1; +import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType.MODERN_3; +import static app.revanced.extension.youtube.utils.ExtendedUtils.isSpoofingToLessThan; + +import android.preference.Preference; +import android.preference.SwitchPreference; + +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.youtube.patches.general.LayoutSwitchPatch; +import app.revanced.extension.youtube.patches.general.MiniplayerPatch; +import app.revanced.extension.youtube.patches.utils.PatchStatus; +import app.revanced.extension.youtube.patches.utils.ReturnYouTubeDislikePatch; +import app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.utils.ExtendedUtils; + +@SuppressWarnings("deprecation") +public class ReVancedSettingsPreference extends ReVancedPreferenceFragment { + + private static void enableDisablePreferences() { + for (Setting setting : Setting.allLoadedSettings()) { + final Preference preference = mPreferenceManager.findPreference(setting.key); + if (preference != null) { + preference.setEnabled(setting.isAvailable()); + } + } + } + + private static void enableDisablePreferences(final boolean isAvailable, final Setting... unavailableEnum) { + if (!isAvailable) { + return; + } + for (Setting setting : unavailableEnum) { + final Preference preference = mPreferenceManager.findPreference(setting.key); + if (preference != null) { + preference.setEnabled(false); + } + } + } + + public static void initializeReVancedSettings() { + enableDisablePreferences(); + + AmbientModePreferenceLinks(); + ChangeHeaderPreferenceLinks(); + ExternalDownloaderPreferenceLinks(); + FullScreenPanelPreferenceLinks(); + LayoutOverrideLinks(); + MiniPlayerPreferenceLinks(); + NavigationPreferenceLinks(); + RYDPreferenceLinks(); + SeekBarPreferenceLinks(); + SpeedOverlayPreferenceLinks(); + QuickActionsPreferenceLinks(); + TabletLayoutLinks(); + WhitelistPreferenceLinks(); + } + + /** + * Enable/Disable Preference related to Ambient Mode + */ + private static void AmbientModePreferenceLinks() { + enableDisablePreferences( + Settings.DISABLE_AMBIENT_MODE.get(), + Settings.BYPASS_AMBIENT_MODE_RESTRICTIONS, + Settings.DISABLE_AMBIENT_MODE_IN_FULLSCREEN + ); + } + + /** + * Enable/Disable Preference related to Change header + */ + private static void ChangeHeaderPreferenceLinks() { + enableDisablePreferences( + PatchStatus.MinimalHeader(), + Settings.CHANGE_YOUTUBE_HEADER + ); + } + + /** + * Enable/Disable Preference for External downloader settings + */ + private static void ExternalDownloaderPreferenceLinks() { + // Override download button will not work if spoofed with YouTube 18.24.xx or earlier. + enableDisablePreferences( + isSpoofingToLessThan("18.24.00"), + Settings.OVERRIDE_VIDEO_DOWNLOAD_BUTTON, + Settings.OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON + ); + } + + /** + * Enable/Disable Layout Override Preference + */ + private static void LayoutOverrideLinks() { + enableDisablePreferences( + ExtendedUtils.isTablet(), + Settings.FORCE_FULLSCREEN + ); + } + + /** + * Enable/Disable Preferences not working in tablet layout + */ + private static void TabletLayoutLinks() { + final boolean isTablet = ExtendedUtils.isTablet() && + !LayoutSwitchPatch.phoneLayoutEnabled(); + + enableDisablePreferences( + isTablet, + Settings.DISABLE_ENGAGEMENT_PANEL, + Settings.HIDE_COMMUNITY_POSTS_HOME_RELATED_VIDEOS, + Settings.HIDE_COMMUNITY_POSTS_SUBSCRIPTIONS, + Settings.HIDE_MIX_PLAYLISTS, + Settings.HIDE_RELATED_VIDEO_OVERLAY, + Settings.SHOW_VIDEO_TITLE_SECTION + ); + } + + /** + * Enable/Disable Preference related to Fullscreen Panel + */ + private static void FullScreenPanelPreferenceLinks() { + enableDisablePreferences( + Settings.DISABLE_ENGAGEMENT_PANEL.get(), + Settings.HIDE_RELATED_VIDEO_OVERLAY, + Settings.HIDE_QUICK_ACTIONS, + Settings.HIDE_QUICK_ACTIONS_COMMENT_BUTTON, + Settings.HIDE_QUICK_ACTIONS_DISLIKE_BUTTON, + Settings.HIDE_QUICK_ACTIONS_LIKE_BUTTON, + Settings.HIDE_QUICK_ACTIONS_LIVE_CHAT_BUTTON, + Settings.HIDE_QUICK_ACTIONS_MORE_BUTTON, + Settings.HIDE_QUICK_ACTIONS_OPEN_MIX_PLAYLIST_BUTTON, + Settings.HIDE_QUICK_ACTIONS_OPEN_PLAYLIST_BUTTON, + Settings.HIDE_QUICK_ACTIONS_SAVE_TO_PLAYLIST_BUTTON, + Settings.HIDE_QUICK_ACTIONS_SHARE_BUTTON + ); + + enableDisablePreferences( + Settings.DISABLE_LANDSCAPE_MODE.get(), + Settings.FORCE_FULLSCREEN + ); + + enableDisablePreferences( + Settings.FORCE_FULLSCREEN.get(), + Settings.DISABLE_LANDSCAPE_MODE + ); + + } + + /** + * Enable/Disable Preference related to Hide Quick Actions + */ + private static void QuickActionsPreferenceLinks() { + final boolean isEnabled = + Settings.DISABLE_ENGAGEMENT_PANEL.get() || Settings.HIDE_QUICK_ACTIONS.get(); + + enableDisablePreferences( + isEnabled, + Settings.HIDE_QUICK_ACTIONS_COMMENT_BUTTON, + Settings.HIDE_QUICK_ACTIONS_DISLIKE_BUTTON, + Settings.HIDE_QUICK_ACTIONS_LIKE_BUTTON, + Settings.HIDE_QUICK_ACTIONS_LIVE_CHAT_BUTTON, + Settings.HIDE_QUICK_ACTIONS_MORE_BUTTON, + Settings.HIDE_QUICK_ACTIONS_OPEN_MIX_PLAYLIST_BUTTON, + Settings.HIDE_QUICK_ACTIONS_OPEN_PLAYLIST_BUTTON, + Settings.HIDE_QUICK_ACTIONS_SAVE_TO_PLAYLIST_BUTTON, + Settings.HIDE_QUICK_ACTIONS_SHARE_BUTTON + ); + } + + /** + * Enable/Disable Preference related to Miniplayer settings + */ + private static void MiniPlayerPreferenceLinks() { + final MiniplayerPatch.MiniplayerType CURRENT_TYPE = Settings.MINIPLAYER_TYPE.get(); + final boolean available = + (CURRENT_TYPE == MODERN_1 || CURRENT_TYPE == MODERN_3) && + !Settings.MINIPLAYER_DOUBLE_TAP_ACTION.get() && + !Settings.MINIPLAYER_DRAG_AND_DROP.get(); + + enableDisablePreferences( + !available, + Settings.MINIPLAYER_HIDE_EXPAND_CLOSE + ); + } + + /** + * Enable/Disable Preference related to Navigation settings + */ + private static void NavigationPreferenceLinks() { + enableDisablePreferences( + Settings.SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON.get(), + Settings.HIDE_NAVIGATION_CREATE_BUTTON + ); + enableDisablePreferences( + !Settings.SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON.get(), + Settings.HIDE_NAVIGATION_NOTIFICATIONS_BUTTON, + Settings.REPLACE_TOOLBAR_CREATE_BUTTON, + Settings.REPLACE_TOOLBAR_CREATE_BUTTON_TYPE + ); + enableDisablePreferences( + !isSDKAbove(31), + Settings.ENABLE_TRANSLUCENT_NAVIGATION_BAR + ); + } + + /** + * Enable/Disable Preference related to RYD settings + */ + private static void RYDPreferenceLinks() { + if (!(mPreferenceManager.findPreference(Settings.RYD_ENABLED.key) instanceof SwitchPreference enabledPreference)) { + return; + } + if (!(mPreferenceManager.findPreference(Settings.RYD_SHORTS.key) instanceof SwitchPreference shortsPreference)) { + return; + } + if (!(mPreferenceManager.findPreference(Settings.RYD_DISLIKE_PERCENTAGE.key) instanceof SwitchPreference percentagePreference)) { + return; + } + if (!(mPreferenceManager.findPreference(Settings.RYD_COMPACT_LAYOUT.key) instanceof SwitchPreference compactLayoutPreference)) { + return; + } + final Preference.OnPreferenceChangeListener clearAllUICaches = (pref, newValue) -> { + ReturnYouTubeDislike.clearAllUICaches(); + + return true; + }; + enabledPreference.setOnPreferenceChangeListener((pref, newValue) -> { + ReturnYouTubeDislikePatch.onRYDStatusChange(); + + return true; + }); + String shortsSummary = ReturnYouTubeDislikePatch.IS_SPOOFING_TO_NON_LITHO_SHORTS_PLAYER + ? str("revanced_ryd_shorts_summary_on") + : str("revanced_ryd_shorts_summary_on_disclaimer"); + shortsPreference.setSummaryOn(shortsSummary); + percentagePreference.setOnPreferenceChangeListener(clearAllUICaches); + compactLayoutPreference.setOnPreferenceChangeListener(clearAllUICaches); + } + + /** + * Enable/Disable Preference related to Seek bar settings + */ + private static void SeekBarPreferenceLinks() { + enableDisablePreferences( + Settings.RESTORE_OLD_SEEKBAR_THUMBNAILS.get(), + Settings.ENABLE_SEEKBAR_THUMBNAILS_HIGH_QUALITY + ); + } + + /** + * Enable/Disable Preference related to Speed overlay settings + */ + private static void SpeedOverlayPreferenceLinks() { + enableDisablePreferences( + Settings.DISABLE_SPEED_OVERLAY.get(), + Settings.SPEED_OVERLAY_VALUE + ); + } + + private static void WhitelistPreferenceLinks() { + final boolean enabled = PatchStatus.RememberPlaybackSpeed() || PatchStatus.SponsorBlock(); + final String[] whitelistKey = {Settings.OVERLAY_BUTTON_WHITELIST.key, "revanced_whitelist_settings"}; + + for (String key : whitelistKey) { + final Preference preference = mPreferenceManager.findPreference(key); + if (preference != null) { + preference.setEnabled(enabled); + } + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SegmentCategoryListPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SegmentCategoryListPreference.java new file mode 100644 index 0000000000..b94ee31357 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SegmentCategoryListPreference.java @@ -0,0 +1,180 @@ +package app.revanced.extension.youtube.settings.preference; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.graphics.Color; +import android.preference.ListPreference; +import android.text.Editable; +import android.text.InputType; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TableLayout; +import android.widget.TableRow; +import android.widget.TextView; + +import java.util.Objects; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour; +import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory; + +@SuppressWarnings({"unused", "deprecation"}) +public class SegmentCategoryListPreference extends ListPreference { + private SegmentCategory mCategory; + private EditText mEditText; + private int mClickedDialogEntryIndex; + + private void init() { + final SegmentCategory segmentCategory = SegmentCategory.byCategoryKey(getKey()); + final boolean isHighlightCategory = segmentCategory == SegmentCategory.HIGHLIGHT; + mCategory = Objects.requireNonNull(segmentCategory); + // Edit: Using preferences to sync together multiple pieces + // of code together is messy and should be rethought. + setKey(segmentCategory.behaviorSetting.key); + setDefaultValue(segmentCategory.behaviorSetting.defaultValue); + + setEntries(isHighlightCategory + ? CategoryBehaviour.getBehaviorDescriptionsWithoutSkipOnce() + : CategoryBehaviour.getBehaviorDescriptions()); + setEntryValues(isHighlightCategory + ? CategoryBehaviour.getBehaviorKeyValuesWithoutSkipOnce() + : CategoryBehaviour.getBehaviorKeyValues()); + updateTitle(); + } + + public SegmentCategoryListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + public SegmentCategoryListPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public SegmentCategoryListPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public SegmentCategoryListPreference(Context context) { + super(context); + init(); + } + + @Override + protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { + try { + Utils.setEditTextDialogTheme(builder); + super.onPrepareDialogBuilder(builder); + + Context context = builder.getContext(); + TableLayout table = new TableLayout(context); + table.setOrientation(LinearLayout.HORIZONTAL); + table.setPadding(70, 0, 150, 0); + + TableRow row = new TableRow(context); + + TextView colorTextLabel = new TextView(context); + colorTextLabel.setText(str("revanced_sb_color_dot_label")); + row.addView(colorTextLabel); + + TextView colorDotView = new TextView(context); + colorDotView.setText(mCategory.getCategoryColorDot()); + colorDotView.setPadding(30, 0, 30, 0); + row.addView(colorDotView); + + mEditText = new EditText(context); + mEditText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS); + mEditText.setText(mCategory.colorString()); + mEditText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable s) { + try { + String colorString = s.toString(); + if (!colorString.startsWith("#")) { + s.insert(0, "#"); // recursively calls back into this method + return; + } + if (colorString.length() > 7) { + s.delete(7, colorString.length()); + return; + } + final int color = Color.parseColor(colorString); + colorDotView.setText(SegmentCategory.getCategoryColorDot(color)); + } catch (IllegalArgumentException ex) { + // ignore + } + } + }); + mEditText.setLayoutParams(new TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 1f)); + row.addView(mEditText); + + table.addView(row); + builder.setView(table); + builder.setTitle(mCategory.title.toString()); + + builder.setPositiveButton(android.R.string.ok, (dialog, which) -> onClick(dialog, DialogInterface.BUTTON_POSITIVE)); + builder.setNeutralButton(str("revanced_sb_reset_color"), (dialog, which) -> { + try { + mCategory.resetColor(); + updateTitle(); + Utils.showToastShort(str("revanced_sb_color_reset")); + } catch (Exception ex) { + Logger.printException(() -> "setNeutralButton failure", ex); + } + }); + builder.setNegativeButton(android.R.string.cancel, null); + mClickedDialogEntryIndex = findIndexOfValue(getValue()); + builder.setSingleChoiceItems(getEntries(), mClickedDialogEntryIndex, (dialog, which) -> mClickedDialogEntryIndex = which); + } catch (Exception ex) { + Logger.printException(() -> "onPrepareDialogBuilder failure", ex); + } + } + + @Override + protected void onDialogClosed(boolean positiveResult) { + try { + if (positiveResult && mClickedDialogEntryIndex >= 0 && getEntryValues() != null) { + String value = getEntryValues()[mClickedDialogEntryIndex].toString(); + if (callChangeListener(value)) { + setValue(value); + mCategory.setBehaviour(Objects.requireNonNull(CategoryBehaviour.byReVancedKeyValue(value))); + SegmentCategory.updateEnabledCategories(); + } + String colorString = mEditText.getText().toString(); + try { + if (!colorString.equals(mCategory.colorString())) { + mCategory.setColor(colorString); + Utils.showToastShort(str("revanced_sb_color_changed")); + } + } catch (IllegalArgumentException ex) { + Utils.showToastShort(str("revanced_sb_color_invalid")); + } + updateTitle(); + } + } catch (Exception ex) { + Logger.printException(() -> "onDialogClosed failure", ex); + } + } + + private void updateTitle() { + setTitle(mCategory.getTitleWithColorDot()); + setEnabled(Settings.SB_ENABLED.get()); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockImportExportPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockImportExportPreference.java new file mode 100644 index 0000000000..1d53842dd6 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockImportExportPreference.java @@ -0,0 +1,108 @@ +package app.revanced.extension.youtube.settings.preference; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.annotation.TargetApi; +import android.app.AlertDialog; +import android.content.Context; +import android.preference.EditTextPreference; +import android.preference.Preference; +import android.text.InputType; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.widget.EditText; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings; + +@SuppressWarnings({"unused", "deprecation"}) +public class SponsorBlockImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener { + + private String existingSettings; + + @TargetApi(26) + private void init() { + setSelectable(true); + + EditText editText = getEditText(); + editText.setTextIsSelectable(true); + editText.setAutofillHints((String) null); + editText.setInputType(editText.getInputType() + | InputType.TYPE_CLASS_TEXT + | InputType.TYPE_TEXT_FLAG_MULTI_LINE + | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + editText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 8); // Use a smaller font to reduce text wrap. + + // If the user has a private user id, then include a subtext that mentions not to share it. + String importExportSummary = SponsorBlockSettings.userHasSBPrivateId() + ? str("revanced_sb_settings_ie_sum_warning") + : str("revanced_sb_settings_ie_sum"); + setSummary(importExportSummary); + + setOnPreferenceClickListener(this); + } + + public SponsorBlockImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + public SponsorBlockImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public SponsorBlockImportExportPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public SponsorBlockImportExportPreference(Context context) { + super(context); + init(); + } + + @Override + public boolean onPreferenceClick(Preference preference) { + try { + // Must set text before preparing dialog, otherwise text is non selectable if this preference is later reopened. + existingSettings = SponsorBlockSettings.exportDesktopSettings(); + getEditText().setText(existingSettings); + } catch (Exception ex) { + Logger.printException(() -> "showDialog failure", ex); + } + return true; + } + + @Override + protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { + try { + Utils.setEditTextDialogTheme(builder); + super.onPrepareDialogBuilder(builder); + // Show the user the settings in JSON format. + builder.setTitle(getTitle()); + builder.setNeutralButton(str("revanced_sb_settings_copy"), (dialog, which) -> + Utils.setClipboard(getEditText().getText().toString(), str("revanced_sb_share_copy_settings_success"))) + .setPositiveButton(android.R.string.ok, (dialog, which) -> + importSettings(getEditText().getText().toString())); + } catch (Exception ex) { + Logger.printException(() -> "onPrepareDialogBuilder failure", ex); + } + } + + private void importSettings(String replacementSettings) { + try { + if (replacementSettings.equals(existingSettings)) { + return; + } + SponsorBlockSettings.importDesktopSettings(replacementSettings); + SponsorBlockSettingsPreference.updateSegmentCategories(); + SponsorBlockSettingsPreference.fetchAndDisplayStats(); + SponsorBlockSettingsPreference.updateUI(); + } catch (Exception ex) { + Logger.printException(() -> "importSettings failure", ex); + } + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockSettingsPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockSettingsPreference.java new file mode 100644 index 0000000000..6a8a4a0b55 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockSettingsPreference.java @@ -0,0 +1,432 @@ +package app.revanced.extension.youtube.settings.preference; + +import static android.text.Html.fromHtml; +import static app.revanced.extension.shared.utils.ResourceUtils.getLayoutIdentifier; +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.net.Uri; +import android.preference.Preference; +import android.preference.PreferenceCategory; +import android.preference.PreferenceScreen; +import android.preference.SwitchPreference; +import android.text.InputType; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TableLayout; +import android.widget.TableRow; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Objects; + +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.settings.preference.ResettableEditTextPreference; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.utils.PatchStatus; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.sponsorblock.SegmentPlaybackController; +import app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings; +import app.revanced.extension.youtube.sponsorblock.SponsorBlockUtils; +import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory; +import app.revanced.extension.youtube.sponsorblock.objects.UserStats; +import app.revanced.extension.youtube.sponsorblock.requests.SBRequester; +import app.revanced.extension.youtube.sponsorblock.ui.SponsorBlockViewController; + +@SuppressWarnings({"unused", "deprecation"}) +public class SponsorBlockSettingsPreference extends ReVancedPreferenceFragment { + + private static PreferenceCategory statsCategory; + + private static final int preferencesCategoryLayout = getLayoutIdentifier("revanced_settings_preferences_category"); + + private static final Preference.OnPreferenceChangeListener updateUI = (pref, newValue) -> { + updateUI(); + + return true; + }; + + @NonNull + private static SwitchPreference findSwitchPreference(BooleanSetting setting) { + final String key = setting.key; + if (mPreferenceManager.findPreference(key) instanceof SwitchPreference switchPreference) { + switchPreference.setOnPreferenceChangeListener(updateUI); + return switchPreference; + } else { + throw new IllegalStateException("SwitchPreference is null: " + key); + } + } + + @NonNull + private static ResettableEditTextPreference findResettableEditTextPreference(Setting setting) { + final String key = setting.key; + if (mPreferenceManager.findPreference(key) instanceof ResettableEditTextPreference switchPreference) { + switchPreference.setOnPreferenceChangeListener(updateUI); + return switchPreference; + } else { + throw new IllegalStateException("ResettableEditTextPreference is null: " + key); + } + } + + public static void updateUI() { + if (!Settings.SB_ENABLED.get()) { + SponsorBlockViewController.hideAll(); + SegmentPlaybackController.clearData(); + } else if (!Settings.SB_CREATE_NEW_SEGMENT.get()) { + SponsorBlockViewController.hideNewSegmentLayout(); + } + } + + @TargetApi(26) + public static void init(Activity mActivity) { + if (!PatchStatus.SponsorBlock()) { + return; + } + + final SwitchPreference sbEnabled = findSwitchPreference(Settings.SB_ENABLED); + sbEnabled.setOnPreferenceClickListener(preference -> { + updateUI(); + fetchAndDisplayStats(); + updateSegmentCategories(); + return false; + }); + + if (!(sbEnabled.getParent() instanceof PreferenceScreen mPreferenceScreen)) { + return; + } + + final SwitchPreference votingEnabled = findSwitchPreference(Settings.SB_VOTING_BUTTON); + final SwitchPreference compactSkipButton = findSwitchPreference(Settings.SB_COMPACT_SKIP_BUTTON); + final SwitchPreference autoHideSkipSegmentButton = findSwitchPreference(Settings.SB_AUTO_HIDE_SKIP_BUTTON); + final SwitchPreference showSkipToast = findSwitchPreference(Settings.SB_TOAST_ON_SKIP); + showSkipToast.setOnPreferenceClickListener(preference -> { + Utils.showToastShort(str("revanced_sb_skipped_sponsor")); + return false; + }); + + final SwitchPreference showTimeWithoutSegments = findSwitchPreference(Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS); + + final SwitchPreference addNewSegment = findSwitchPreference(Settings.SB_CREATE_NEW_SEGMENT); + addNewSegment.setOnPreferenceChangeListener((preference, newValue) -> { + if ((Boolean) newValue && !Settings.SB_SEEN_GUIDELINES.get()) { + Context context = preference.getContext(); + new AlertDialog.Builder(context) + .setTitle(str("revanced_sb_guidelines_popup_title")) + .setMessage(str("revanced_sb_guidelines_popup_content")) + .setNegativeButton(str("revanced_sb_guidelines_popup_already_read"), null) + .setPositiveButton(str("revanced_sb_guidelines_popup_open"), (dialogInterface, i) -> openGuidelines(context)) + .setOnDismissListener(dialog -> Settings.SB_SEEN_GUIDELINES.save(true)) + .setCancelable(false) + .show(); + } + updateUI(); + return true; + }); + + final ResettableEditTextPreference newSegmentStep = findResettableEditTextPreference(Settings.SB_CREATE_NEW_SEGMENT_STEP); + newSegmentStep.setOnPreferenceChangeListener((preference, newValue) -> { + try { + final int newAdjustmentValue = Integer.parseInt(newValue.toString()); + if (newAdjustmentValue != 0) { + Settings.SB_CREATE_NEW_SEGMENT_STEP.save(newAdjustmentValue); + return true; + } + } catch (NumberFormatException ex) { + Logger.printInfo(() -> "Invalid new segment step", ex); + } + + Utils.showToastLong(str("revanced_sb_general_adjusting_invalid")); + updateUI(); + return false; + }); + final Preference guidelinePreferences = Objects.requireNonNull(mPreferenceManager.findPreference("revanced_sb_guidelines_preference")); + guidelinePreferences.setDependency(Settings.SB_ENABLED.key); + guidelinePreferences.setOnPreferenceClickListener(preference -> { + openGuidelines(preference.getContext()); + return true; + }); + + final SwitchPreference toastOnConnectionError = findSwitchPreference(Settings.SB_TOAST_ON_CONNECTION_ERROR); + final SwitchPreference trackSkips = findSwitchPreference(Settings.SB_TRACK_SKIP_COUNT); + final ResettableEditTextPreference minSegmentDuration = findResettableEditTextPreference(Settings.SB_SEGMENT_MIN_DURATION); + minSegmentDuration.setOnPreferenceChangeListener((preference, newValue) -> { + try { + Float minTimeDuration = Float.valueOf(newValue.toString()); + Settings.SB_SEGMENT_MIN_DURATION.save(minTimeDuration); + return true; + } catch (NumberFormatException ex) { + Logger.printInfo(() -> "Invalid minimum segment duration", ex); + } + + Utils.showToastLong(str("revanced_sb_general_min_duration_invalid")); + updateUI(); + return false; + }); + final ResettableEditTextPreference privateUserId = findResettableEditTextPreference(Settings.SB_PRIVATE_USER_ID); + privateUserId.setOnPreferenceChangeListener((preference, newValue) -> { + String newUUID = newValue.toString(); + if (!SponsorBlockSettings.isValidSBUserId(newUUID)) { + Utils.showToastLong(str("revanced_sb_general_uuid_invalid")); + return false; + } + + Settings.SB_PRIVATE_USER_ID.save(newUUID); + try { + updateUI(); + } catch (Exception e) { + throw new RuntimeException(e); + } + fetchAndDisplayStats(); + return true; + }); + final Preference apiUrl = mPreferenceManager.findPreference(Settings.SB_API_URL.key); + if (apiUrl != null) { + apiUrl.setOnPreferenceClickListener(preference -> { + Context context = preference.getContext(); + + TableLayout table = new TableLayout(context); + table.setOrientation(LinearLayout.HORIZONTAL); + table.setPadding(15, 0, 15, 0); + + TableRow row = new TableRow(context); + + EditText editText = new EditText(context); + editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); + editText.setText(Settings.SB_API_URL.get()); + editText.setLayoutParams(new TableRow.LayoutParams(TableRow.LayoutParams.MATCH_PARENT, TableRow.LayoutParams.WRAP_CONTENT, 1f)); + row.addView(editText); + table.addView(row); + + DialogInterface.OnClickListener urlChangeListener = (dialog, buttonPressed) -> { + if (buttonPressed == DialogInterface.BUTTON_NEUTRAL) { + Settings.SB_API_URL.resetToDefault(); + Utils.showToastLong(str("revanced_sb_api_url_reset")); + } else if (buttonPressed == DialogInterface.BUTTON_POSITIVE) { + String serverAddress = editText.getText().toString(); + if (!SponsorBlockSettings.isValidSBServerAddress(serverAddress)) { + Utils.showToastLong(str("revanced_sb_api_url_invalid")); + } else if (!serverAddress.equals(Settings.SB_API_URL.get())) { + Settings.SB_API_URL.save(serverAddress); + Utils.showToastLong(str("revanced_sb_api_url_changed")); + } + } + }; + Utils.getEditTextDialogBuilder(context) + .setView(table) + .setTitle(apiUrl.getTitle()) + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(str("revanced_sb_reset"), urlChangeListener) + .setPositiveButton(android.R.string.ok, urlChangeListener) + .show(); + return true; + }); + } + + statsCategory = new PreferenceCategory(mActivity); + statsCategory.setLayoutResource(preferencesCategoryLayout); + statsCategory.setTitle(str("revanced_sb_stats")); + mPreferenceScreen.addPreference(statsCategory); + fetchAndDisplayStats(); + + final PreferenceCategory aboutCategory = new PreferenceCategory(mActivity); + aboutCategory.setLayoutResource(preferencesCategoryLayout); + aboutCategory.setTitle(str("revanced_sb_about")); + mPreferenceScreen.addPreference(aboutCategory); + + Preference aboutPreference = new Preference(mActivity); + aboutCategory.addPreference(aboutPreference); + aboutPreference.setTitle(str("revanced_sb_about_api")); + aboutPreference.setSummary(str("revanced_sb_about_api_sum")); + aboutPreference.setOnPreferenceClickListener(preference -> { + Intent i = new Intent(Intent.ACTION_VIEW); + i.setData(Uri.parse("https://sponsor.ajay.app")); + preference.getContext().startActivity(i); + return false; + }); + + updateUI(); + } + + public static void updateSegmentCategories() { + try { + for (SegmentCategory category : SegmentCategory.categoriesWithoutUnsubmitted()) { + final String key = category.keyValue; + if (mPreferenceManager.findPreference(key) instanceof SegmentCategoryListPreference segmentCategoryListPreference) { + segmentCategoryListPreference.setTitle(category.getTitleWithColorDot()); + segmentCategoryListPreference.setEnabled(Settings.SB_ENABLED.get()); + } + } + } catch (Exception ex) { + Logger.printException(() -> "updateSegmentCategories failure", ex); + } + } + + private static void openGuidelines(Context context) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse("https://wiki.sponsor.ajay.app/w/Guidelines")); + context.startActivity(intent); + } + + public static void fetchAndDisplayStats() { + try { + if (statsCategory == null) { + return; + } + statsCategory.removeAll(); + if (!SponsorBlockSettings.userHasSBPrivateId()) { + // User has never voted or created any segments. No stats to show. + addLocalUserStats(); + return; + } + + Context context = statsCategory.getContext(); + + Preference loadingPlaceholderPreference = new Preference(context); + loadingPlaceholderPreference.setEnabled(false); + statsCategory.addPreference(loadingPlaceholderPreference); + if (Settings.SB_ENABLED.get()) { + loadingPlaceholderPreference.setTitle(str("revanced_sb_stats_loading")); + Utils.runOnBackgroundThread(() -> { + UserStats stats = SBRequester.retrieveUserStats(); + Utils.runOnMainThread(() -> { // get back on main thread to modify UI elements + addUserStats(loadingPlaceholderPreference, stats); + addLocalUserStats(); + }); + }); + } else { + loadingPlaceholderPreference.setTitle(str("revanced_sb_stats_sb_disabled")); + } + } catch (Exception ex) { + Logger.printException(() -> "fetchAndDisplayStats failure", ex); + } + } + + private static void addUserStats(@NonNull Preference loadingPlaceholder, @Nullable UserStats stats) { + Utils.verifyOnMainThread(); + try { + if (stats == null) { + loadingPlaceholder.setTitle(str("revanced_sb_stats_connection_failure")); + return; + } + statsCategory.removeAll(); + Context context = statsCategory.getContext(); + + if (stats.totalSegmentCountIncludingIgnored > 0) { + // If user has not created any segments, there's no reason to set a username. + ResettableEditTextPreference preference = new ResettableEditTextPreference(context); + statsCategory.addPreference(preference); + String userName = stats.userName; + preference.setTitle(fromHtml(str("revanced_sb_stats_username", userName))); + preference.setSummary(str("revanced_sb_stats_username_change")); + preference.setText(userName); + preference.setOnPreferenceChangeListener((preference1, value) -> { + Utils.runOnBackgroundThread(() -> { + String newUserName = (String) value; + String errorMessage = SBRequester.setUsername(newUserName); + Utils.runOnMainThread(() -> { + if (errorMessage == null) { + preference.setTitle(fromHtml(str("revanced_sb_stats_username", newUserName))); + preference.setText(newUserName); + Utils.showToastLong(str("revanced_sb_stats_username_changed")); + } else { + preference.setText(userName); // revert to previous + Utils.showToastLong(errorMessage); + } + }); + }); + return true; + }); + } + + { + // number of segment submissions (does not include ignored segments) + Preference preference = new Preference(context); + statsCategory.addPreference(preference); + String formatted = SponsorBlockUtils.getNumberOfSkipsString(stats.segmentCount); + preference.setTitle(fromHtml(str("revanced_sb_stats_submissions", formatted))); + preference.setSummary(str("revanced_sb_stats_submissions_sum")); + if (stats.totalSegmentCountIncludingIgnored == 0) { + preference.setSelectable(false); + } else { + preference.setOnPreferenceClickListener(preference1 -> { + Intent i = new Intent(Intent.ACTION_VIEW); + i.setData(Uri.parse("https://sb.ltn.fi/userid/" + stats.publicUserId)); + preference1.getContext().startActivity(i); + return true; + }); + } + } + + { + // "user reputation". Usually not useful, since it appears most users have zero reputation. + // But if there is a reputation, then show it here + Preference preference = new Preference(context); + preference.setTitle(fromHtml(str("revanced_sb_stats_reputation", stats.reputation))); + preference.setSelectable(false); + if (stats.reputation != 0) { + statsCategory.addPreference(preference); + } + } + + { + // time saved for other users + Preference preference = new Preference(context); + statsCategory.addPreference(preference); + + String stats_saved; + String stats_saved_sum; + if (stats.totalSegmentCountIncludingIgnored == 0) { + stats_saved = str("revanced_sb_stats_saved_zero"); + stats_saved_sum = str("revanced_sb_stats_saved_sum_zero"); + } else { + stats_saved = str("revanced_sb_stats_saved", + SponsorBlockUtils.getNumberOfSkipsString(stats.viewCount)); + stats_saved_sum = str("revanced_sb_stats_saved_sum", SponsorBlockUtils.getTimeSavedString((long) (60 * stats.minutesSaved))); + } + preference.setTitle(fromHtml(stats_saved)); + preference.setSummary(fromHtml(stats_saved_sum)); + preference.setOnPreferenceClickListener(preference1 -> { + Intent i = new Intent(Intent.ACTION_VIEW); + i.setData(Uri.parse("https://sponsor.ajay.app/stats/")); + preference1.getContext().startActivity(i); + return false; + }); + } + } catch (Exception ex) { + Logger.printException(() -> "addUserStats failure", ex); + } + } + + private static void addLocalUserStats() { + // time the user saved by using SB + Preference preference = new Preference(statsCategory.getContext()); + statsCategory.addPreference(preference); + + Runnable updateStatsSelfSaved = () -> { + String formatted = SponsorBlockUtils.getNumberOfSkipsString(Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.get()); + preference.setTitle(fromHtml(str("revanced_sb_stats_self_saved", formatted))); + String formattedSaved = SponsorBlockUtils.getTimeSavedString(Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.get() / 1000); + preference.setSummary(fromHtml(str("revanced_sb_stats_self_saved_sum", formattedSaved))); + }; + updateStatsSelfSaved.run(); + preference.setOnPreferenceClickListener(preference1 -> { + new AlertDialog.Builder(preference1.getContext()) + .setTitle(str("revanced_sb_stats_self_saved_reset_title")) + .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> { + Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.resetToDefault(); + Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.resetToDefault(); + updateStatsSelfSaved.run(); + }) + .setNegativeButton(android.R.string.no, null).show(); + return true; + }); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SpoofStreamingDataSideEffectsPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SpoofStreamingDataSideEffectsPreference.java new file mode 100644 index 0000000000..3ada4f0ade --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SpoofStreamingDataSideEffectsPreference.java @@ -0,0 +1,78 @@ +package app.revanced.extension.youtube.settings.preference; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.Preference; +import android.preference.PreferenceManager; +import android.util.AttributeSet; + +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.misc.client.AppClient.ClientType; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings({"deprecation", "unused"}) +public class SpoofStreamingDataSideEffectsPreference extends Preference { + + private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> { + // Because this listener may run before the ReVanced settings fragment updates Settings, + // this could show the prior config and not the current. + // + // Push this call to the end of the main run queue, + // so all other listeners are done and Settings is up to date. + Utils.runOnMainThread(this::updateUI); + }; + + public SpoofStreamingDataSideEffectsPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public SpoofStreamingDataSideEffectsPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public SpoofStreamingDataSideEffectsPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SpoofStreamingDataSideEffectsPreference(Context context) { + super(context); + } + + private void addChangeListener() { + Setting.preferences.preferences.registerOnSharedPreferenceChangeListener(listener); + } + + private void removeChangeListener() { + Setting.preferences.preferences.unregisterOnSharedPreferenceChangeListener(listener); + } + + @Override + protected void onAttachedToHierarchy(PreferenceManager preferenceManager) { + super.onAttachedToHierarchy(preferenceManager); + updateUI(); + addChangeListener(); + } + + @Override + protected void onPrepareForRemoval() { + super.onPrepareForRemoval(); + removeChangeListener(); + } + + private void updateUI() { + final ClientType clientType = Settings.SPOOF_STREAMING_DATA_TYPE.get(); + + final String summaryTextKey; + if (clientType == ClientType.IOS && Settings.SPOOF_STREAMING_DATA_IOS_SKIP_LIVESTREAM_PLAYBACK.get()) { + summaryTextKey = "revanced_spoof_streaming_data_side_effects_ios_skip_livestream_playback"; + } else { + summaryTextKey = "revanced_spoof_streaming_data_side_effects_" + clientType.name().toLowerCase(); + } + + setSummary(str(summaryTextKey)); + setEnabled(Settings.SPOOF_STREAMING_DATA.get()); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ThirdPartyYouTubeMusicPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ThirdPartyYouTubeMusicPreference.java new file mode 100644 index 0000000000..b810cd9a5f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ThirdPartyYouTubeMusicPreference.java @@ -0,0 +1,142 @@ +package app.revanced.extension.youtube.settings.preference; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.Context; +import android.preference.Preference; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TableLayout; +import android.widget.TableRow; + +import java.util.Arrays; + +import app.revanced.extension.shared.settings.StringSetting; +import app.revanced.extension.shared.utils.ResourceUtils; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.utils.ExtendedUtils; + +@SuppressWarnings({"unused", "deprecation"}) +public class ThirdPartyYouTubeMusicPreference extends Preference implements Preference.OnPreferenceClickListener { + + private static final StringSetting settings = Settings.THIRD_PARTY_YOUTUBE_MUSIC_PACKAGE_NAME; + private static final String[] mEntries = ResourceUtils.getStringArray("revanced_third_party_youtube_music_label"); + private static final String[] mEntryValues = ResourceUtils.getStringArray("revanced_third_party_youtube_music_package_name"); + + @SuppressLint("StaticFieldLeak") + private static EditText mEditText; + private static String packageName; + private static int mClickedDialogEntryIndex; + + private final TextWatcher textWatcher = new TextWatcher() { + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + public void afterTextChanged(Editable s) { + packageName = s.toString(); + mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName); + } + }; + + private void init() { + setSelectable(true); + setOnPreferenceClickListener(this); + } + + public ThirdPartyYouTubeMusicPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + public ThirdPartyYouTubeMusicPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public ThirdPartyYouTubeMusicPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ThirdPartyYouTubeMusicPreference(Context context) { + super(context); + init(); + } + + @Override + public boolean onPreferenceClick(Preference preference) { + packageName = settings.get(); + mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName); + + final Context context = getContext(); + AlertDialog.Builder builder = Utils.getEditTextDialogBuilder(context); + + TableLayout table = new TableLayout(context); + table.setOrientation(LinearLayout.HORIZONTAL); + table.setPadding(15, 0, 15, 0); + + TableRow row = new TableRow(context); + + mEditText = new EditText(context); + mEditText.setHint(settings.defaultValue); + mEditText.setText(packageName); + mEditText.addTextChangedListener(textWatcher); + mEditText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 9); + mEditText.setLayoutParams(new TableRow.LayoutParams(TableRow.LayoutParams.MATCH_PARENT, TableRow.LayoutParams.WRAP_CONTENT, 1f)); + row.addView(mEditText); + + table.addView(row); + builder.setView(table); + + builder.setTitle(str("revanced_third_party_youtube_music_dialog_title")); + builder.setSingleChoiceItems(mEntries, mClickedDialogEntryIndex, (dialog, which) -> { + mClickedDialogEntryIndex = which; + mEditText.setText(mEntryValues[which]); + }); + builder.setPositiveButton(android.R.string.ok, (dialog, which) -> { + final String packageName = mEditText.getText().toString().trim(); + settings.save(packageName); + checkPackageIsValid(context, packageName); + dialog.dismiss(); + }); + builder.setNeutralButton(str("revanced_extended_settings_reset"), (dialog, which) -> settings.resetToDefault()); + builder.setNegativeButton(android.R.string.cancel, null); + + builder.show(); + + return true; + } + + private static void checkPackageIsValid(Context context, String packageName) { + if (packageName.isEmpty()) { + settings.resetToDefault(); + return; + } + + String appName = ""; + if (mClickedDialogEntryIndex >= 0) { + appName = mEntries[mClickedDialogEntryIndex]; + } + + showToastOrOpenWebsites(context, appName, packageName); + } + + private static void showToastOrOpenWebsites(Context context, String appName, String packageName) { + if (ExtendedUtils.isPackageEnabled(packageName)) { + return; + } + + Utils.showToastShort(str("revanced_third_party_youtube_music_not_installed_warning", appName.isEmpty() ? packageName : appName)); + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/WatchHistoryStatusPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/WatchHistoryStatusPreference.java new file mode 100644 index 0000000000..104785fc18 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/WatchHistoryStatusPreference.java @@ -0,0 +1,81 @@ +package app.revanced.extension.youtube.settings.preference; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.Preference; +import android.preference.PreferenceManager; +import android.util.AttributeSet; + +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.misc.WatchHistoryPatch.WatchHistoryType; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings({"deprecation", "unused"}) +public class WatchHistoryStatusPreference extends Preference { + + private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> { + // Because this listener may run before the ReVanced settings fragment updates Settings, + // this could show the prior config and not the current. + // + // Push this call to the end of the main run queue, + // so all other listeners are done and Settings is up to date. + Utils.runOnMainThread(this::updateUI); + }; + + public WatchHistoryStatusPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public WatchHistoryStatusPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public WatchHistoryStatusPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public WatchHistoryStatusPreference(Context context) { + super(context); + } + + private void addChangeListener() { + Setting.preferences.preferences.registerOnSharedPreferenceChangeListener(listener); + } + + private void removeChangeListener() { + Setting.preferences.preferences.unregisterOnSharedPreferenceChangeListener(listener); + } + + @Override + protected void onAttachedToHierarchy(PreferenceManager preferenceManager) { + super.onAttachedToHierarchy(preferenceManager); + updateUI(); + addChangeListener(); + } + + @Override + protected void onPrepareForRemoval() { + super.onPrepareForRemoval(); + removeChangeListener(); + } + + private void updateUI() { + final WatchHistoryType watchHistoryType = Settings.WATCH_HISTORY_TYPE.get(); + final boolean blockWatchHistory = watchHistoryType == WatchHistoryType.BLOCK; + final boolean replaceWatchHistory = watchHistoryType == WatchHistoryType.REPLACE; + + final String summaryTextKey; + if (blockWatchHistory) { + summaryTextKey = "revanced_watch_history_about_status_blocked"; + } else if (replaceWatchHistory) { + summaryTextKey = "revanced_watch_history_about_status_replaced"; + } else { + summaryTextKey = "revanced_watch_history_about_status_original"; + } + + setSummary(str(summaryTextKey)); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/WhitelistedChannelsPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/WhitelistedChannelsPreference.java new file mode 100644 index 0000000000..ffa5d2ba45 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/WhitelistedChannelsPreference.java @@ -0,0 +1,162 @@ +package app.revanced.extension.youtube.settings.preference; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.app.AlertDialog; +import android.content.Context; +import android.preference.Preference; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.View; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.apache.commons.lang3.BooleanUtils; + +import java.util.ArrayList; + +import app.revanced.extension.youtube.patches.utils.PatchStatus; +import app.revanced.extension.youtube.utils.ThemeUtils; +import app.revanced.extension.youtube.whitelist.VideoChannel; +import app.revanced.extension.youtube.whitelist.Whitelist; +import app.revanced.extension.youtube.whitelist.Whitelist.WhitelistType; + +@SuppressWarnings({"unused", "deprecation"}) +public class WhitelistedChannelsPreference extends Preference implements Preference.OnPreferenceClickListener { + + private static final WhitelistType whitelistTypePlaybackSpeed = WhitelistType.PLAYBACK_SPEED; + private static final WhitelistType whitelistTypeSponsorBlock = WhitelistType.SPONSOR_BLOCK; + private static final boolean playbackSpeedIncluded = PatchStatus.RememberPlaybackSpeed(); + private static final boolean sponsorBlockIncluded = PatchStatus.SponsorBlock(); + private static String[] mEntries; + private static WhitelistType[] mEntryValues; + + static { + final int entrySize = BooleanUtils.toInteger(playbackSpeedIncluded) + + BooleanUtils.toInteger(sponsorBlockIncluded); + + if (entrySize != 0) { + mEntries = new String[entrySize]; + mEntryValues = new WhitelistType[entrySize]; + + int index = 0; + if (playbackSpeedIncluded) { + mEntries[index] = " " + whitelistTypePlaybackSpeed.getFriendlyName() + " "; + mEntryValues[index] = whitelistTypePlaybackSpeed; + index++; + } + if (sponsorBlockIncluded) { + mEntries[index] = " " + whitelistTypeSponsorBlock.getFriendlyName() + " "; + mEntryValues[index] = whitelistTypeSponsorBlock; + } + } + } + + private void init() { + setOnPreferenceClickListener(this); + } + + public WhitelistedChannelsPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + public WhitelistedChannelsPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public WhitelistedChannelsPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public WhitelistedChannelsPreference(Context context) { + super(context); + init(); + } + + @Override + public boolean onPreferenceClick(Preference preference) { + showWhitelistedChannelDialog(getContext()); + + return true; + } + + public static void showWhitelistedChannelDialog(Context context) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(str("revanced_whitelist_settings_title")); + builder.setItems(mEntries, (dialog, which) -> showWhitelistedChannelDialog(context, mEntryValues[which])); + builder.setNegativeButton(android.R.string.cancel, null); + builder.show(); + } + + private static void showWhitelistedChannelDialog(Context context, WhitelistType whitelistType) { + final ArrayList mEntries = Whitelist.getWhitelistedChannels(whitelistType); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(whitelistType.getFriendlyName()); + + if (mEntries.isEmpty()) { + TextView emptyView = new TextView(context); + emptyView.setText(str("revanced_whitelist_empty")); + emptyView.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_START); + emptyView.setTextSize(16); + emptyView.setPadding(60, 40, 60, 0); + builder.setView(emptyView); + } else { + LinearLayout entriesContainer = new LinearLayout(context); + entriesContainer.setOrientation(LinearLayout.VERTICAL); + for (final VideoChannel entry : mEntries) { + String author = entry.getChannelName(); + View entryView = getEntryView(context, author, v -> new AlertDialog.Builder(context) + .setMessage(str("revanced_whitelist_remove_dialog_message", author, whitelistType.getFriendlyName())) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + Whitelist.removeFromWhitelist(whitelistType, entry.getChannelId()); + entriesContainer.removeView(entriesContainer.findViewWithTag(author)); + }) + .setNegativeButton(android.R.string.cancel, null) + .show()); + entryView.setTag(author); + entriesContainer.addView(entryView); + } + builder.setView(entriesContainer); + } + + builder.setPositiveButton(android.R.string.ok, null); + builder.show(); + } + + private static View getEntryView(Context context, CharSequence entry, View.OnClickListener onDeleteClickListener) { + LinearLayout.LayoutParams entryContainerParams = new LinearLayout.LayoutParams( + new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + entryContainerParams.setMargins(60, 40, 60, 0); + + LinearLayout.LayoutParams entryLabelLayoutParams = new LinearLayout.LayoutParams( + 0, LinearLayout.LayoutParams.WRAP_CONTENT, 1); + entryLabelLayoutParams.gravity = Gravity.CENTER; + + LinearLayout entryContainer = new LinearLayout(context); + entryContainer.setOrientation(LinearLayout.HORIZONTAL); + entryContainer.setLayoutParams(entryContainerParams); + + TextView entryLabel = new TextView(context); + entryLabel.setText(entry); + entryLabel.setLayoutParams(entryLabelLayoutParams); + entryLabel.setTextSize(16); + entryLabel.setOnClickListener(onDeleteClickListener); + + ImageButton deleteButton = new ImageButton(context); + deleteButton.setImageDrawable(ThemeUtils.getTrashButtonDrawable()); + deleteButton.setOnClickListener(onDeleteClickListener); + deleteButton.setBackground(null); + + entryContainer.addView(entryLabel); + entryContainer.addView(deleteButton); + return entryContainer; + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/BottomSheetState.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/BottomSheetState.kt new file mode 100644 index 0000000000..2d8b513a36 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/BottomSheetState.kt @@ -0,0 +1,51 @@ +package app.revanced.extension.youtube.shared + +import app.revanced.extension.shared.utils.Event +import app.revanced.extension.shared.utils.Logger + +/** + * BottomSheetState bottom sheet state. + */ +enum class BottomSheetState { + CLOSED, + OPEN; + + companion object { + + @JvmStatic + fun set(enum: BottomSheetState) { + if (current != enum) { + Logger.printDebug { "BottomSheetState changed to: ${enum.name}" } + current = enum + } + } + + /** + * The current bottom sheet state. + */ + @JvmStatic + var current + get() = currentBottomSheetState + private set(value) { + currentBottomSheetState = value + onChange(currentBottomSheetState) + } + + @Volatile // value is read/write from different threads + private var currentBottomSheetState = CLOSED + + /** + * bottom sheet state change listener + */ + @JvmStatic + val onChange = Event() + } + + /** + * Check if the bottom sheet is [OPEN]. + * Useful for checking if a bottom sheet is open. + */ + fun isOpen(): Boolean { + return this == OPEN + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/LockModeState.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/LockModeState.kt new file mode 100644 index 0000000000..9a330e687e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/LockModeState.kt @@ -0,0 +1,56 @@ +package app.revanced.extension.youtube.shared + +import app.revanced.extension.shared.utils.Event +import app.revanced.extension.shared.utils.Logger + +/** + * LockModeState. + */ +enum class LockModeState { + LOCK_MODE_STATE_ENUM_UNKNOWN, + LOCK_MODE_STATE_ENUM_UNLOCKED, + LOCK_MODE_STATE_ENUM_LOCKED, + LOCK_MODE_STATE_ENUM_CAN_UNLOCK, + LOCK_MODE_STATE_ENUM_UNLOCK_EXPANDED, + LOCK_MODE_STATE_ENUM_LOCKED_TEMPORARY_SUSPENSION; + + companion object { + + private val nameToLockModeState = entries.associateBy { it.name } + + @JvmStatic + fun setFromString(enumName: String) { + val newType = nameToLockModeState[enumName] + if (newType == null) { + Logger.printException { "Unknown LockModeState encountered: $enumName" } + } else if (current != newType) { + Logger.printDebug { "LockModeState changed to: $newType" } + current = newType + } + } + + /** + * The current lock mode state. + */ + @JvmStatic + var current + get() = currentLockModeState + private set(value) { + currentLockModeState = value + onChange(value) + } + + @Volatile // value is read/write from different threads + private var currentLockModeState = LOCK_MODE_STATE_ENUM_UNKNOWN + + /** + * player type change listener + */ + @JvmStatic + val onChange = Event() + } + + fun isLocked(): Boolean { + return this == LOCK_MODE_STATE_ENUM_LOCKED || this == LOCK_MODE_STATE_ENUM_UNLOCK_EXPANDED + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/NavigationBar.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/NavigationBar.java new file mode 100644 index 0000000000..0f3c071057 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/NavigationBar.java @@ -0,0 +1,282 @@ +package app.revanced.extension.youtube.shared; + +import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton.CREATE; + +import android.app.Activity; +import android.view.View; + +import androidx.annotation.Nullable; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class NavigationBar { + + /** + * How long to wait for the set nav button latch to be released. Maximum wait time must + * be as small as possible while still allowing enough time for the nav bar to update. + *

+ * YT calls it's back button handlers out of order, + * and litho starts filtering before the navigation bar is updated. + *

+ * Fixing this situation and not needlessly waiting requires somehow + * detecting if a back button key-press will cause a tab change. + *

+ * Typically after pressing the back button, the time between the first litho event and + * when the nav button is updated is about 10-20ms. Using 50-100ms here should be enough time + * and not noticeable, since YT typically takes 100-200ms (or more) to update the view anyways. + *

+ * This issue can also be avoided on a patch by patch basis, by avoiding calls to + * {@link NavigationButton#getSelectedNavigationButton()} unless absolutely necessary. + */ + private static final long LATCH_AWAIT_TIMEOUT_MILLISECONDS = 75; + + /** + * Used as a workaround to fix the issue of YT calling back button handlers out of order. + * Used to hold calls to {@link NavigationButton#getSelectedNavigationButton()} + * until the current navigation button can be determined. + *

+ * Only used when the hardware back button is pressed. + */ + @Nullable + private static volatile CountDownLatch navButtonLatch; + + /** + * Map of nav button layout views to Enum type. + * No synchronization is needed, and this is always accessed from the main thread. + */ + private static final Map viewToButtonMap = new WeakHashMap<>(); + + static { + // On app startup litho can start before the navigation bar is initialized. + // Force it to wait until the nav bar is updated. + createNavButtonLatch(); + } + + private static void createNavButtonLatch() { + navButtonLatch = new CountDownLatch(1); + } + + private static void releaseNavButtonLatch() { + CountDownLatch latch = navButtonLatch; + if (latch != null) { + navButtonLatch = null; + latch.countDown(); + } + } + + private static void waitForNavButtonLatchIfNeeded() { + CountDownLatch latch = navButtonLatch; + if (latch == null) { + return; + } + + if (Utils.isCurrentlyOnMainThread()) { + // The latch is released from the main thread, and waiting from the main thread will always timeout. + // This situation has only been observed when navigating out of a submenu and not changing tabs. + // and for that use case the nav bar does not change so it's safe to return here. + Logger.printDebug(() -> "Cannot block main thread waiting for nav button. Using last known navbar button status."); + return; + } + + try { + Logger.printDebug(() -> "Latch wait started"); + if (latch.await(LATCH_AWAIT_TIMEOUT_MILLISECONDS, TimeUnit.MILLISECONDS)) { + // Back button changed the navigation tab. + Logger.printDebug(() -> "Latch wait complete"); + return; + } + + // Timeout occurred, and a normal event when pressing the physical back button + // does not change navigation tabs. + releaseNavButtonLatch(); // Prevent other threads from waiting for no reason. + Logger.printDebug(() -> "Latch wait timed out"); + + } catch (InterruptedException ex) { + Logger.printException(() -> "Latch wait interrupted failure", ex); // Will never happen. + } + } + + /** + * Last YT navigation enum loaded. Not necessarily the active navigation tab. + * Always accessed from the main thread. + */ + @Nullable + private static String lastYTNavigationEnumName; + + /** + * Injection point. + */ + public static void setLastAppNavigationEnum(@Nullable Enum ytNavigationEnumName) { + if (ytNavigationEnumName != null) { + lastYTNavigationEnumName = ytNavigationEnumName.name(); + } + } + + /** + * Injection point. + */ + public static void navigationTabLoaded(final View navigationButtonGroup) { + try { + String lastEnumName = lastYTNavigationEnumName; + + for (NavigationButton buttonType : NavigationButton.values()) { + if (buttonType.ytEnumNames.contains(lastEnumName)) { + Logger.printDebug(() -> "navigationTabLoaded: " + lastEnumName); + viewToButtonMap.put(navigationButtonGroup, buttonType); + navigationTabCreatedCallback(buttonType, navigationButtonGroup); + return; + } + } + + // Log the unknown tab as exception level, only if debug is enabled. + // This is because unknown tabs do no harm, and it's only relevant to developers. + if (Settings.ENABLE_DEBUG_LOGGING.get()) { + Logger.printException(() -> "Unknown tab: " + lastEnumName + + " view: " + navigationButtonGroup.getClass()); + } + } catch (Exception ex) { + Logger.printException(() -> "navigationTabLoaded failure", ex); + } + } + + /** + * Injection point. + *

+ * Unique hook just for the 'Create' and 'You' tab. + */ + public static void navigationImageResourceTabLoaded(View view) { + // 'You' tab has no YT enum name and the enum hook is not called for it. + // Compare the last enum to figure out which tab this actually is. + if (CREATE.ytEnumNames.contains(lastYTNavigationEnumName)) { + navigationTabLoaded(view); + } else { + lastYTNavigationEnumName = NavigationButton.LIBRARY.ytEnumNames.get(0); + navigationTabLoaded(view); + } + } + + /** + * Injection point. + */ + public static void navigationTabSelected(View navButtonImageView, boolean isSelected) { + try { + if (!isSelected) { + return; + } + + NavigationButton button = viewToButtonMap.get(navButtonImageView); + + if (button == null) { // An unknown tab was selected. + // Show a toast only if debug mode is enabled. + if (Settings.ENABLE_DEBUG_LOGGING.get()) { + Logger.printException(() -> "Unknown navigation view selected: " + navButtonImageView); + } + + NavigationButton.selectedNavigationButton = null; + return; + } + + NavigationButton.selectedNavigationButton = button; + Logger.printDebug(() -> "Changed to navigation button: " + button); + + // Release any threads waiting for the selected nav button. + releaseNavButtonLatch(); + } catch (Exception ex) { + Logger.printException(() -> "navigationTabSelected failure", ex); + } + } + + /** + * Injection point. + */ + public static void onBackPressed(Activity activity) { + Logger.printDebug(() -> "Back button pressed"); + createNavButtonLatch(); + } + + /** + * @noinspection EmptyMethod + */ + private static void navigationTabCreatedCallback(NavigationButton button, View tabView) { + // Code is added during patching. + } + + public enum NavigationButton { + HOME("PIVOT_HOME", "TAB_HOME_CAIRO"), + SHORTS("TAB_SHORTS", "TAB_SHORTS_CAIRO"), + /** + * Create new video tab. + * This tab will never be in a selected state, even if the create video UI is on screen. + */ + CREATE("CREATION_TAB_LARGE", "CREATION_TAB_LARGE_CAIRO"), + SUBSCRIPTIONS("PIVOT_SUBSCRIPTIONS", "TAB_SUBSCRIPTIONS_CAIRO"), + /** + * Notifications tab. Only present when + * {@link Settings#SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON} is active. + */ + NOTIFICATIONS("TAB_ACTIVITY", "TAB_ACTIVITY_CAIRO"), + /** + * Library tab, including if the user is in incognito mode or when logged out. + */ + LIBRARY( + // Modern library tab with 'You' layout. + // The hooked YT code does not use an enum, and a dummy name is used here. + "YOU_LIBRARY_DUMMY_PLACEHOLDER_NAME", + // User is logged out. + "ACCOUNT_CIRCLE", + "ACCOUNT_CIRCLE_CAIRO", + // User is logged in with incognito mode enabled. + "INCOGNITO_CIRCLE", + "INCOGNITO_CAIRO", + // Old library tab (pre 'You' layout), only present when version spoofing. + "VIDEO_LIBRARY_WHITE", + // 'You' library tab that is sometimes momentarily loaded. + // This might be a temporary tab while the user profile photo is loading, + // but its exact purpose is not entirely clear. + "PIVOT_LIBRARY" + ); + + @Nullable + private static volatile NavigationButton selectedNavigationButton; + + /** + * This will return null only if the currently selected tab is unknown. + * This scenario will only happen if the UI has different tabs due to an A/B user test + * or YT abruptly changes the navigation layout for some other reason. + *

+ * All code calling this method should handle a null return value. + *

+ * Due to issues with how YT processes physical back button events, + * this patch uses workarounds that can cause this method to take up to 75ms + * if the device back button was recently pressed. + * + * @return The active navigation tab. + * If the user is in the upload video UI, this returns tab that is still visually + * selected on screen (whatever tab the user was on before tapping the upload button). + */ + @Nullable + public static NavigationButton getSelectedNavigationButton() { + waitForNavButtonLatchIfNeeded(); + return selectedNavigationButton; + } + + /** + * YouTube enum name for this tab. + */ + private final List ytEnumNames; + + NavigationButton(String... ytEnumNames) { + this.ytEnumNames = Arrays.asList(ytEnumNames); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerControlsVisibility.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerControlsVisibility.kt new file mode 100644 index 0000000000..e9d5468d4b --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerControlsVisibility.kt @@ -0,0 +1,43 @@ +package app.revanced.extension.youtube.shared + +import app.revanced.extension.shared.utils.Logger + +/** + * PlayerControls visibility state. + */ +enum class PlayerControlsVisibility { + PLAYER_CONTROLS_VISIBILITY_UNKNOWN, + PLAYER_CONTROLS_VISIBILITY_WILL_HIDE, + PLAYER_CONTROLS_VISIBILITY_HIDDEN, + PLAYER_CONTROLS_VISIBILITY_WILL_SHOW, + PLAYER_CONTROLS_VISIBILITY_SHOWN; + + companion object { + + private val nameToPlayerControlsVisibility = values().associateBy { it.name } + + @JvmStatic + fun setFromString(enumName: String) { + val state = nameToPlayerControlsVisibility[enumName] + if (state == null) { + Logger.printException { "Unknown PlayerControlsVisibility encountered: $enumName" } + } else if (currentPlayerControlsVisibility != state) { + Logger.printDebug { "PlayerControlsVisibility changed to: $state" } + currentPlayerControlsVisibility = state + } + } + + /** + * Depending on which hook this is called from, + * this value may not be up to date with the actual playback state. + */ + @JvmStatic + var current: PlayerControlsVisibility? + get() = currentPlayerControlsVisibility + private set(value) { + currentPlayerControlsVisibility = value + } + + private var currentPlayerControlsVisibility: PlayerControlsVisibility? = null + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerControlsVisibilityObserver.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerControlsVisibilityObserver.kt new file mode 100644 index 0000000000..569292d850 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerControlsVisibilityObserver.kt @@ -0,0 +1,89 @@ +package app.revanced.extension.youtube.shared + +import android.app.Activity +import android.view.View +import android.view.ViewGroup +import app.revanced.extension.shared.utils.ResourceUtils.ResourceType +import app.revanced.extension.shared.utils.ResourceUtils.getIdentifier +import java.lang.ref.WeakReference + +/** + * default implementation of [PlayerControlsVisibilityObserver] + * + * @param activity activity that contains the controls_layout view + */ +class PlayerControlsVisibilityObserverImpl( + private val activity: Activity +) : PlayerControlsVisibilityObserver { + + /** + * id of the direct parent of controls_layout, R.id.controls_button_group_layout + */ + private val controlsLayoutParentId = + getIdentifier("controls_button_group_layout", ResourceType.ID, activity) + + /** + * id of R.id.player_control_play_pause_replay_button_touch_area + */ + private val controlsLayoutId = + getIdentifier( + "player_control_play_pause_replay_button_touch_area", + ResourceType.ID, + activity + ) + + /** + * reference to the controls layout view + */ + private var controlsLayoutView = WeakReference(null) + + /** + * is the [controlsLayoutView] set to a valid reference of a view? + */ + private val isAttached: Boolean + get() { + val view = controlsLayoutView.get() + return view != null && view.parent != null + } + + /** + * find and attach the controls_layout view if needed + */ + private fun maybeAttach() { + if (isAttached) return + + // find parent, then controls_layout view + // this is needed because there may be two views where id=R.id.controls_layout + // because why should google confine themselves to their own guidelines... + activity.findViewById(controlsLayoutParentId)?.let { parent -> + parent.findViewById(controlsLayoutId)?.let { + controlsLayoutView = WeakReference(it) + } + } + } + + override val playerControlsVisibility: Int + get() { + maybeAttach() + return controlsLayoutView.get()?.visibility ?: View.GONE + } + + override val arePlayerControlsVisible: Boolean + get() = playerControlsVisibility == View.VISIBLE +} + +/** + * provides the visibility status of the fullscreen player controls_layout view. + * this can be used for detecting when the player controls are shown + */ +interface PlayerControlsVisibilityObserver { + /** + * current visibility int of the controls_layout view + */ + val playerControlsVisibility: Int + + /** + * is the value of [playerControlsVisibility] equal to [View.VISIBLE]? + */ + val arePlayerControlsVisible: Boolean +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerType.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerType.kt new file mode 100644 index 0000000000..9bfaffe58d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerType.kt @@ -0,0 +1,150 @@ +package app.revanced.extension.youtube.shared + +import app.revanced.extension.shared.utils.Event +import app.revanced.extension.shared.utils.Logger + +/** + * WatchWhile player type + */ +enum class PlayerType { + /** + * Either no video, or a Short is playing. + */ + NONE, + + /** + * A Short is playing. Occurs if a regular video is first opened + * and then a Short is opened (without first closing the regular video). + */ + HIDDEN, + + /** + * A regular video is minimized. + * + * When spoofing to 16.x YouTube and watching a short with a regular video in the background, + * the type can be this (and not [HIDDEN]). + */ + WATCH_WHILE_MINIMIZED, + WATCH_WHILE_MAXIMIZED, + WATCH_WHILE_FULLSCREEN, + WATCH_WHILE_SLIDING_MAXIMIZED_FULLSCREEN, + WATCH_WHILE_SLIDING_MINIMIZED_MAXIMIZED, + + /** + * Player is either sliding to [HIDDEN] state because a Short was opened while a regular video is on screen. + * OR + * The user has swiped a minimized player away to be closed (and no Short is being opened). + */ + WATCH_WHILE_SLIDING_MINIMIZED_DISMISSED, + WATCH_WHILE_SLIDING_FULLSCREEN_DISMISSED, + + /** + * Home feed video playback. + */ + INLINE_MINIMAL, + VIRTUAL_REALITY_FULLSCREEN, + WATCH_WHILE_PICTURE_IN_PICTURE; + + companion object { + + private val nameToPlayerType = entries.associateBy { it.name } + + @JvmStatic + fun setFromString(enumName: String) { + val newType = nameToPlayerType[enumName] + if (newType == null) { + Logger.printException { "Unknown PlayerType encountered: $enumName" } + } else if (current != newType) { + Logger.printDebug { "PlayerType changed to: $newType" } + current = newType + } + } + + /** + * The current player type. + */ + @JvmStatic + var current + get() = currentPlayerType + private set(value) { + currentPlayerType = value + onChange(value) + } + + @Volatile // value is read/write from different threads + private var currentPlayerType = NONE + + /** + * player type change listener + */ + @JvmStatic + val onChange = Event() + } + + /** + * Check if the current player type is [NONE] or [HIDDEN]. + * Useful to check if a short is currently playing. + * + * Does not include the first moment after a short is opened when a regular video is minimized on screen, + * or while watching a short with a regular video present on a spoofed 16.x version of YouTube. + * To include those situations instead use [isNoneHiddenOrMinimized]. + */ + fun isNoneOrHidden(): Boolean { + return this == NONE || this == HIDDEN + } + + /** + * Check if the current player type is + * [NONE], [HIDDEN], [WATCH_WHILE_SLIDING_MINIMIZED_DISMISSED]. + * + * Useful to check if a Short is being played or opened. + * + * Usually covers all use cases with no false positives, except if called from some hooks + * when spoofing to an old version this will return false even + * though a Short is being opened or is on screen (see [isNoneHiddenOrMinimized]). + * + * @return If nothing, a Short, or a regular video is sliding off screen to a dismissed or hidden state. + */ + fun isNoneHiddenOrSlidingMinimized(): Boolean { + return isNoneOrHidden() || this == WATCH_WHILE_SLIDING_MINIMIZED_DISMISSED + } + + /** + * Check if the current player type is + * [NONE], [HIDDEN], [WATCH_WHILE_MINIMIZED], [WATCH_WHILE_SLIDING_MINIMIZED_DISMISSED]. + * + * Useful to check if a Short is being played, + * although will return false positive if a regular video is + * opened and minimized (and a Short is not playing or being opened). + * + * Typically used to detect if a Short is playing when the player cannot be in a minimized state, + * such as the user interacting with a button or element of the player. + * + * @return If nothing, a Short, a regular video is sliding off screen to a dismissed or hidden state, + * a regular video is minimized (and a new video is not being opened). + */ + fun isNoneHiddenOrMinimized(): Boolean { + return isNoneHiddenOrSlidingMinimized() || this == WATCH_WHILE_MINIMIZED + } + + /** + * Check if the current player type is + * [WATCH_WHILE_MAXIMIZED], [WATCH_WHILE_FULLSCREEN]. + * + * Useful to check if a regular video is being played. + */ + fun isMaximizedOrFullscreen(): Boolean { + return this == WATCH_WHILE_MAXIMIZED || this == WATCH_WHILE_FULLSCREEN + } + + /** + * Check if the current player type is + * [WATCH_WHILE_FULLSCREEN], [WATCH_WHILE_SLIDING_MAXIMIZED_FULLSCREEN]. + * + * Useful to check if a video is fullscreen. + */ + fun isFullScreenOrSlidingFullScreen(): Boolean { + return this == WATCH_WHILE_FULLSCREEN || this == WATCH_WHILE_SLIDING_MAXIMIZED_FULLSCREEN + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlaylistIdPrefix.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlaylistIdPrefix.java new file mode 100644 index 0000000000..456a561434 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlaylistIdPrefix.java @@ -0,0 +1,32 @@ +package app.revanced.extension.youtube.shared; + +import androidx.annotation.NonNull; + +public enum PlaylistIdPrefix { + /** + * To check all available prefixes, + * See this document. + */ + ALL_CONTENTS_WITH_TIME_DESCENDING("UU"), + ALL_CONTENTS_WITH_POPULAR_DESCENDING("PU"), + VIDEOS_ONLY_WITH_TIME_DESCENDING("UULF"), + VIDEOS_ONLY_WITH_POPULAR_DESCENDING("UULP"), + SHORTS_ONLY_WITH_TIME_DESCENDING("UUSH"), + SHORTS_ONLY_WITH_POPULAR_DESCENDING("UUPS"), + LIVESTREAMS_ONLY_WITH_TIME_DESCENDING("UULV"), + LIVESTREAMS_ONLY_WITH_POPULAR_DESCENDING("UUPV"), + ALL_MEMBERSHIPS_CONTENTS("UUMO"), + MEMBERSHIPS_VIDEOS_ONLY("UUMF"), + MEMBERSHIPS_SHORTS_ONLY("UUMS"), + MEMBERSHIPS_LIVESTREAMS_ONLY("UUMV"); + + /** + * Prefix of playlist id. + */ + @NonNull + public final String prefixId; + + PlaylistIdPrefix(@NonNull String prefixId) { + this.prefixId = prefixId; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/RootView.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/RootView.java new file mode 100644 index 0000000000..2dcebb9b82 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/RootView.java @@ -0,0 +1,41 @@ +package app.revanced.extension.youtube.shared; + +import static app.revanced.extension.youtube.patches.components.RelatedVideoFilter.isActionBarVisible; + +import android.view.View; + +import java.lang.ref.WeakReference; + +@SuppressWarnings("unused") +public final class RootView { + private static volatile WeakReference searchBarResultsRef = new WeakReference<>(null); + + /** + * Injection point. + */ + public static void searchBarResultsViewLoaded(View searchbarResults) { + searchBarResultsRef = new WeakReference<>(searchbarResults); + } + + /** + * @return If the search bar is on screen. This includes if the player + * is on screen and the search results are behind the player (and not visible). + * Detecting the search is covered by the player can be done by checking {@link RootView#isPlayerActive()}. + */ + public static boolean isSearchBarActive() { + View searchbarResults = searchBarResultsRef.get(); + return searchbarResults != null && searchbarResults.getParent() != null; + } + + public static boolean isPlayerActive() { + return PlayerType.getCurrent().isMaximizedOrFullscreen() || isActionBarVisible.get(); + } + + /** + * Get current BrowseId. + * Rest of the implementation added by patch. + */ + public static String getBrowseId() { + return ""; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/ShortsPlayerState.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/ShortsPlayerState.kt new file mode 100644 index 0000000000..b0aed2e792 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/ShortsPlayerState.kt @@ -0,0 +1,51 @@ +package app.revanced.extension.youtube.shared + +import app.revanced.extension.shared.utils.Event +import app.revanced.extension.shared.utils.Logger + +/** + * ShortsPlayerState shorts player state. + */ +enum class ShortsPlayerState { + CLOSED, + OPEN; + + companion object { + + @JvmStatic + fun set(enum: ShortsPlayerState) { + if (current != enum) { + Logger.printDebug { "ShortsPlayerState changed to: ${enum.name}" } + current = enum + } + } + + /** + * The current shorts player state. + */ + @JvmStatic + var current + get() = currentShortsPlayerState + private set(value) { + currentShortsPlayerState = value + onChange(value) + } + + @Volatile // value is read/write from different threads + private var currentShortsPlayerState = CLOSED + + /** + * shorts player state change listener + */ + @JvmStatic + val onChange = Event() + } + + /** + * Check if the shorts player is [CLOSED]. + * Useful for checking if a shorts player is closed. + */ + fun isClosed(): Boolean { + return this == CLOSED + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/VideoInformation.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/VideoInformation.java new file mode 100644 index 0000000000..262c9d4d24 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/VideoInformation.java @@ -0,0 +1,565 @@ +package app.revanced.extension.youtube.shared; + +import static app.revanced.extension.shared.utils.ResourceUtils.getString; +import static app.revanced.extension.shared.utils.Utils.getFormattedTimeStamp; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.utils.AlwaysRepeatPatch; + +/** + * Hooking class for the current playing video. + */ +@SuppressWarnings("all") +public final class VideoInformation { + private static final float DEFAULT_YOUTUBE_PLAYBACK_SPEED = 1.0f; + private static final int DEFAULT_YOUTUBE_VIDEO_QUALITY = -2; + private static final String DEFAULT_YOUTUBE_VIDEO_QUALITY_STRING = getString("quality_auto"); + /** + * Prefix present in all Short player parameters signature. + */ + private static final String SHORTS_PLAYER_PARAMETERS = "8AEB"; + /** + * Prefix that presents in the player parameter signature when a user manually opens a YouTube Mix and plays a video included in the YouTube Mix. + */ + private static final String YOUTUBE_MIX_PLAYER_PARAMETERS = "8AUB"; + /** + * Prefix present in all YouTube Mix (auto-generated playlist) playlist id. + */ + private static final String YOUTUBE_MIX_PLAYLIST_ID_PREFIX = "RD"; + + @NonNull + private static String channelId = ""; + @NonNull + private static String channelName = ""; + @NonNull + private static String videoId = ""; + @NonNull + private static String videoTitle = ""; + private static long videoLength = 0; + private static boolean videoIsLiveStream; + private static long videoTime = -1; + + @NonNull + private static volatile String playerResponseVideoId = ""; + private static volatile boolean playerResponseVideoIdIsShort; + private static volatile boolean videoIdIsShort; + private static volatile boolean playerResponseVideoIdIsAutoGeneratedMixPlaylist; + + /** + * The current playback speed + */ + private static float playbackSpeed = DEFAULT_YOUTUBE_PLAYBACK_SPEED; + /** + * The current video quality + */ + private static int videoQuality = DEFAULT_YOUTUBE_VIDEO_QUALITY; + /** + * The current video quality string + */ + private static String videoQualityString = DEFAULT_YOUTUBE_VIDEO_QUALITY_STRING; + /** + * The available qualities of the current video in human readable form: [1080, 720, 480] + */ + @Nullable + private static List videoQualities; + + /** + * Injection point. + */ + public static void initialize() { + videoTime = -1; + videoLength = 0; + playbackSpeed = DEFAULT_YOUTUBE_PLAYBACK_SPEED; + Logger.printDebug(() -> "Initialized Player"); + } + + /** + * Injection point. + */ + public static void initializeMdx() { + Logger.printDebug(() -> "Initialized Mdx Player"); + } + + public static boolean seekTo(final long seekTime) { + return seekTo(seekTime, getVideoLength()); + } + + /** + * Seek on the current video. + * Does not function for playback of Shorts. + *

+ * Caution: If called from a videoTimeHook() callback, + * this will cause a recursive call into the same videoTimeHook() callback. + * + * @param seekTime The seekTime to seek the video to. + * @return true if the seek was successful. + */ + public static boolean seekTo(final long seekTime, final long videoLength) { + Utils.verifyOnMainThread(); + try { + final long videoTime = getVideoTime(); + final long adjustedSeekTime = getAdjustedSeekTime(seekTime, videoLength); + + Logger.printDebug(() -> "Seeking to: " + getFormattedTimeStamp(adjustedSeekTime)); + + // Try regular playback controller first, and it will not succeed if casting. + if (overrideVideoTime(adjustedSeekTime)) return true; + Logger.printDebug(() -> "seekTo did not succeeded. Trying MXD."); + // Else the video is loading or changing videos, or video is casting to a different device. + + // Try calling the seekTo method of the MDX player director (called when casting). + // The difference has to be a different second mark in order to avoid infinite skip loops + // as the Lounge API only supports seconds. + if (adjustedSeekTime / 1000 == videoTime / 1000) { + Logger.printDebug(() -> "Skipping seekTo for MDX because seek time is too small " + + "(" + (adjustedSeekTime - videoTime) + "ms)"); + return false; + } + + return overrideMDXVideoTime(adjustedSeekTime); + } catch (Exception ex) { + Logger.printException(() -> "Failed to seek", ex); + return false; + } + } + + // Prevent issues such as play/pause button or autoplay not working. + private static long getAdjustedSeekTime(final long seekTime, final long videoLength) { + // If the user skips to a section that is 500 ms before the video length, + // it will get stuck in a loop. + if (videoLength - seekTime > 500) { + return seekTime; + } + + // Both the current video time and the seekTo are in the last 500ms of the video. + if (AlwaysRepeatPatch.alwaysRepeatEnabled()) { + // If always-repeat is turned on, just skips to time 0. + return 0; + } else { + // Otherwise, just skips to a time longer than the video length. + // Paradoxically, if user skips to a section much longer than the video length, does not get stuck in a loop. + return Integer.MAX_VALUE; + } + } + + /** + * Seeks a relative amount. Should always be used over {@link #seekTo(long)} + * when the desired seek time is an offset of the current time. + * + * @noinspection UnusedReturnValue + */ + public static boolean seekToRelative(long seekTime) { + Utils.verifyOnMainThread(); + try { + Logger.printDebug(() -> "Seeking relative to: " + seekTime); + + // Try regular playback controller first, and it will not succeed if casting. + if (overrideVideoTimeRelative(seekTime)) return true; + Logger.printDebug(() -> "seekToRelative did not succeeded. Trying MXD."); + + // Adjust the fine adjustment function so it's at least 1 second before/after. + // Otherwise the fine adjustment will do nothing when casting. + final long adjustedSeekTime = seekTime < 0 + ? Math.min(seekTime, -1000) + : Math.max(seekTime, 1000); + + return overrideMDXVideoTimeRelative(adjustedSeekTime); + } catch (Exception ex) { + Logger.printException(() -> "Failed to seek relative", ex); + return false; + } + } + + /** + * Injection point. + * + * @param newlyLoadedChannelId id of the current channel. + * @param newlyLoadedChannelName name of the current channel. + * @param newlyLoadedVideoId id of the current video. + * @param newlyLoadedVideoTitle title of the current video. + * @param newlyLoadedVideoLength length of the video in milliseconds. + * @param newlyLoadedLiveStreamValue whether the current video is a livestream. + */ + public static void setVideoInformation(@NonNull String newlyLoadedChannelId, @NonNull String newlyLoadedChannelName, + @NonNull String newlyLoadedVideoId, @NonNull String newlyLoadedVideoTitle, + final long newlyLoadedVideoLength, boolean newlyLoadedLiveStreamValue) { + if (videoId.equals(newlyLoadedVideoId)) + return; + + channelId = newlyLoadedChannelId; + channelName = newlyLoadedChannelName; + videoId = newlyLoadedVideoId; + videoTitle = newlyLoadedVideoTitle; + videoLength = newlyLoadedVideoLength; + videoIsLiveStream = newlyLoadedLiveStreamValue; + + Logger.printDebug(() -> + "channelId='" + + newlyLoadedChannelId + + "'\nchannelName='" + + newlyLoadedChannelName + + "'\nvideoId='" + + newlyLoadedVideoId + + "'\nvideoTitle='" + + newlyLoadedVideoTitle + + "'\nvideoLength=" + + getFormattedTimeStamp(newlyLoadedVideoLength) + + "videoIsLiveStream='" + + newlyLoadedLiveStreamValue + + "'" + ); + } + + /** + * Injection point. + * + * @param newlyLoadedVideoId id of the current video + */ + public static void setVideoId(@NonNull String newlyLoadedVideoId) { + if (videoId.equals(newlyLoadedVideoId)) + return; + + videoId = newlyLoadedVideoId; + } + + /** + * Id of the last video opened. Includes Shorts. + * + * @return The id of the video, or an empty string if no videos have been opened yet. + */ + @NonNull + public static String getVideoId() { + return videoId; + } + + /** + * Channel Name of the last video opened. Includes Shorts. + * + * @return The channel name of the video. + */ + @NonNull + public static String getChannelName() { + return channelName; + } + + /** + * ChannelId of the last video opened. Includes Shorts. + * + * @return The channel id of the video. + */ + @NonNull + public static String getChannelId() { + return channelId; + } + + public static boolean getLiveStreamState() { + return videoIsLiveStream; + } + + + /** + * Differs from {@link #videoId} as this is the video id for the + * last player response received, which may not be the last video opened. + *

+ * If Shorts are loading the background, this commonly will be + * different from the Short that is currently on screen. + *

+ * For most use cases, you should instead use {@link #getVideoId()}. + * + * @return The id of the last video loaded, or an empty string if no videos have been loaded yet. + */ + @NonNull + public static String getPlayerResponseVideoId() { + return playerResponseVideoId; + } + + + /** + * @return If the last player response video id was a Short. + * Includes Shorts shelf items appearing in the feed that are not opened. + * @see #lastVideoIdIsShort() + */ + public static boolean lastPlayerResponseIsShort() { + return playerResponseVideoIdIsShort; + } + + /** + * @return If the last player response video id _that was opened_ was a Short. + */ + public static boolean lastVideoIdIsShort() { + return videoIdIsShort; + } + + /** + * @return If the last player response video id was an auto-generated YouTube Mix. + */ + public static boolean lastPlayerResponseIsAutoGeneratedMixPlaylist() { + return playerResponseVideoIdIsAutoGeneratedMixPlaylist; + } + + /** + * @return If the player parameters are for a Short. + */ + public static boolean playerParametersAreShort(@Nullable String playerParameter) { + return playerParameter != null && playerParameter.startsWith(SHORTS_PLAYER_PARAMETERS); + } + + /** + * @return Whether given id belongs to a YouTube Mix. + */ + private static boolean isYoutubeMixId(@Nullable final String playlistId) { + return playlistId != null && playlistId.startsWith(YOUTUBE_MIX_PLAYLIST_ID_PREFIX); + } + + /** + * Whether the user manually opened a YouTube Mix. + */ + public static boolean isMixPlaylistsOpenedByUser(String parameter) { + return parameter != null && (parameter.isEmpty() || parameter.startsWith(YOUTUBE_MIX_PLAYER_PARAMETERS)); + } + + /** + * Injection point. + */ + @Nullable + public static String newPlayerResponseParameter(@NonNull String videoId, @Nullable String playerParameter, + @Nullable String playlistId, boolean isShortAndOpeningOrPlaying) { + final boolean isShort = playerParametersAreShort(playerParameter); + playerResponseVideoIdIsShort = isShort; + if (!isShort || isShortAndOpeningOrPlaying) { + if (videoIdIsShort != isShort) { + videoIdIsShort = isShort; + } + } + playerResponseVideoIdIsAutoGeneratedMixPlaylist = isYoutubeMixId(playlistId) && !isMixPlaylistsOpenedByUser(playerParameter); + return playerParameter; // Return the original value since we are observing and not modifying. + } + + /** + * Injection point. Called off the main thread. + * + * @param videoId The id of the last video loaded. + */ + public static void setPlayerResponseVideoId(@NonNull String videoId, boolean isShortAndOpeningOrPlaying) { + if (!playerResponseVideoId.equals(videoId)) { + playerResponseVideoId = videoId; + } + } + + /** + * @return The current playback speed. + */ + public static float getPlaybackSpeed() { + return playbackSpeed; + } + + /** + * Injection point. + * + * @param newlyLoadedPlaybackSpeed The current playback speed. + */ + public static void setPlaybackSpeed(float newlyLoadedPlaybackSpeed) { + playbackSpeed = newlyLoadedPlaybackSpeed; + } + + /** + * @return The current video quality. + */ + public static int getVideoQuality() { + return videoQuality; + } + + /** + * @return The current video quality string. + */ + public static String getVideoQualityString() { + return videoQualityString; + } + + /** + * Injection point. + * + * @param newlyLoadedQuality The current video quality string. + */ + public static void setVideoQuality(String newlyLoadedQuality) { + if (newlyLoadedQuality == null) { + return; + } + try { + String splitVideoQuality; + if (newlyLoadedQuality.contains("p")) { + splitVideoQuality = newlyLoadedQuality.split("p")[0]; + videoQuality = Integer.parseInt(splitVideoQuality); + videoQualityString = splitVideoQuality + "p"; + } else if (newlyLoadedQuality.contains("s")) { + splitVideoQuality = newlyLoadedQuality.split("s")[0]; + videoQuality = Integer.parseInt(splitVideoQuality); + videoQualityString = splitVideoQuality + "s"; + } else { + videoQuality = DEFAULT_YOUTUBE_VIDEO_QUALITY; + videoQualityString = DEFAULT_YOUTUBE_VIDEO_QUALITY_STRING; + } + } catch (NumberFormatException ignored) { + } + } + + /** + * @return available video quality. + */ + public static int getAvailableVideoQuality(int preferredQuality) { + if (videoQualities != null) { + int qualityToUse = videoQualities.get(0); // first element is automatic mode + for (Integer quality : videoQualities) { + if (quality <= preferredQuality && qualityToUse < quality) { + qualityToUse = quality; + } + } + preferredQuality = qualityToUse; + } + return preferredQuality; + } + + /** + * Injection point. + * + * @param qualities Video qualities available, ordered from largest to smallest, with index 0 being the 'automatic' value of -2 + */ + public static void setVideoQualityList(Object[] qualities) { + try { + if (videoQualities == null || videoQualities.size() != qualities.length) { + videoQualities = new ArrayList<>(qualities.length); + for (Object streamQuality : qualities) { + for (Field field : streamQuality.getClass().getFields()) { + if (field.getType().isAssignableFrom(Integer.TYPE) + && field.getName().length() <= 2) { + videoQualities.add(field.getInt(streamQuality)); + } + } + } + Logger.printDebug(() -> "videoQualities: " + videoQualities); + } + } catch (Exception ex) { + Logger.printException(() -> "Failed to set quality list", ex); + } + } + + /** + * Length of the current video playing. Includes Shorts. + * + * @return The length of the video in milliseconds. + * If the video is not yet loaded, or if the video is playing in the background with no video visible, + * then this returns zero. + */ + public static long getVideoLength() { + return videoLength; + } + + /** + * Playback time of the current video playing. Includes Shorts. + *

+ * Value will lag behind the actual playback time by a variable amount based on the playback speed. + *

+ * If playback speed is 2.0x, this value may be up to 2000ms behind the actual playback time. + * If playback speed is 1.0x, this value may be up to 1000ms behind the actual playback time. + * If playback speed is 0.5x, this value may be up to 500ms behind the actual playback time. + * Etc. + * + * @return The time of the video in milliseconds. -1 if not set yet. + */ + public static long getVideoTime() { + return videoTime; + } + + public static long getVideoTimeInSeconds() { + return videoTime / 1000; + } + + /** + * Injection point. + * Called on the main thread every 100ms. + * + * @param time The current playback time of the video in milliseconds. + */ + public static void setVideoTime(final long time) { + videoTime = time; + Logger.printDebug(() -> "setVideoTime: " + getFormattedTimeStamp(time)); + } + + /** + * @return If the playback is at the end of the video. + *

+ * If video is playing in the background with no video visible, + * this always returns false (even if the video is actually at the end). + *

+ * This is equivalent to checking for {@link VideoState#ENDED}, + * but can give a more up-to-date result for code calling from some hooks. + * @see VideoState + */ + public static boolean isAtEndOfVideo() { + return videoTime >= videoLength && videoLength > 0; + } + + /** + * Overrides the current playback speed. + * Rest of the implementation added by patch. + */ + public static void overridePlaybackSpeed(float speedOverride) { + Logger.printDebug(() -> "Overriding playback speed to: " + speedOverride); + } + + /** + * Overrides the current quality. + * Rest of the implementation added by patch. + */ + public static void overrideVideoQuality(int qualityOverride) { + Logger.printDebug(() -> "Overriding video quality to: " + qualityOverride); + } + + /** + * Overrides the current video time by seeking. + * Rest of the implementation added by patch. + */ + public static boolean overrideVideoTime(final long seekTime) { + // These instructions are ignored by patch. + Logger.printDebug(() -> "Seeking to " + seekTime); + return false; + } + + /** + * Overrides the current video time by seeking. (MDX player) + * Rest of the implementation added by patch. + */ + public static boolean overrideMDXVideoTime(final long seekTime) { + // These instructions are ignored by patch. + Logger.printDebug(() -> "Seeking to " + seekTime); + return false; + } + + /** + * Overrides the current video time by seeking relative. + * Rest of the implementation added by patch. + */ + public static boolean overrideVideoTimeRelative(final long seekTime) { + // These instructions are ignored by patch. + Logger.printDebug(() -> "Seeking to " + seekTime); + return false; + } + + /** + * Overrides the current video time by seeking relative. (MDX player) + * Rest of the implementation added by patch. + */ + public static boolean overrideMDXVideoTimeRelative(final long seekTime) { + // These instructions are ignored by patch. + Logger.printDebug(() -> "Seeking to " + seekTime); + return false; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/VideoState.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/VideoState.kt new file mode 100644 index 0000000000..4e1888a7cc --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/VideoState.kt @@ -0,0 +1,44 @@ +package app.revanced.extension.youtube.shared + +import app.revanced.extension.shared.utils.Logger + +/** + * VideoState playback state. + */ +enum class VideoState { + NEW, + PLAYING, + PAUSED, + RECOVERABLE_ERROR, + UNRECOVERABLE_ERROR, + ENDED; + + companion object { + + private val nameToVideoState = entries.associateBy { it.name } + + @JvmStatic + fun setFromString(enumName: String) { + val state = nameToVideoState[enumName] + if (state == null) { + Logger.printException { "Unknown VideoState encountered: $enumName" } + } else if (current != state) { + Logger.printDebug { "VideoState changed to: $state" } + current = state + } + } + + /** + * Depending on which hook this is called from, + * this value may not be up to date with the actual playback state. + */ + @JvmStatic + var current + get() = currentVideoState + private set(value) { + currentVideoState = value + } + + private var currentVideoState: VideoState? = null + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SegmentPlaybackController.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SegmentPlaybackController.java new file mode 100644 index 0000000000..948f2d0441 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SegmentPlaybackController.java @@ -0,0 +1,797 @@ +package app.revanced.extension.youtube.sponsorblock; + +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.youtube.utils.VideoUtils.getFormattedTimeStamp; + +import android.annotation.SuppressLint; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.text.TextUtils; +import android.util.TypedValue; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.PlayerType; +import app.revanced.extension.youtube.shared.VideoInformation; +import app.revanced.extension.youtube.shared.VideoState; +import app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour; +import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory; +import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment; +import app.revanced.extension.youtube.sponsorblock.requests.SBRequester; +import app.revanced.extension.youtube.sponsorblock.ui.SponsorBlockViewController; +import app.revanced.extension.youtube.whitelist.Whitelist; + +/** + * Handles showing, scheduling, and skipping of all {@link SponsorSegment} for the current video. + *

+ * Class is not thread safe. All methods must be called on the main thread unless otherwise specified. + */ +@SuppressWarnings("unused") +public class SegmentPlaybackController { + /** + * Length of time to show a skip button for a highlight segment, + * or a regular segment if {@link Settings#SB_AUTO_HIDE_SKIP_BUTTON} is enabled. + *

+ * Effectively this value is rounded up to the next second. + */ + private static final long DURATION_TO_SHOW_SKIP_BUTTON = 3800; + + /* + * Highlight segments have zero length as they are a point in time. + * Draw them on screen using a fixed width bar. + * Value is independent of device dpi. + */ + private static final int HIGHLIGHT_SEGMENT_DRAW_BAR_WIDTH = 7; + /** + * Used to prevent re-showing a previously hidden skip button when exiting an embedded segment. + * Only used when {@link Settings#SB_AUTO_HIDE_SKIP_BUTTON} is enabled. + *

+ * A collection of segments that have automatically hidden the skip button for, and all segments in this list + * contain the current video time. Segment are removed when playback exits the segment. + */ + private static final List hiddenSkipSegmentsForCurrentVideoTime = new ArrayList<>(); + @NonNull + private static String videoId = ""; + private static long videoLength = 0; + + @Nullable + private static SponsorSegment[] segments; + /** + * Highlight segment, if one exists and the skip behavior is not set to {@link CategoryBehaviour#SHOW_IN_SEEKBAR}. + */ + @Nullable + private static SponsorSegment highlightSegment; + /** + * Because loading can take time, show the skip to highlight for a few seconds after the segments load. + * This is the system time (in milliseconds) to no longer show the initial display skip to highlight. + * Value will be zero if no highlight segment exists, or if the system time to show the highlight has passed. + */ + private static long highlightSegmentInitialShowEndTime; + /** + * Currently playing (non-highlight) segment that user can manually skip. + */ + @Nullable + private static SponsorSegment segmentCurrentlyPlaying; + /** + * Currently playing manual skip segment that is scheduled to hide. + * This will always be NULL or equal to {@link #segmentCurrentlyPlaying}. + */ + @Nullable + private static SponsorSegment scheduledHideSegment; + /** + * Upcoming segment that is scheduled to either autoskip or show the manual skip button. + */ + @Nullable + private static SponsorSegment scheduledUpcomingSegment; + /** + * System time (in milliseconds) of when to hide the skip button of {@link #segmentCurrentlyPlaying}. + * Value is zero if playback is not inside a segment ({@link #segmentCurrentlyPlaying} is null), + * or if {@link Settings#SB_AUTO_HIDE_SKIP_BUTTON} is not enabled. + */ + private static long skipSegmentButtonEndTime; + + @Nullable + private static String timeWithoutSegments; + + private static int sponsorBarAbsoluteLeft; + private static int sponsorAbsoluteBarRight; + private static int sponsorBarThickness; + private static SponsorSegment lastSegmentSkipped; + private static long lastSegmentSkippedTime; + private static int toastNumberOfSegmentsSkipped; + @Nullable + private static SponsorSegment toastSegmentSkipped; + private static int highlightSegmentTimeBarScreenWidth = -1; // actual pixel width to use + + @Nullable + static SponsorSegment[] getSegments() { + return segments; + } + + private static void setSegments(@NonNull SponsorSegment[] videoSegments) { + Arrays.sort(videoSegments); + segments = videoSegments; + calculateTimeWithoutSegments(); + + if (SegmentCategory.HIGHLIGHT.behaviour == CategoryBehaviour.SKIP_AUTOMATICALLY + || SegmentCategory.HIGHLIGHT.behaviour == CategoryBehaviour.MANUAL_SKIP) { + for (SponsorSegment segment : videoSegments) { + if (segment.category == SegmentCategory.HIGHLIGHT) { + highlightSegment = segment; + return; + } + } + } + highlightSegment = null; + } + + static void addUnsubmittedSegment(@NonNull SponsorSegment segment) { + Objects.requireNonNull(segment); + if (segments == null) { + segments = new SponsorSegment[1]; + } else { + segments = Arrays.copyOf(segments, segments.length + 1); + } + segments[segments.length - 1] = segment; + setSegments(segments); + } + + static void removeUnsubmittedSegments() { + if (segments == null || segments.length == 0) { + return; + } + List replacement = new ArrayList<>(); + for (SponsorSegment segment : segments) { + if (segment.category != SegmentCategory.UNSUBMITTED) { + replacement.add(segment); + } + } + if (replacement.size() != segments.length) { + setSegments(replacement.toArray(new SponsorSegment[0])); + } + } + + public static boolean videoHasSegments() { + return segments != null && segments.length > 0; + } + + /** + * Clears all downloaded data. + */ + public static void clearData() { + videoId = ""; + videoLength = 0; + segments = null; + highlightSegment = null; + highlightSegmentInitialShowEndTime = 0; + timeWithoutSegments = null; + segmentCurrentlyPlaying = null; + scheduledUpcomingSegment = null; + scheduledHideSegment = null; + skipSegmentButtonEndTime = 0; + toastSegmentSkipped = null; + toastNumberOfSegmentsSkipped = 0; + hiddenSkipSegmentsForCurrentVideoTime.clear(); + } + + /** + * Injection point. + * Initializes SponsorBlock when the video player starts playing a new video. + */ + public static void initialize() { + try { + Utils.verifyOnMainThread(); + SponsorBlockSettings.initialize(); + clearData(); + SponsorBlockViewController.hideAll(); + SponsorBlockUtils.clearUnsubmittedSegmentTimes(); + Logger.printDebug(() -> "Initialized SponsorBlock"); + } catch (Exception ex) { + Logger.printException(() -> "Failed to initialize SponsorBlock", ex); + } + } + + /** + * Injection point. + */ + public static void newVideoStarted(@NonNull String newlyLoadedChannelId, @NonNull String newlyLoadedChannelName, + @NonNull String newlyLoadedVideoId, @NonNull String newlyLoadedVideoTitle, + final long newlyLoadedVideoLength, boolean newlyLoadedLiveStreamValue) { + try { + if (Objects.equals(videoId, newlyLoadedVideoId)) { + return; + } + clearData(); + if (!Settings.SB_ENABLED.get()) { + return; + } + if (PlayerType.getCurrent().isNoneOrHidden()) { + Logger.printDebug(() -> "ignoring Short"); + return; + } + if (Utils.isNetworkNotConnected()) { + Logger.printDebug(() -> "Network not connected, ignoring video"); + return; + } + + videoId = newlyLoadedVideoId; + videoLength = newlyLoadedVideoLength; + Logger.printDebug(() -> "newVideoStarted: " + newlyLoadedVideoId); + + if (Whitelist.isChannelWhitelistedSponsorBlock(newlyLoadedChannelId)) { + return; + } + + Utils.runOnBackgroundThread(() -> { + try { + executeDownloadSegments(newlyLoadedVideoId); + } catch (Exception e) { + Logger.printException(() -> "Failed to download segments", e); + } + }); + } catch (Exception ex) { + Logger.printException(() -> "setCurrentVideoId failure", ex); + } + } + + /** + * Id of the last video opened. Includes Shorts. + * + * @return The id of the video, or an empty string if no videos have been opened yet. + */ + @NonNull + public static String getVideoId() { + return videoId; + } + + /** + * Length of the current video playing. Includes Shorts. + * + * @return The length of the video in milliseconds. + * If the video is not yet loaded, or if the video is playing in the background with no video visible, + * then this returns zero. + */ + public static long getVideoLength() { + return videoLength; + } + + /** + * Must be called off main thread + */ + static void executeDownloadSegments(@NonNull String newlyLoadedVideoId) { + Objects.requireNonNull(newlyLoadedVideoId); + try { + SponsorSegment[] segments = SBRequester.getSegments(newlyLoadedVideoId); + + Utils.runOnMainThread(() -> { + if (!newlyLoadedVideoId.equals(videoId)) { + // user changed videos before get segments network call could complete + Logger.printDebug(() -> "Ignoring segments for prior video: " + newlyLoadedVideoId); + return; + } + setSegments(segments); + + final long videoTime = VideoInformation.getVideoTime(); + if (highlightSegment != null) { + // If the current video time is before the highlight. + final long timeUntilHighlight = highlightSegment.start - videoTime; + if (timeUntilHighlight > 0) { + if (highlightSegment.shouldAutoSkip()) { + skipSegment(highlightSegment, false); + return; + } + highlightSegmentInitialShowEndTime = System.currentTimeMillis() + Math.min( + (long) (timeUntilHighlight / VideoInformation.getPlaybackSpeed()), + DURATION_TO_SHOW_SKIP_BUTTON); + } + } + + // check for any skips now, instead of waiting for the next update to setVideoTime() + setVideoTime(videoTime); + }); + } catch (Exception ex) { + Logger.printException(() -> "executeDownloadSegments failure", ex); + } + } + + /** + * Injection point. + * Updates SponsorBlock every 100ms. + * When changing videos, this is first called with value 0 and then the video is changed. + */ + public static void setVideoTime(long millis) { + try { + if (!Settings.SB_ENABLED.get() + || PlayerType.getCurrent().isNoneOrHidden() // Shorts playback. + || segments == null || segments.length == 0) { + return; + } + Logger.printDebug(() -> "setVideoTime: " + getFormattedTimeStamp(millis)); + + updateHiddenSegments(millis); + + final float playbackSpeed = VideoInformation.getPlaybackSpeed(); + // Amount of time to look ahead for the next segment, + // and the threshold to determine if a scheduled show/hide is at the correct video time when it's run. + // + // This value must be greater than largest time between calls to this method (1000ms), + // and must be adjusted for the video speed. + // + // To debug the stale skip logic, set this to a very large value (5000 or more) + // then try manually seeking just before playback reaches a segment skip. + final long speedAdjustedTimeThreshold = (long) (playbackSpeed * 1000); + final long startTimerLookAheadThreshold = millis + speedAdjustedTimeThreshold; + + SponsorSegment foundSegmentCurrentlyPlaying = null; + SponsorSegment foundUpcomingSegment = null; + + for (final SponsorSegment segment : segments) { + if (segment.category.behaviour == CategoryBehaviour.SHOW_IN_SEEKBAR + || segment.category.behaviour == CategoryBehaviour.IGNORE + || segment.category == SegmentCategory.HIGHLIGHT) { + continue; + } + if (segment.end <= millis) { + continue; // past this segment + } + + if (segment.start <= millis) { + // we are in the segment! + if (segment.shouldAutoSkip()) { + skipSegment(segment, false); + return; // must return, as skipping causes a recursive call back into this method + } + + // first found segment, or it's an embedded segment and fully inside the outer segment + if (foundSegmentCurrentlyPlaying == null || foundSegmentCurrentlyPlaying.containsSegment(segment)) { + // If the found segment is not currently displayed, then do not show if the segment is nearly over. + // This check prevents the skip button text from rapidly changing when multiple segments end at nearly the same time. + // Also prevents showing the skip button if user seeks into the last 800ms of the segment. + final long minMillisOfSegmentRemainingThreshold = 800; + if (segmentCurrentlyPlaying == segment + || !segment.endIsNear(millis, minMillisOfSegmentRemainingThreshold)) { + foundSegmentCurrentlyPlaying = segment; + } else { + Logger.printDebug(() -> "Ignoring segment that ends very soon: " + segment); + } + } + // Keep iterating and looking. There may be an upcoming autoskip, + // or there may be another smaller segment nested inside this segment + continue; + } + + // segment is upcoming + if (startTimerLookAheadThreshold < segment.start) { + break; // segment is not close enough to schedule, and no segments after this are of interest + } + if (segment.shouldAutoSkip()) { // upcoming autoskip + foundUpcomingSegment = segment; + break; // must stop here + } + + // upcoming manual skip + + // do not schedule upcoming segment, if it is not fully contained inside the current segment + if ((foundSegmentCurrentlyPlaying == null || foundSegmentCurrentlyPlaying.containsSegment(segment)) + // use the most inner upcoming segment + && (foundUpcomingSegment == null || foundUpcomingSegment.containsSegment(segment))) { + + // Only schedule, if the segment start time is not near the end time of the current segment. + // This check is needed to prevent scheduled hide and show from clashing with each other. + // Instead the upcoming segment will be handled when the current segment scheduled hide calls back into this method. + final long minTimeBetweenStartEndOfSegments = 1000; + if (foundSegmentCurrentlyPlaying == null + || !foundSegmentCurrentlyPlaying.endIsNear(segment.start, minTimeBetweenStartEndOfSegments)) { + foundUpcomingSegment = segment; + } else { + Logger.printDebug(() -> "Not scheduling segment (start time is near end of current segment): " + segment); + } + } + } + + if (highlightSegment != null) { + if (millis < DURATION_TO_SHOW_SKIP_BUTTON || (highlightSegmentInitialShowEndTime != 0 + && System.currentTimeMillis() < highlightSegmentInitialShowEndTime)) { + SponsorBlockViewController.showSkipHighlightButton(highlightSegment); + } else { + highlightSegmentInitialShowEndTime = 0; + SponsorBlockViewController.hideSkipHighlightButton(); + } + } + + if (segmentCurrentlyPlaying != foundSegmentCurrentlyPlaying) { + setSegmentCurrentlyPlaying(foundSegmentCurrentlyPlaying); + } else if (foundSegmentCurrentlyPlaying != null + && skipSegmentButtonEndTime != 0 && skipSegmentButtonEndTime <= System.currentTimeMillis()) { + Logger.printDebug(() -> "Auto hiding skip button for segment: " + segmentCurrentlyPlaying); + skipSegmentButtonEndTime = 0; + hiddenSkipSegmentsForCurrentVideoTime.add(foundSegmentCurrentlyPlaying); + SponsorBlockViewController.hideSkipSegmentButton(); + } + + // schedule a hide, only if the segment end is near + final SponsorSegment segmentToHide = + (foundSegmentCurrentlyPlaying != null && foundSegmentCurrentlyPlaying.endIsNear(millis, speedAdjustedTimeThreshold)) + ? foundSegmentCurrentlyPlaying + : null; + + if (scheduledHideSegment != segmentToHide) { + if (segmentToHide == null) { + Logger.printDebug(() -> "Clearing scheduled hide: " + scheduledHideSegment); + scheduledHideSegment = null; + } else { + scheduledHideSegment = segmentToHide; + Logger.printDebug(() -> "Scheduling hide segment: " + segmentToHide + " playbackSpeed: " + playbackSpeed); + final long delayUntilHide = (long) ((segmentToHide.end - millis) / playbackSpeed); + Utils.runOnMainThreadDelayed(() -> { + if (scheduledHideSegment != segmentToHide) { + Logger.printDebug(() -> "Ignoring old scheduled hide segment: " + segmentToHide); + return; + } + scheduledHideSegment = null; + if (VideoState.getCurrent() != VideoState.PLAYING) { + Logger.printDebug(() -> "Ignoring scheduled hide segment as video is paused: " + segmentToHide); + return; + } + + final long videoTime = VideoInformation.getVideoTime(); + if (!segmentToHide.endIsNear(videoTime, speedAdjustedTimeThreshold)) { + // current video time is not what's expected. User paused playback + Logger.printDebug(() -> "Ignoring outdated scheduled hide: " + segmentToHide + + " videoInformation time: " + videoTime); + return; + } + Logger.printDebug(() -> "Running scheduled hide segment: " + segmentToHide); + // Need more than just hide the skip button, as this may have been an embedded segment + // Instead call back into setVideoTime to check everything again. + // Should not use VideoInformation time as it is less accurate, + // but this scheduled handler was scheduled precisely so we can just use the segment end time + setSegmentCurrentlyPlaying(null); + setVideoTime(segmentToHide.end); + }, delayUntilHide); + } + } + + if (scheduledUpcomingSegment != foundUpcomingSegment) { + if (foundUpcomingSegment == null) { + Logger.printDebug(() -> "Clearing scheduled segment: " + scheduledUpcomingSegment); + scheduledUpcomingSegment = null; + } else { + scheduledUpcomingSegment = foundUpcomingSegment; + final SponsorSegment segmentToSkip = foundUpcomingSegment; + + Logger.printDebug(() -> "Scheduling segment: " + segmentToSkip + " playbackSpeed: " + playbackSpeed); + final long delayUntilSkip = (long) ((segmentToSkip.start - millis) / playbackSpeed); + Utils.runOnMainThreadDelayed(() -> { + if (scheduledUpcomingSegment != segmentToSkip) { + Logger.printDebug(() -> "Ignoring old scheduled segment: " + segmentToSkip); + return; + } + scheduledUpcomingSegment = null; + if (VideoState.getCurrent() != VideoState.PLAYING) { + Logger.printDebug(() -> "Ignoring scheduled hide segment as video is paused: " + segmentToSkip); + return; + } + + final long videoTime = VideoInformation.getVideoTime(); + if (!segmentToSkip.startIsNear(videoTime, speedAdjustedTimeThreshold)) { + // current video time is not what's expected. User paused playback + Logger.printDebug(() -> "Ignoring outdated scheduled segment: " + segmentToSkip + + " videoInformation time: " + videoTime); + return; + } + if (segmentToSkip.shouldAutoSkip()) { + Logger.printDebug(() -> "Running scheduled skip segment: " + segmentToSkip); + skipSegment(segmentToSkip, false); + } else { + Logger.printDebug(() -> "Running scheduled show segment: " + segmentToSkip); + setSegmentCurrentlyPlaying(segmentToSkip); + } + }, delayUntilSkip); + } + } + } catch (Exception e) { + Logger.printException(() -> "setVideoTime failure", e); + } + } + + /** + * Removes all previously hidden segments that are not longer contained in the given video time. + */ + private static void updateHiddenSegments(long currentVideoTime) { + Iterator i = hiddenSkipSegmentsForCurrentVideoTime.iterator(); + while (i.hasNext()) { + SponsorSegment hiddenSegment = i.next(); + if (!hiddenSegment.containsTime(currentVideoTime)) { + Logger.printDebug(() -> "Resetting hide skip button: " + hiddenSegment); + i.remove(); + } + } + } + + private static void setSegmentCurrentlyPlaying(@Nullable SponsorSegment segment) { + if (segment == null) { + if (segmentCurrentlyPlaying != null) + Logger.printDebug(() -> "Hiding segment: " + segmentCurrentlyPlaying); + segmentCurrentlyPlaying = null; + skipSegmentButtonEndTime = 0; + SponsorBlockViewController.hideSkipSegmentButton(); + return; + } + segmentCurrentlyPlaying = segment; + skipSegmentButtonEndTime = 0; + if (Settings.SB_AUTO_HIDE_SKIP_BUTTON.get()) { + if (hiddenSkipSegmentsForCurrentVideoTime.contains(segment)) { + // Playback exited a nested segment and the outer segment skip button was previously hidden. + Logger.printDebug(() -> "Ignoring previously auto-hidden segment: " + segment); + SponsorBlockViewController.hideSkipSegmentButton(); + return; + } + skipSegmentButtonEndTime = System.currentTimeMillis() + DURATION_TO_SHOW_SKIP_BUTTON; + } + Logger.printDebug(() -> "Showing segment: " + segment); + SponsorBlockViewController.showSkipSegmentButton(segment); + } + + private static void skipSegment(@NonNull SponsorSegment segmentToSkip, boolean userManuallySkipped) { + try { + SponsorBlockViewController.hideSkipHighlightButton(); + SponsorBlockViewController.hideSkipSegmentButton(); + + final long now = System.currentTimeMillis(); + if (lastSegmentSkipped == segmentToSkip) { + // If trying to seek to end of the video, YouTube can seek just before of the actual end. + // (especially if the video does not end on a whole second boundary). + // This causes additional segment skip attempts, even though it cannot seek any closer to the desired time. + // Check for and ignore repeated skip attempts of the same segment over a small time period. + final long minTimeBetweenSkippingSameSegment = Math.max(500, (long) (500 / VideoInformation.getPlaybackSpeed())); + if (now - lastSegmentSkippedTime < minTimeBetweenSkippingSameSegment) { + Logger.printDebug(() -> "Ignoring skip segment request (already skipped as close as possible): " + segmentToSkip); + return; + } + } + + Logger.printDebug(() -> "Skipping segment: " + segmentToSkip); + lastSegmentSkipped = segmentToSkip; + lastSegmentSkippedTime = now; + setSegmentCurrentlyPlaying(null); + scheduledHideSegment = null; + scheduledUpcomingSegment = null; + if (segmentToSkip == highlightSegment) { + highlightSegmentInitialShowEndTime = 0; + } + + // If the seek is successful, then the seek causes a recursive call back into this class. + final boolean seekSuccessful = VideoInformation.seekTo(segmentToSkip.end, getVideoLength()); + if (!seekSuccessful) { + // can happen when switching videos and is normal + Logger.printDebug(() -> "Could not skip segment (seek unsuccessful): " + segmentToSkip); + return; + } + + final boolean videoIsPaused = VideoState.getCurrent() == VideoState.PAUSED; + if (!userManuallySkipped) { + // check for any smaller embedded segments, and count those as autoskipped + final boolean showSkipToast = Settings.SB_TOAST_ON_SKIP.get(); + for (final SponsorSegment otherSegment : Objects.requireNonNull(segments)) { + if (segmentToSkip.end < otherSegment.start) { + break; // no other segments can be contained + } + if (otherSegment == segmentToSkip || + (otherSegment.category != SegmentCategory.HIGHLIGHT && segmentToSkip.containsSegment(otherSegment))) { + otherSegment.didAutoSkipped = true; + // Do not show a toast if the user is scrubbing thru a paused video. + // Cannot do this video state check in setTime or earlier in this method, as the video state may not be up to date. + // So instead, only hide toasts because all other skip logic done while paused causes no harm. + if (showSkipToast && !videoIsPaused) { + showSkippedSegmentToast(otherSegment); + } + } + } + } + + if (segmentToSkip.category == SegmentCategory.UNSUBMITTED) { + removeUnsubmittedSegments(); + SponsorBlockUtils.setNewSponsorSegmentPreviewed(); + } else if (!videoIsPaused) { + SponsorBlockUtils.sendViewRequestAsync(segmentToSkip); + } + } catch (Exception ex) { + Logger.printException(() -> "skipSegment failure", ex); + } + } + + private static void showSkippedSegmentToast(@NonNull SponsorSegment segment) { + Utils.verifyOnMainThread(); + toastNumberOfSegmentsSkipped++; + if (toastNumberOfSegmentsSkipped > 1) { + return; // toast already scheduled + } + toastSegmentSkipped = segment; + + final long delayToToastMilliseconds = 250; // also the maximum time between skips to be considered skipping multiple segments + Utils.runOnMainThreadDelayed(() -> { + try { + if (toastSegmentSkipped == null) { // video was changed just after skipping segment + Logger.printDebug(() -> "Ignoring old scheduled show toast"); + return; + } + Utils.showToastShort(toastNumberOfSegmentsSkipped == 1 + ? toastSegmentSkipped.getSkippedToastText() + : str("revanced_sb_skipped_multiple_segments")); + } catch (Exception ex) { + Logger.printException(() -> "showSkippedSegmentToast failure", ex); + } finally { + toastNumberOfSegmentsSkipped = 0; + toastSegmentSkipped = null; + } + }, delayToToastMilliseconds); + } + + /** + * @param segment can be either a highlight or a regular manual skip segment. + */ + public static void onSkipSegmentClicked(@NonNull SponsorSegment segment) { + try { + if (segment != highlightSegment && segment != segmentCurrentlyPlaying) { + Logger.printException(() -> "error: segment not available to skip"); // should never happen + SponsorBlockViewController.hideSkipSegmentButton(); + SponsorBlockViewController.hideSkipHighlightButton(); + return; + } + skipSegment(segment, true); + } catch (Exception ex) { + Logger.printException(() -> "onSkipSegmentClicked failure", ex); + } + } + + /** + * Injection point + */ + public static void setSponsorBarRect(final Object self) { + try { + Field field = self.getClass().getDeclaredField("replaceMeWithsetSponsorBarRect"); + field.setAccessible(true); + Rect rect = (Rect) Objects.requireNonNull(field.get(self)); + setSponsorBarAbsoluteLeft(rect); + setSponsorBarAbsoluteRight(rect); + } catch (Exception ex) { + Logger.printException(() -> "setSponsorBarRect failure", ex); + } + } + + private static void setSponsorBarAbsoluteLeft(Rect rect) { + final int left = rect.left; + if (sponsorBarAbsoluteLeft != left) { + sponsorBarAbsoluteLeft = left; + } + } + + private static void setSponsorBarAbsoluteRight(Rect rect) { + final int right = rect.right; + if (sponsorAbsoluteBarRight != right) { + sponsorAbsoluteBarRight = right; + } + } + + /** + * Injection point + */ + public static void setSponsorBarThickness(int thickness) { + if (sponsorBarThickness != thickness) { + sponsorBarThickness = thickness; + } + } + + /** + * Injection point. + */ + public static String appendTimeWithoutSegments(String totalTime) { + try { + if (Settings.SB_ENABLED.get() && Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.get() + && !TextUtils.isEmpty(totalTime) && !TextUtils.isEmpty(timeWithoutSegments)) { + // Force LTR layout, to match the same LTR video time/length layout YouTube uses for all languages + return "\u202D" + totalTime + timeWithoutSegments; // u202D = left to right override + } + } catch (Exception ex) { + Logger.printException(() -> "appendTimeWithoutSegments failure", ex); + } + + return totalTime; + } + + @SuppressLint("DefaultLocale") + private static void calculateTimeWithoutSegments() { + if (!Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.get() || videoLength <= 0 + || segments == null || segments.length == 0) { + timeWithoutSegments = null; + return; + } + + boolean foundNonhighlightSegments = false; + long timeWithoutSegmentsValue = videoLength; + + for (int i = 0, length = segments.length; i < length; i++) { + SponsorSegment segment = segments[i]; + if (segment.category == SegmentCategory.HIGHLIGHT) { + continue; + } + foundNonhighlightSegments = true; + long start = segment.start; + final long end = segment.end; + // To prevent nested segments from incorrectly counting additional time, + // check if the segment overlaps any earlier segments. + for (int j = 0; j < i; j++) { + start = Math.max(start, segments[j].end); + } + if (start < end) { + timeWithoutSegmentsValue -= (end - start); + } + } + + if (!foundNonhighlightSegments) { + timeWithoutSegments = null; + return; + } + + final long hours = timeWithoutSegmentsValue / 3600000; + final long minutes = (timeWithoutSegmentsValue / 60000) % 60; + final long seconds = (timeWithoutSegmentsValue / 1000) % 60; + if (hours > 0) { + timeWithoutSegments = String.format(Locale.ENGLISH, "\u2009(%d:%02d:%02d)", hours, minutes, seconds); + } else { + timeWithoutSegments = String.format(Locale.ENGLISH, "\u2009(%d:%02d)", minutes, seconds); + } + } + + private static int getHighlightSegmentTimeBarScreenWidth() { + if (highlightSegmentTimeBarScreenWidth == -1) { + highlightSegmentTimeBarScreenWidth = (int) TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, HIGHLIGHT_SEGMENT_DRAW_BAR_WIDTH, + Objects.requireNonNull(Utils.getContext()).getResources().getDisplayMetrics()); + } + return highlightSegmentTimeBarScreenWidth; + } + + /** + * Injection point. + */ + public static void drawSponsorTimeBars(final Canvas canvas, final float posY) { + try { + if (segments == null) return; + if (videoLength <= 0) return; + + final int thicknessDiv2 = sponsorBarThickness / 2; // rounds down + final float top = posY - (sponsorBarThickness - thicknessDiv2); + final float bottom = posY + thicknessDiv2; + final float videoMillisecondsToPixels = (1f / videoLength) * (sponsorAbsoluteBarRight - sponsorBarAbsoluteLeft); + final float leftPadding = sponsorBarAbsoluteLeft; + + for (SponsorSegment segment : segments) { + final float left = leftPadding + segment.start * videoMillisecondsToPixels; + final float right; + if (segment.category == SegmentCategory.HIGHLIGHT) { + right = left + getHighlightSegmentTimeBarScreenWidth(); + } else { + right = leftPadding + segment.end * videoMillisecondsToPixels; + } + canvas.drawRect(left, top, right, bottom, segment.category.paint); + } + } catch (Exception ex) { + Logger.printException(() -> "drawSponsorTimeBars failure", ex); + } + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockSettings.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockSettings.java new file mode 100644 index 0000000000..c8d6e171b1 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockSettings.java @@ -0,0 +1,246 @@ +package app.revanced.extension.youtube.sponsorblock; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.app.AlertDialog; +import android.content.Context; +import android.util.Patterns; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.UUID; + +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.settings.preference.SponsorBlockSettingsPreference; +import app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour; +import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory; + +public class SponsorBlockSettings { + /** + * Minimum length a SB user id must be, as set by SB API. + */ + private static final int SB_PRIVATE_USER_ID_MINIMUM_LENGTH = 30; + + public static void importDesktopSettings(@NonNull String json) { + Utils.verifyOnMainThread(); + try { + JSONObject settingsJson = new JSONObject(json); + JSONObject barTypesObject = settingsJson.getJSONObject("barTypes"); + JSONArray categorySelectionsArray = settingsJson.getJSONArray("categorySelections"); + + for (SegmentCategory category : SegmentCategory.categoriesWithoutUnsubmitted()) { + // clear existing behavior, as browser plugin exports no behavior for ignored categories + category.setBehaviour(CategoryBehaviour.IGNORE); + if (barTypesObject.has(category.keyValue)) { + JSONObject categoryObject = barTypesObject.getJSONObject(category.keyValue); + category.setColor(categoryObject.getString("color")); + } + } + + for (int i = 0; i < categorySelectionsArray.length(); i++) { + JSONObject categorySelectionObject = categorySelectionsArray.getJSONObject(i); + + String categoryKey = categorySelectionObject.getString("name"); + SegmentCategory category = SegmentCategory.byCategoryKey(categoryKey); + if (category == null) { + continue; // unsupported category, ignore + } + + final int desktopValue = categorySelectionObject.getInt("option"); + CategoryBehaviour behaviour = CategoryBehaviour.byDesktopKeyValue(desktopValue); + if (behaviour == null) { + Utils.showToastLong(categoryKey + " unknown behavior key: " + categoryKey); + } else if (category == SegmentCategory.HIGHLIGHT && behaviour == CategoryBehaviour.SKIP_AUTOMATICALLY_ONCE) { + Utils.showToastLong("Skip-once behavior not allowed for " + category.keyValue); + category.setBehaviour(CategoryBehaviour.SKIP_AUTOMATICALLY); // use closest match + } else { + category.setBehaviour(behaviour); + } + } + SegmentCategory.updateEnabledCategories(); + + if (settingsJson.has("userID")) { + // User id does not exist if user never voted or created any segments. + String userID = settingsJson.getString("userID"); + if (isValidSBUserId(userID)) { + Settings.SB_PRIVATE_USER_ID.save(userID); + } + } + Settings.SB_USER_IS_VIP.save(settingsJson.getBoolean("isVip")); + Settings.SB_TOAST_ON_SKIP.save(!settingsJson.getBoolean("dontShowNotice")); + Settings.SB_TRACK_SKIP_COUNT.save(settingsJson.getBoolean("trackViewCount")); + Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.save(settingsJson.getBoolean("showTimeWithSkips")); + + String serverAddress = settingsJson.getString("serverAddress"); + if (isValidSBServerAddress(serverAddress)) { // Old versions of ReVanced exported wrong url format + Settings.SB_API_URL.save(serverAddress); + } + + final float minDuration = (float) settingsJson.getDouble("minDuration"); + if (minDuration < 0) { + throw new IllegalArgumentException("invalid minDuration: " + minDuration); + } + Settings.SB_SEGMENT_MIN_DURATION.save(minDuration); + + if (settingsJson.has("skipCount")) { // Value not exported in old versions of ReVanced + int skipCount = settingsJson.getInt("skipCount"); + if (skipCount < 0) { + throw new IllegalArgumentException("invalid skipCount: " + skipCount); + } + Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.save(skipCount); + } + + if (settingsJson.has("minutesSaved")) { + final double minutesSaved = settingsJson.getDouble("minutesSaved"); + if (minutesSaved < 0) { + throw new IllegalArgumentException("invalid minutesSaved: " + minutesSaved); + } + Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.save((long) (minutesSaved * 60 * 1000)); + } + + Utils.showToastLong(str("revanced_sb_settings_import_successful")); + } catch (Exception ex) { + Logger.printInfo(() -> "failed to import settings", ex); // use info level, as we are showing our own toast + Utils.showToastLong(str("revanced_sb_settings_import_failed", ex.getMessage())); + } + } + + @NonNull + public static String exportDesktopSettings() { + Utils.verifyOnMainThread(); + try { + Logger.printDebug(() -> "Creating SponsorBlock export settings string"); + JSONObject json = new JSONObject(); + + JSONObject barTypesObject = new JSONObject(); // categories' colors + JSONArray categorySelectionsArray = new JSONArray(); // categories' behavior + + SegmentCategory[] categories = SegmentCategory.categoriesWithoutUnsubmitted(); + for (SegmentCategory category : categories) { + JSONObject categoryObject = new JSONObject(); + String categoryKey = category.keyValue; + categoryObject.put("color", category.colorString()); + barTypesObject.put(categoryKey, categoryObject); + + if (category.behaviour != CategoryBehaviour.IGNORE) { + JSONObject behaviorObject = new JSONObject(); + behaviorObject.put("name", categoryKey); + behaviorObject.put("option", category.behaviour.desktopKeyValue); + categorySelectionsArray.put(behaviorObject); + } + } + if (SponsorBlockSettings.userHasSBPrivateId()) { + json.put("userID", Settings.SB_PRIVATE_USER_ID.get()); + } + json.put("isVip", Settings.SB_USER_IS_VIP.get()); + json.put("serverAddress", Settings.SB_API_URL.get()); + json.put("dontShowNotice", !Settings.SB_TOAST_ON_SKIP.get()); + json.put("showTimeWithSkips", Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.get()); + json.put("minDuration", Settings.SB_SEGMENT_MIN_DURATION.get()); + json.put("trackViewCount", Settings.SB_TRACK_SKIP_COUNT.get()); + json.put("skipCount", Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.get()); + json.put("minutesSaved", Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.get() / (60f * 1000)); + + json.put("categorySelections", categorySelectionsArray); + json.put("barTypes", barTypesObject); + + return json.toString(2); + } catch (Exception ex) { + Logger.printInfo(() -> "failed to export settings", ex); // use info level, as we are showing our own toast + Utils.showToastLong(str("revanced_sb_settings_export_failed", ex)); + return ""; + } + } + + /** + * Export the categories using flatten json (no embedded dictionaries or arrays). + */ + public static void showExportWarningIfNeeded(@Nullable Context dialogContext) { + Utils.verifyOnMainThread(); + initialize(); + + // If user has a SponsorBlock user id then show a warning. + if (dialogContext != null && SponsorBlockSettings.userHasSBPrivateId() + && !Settings.SB_HIDE_EXPORT_WARNING.get()) { + new AlertDialog.Builder(dialogContext) + .setMessage(str("revanced_sb_settings_revanced_export_user_id_warning")) + .setNeutralButton(str("revanced_sb_settings_revanced_export_user_id_warning_dismiss"), + (dialog, which) -> Settings.SB_HIDE_EXPORT_WARNING.save(true)) + .setPositiveButton(android.R.string.ok, null) + .setCancelable(false) + .show(); + } + } + + public static boolean isValidSBUserId(@NonNull String userId) { + return !userId.isEmpty() && userId.length() >= SB_PRIVATE_USER_ID_MINIMUM_LENGTH; + } + + /** + * A non comprehensive check if a SB api server address is valid. + */ + public static boolean isValidSBServerAddress(@NonNull String serverAddress) { + if (!Patterns.WEB_URL.matcher(serverAddress).matches()) { + return false; + } + // Verify url is only the server address and does not contain a path such as: "https://sponsor.ajay.app/api/" + // Could use Patterns.compile, but this is simpler + final int lastDotIndex = serverAddress.lastIndexOf('.'); + if (lastDotIndex != -1 && serverAddress.substring(lastDotIndex).contains("/")) { + return false; + } + // Optionally, could also verify the domain exists using "InetAddress.getByName(serverAddress)" + // but that should not be done on the main thread. + // Instead, assume the domain exists and the user knows what they're doing. + return true; + } + + /** + * @return if the user has ever voted, created a segment, or imported existing SB settings. + */ + public static boolean userHasSBPrivateId() { + return !Settings.SB_PRIVATE_USER_ID.get().isEmpty(); + } + + /** + * Use this only if a user id is required (creating segments, voting). + */ + @NonNull + public static String getSBPrivateUserID() { + String uuid = Settings.SB_PRIVATE_USER_ID.get(); + if (uuid.isEmpty()) { + uuid = (UUID.randomUUID().toString() + + UUID.randomUUID().toString() + + UUID.randomUUID().toString()) + .replace("-", ""); + Settings.SB_PRIVATE_USER_ID.save(uuid); + } + return uuid; + } + + private static boolean initialized; + + public static void initialize() { + if (initialized) { + return; + } + initialized = true; + + SegmentCategory.updateEnabledCategories(); + } + + /** + * Updates internal data based on {@link Setting} values. + */ + public static void updateFromImportedSettings() { + SegmentCategory.loadAllCategoriesFromSettings(); + SponsorBlockSettingsPreference.updateSegmentCategories(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockUtils.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockUtils.java new file mode 100644 index 0000000000..88d128e7e2 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockUtils.java @@ -0,0 +1,495 @@ +package app.revanced.extension.youtube.sponsorblock; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import android.annotation.TargetApi; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.text.Html; +import android.widget.EditText; + +import androidx.annotation.NonNull; + +import java.lang.ref.WeakReference; +import java.text.NumberFormat; +import java.time.Duration; +import java.util.Locale; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.VideoInformation; +import app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour; +import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory; +import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment; +import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment.SegmentVote; +import app.revanced.extension.youtube.sponsorblock.requests.SBRequester; +import app.revanced.extension.youtube.sponsorblock.ui.SponsorBlockViewController; + +/** + * Not thread safe. All fields/methods must be accessed from the main thread. + * + * @noinspection deprecation + */ +public class SponsorBlockUtils { + private static final String LOCKED_COLOR = "#FFC83D"; + + private static final String MANUAL_EDIT_TIME_TEXT_HINT = "hh:mm:ss.sss"; + private static final Pattern manualEditTimePattern + = Pattern.compile("((\\d{1,2}):)?(\\d{1,2}):(\\d{2})(\\.(\\d{1,3}))?"); + private static final NumberFormat statsNumberFormatter = NumberFormat.getNumberInstance(); + + private static long newSponsorSegmentDialogShownMillis; + private static long newSponsorSegmentStartMillis = -1; + private static long newSponsorSegmentEndMillis = -1; + private static boolean newSponsorSegmentPreviewed; + private static final DialogInterface.OnClickListener newSponsorSegmentDialogListener = new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + switch (which) { + // start + case DialogInterface.BUTTON_NEGATIVE -> + newSponsorSegmentStartMillis = newSponsorSegmentDialogShownMillis; + // end + case DialogInterface.BUTTON_POSITIVE -> + newSponsorSegmentEndMillis = newSponsorSegmentDialogShownMillis; + } + dialog.dismiss(); + } + }; + private static SegmentCategory newUserCreatedSegmentCategory; + private static final DialogInterface.OnClickListener segmentTypeListener = new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + try { + SegmentCategory category = SegmentCategory.categoriesWithoutHighlights()[which]; + final boolean enableButton; + if (category.behaviour == CategoryBehaviour.IGNORE) { + Utils.showToastLong(str("revanced_sb_new_segment_disabled_category")); + enableButton = false; + } else { + newUserCreatedSegmentCategory = category; + enableButton = true; + } + + ((AlertDialog) dialog) + .getButton(DialogInterface.BUTTON_POSITIVE) + .setEnabled(enableButton); + } catch (Exception ex) { + Logger.printException(() -> "segmentTypeListener failure", ex); + } + } + }; + private static final DialogInterface.OnClickListener segmentReadyDialogButtonListener = new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + try { + SponsorBlockViewController.hideNewSegmentLayout(); + Context context = ((AlertDialog) dialog).getContext(); + dialog.dismiss(); + + SegmentCategory[] categories = SegmentCategory.categoriesWithoutHighlights(); + CharSequence[] titles = new CharSequence[categories.length]; + for (int i = 0, length = categories.length; i < length; i++) { + titles[i] = categories[i].getTitleWithColorDot(); + } + + newUserCreatedSegmentCategory = null; + new AlertDialog.Builder(context) + .setTitle(str("revanced_sb_new_segment_choose_category")) + .setSingleChoiceItems(titles, -1, segmentTypeListener) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, segmentCategorySelectedDialogListener) + .show() + .getButton(DialogInterface.BUTTON_POSITIVE) + .setEnabled(false); + } catch (Exception ex) { + Logger.printException(() -> "segmentReadyDialogButtonListener failure", ex); + } + } + }; + private static final DialogInterface.OnClickListener segmentCategorySelectedDialogListener = (dialog, which) -> { + dialog.dismiss(); + submitNewSegment(); + }; + private static final EditByHandSaveDialogListener editByHandSaveDialogListener = new EditByHandSaveDialogListener(); + private static final DialogInterface.OnClickListener editByHandDialogListener = (dialog, which) -> { + try { + Context context = ((AlertDialog) dialog).getContext(); + + final boolean isStart = DialogInterface.BUTTON_NEGATIVE == which; + + final EditText textView = new EditText(context); + textView.setHint(MANUAL_EDIT_TIME_TEXT_HINT); + if (isStart) { + if (newSponsorSegmentStartMillis >= 0) + textView.setText(formatSegmentTime(newSponsorSegmentStartMillis)); + } else { + if (newSponsorSegmentEndMillis >= 0) + textView.setText(formatSegmentTime(newSponsorSegmentEndMillis)); + } + + editByHandSaveDialogListener.settingStart = isStart; + editByHandSaveDialogListener.editTextRef = new WeakReference<>(textView); + new AlertDialog.Builder(context) + .setTitle(str(isStart ? "revanced_sb_new_segment_time_start" : "revanced_sb_new_segment_time_end")) + .setView(textView) + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(str("revanced_sb_new_segment_now"), editByHandSaveDialogListener) + .setPositiveButton(android.R.string.ok, editByHandSaveDialogListener) + .show(); + + dialog.dismiss(); + } catch (Exception ex) { + Logger.printException(() -> "editByHandDialogListener failure", ex); + } + }; + private static final DialogInterface.OnClickListener segmentVoteClickListener = (dialog, which) -> { + try { + final Context context = ((AlertDialog) dialog).getContext(); + SponsorSegment[] segments = SegmentPlaybackController.getSegments(); + if (segments == null || segments.length == 0) { + // should never be reached + Logger.printException(() -> "Segment is no longer available on the client"); + return; + } + SponsorSegment segment = segments[which]; + + SegmentVote[] voteOptions = (segment.category == SegmentCategory.HIGHLIGHT) + ? SegmentVote.voteTypesWithoutCategoryChange // highlight segments cannot change category + : SegmentVote.values(); + CharSequence[] items = new CharSequence[voteOptions.length]; + + for (int i = 0; i < voteOptions.length; i++) { + SegmentVote voteOption = voteOptions[i]; + String title = voteOption.title.toString(); + if (Settings.SB_USER_IS_VIP.get() && segment.isLocked && voteOption.shouldHighlight) { + items[i] = Html.fromHtml(String.format("%s", LOCKED_COLOR, title)); + } else { + items[i] = title; + } + } + + new AlertDialog.Builder(context) + .setItems(items, (dialog1, which1) -> { + SegmentVote voteOption = voteOptions[which1]; + switch (voteOption) { + case UPVOTE, DOWNVOTE -> + SBRequester.voteForSegmentOnBackgroundThread(segment, voteOption); + case CATEGORY_CHANGE -> onNewCategorySelect(segment, context); + } + }) + .show(); + } catch (Exception ex) { + Logger.printException(() -> "segmentVoteClickListener failure", ex); + } + }; + + private SponsorBlockUtils() { + } + + static void setNewSponsorSegmentPreviewed() { + newSponsorSegmentPreviewed = true; + } + + static void clearUnsubmittedSegmentTimes() { + newSponsorSegmentDialogShownMillis = 0; + newSponsorSegmentEndMillis = newSponsorSegmentStartMillis = -1; + newSponsorSegmentPreviewed = false; + } + + private static void submitNewSegment() { + try { + Utils.verifyOnMainThread(); + final long start = newSponsorSegmentStartMillis; + final long end = newSponsorSegmentEndMillis; + final String videoId = SegmentPlaybackController.getVideoId(); + final long videoLength = SegmentPlaybackController.getVideoLength(); + final SegmentCategory segmentCategory = newUserCreatedSegmentCategory; + if (start < 0 || end < 0 || start >= end || videoLength <= 0 || videoId.isEmpty() || segmentCategory == null) { + Logger.printException(() -> "invalid parameters"); + return; + } + clearUnsubmittedSegmentTimes(); + Utils.runOnBackgroundThread(() -> { + SBRequester.submitSegments(videoId, segmentCategory.keyValue, start, end, videoLength); + SegmentPlaybackController.executeDownloadSegments(videoId); + }); + } catch (Exception e) { + Logger.printException(() -> "Unable to submit segment", e); + } + } + + public static void onMarkLocationClicked() { + try { + Utils.verifyOnMainThread(); + newSponsorSegmentDialogShownMillis = VideoInformation.getVideoTime(); + + new AlertDialog.Builder(SponsorBlockViewController.getOverLaysViewGroupContext()) + .setTitle(str("revanced_sb_new_segment_title")) + .setMessage(str("revanced_sb_new_segment_mark_current_time_as_question", + formatSegmentTime(newSponsorSegmentDialogShownMillis))) + .setNeutralButton(android.R.string.cancel, null) + .setNegativeButton(str("revanced_sb_new_segment_mark_start"), newSponsorSegmentDialogListener) + .setPositiveButton(str("revanced_sb_new_segment_mark_end"), newSponsorSegmentDialogListener) + .show(); + } catch (Exception ex) { + Logger.printException(() -> "onMarkLocationClicked failure", ex); + } + } + + public static void onPublishClicked() { + try { + Utils.verifyOnMainThread(); + if (newSponsorSegmentStartMillis < 0 || newSponsorSegmentEndMillis < 0) { + Utils.showToastShort(str("revanced_sb_new_segment_mark_locations_first")); + } else if (newSponsorSegmentStartMillis >= newSponsorSegmentEndMillis) { + Utils.showToastShort(str("revanced_sb_new_segment_start_is_before_end")); + } else if (!newSponsorSegmentPreviewed && newSponsorSegmentStartMillis != 0) { + Utils.showToastLong(str("revanced_sb_new_segment_preview_segment_first")); + } else { + final long segmentLength = (newSponsorSegmentEndMillis - newSponsorSegmentStartMillis) / 1000; + new AlertDialog.Builder(SponsorBlockViewController.getOverLaysViewGroupContext()) + .setTitle(str("revanced_sb_new_segment_confirm_title")) + .setMessage(str("revanced_sb_new_segment_confirm_contents", + formatSegmentTime(newSponsorSegmentStartMillis), + formatSegmentTime(newSponsorSegmentEndMillis), + getTimeSavedString(segmentLength))) + .setNegativeButton(android.R.string.no, null) + .setPositiveButton(android.R.string.yes, segmentReadyDialogButtonListener) + .show(); + } + } catch (Exception ex) { + Logger.printException(() -> "onPublishClicked failure", ex); + } + } + + public static void onVotingClicked(@NonNull Context context) { + try { + Utils.verifyOnMainThread(); + SponsorSegment[] segments = SegmentPlaybackController.getSegments(); + if (segments == null || segments.length == 0) { + // Button is hidden if no segments exist. + // But if prior video had segments, and current video does not, + // then the button persists until the overlay fades out (this is intentional, as abruptly hiding the button is jarring). + Utils.showToastShort(str("revanced_sb_vote_no_segments")); + return; + } + + final int numberOfSegments = segments.length; + CharSequence[] titles = new CharSequence[numberOfSegments]; + for (int i = 0; i < numberOfSegments; i++) { + SponsorSegment segment = segments[i]; + if (segment.category == SegmentCategory.UNSUBMITTED) { + continue; + } + StringBuilder htmlBuilder = new StringBuilder(); + htmlBuilder.append(String.format(" %s
", + segment.category.color, segment.category.title)); + htmlBuilder.append(formatSegmentTime(segment.start)); + if (segment.category != SegmentCategory.HIGHLIGHT) { + htmlBuilder.append(" to ").append(formatSegmentTime(segment.end)); + } + htmlBuilder.append("
"); + if (i + 1 != numberOfSegments) // prevents trailing new line after last segment + htmlBuilder.append("
"); + titles[i] = Html.fromHtml(htmlBuilder.toString()); + } + + new AlertDialog.Builder(context) + .setItems(titles, segmentVoteClickListener) + .show(); + } catch (Exception ex) { + Logger.printException(() -> "onVotingClicked failure", ex); + } + } + + private static void onNewCategorySelect(@NonNull SponsorSegment segment, @NonNull Context context) { + try { + Utils.verifyOnMainThread(); + final SegmentCategory[] values = SegmentCategory.categoriesWithoutHighlights(); + CharSequence[] titles = new CharSequence[values.length]; + for (int i = 0; i < values.length; i++) { + titles[i] = values[i].getTitleWithColorDot(); + } + + new AlertDialog.Builder(context) + .setTitle(str("revanced_sb_new_segment_choose_category")) + .setItems(titles, (dialog, which) -> SBRequester.voteToChangeCategoryOnBackgroundThread(segment, values[which])) + .show(); + } catch (Exception ex) { + Logger.printException(() -> "onNewCategorySelect failure", ex); + } + } + + public static void onPreviewClicked() { + try { + Utils.verifyOnMainThread(); + if (newSponsorSegmentStartMillis < 0 || newSponsorSegmentEndMillis < 0) { + Utils.showToastShort(str("revanced_sb_new_segment_mark_locations_first")); + } else if (newSponsorSegmentStartMillis >= newSponsorSegmentEndMillis) { + Utils.showToastShort(str("revanced_sb_new_segment_start_is_before_end")); + } else { + SegmentPlaybackController.removeUnsubmittedSegments(); // If user hits preview more than once before playing. + SegmentPlaybackController.addUnsubmittedSegment( + new SponsorSegment(SegmentCategory.UNSUBMITTED, null, + newSponsorSegmentStartMillis, newSponsorSegmentEndMillis, false)); + VideoInformation.seekTo(newSponsorSegmentStartMillis - 2000, SegmentPlaybackController.getVideoLength()); + } + } catch (Exception ex) { + Logger.printException(() -> "onPreviewClicked failure", ex); + } + } + + + static void sendViewRequestAsync(@NonNull SponsorSegment segment) { + if (segment.recordedAsSkipped || segment.category == SegmentCategory.UNSUBMITTED) { + return; + } + segment.recordedAsSkipped = true; + final long totalTimeSkipped = Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.get() + segment.length(); + Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.save(totalTimeSkipped); + Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.save(Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.get() + 1); + + if (Settings.SB_TRACK_SKIP_COUNT.get()) { + Utils.runOnBackgroundThread(() -> SBRequester.sendSegmentSkippedViewedRequest(segment)); + } + } + + public static void onEditByHandClicked() { + try { + Utils.verifyOnMainThread(); + new AlertDialog.Builder(SponsorBlockViewController.getOverLaysViewGroupContext()) + .setTitle(str("revanced_sb_new_segment_edit_by_hand_title")) + .setMessage(str("revanced_sb_new_segment_edit_by_hand_content")) + .setNeutralButton(android.R.string.cancel, null) + .setNegativeButton(str("revanced_sb_new_segment_mark_start"), editByHandDialogListener) + .setPositiveButton(str("revanced_sb_new_segment_mark_end"), editByHandDialogListener) + .show(); + } catch (Exception ex) { + Logger.printException(() -> "onEditByHandClicked failure", ex); + } + } + + public static String getNumberOfSkipsString(int viewCount) { + return statsNumberFormatter.format(viewCount); + } + + @SuppressWarnings("ConstantConditions") + private static long parseSegmentTime(@NonNull String time) { + Matcher matcher = manualEditTimePattern.matcher(time); + if (!matcher.matches()) { + return -1; + } + String hoursStr = matcher.group(2); // Hours is optional. + String minutesStr = matcher.group(3); + String secondsStr = matcher.group(4); + String millisecondsStr = matcher.group(6); // Milliseconds is optional. + + try { + final int hours = (hoursStr != null) ? Integer.parseInt(hoursStr) : 0; + final int minutes = Integer.parseInt(minutesStr); + final int seconds = Integer.parseInt(secondsStr); + final int milliseconds; + if (millisecondsStr != null) { + // Pad out with zeros if not all decimal places were used. + millisecondsStr = String.format(Locale.US, "%-3s", millisecondsStr).replace(' ', '0'); + milliseconds = Integer.parseInt(millisecondsStr); + } else { + milliseconds = 0; + } + + return (hours * 3600000L) + (minutes * 60000L) + (seconds * 1000L) + milliseconds; + } catch (NumberFormatException ex) { + Logger.printInfo(() -> "Time format exception: " + time, ex); + return -1; + } + } + + private static String formatSegmentTime(long segmentTime) { + // Use same time formatting as shown in the video player. + final long videoLength = SegmentPlaybackController.getVideoLength(); + + // Cannot use DateFormatter, as videos over 24 hours will rollover and not display correctly. + final long hours = TimeUnit.MILLISECONDS.toHours(segmentTime); + final long minutes = TimeUnit.MILLISECONDS.toMinutes(segmentTime) % 60; + final long seconds = TimeUnit.MILLISECONDS.toSeconds(segmentTime) % 60; + final long milliseconds = segmentTime % 1000; + + final String formatPattern; + Object[] formatArgs = {minutes, seconds, milliseconds}; + + if (videoLength < (10 * 60 * 1000)) { + formatPattern = "%01d:%02d.%03d"; // Less than 10 minutes. + } else if (videoLength < (60 * 60 * 1000)) { + formatPattern = "%02d:%02d.%03d"; // Less than 1 hour. + } else if (videoLength < (10 * 60 * 60 * 1000)) { + formatPattern = "%01d:%02d:%02d.%03d"; // Less than 10 hours. + formatArgs = new Object[]{hours, minutes, seconds, milliseconds}; + } else { + formatPattern = "%02d:%02d:%02d.%03d"; // Why is this on YouTube? + formatArgs = new Object[]{hours, minutes, seconds, milliseconds}; + } + + return String.format(Locale.US, formatPattern, formatArgs); + } + + @TargetApi(26) + public static String getTimeSavedString(long totalSecondsSaved) { + Duration duration = Duration.ofSeconds(totalSecondsSaved); + final long hours = duration.toHours(); + final long minutes = duration.toMinutes() % 60; + // Format all numbers so non-western numbers use a consistent appearance. + String minutesFormatted = statsNumberFormatter.format(minutes); + if (hours > 0) { + String hoursFormatted = statsNumberFormatter.format(hours); + return str("revanced_sb_stats_saved_hour_format", hoursFormatted, minutesFormatted); + } + final long seconds = duration.getSeconds() % 60; + String secondsFormatted = statsNumberFormatter.format(seconds); + if (minutes > 0) { + return str("revanced_sb_stats_saved_minute_format", minutesFormatted, secondsFormatted); + } + return str("revanced_sb_stats_saved_second_format", secondsFormatted); + } + + private static class EditByHandSaveDialogListener implements DialogInterface.OnClickListener { + boolean settingStart; + WeakReference editTextRef = new WeakReference<>(null); + + @Override + public void onClick(DialogInterface dialog, int which) { + try { + final EditText editText = editTextRef.get(); + if (editText == null) return; + + final long time; + if (which == DialogInterface.BUTTON_NEUTRAL) { + time = VideoInformation.getVideoTime(); + } else { + time = parseSegmentTime(editText.getText().toString()); + if (time < 0) { + Utils.showToastLong(str("revanced_sb_new_segment_edit_by_hand_parse_error")); + return; + } + } + + if (settingStart) + newSponsorSegmentStartMillis = Math.max(time, 0); + else + newSponsorSegmentEndMillis = time; + + if (which == DialogInterface.BUTTON_NEUTRAL) + editByHandDialogListener.onClick(dialog, settingStart ? + DialogInterface.BUTTON_NEGATIVE : + DialogInterface.BUTTON_POSITIVE); + } catch (Exception ex) { + Logger.printException(() -> "EditByHandSaveDialogListener failure", ex); + } + } + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/CategoryBehaviour.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/CategoryBehaviour.java new file mode 100644 index 0000000000..5e5b4e8f11 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/CategoryBehaviour.java @@ -0,0 +1,124 @@ +package app.revanced.extension.youtube.sponsorblock.objects; + +import static app.revanced.extension.shared.utils.StringRef.sf; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Objects; + +import app.revanced.extension.shared.utils.StringRef; +import app.revanced.extension.shared.utils.Utils; + +public enum CategoryBehaviour { + SKIP_AUTOMATICALLY("skip", 2, true, sf("revanced_sb_skip_automatically")), + // desktop does not have skip-once behavior. Key is unique to ReVanced + SKIP_AUTOMATICALLY_ONCE("skip-once", 3, true, sf("revanced_sb_skip_automatically_once")), + MANUAL_SKIP("manual-skip", 1, false, sf("revanced_sb_skip_showbutton")), + SHOW_IN_SEEKBAR("seekbar-only", 0, false, sf("revanced_sb_skip_seekbaronly")), + // ignored categories are not exported to json, and ignore is the default behavior when importing + IGNORE("ignore", -1, false, sf("revanced_sb_skip_ignore")); + + /** + * ReVanced specific value. + */ + @NonNull + public final String reVancedKeyValue; + /** + * Desktop specific value. + */ + public final int desktopKeyValue; + /** + * If the segment should skip automatically + */ + public final boolean skipAutomatically; + @NonNull + public final StringRef description; + + CategoryBehaviour(String reVancedKeyValue, int desktopKeyValue, boolean skipAutomatically, StringRef description) { + this.reVancedKeyValue = Objects.requireNonNull(reVancedKeyValue); + this.desktopKeyValue = desktopKeyValue; + this.skipAutomatically = skipAutomatically; + this.description = Objects.requireNonNull(description); + } + + @Nullable + public static CategoryBehaviour byReVancedKeyValue(@NonNull String keyValue) { + for (CategoryBehaviour behaviour : values()) { + if (behaviour.reVancedKeyValue.equals(keyValue)) { + return behaviour; + } + } + return null; + } + + @Nullable + public static CategoryBehaviour byDesktopKeyValue(int desktopKeyValue) { + for (CategoryBehaviour behaviour : values()) { + if (behaviour.desktopKeyValue == desktopKeyValue) { + return behaviour; + } + } + return null; + } + + private static String[] behaviorKeyValues; + private static String[] behaviorDescriptions; + + private static String[] behaviorKeyValuesWithoutSkipOnce; + private static String[] behaviorDescriptionsWithoutSkipOnce; + + private static void createNameAndKeyArrays() { + Utils.verifyOnMainThread(); + + CategoryBehaviour[] behaviours = values(); + final int behaviorLength = behaviours.length; + behaviorKeyValues = new String[behaviorLength]; + behaviorDescriptions = new String[behaviorLength]; + behaviorKeyValuesWithoutSkipOnce = new String[behaviorLength - 1]; + behaviorDescriptionsWithoutSkipOnce = new String[behaviorLength - 1]; + + int behaviorIndex = 0, behaviorHighlightIndex = 0; + while (behaviorIndex < behaviorLength) { + CategoryBehaviour behaviour = behaviours[behaviorIndex]; + String value = behaviour.reVancedKeyValue; + String description = behaviour.description.toString(); + behaviorKeyValues[behaviorIndex] = value; + behaviorDescriptions[behaviorIndex] = description; + behaviorIndex++; + if (behaviour != SKIP_AUTOMATICALLY_ONCE) { + behaviorKeyValuesWithoutSkipOnce[behaviorHighlightIndex] = value; + behaviorDescriptionsWithoutSkipOnce[behaviorHighlightIndex] = description; + behaviorHighlightIndex++; + } + } + } + + public static String[] getBehaviorKeyValues() { + if (behaviorKeyValues == null) { + createNameAndKeyArrays(); + } + return behaviorKeyValues; + } + + public static String[] getBehaviorKeyValuesWithoutSkipOnce() { + if (behaviorKeyValuesWithoutSkipOnce == null) { + createNameAndKeyArrays(); + } + return behaviorKeyValuesWithoutSkipOnce; + } + + public static String[] getBehaviorDescriptions() { + if (behaviorDescriptions == null) { + createNameAndKeyArrays(); + } + return behaviorDescriptions; + } + + public static String[] getBehaviorDescriptionsWithoutSkipOnce() { + if (behaviorDescriptionsWithoutSkipOnce == null) { + createNameAndKeyArrays(); + } + return behaviorDescriptionsWithoutSkipOnce; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategory.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategory.java new file mode 100644 index 0000000000..3d1e90f66d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategory.java @@ -0,0 +1,351 @@ +package app.revanced.extension.youtube.sponsorblock.objects; + +import static app.revanced.extension.shared.utils.StringRef.sf; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_FILLER; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_FILLER_COLOR; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_HIGHLIGHT; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_HIGHLIGHT_COLOR; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_INTERACTION; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_INTERACTION_COLOR; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_INTRO; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_INTRO_COLOR; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_MUSIC_OFFTOPIC; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_MUSIC_OFFTOPIC_COLOR; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_OUTRO; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_OUTRO_COLOR; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_PREVIEW; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_PREVIEW_COLOR; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_SELF_PROMO; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_SELF_PROMO_COLOR; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_SPONSOR; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_SPONSOR_COLOR; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_UNSUBMITTED; +import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_UNSUBMITTED_COLOR; + +import android.graphics.Color; +import android.graphics.Paint; +import android.text.Html; +import android.text.Spanned; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import app.revanced.extension.shared.settings.StringSetting; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.StringRef; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings({"deprecation", "StaticFieldLeak"}) +public enum SegmentCategory { + SPONSOR("sponsor", sf("revanced_sb_segments_sponsor"), sf("revanced_sb_skip_button_sponsor"), sf("revanced_sb_skipped_sponsor"), + SB_CATEGORY_SPONSOR, SB_CATEGORY_SPONSOR_COLOR), + SELF_PROMO("selfpromo", sf("revanced_sb_segments_selfpromo"), sf("revanced_sb_skip_button_selfpromo"), sf("revanced_sb_skipped_selfpromo"), + SB_CATEGORY_SELF_PROMO, SB_CATEGORY_SELF_PROMO_COLOR), + INTERACTION("interaction", sf("revanced_sb_segments_interaction"), sf("revanced_sb_skip_button_interaction"), sf("revanced_sb_skipped_interaction"), + SB_CATEGORY_INTERACTION, SB_CATEGORY_INTERACTION_COLOR), + /** + * Unique category that is treated differently than the rest. + */ + HIGHLIGHT("poi_highlight", sf("revanced_sb_segments_highlight"), sf("revanced_sb_skip_button_highlight"), sf("revanced_sb_skipped_highlight"), + SB_CATEGORY_HIGHLIGHT, SB_CATEGORY_HIGHLIGHT_COLOR), + INTRO("intro", sf("revanced_sb_segments_intro"), + sf("revanced_sb_skip_button_intro_beginning"), sf("revanced_sb_skip_button_intro_middle"), sf("revanced_sb_skip_button_intro_end"), + sf("revanced_sb_skipped_intro_beginning"), sf("revanced_sb_skipped_intro_middle"), sf("revanced_sb_skipped_intro_end"), + SB_CATEGORY_INTRO, SB_CATEGORY_INTRO_COLOR), + OUTRO("outro", sf("revanced_sb_segments_outro"), sf("revanced_sb_skip_button_outro"), sf("revanced_sb_skipped_outro"), + SB_CATEGORY_OUTRO, SB_CATEGORY_OUTRO_COLOR), + PREVIEW("preview", sf("revanced_sb_segments_preview"), + sf("revanced_sb_skip_button_preview_beginning"), sf("revanced_sb_skip_button_preview_middle"), sf("revanced_sb_skip_button_preview_end"), + sf("revanced_sb_skipped_preview_beginning"), sf("revanced_sb_skipped_preview_middle"), sf("revanced_sb_skipped_preview_end"), + SB_CATEGORY_PREVIEW, SB_CATEGORY_PREVIEW_COLOR), + FILLER("filler", sf("revanced_sb_segments_filler"), sf("revanced_sb_skip_button_filler"), sf("revanced_sb_skipped_filler"), + SB_CATEGORY_FILLER, SB_CATEGORY_FILLER_COLOR), + MUSIC_OFFTOPIC("music_offtopic", sf("revanced_sb_segments_nomusic"), sf("revanced_sb_skip_button_nomusic"), sf("revanced_sb_skipped_nomusic"), + SB_CATEGORY_MUSIC_OFFTOPIC, SB_CATEGORY_MUSIC_OFFTOPIC_COLOR), + UNSUBMITTED("unsubmitted", StringRef.empty, sf("revanced_sb_skip_button_unsubmitted"), sf("revanced_sb_skipped_unsubmitted"), + SB_CATEGORY_UNSUBMITTED, SB_CATEGORY_UNSUBMITTED_COLOR), + ; + + private static final StringRef skipSponsorTextCompact = sf("revanced_sb_skip_button_compact"); + private static final StringRef skipSponsorTextCompactHighlight = sf("revanced_sb_skip_button_compact_highlight"); + + private static final SegmentCategory[] categoriesWithoutHighlights = new SegmentCategory[]{ + SPONSOR, + SELF_PROMO, + INTERACTION, + INTRO, + OUTRO, + PREVIEW, + FILLER, + MUSIC_OFFTOPIC, + }; + + private static final SegmentCategory[] categoriesWithoutUnsubmitted = new SegmentCategory[]{ + SPONSOR, + SELF_PROMO, + INTERACTION, + HIGHLIGHT, + INTRO, + OUTRO, + PREVIEW, + FILLER, + MUSIC_OFFTOPIC, + }; + private static final Map mValuesMap = new HashMap<>(2 * categoriesWithoutUnsubmitted.length); + + /** + * Categories currently enabled, formatted for an API call + */ + public static String sponsorBlockAPIFetchCategories = "[]"; + + static { + for (SegmentCategory value : categoriesWithoutUnsubmitted) + mValuesMap.put(value.keyValue, value); + } + + @NonNull + public static SegmentCategory[] categoriesWithoutUnsubmitted() { + return categoriesWithoutUnsubmitted; + } + + @NonNull + public static SegmentCategory[] categoriesWithoutHighlights() { + return categoriesWithoutHighlights; + } + + @Nullable + public static SegmentCategory byCategoryKey(@NonNull String key) { + return mValuesMap.get(key); + } + + /** + * Must be called if behavior of any category is changed + */ + public static void updateEnabledCategories() { + Utils.verifyOnMainThread(); + Logger.printDebug(() -> "updateEnabledCategories"); + SegmentCategory[] categories = categoriesWithoutUnsubmitted(); + List enabledCategories = new ArrayList<>(categories.length); + for (SegmentCategory category : categories) { + if (category.behaviour != CategoryBehaviour.IGNORE) { + enabledCategories.add(category.keyValue); + } + } + + //"[%22sponsor%22,%22outro%22,%22music_offtopic%22,%22intro%22,%22selfpromo%22,%22interaction%22,%22preview%22]"; + if (enabledCategories.isEmpty()) + sponsorBlockAPIFetchCategories = "[]"; + else + sponsorBlockAPIFetchCategories = "[%22" + TextUtils.join("%22,%22", enabledCategories) + "%22]"; + } + + public static void loadAllCategoriesFromSettings() { + for (SegmentCategory category : values()) { + category.loadFromSettings(); + } + updateEnabledCategories(); + } + + @NonNull + public final String keyValue; + @NonNull + public final StringSetting behaviorSetting; + @NonNull + private final StringSetting colorSetting; + + @NonNull + public final StringRef title; + + /** + * Skip button text, if the skip occurs in the first quarter of the video + */ + @NonNull + public final StringRef skipButtonTextBeginning; + /** + * Skip button text, if the skip occurs in the middle half of the video + */ + @NonNull + public final StringRef skipButtonTextMiddle; + /** + * Skip button text, if the skip occurs in the last quarter of the video + */ + @NonNull + public final StringRef skipButtonTextEnd; + /** + * Skipped segment toast, if the skip occurred in the first quarter of the video + */ + @NonNull + public final StringRef skippedToastBeginning; + /** + * Skipped segment toast, if the skip occurred in the middle half of the video + */ + @NonNull + public final StringRef skippedToastMiddle; + /** + * Skipped segment toast, if the skip occurred in the last quarter of the video + */ + @NonNull + public final StringRef skippedToastEnd; + + @NonNull + public final Paint paint; + + /** + * Value must be changed using {@link #setColor(String)}. + */ + public int color; + + /** + * Value must be changed using {@link #setBehaviour(CategoryBehaviour)}. + * Caller must also {@link #updateEnabledCategories()}. + */ + @NonNull + public CategoryBehaviour behaviour = CategoryBehaviour.IGNORE; + + SegmentCategory(String keyValue, StringRef title, + StringRef skipButtonText, + StringRef skippedToastText, + StringSetting behavior, StringSetting color) { + this(keyValue, title, + skipButtonText, skipButtonText, skipButtonText, + skippedToastText, skippedToastText, skippedToastText, + behavior, color); + } + + SegmentCategory(String keyValue, StringRef title, + StringRef skipButtonTextBeginning, StringRef skipButtonTextMiddle, StringRef skipButtonTextEnd, + StringRef skippedToastBeginning, StringRef skippedToastMiddle, StringRef skippedToastEnd, + StringSetting behavior, StringSetting color) { + this.keyValue = Objects.requireNonNull(keyValue); + this.title = Objects.requireNonNull(title); + this.skipButtonTextBeginning = Objects.requireNonNull(skipButtonTextBeginning); + this.skipButtonTextMiddle = Objects.requireNonNull(skipButtonTextMiddle); + this.skipButtonTextEnd = Objects.requireNonNull(skipButtonTextEnd); + this.skippedToastBeginning = Objects.requireNonNull(skippedToastBeginning); + this.skippedToastMiddle = Objects.requireNonNull(skippedToastMiddle); + this.skippedToastEnd = Objects.requireNonNull(skippedToastEnd); + this.behaviorSetting = Objects.requireNonNull(behavior); + this.colorSetting = Objects.requireNonNull(color); + this.paint = new Paint(); + loadFromSettings(); + } + + private void loadFromSettings() { + String behaviorString = behaviorSetting.get(); + CategoryBehaviour savedBehavior = CategoryBehaviour.byReVancedKeyValue(behaviorString); + if (savedBehavior == null) { + Logger.printException(() -> "Invalid behavior: " + behaviorString); + behaviorSetting.resetToDefault(); + loadFromSettings(); + return; + } + this.behaviour = savedBehavior; + + String colorString = colorSetting.get(); + try { + setColor(colorString); + } catch (Exception ex) { + Logger.printException(() -> "Invalid color: " + colorString, ex); + colorSetting.resetToDefault(); + loadFromSettings(); + } + } + + public void setBehaviour(@NonNull CategoryBehaviour behaviour) { + this.behaviour = Objects.requireNonNull(behaviour); + this.behaviorSetting.save(behaviour.reVancedKeyValue); + } + + /** + * @return HTML color format string + */ + @NonNull + public String colorString() { + return String.format("#%06X", color); + } + + public void setColor(@NonNull String colorString) throws IllegalArgumentException { + final int color = Color.parseColor(colorString) & 0xFFFFFF; + this.color = color; + paint.setColor(color); + paint.setAlpha(255); + colorSetting.save(colorString); // Save after parsing. + } + + public void resetColor() { + setColor(colorSetting.defaultValue); + } + + @NonNull + private static String getCategoryColorDotHTML(int color) { + color &= 0xFFFFFF; + return String.format("", color); + } + + @NonNull + public static Spanned getCategoryColorDot(int color) { + return Html.fromHtml(getCategoryColorDotHTML(color)); + } + + @NonNull + public Spanned getCategoryColorDot() { + return getCategoryColorDot(color); + } + + @NonNull + public Spanned getTitleWithColorDot() { + return Html.fromHtml(getCategoryColorDotHTML(color) + " " + title); + } + + /** + * @param segmentStartTime video time the segment category started + * @param videoLength length of the video + * @return the skip button text + */ + @NonNull + StringRef getSkipButtonText(long segmentStartTime, long videoLength) { + if (Settings.SB_COMPACT_SKIP_BUTTON.get()) { + return (this == SegmentCategory.HIGHLIGHT) + ? skipSponsorTextCompactHighlight + : skipSponsorTextCompact; + } + + if (videoLength == 0) { + return skipButtonTextBeginning; // video is still loading. Assume it's the beginning + } + final float position = segmentStartTime / (float) videoLength; + if (position < 0.25f) { + return skipButtonTextBeginning; + } else if (position < 0.75f) { + return skipButtonTextMiddle; + } + return skipButtonTextEnd; + } + + /** + * @param segmentStartTime video time the segment category started + * @param videoLength length of the video + * @return 'skipped segment' toast message + */ + @NonNull + StringRef getSkippedToastText(long segmentStartTime, long videoLength) { + if (videoLength == 0) { + return skippedToastBeginning; // video is still loading. Assume it's the beginning + } + final float position = segmentStartTime / (float) videoLength; + if (position < 0.25f) { + return skippedToastBeginning; + } else if (position < 0.75f) { + return skippedToastMiddle; + } + return skippedToastEnd; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SponsorSegment.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SponsorSegment.java new file mode 100644 index 0000000000..51208c1ccf --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SponsorSegment.java @@ -0,0 +1,146 @@ +package app.revanced.extension.youtube.sponsorblock.objects; + +import static app.revanced.extension.shared.utils.StringRef.sf; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Objects; + +import app.revanced.extension.shared.utils.StringRef; +import app.revanced.extension.youtube.sponsorblock.SegmentPlaybackController; + +public class SponsorSegment implements Comparable { + public enum SegmentVote { + UPVOTE(sf("revanced_sb_vote_upvote"), 1, false), + DOWNVOTE(sf("revanced_sb_vote_downvote"), 0, true), + CATEGORY_CHANGE(sf("revanced_sb_vote_category"), -1, true); // apiVoteType is not used for category change + + public static final SegmentVote[] voteTypesWithoutCategoryChange = { + UPVOTE, + DOWNVOTE, + }; + + @NonNull + public final StringRef title; + public final int apiVoteType; + public final boolean shouldHighlight; + + SegmentVote(@NonNull StringRef title, int apiVoteType, boolean shouldHighlight) { + this.title = title; + this.apiVoteType = apiVoteType; + this.shouldHighlight = shouldHighlight; + } + } + + @NonNull + public final SegmentCategory category; + /** + * NULL if segment is unsubmitted + */ + @Nullable + public final String UUID; + public final long start; + public final long end; + public final boolean isLocked; + public boolean didAutoSkipped = false; + /** + * If this segment has been counted as 'skipped' + */ + public boolean recordedAsSkipped = false; + + public SponsorSegment(@NonNull SegmentCategory category, @Nullable String UUID, long start, long end, boolean isLocked) { + this.category = category; + this.UUID = UUID; + this.start = start; + this.end = end; + this.isLocked = isLocked; + } + + public boolean shouldAutoSkip() { + return category.behaviour.skipAutomatically && !(didAutoSkipped && category.behaviour == CategoryBehaviour.SKIP_AUTOMATICALLY_ONCE); + } + + /** + * @param nearThreshold threshold to declare the time parameter is near this segment. Must be a positive number + */ + public boolean startIsNear(long videoTime, long nearThreshold) { + return Math.abs(start - videoTime) <= nearThreshold; + } + + /** + * @param nearThreshold threshold to declare the time parameter is near this segment. Must be a positive number + */ + public boolean endIsNear(long videoTime, long nearThreshold) { + return Math.abs(end - videoTime) <= nearThreshold; + } + + /** + * @return if the time parameter is within this segment + */ + public boolean containsTime(long videoTime) { + return start <= videoTime && videoTime < end; + } + + /** + * @return if the segment is completely contained inside this segment + */ + public boolean containsSegment(SponsorSegment other) { + return start <= other.start && other.end <= end; + } + + /** + * @return the length of this segment, in milliseconds. Always a positive number. + */ + public long length() { + return end - start; + } + + /** + * @return 'skip segment' UI overlay button text + */ + @NonNull + public String getSkipButtonText() { + return category.getSkipButtonText(start, SegmentPlaybackController.getVideoLength()).toString(); + } + + /** + * @return 'skipped segment' toast message + */ + @NonNull + public String getSkippedToastText() { + return category.getSkippedToastText(start, SegmentPlaybackController.getVideoLength()).toString(); + } + + @Override + public int compareTo(SponsorSegment o) { + // If both segments start at the same time, then sort with the longer segment first. + // This keeps the seekbar drawing correct since it draws the segments using the sorted order. + return start == o.start ? Long.compare(o.length(), length()) : Long.compare(start, o.start); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SponsorSegment other)) return false; + return Objects.equals(UUID, other.UUID) + && category == other.category + && start == other.start + && end == other.end; + } + + @Override + public int hashCode() { + return Objects.hashCode(UUID); + } + + @NonNull + @Override + public String toString() { + return "SponsorSegment{" + + "category=" + category + + ", start=" + start + + ", end=" + end + + '}'; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/UserStats.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/UserStats.java new file mode 100644 index 0000000000..6a9b9a3e69 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/UserStats.java @@ -0,0 +1,53 @@ +package app.revanced.extension.youtube.sponsorblock.objects; + +import androidx.annotation.NonNull; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * SponsorBlock user stats + */ +public class UserStats { + @NonNull + public final String publicUserId; + @NonNull + public final String userName; + /** + * "User reputation". Unclear how SB determines this value. + */ + public final float reputation; + /** + * {@link #segmentCount} plus {@link #ignoredSegmentCount} + */ + public final int totalSegmentCountIncludingIgnored; + public final int segmentCount; + public final int ignoredSegmentCount; + public final int viewCount; + public final double minutesSaved; + + public UserStats(@NonNull JSONObject json) throws JSONException { + publicUserId = json.getString("userID"); + userName = json.getString("userName"); + reputation = (float) json.getDouble("reputation"); + segmentCount = json.getInt("segmentCount"); + ignoredSegmentCount = json.getInt("ignoredSegmentCount"); + totalSegmentCountIncludingIgnored = segmentCount + ignoredSegmentCount; + viewCount = json.getInt("viewCount"); + minutesSaved = json.getDouble("minutesSaved"); + } + + @NonNull + @Override + public String toString() { + return "UserStats{" + + "publicUserId='" + publicUserId + '\'' + + ", userName='" + userName + '\'' + + ", reputation=" + reputation + + ", segmentCount=" + segmentCount + + ", ignoredSegmentCount=" + ignoredSegmentCount + + ", viewCount=" + viewCount + + ", minutesSaved=" + minutesSaved + + '}'; + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/requests/SBRequester.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/requests/SBRequester.java new file mode 100644 index 0000000000..1ff8a8d035 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/requests/SBRequester.java @@ -0,0 +1,282 @@ +package app.revanced.extension.youtube.sponsorblock.requests; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +import app.revanced.extension.shared.requests.Requester; +import app.revanced.extension.shared.requests.Route; +import app.revanced.extension.shared.sponsorblock.requests.SBRoutes; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings; +import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory; +import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment; +import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment.SegmentVote; +import app.revanced.extension.youtube.sponsorblock.objects.UserStats; + +public class SBRequester { + private static final String TIME_TEMPLATE = "%.3f"; + + /** + * TCP timeout + */ + private static final int TIMEOUT_TCP_DEFAULT_MILLISECONDS = 7000; + + /** + * HTTP response timeout + */ + private static final int TIMEOUT_HTTP_DEFAULT_MILLISECONDS = 10000; + + /** + * Response code of a successful API call + */ + private static final int HTTP_STATUS_CODE_SUCCESS = 200; + + private SBRequester() { + } + + private static void handleConnectionError(@NonNull String toastMessage, @Nullable Exception ex) { + if (Settings.SB_TOAST_ON_CONNECTION_ERROR.get()) { + Utils.showToastShort(toastMessage); + } + if (ex != null) { + Logger.printInfo(() -> toastMessage, ex); + } + } + + @NonNull + public static SponsorSegment[] getSegments(@NonNull String videoId) { + Utils.verifyOffMainThread(); + List segments = new ArrayList<>(); + try { + HttpURLConnection connection = getConnectionFromRoute(SBRoutes.GET_SEGMENTS, videoId, SegmentCategory.sponsorBlockAPIFetchCategories); + final int responseCode = connection.getResponseCode(); + + if (responseCode == HTTP_STATUS_CODE_SUCCESS) { + JSONArray responseArray = Requester.parseJSONArray(connection); + final long minSegmentDuration = (long) (Settings.SB_SEGMENT_MIN_DURATION.get() * 1000); + for (int i = 0, length = responseArray.length(); i < length; i++) { + JSONObject obj = (JSONObject) responseArray.get(i); + JSONArray segment = obj.getJSONArray("segment"); + final long start = (long) (segment.getDouble(0) * 1000); + final long end = (long) (segment.getDouble(1) * 1000); + + String uuid = obj.getString("UUID"); + final boolean locked = obj.getInt("locked") == 1; + String categoryKey = obj.getString("category"); + SegmentCategory category = SegmentCategory.byCategoryKey(categoryKey); + if (category == null) { + Logger.printException(() -> "Received unknown category: " + categoryKey); // should never happen + } else if ((end - start) >= minSegmentDuration || category == SegmentCategory.HIGHLIGHT) { + segments.add(new SponsorSegment(category, uuid, start, end, locked)); + } + } + Logger.printDebug(() -> { + StringBuilder builder = new StringBuilder("Downloaded segments:"); + for (SponsorSegment segment : segments) { + builder.append('\n').append(segment); + } + return builder.toString(); + }); + runVipCheckInBackgroundIfNeeded(); + } else if (responseCode == 404) { + // no segments are found. a normal response + Logger.printDebug(() -> "No segments found for video: " + videoId); + } else { + handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_status", responseCode), null); + connection.disconnect(); // something went wrong, might as well disconnect + } + } catch (SocketTimeoutException ex) { + handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_timeout"), ex); + } catch (IOException ex) { + handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_generic"), ex); + } catch (Exception ex) { + // Should never happen + Logger.printException(() -> "getSegments failure", ex); + } + + return segments.toArray(new SponsorSegment[0]); + } + + public static void submitSegments(@NonNull String videoId, @NonNull String category, + long startTime, long endTime, long videoLength) { + Utils.verifyOffMainThread(); + try { + String privateUserId = SponsorBlockSettings.getSBPrivateUserID(); + String start = String.format(Locale.US, TIME_TEMPLATE, startTime / 1000f); + String end = String.format(Locale.US, TIME_TEMPLATE, endTime / 1000f); + String duration = String.format(Locale.US, TIME_TEMPLATE, videoLength / 1000f); + + HttpURLConnection connection = getConnectionFromRoute(SBRoutes.SUBMIT_SEGMENTS, privateUserId, videoId, category, start, end, duration); + final int responseCode = connection.getResponseCode(); + + final String messageToToast = switch (responseCode) { + case HTTP_STATUS_CODE_SUCCESS -> str("revanced_sb_submit_succeeded"); + case 409 -> str("revanced_sb_submit_failed_duplicate"); + case 403 -> + str("revanced_sb_submit_failed_forbidden", Requester.parseErrorStringAndDisconnect(connection)); + case 429 -> str("revanced_sb_submit_failed_rate_limit"); + case 400 -> + str("revanced_sb_submit_failed_invalid", Requester.parseErrorStringAndDisconnect(connection)); + default -> + str("revanced_sb_submit_failed_unknown_error", responseCode, connection.getResponseMessage()); + }; + Utils.showToastLong(messageToToast); + } catch (SocketTimeoutException ex) { + // Always show, even if show connection toasts is turned off + Utils.showToastLong(str("revanced_sb_submit_failed_timeout")); + } catch (IOException ex) { + Utils.showToastLong(str("revanced_sb_submit_failed_unknown_error", 0, ex.getMessage())); + } catch (Exception ex) { + Logger.printException(() -> "failed to submit segments", ex); + } + } + + public static void sendSegmentSkippedViewedRequest(@NonNull SponsorSegment segment) { + Utils.verifyOffMainThread(); + try { + HttpURLConnection connection = getConnectionFromRoute(SBRoutes.VIEWED_SEGMENT, segment.UUID); + final int responseCode = connection.getResponseCode(); + + if (responseCode == HTTP_STATUS_CODE_SUCCESS) { + Logger.printDebug(() -> "Successfully sent view count for segment: " + segment); + } else { + Logger.printDebug(() -> "Failed to sent view count for segment: " + segment.UUID + + " responseCode: " + responseCode); // debug level, no toast is shown + } + } catch (IOException ex) { + Logger.printInfo(() -> "Failed to send view count", ex); // do not show a toast + } catch (Exception ex) { + Logger.printException(() -> "Failed to send view count request", ex); // should never happen + } + } + + public static void voteForSegmentOnBackgroundThread(@NonNull SponsorSegment segment, @NonNull SegmentVote voteOption) { + voteOrRequestCategoryChange(segment, voteOption, null); + } + + public static void voteToChangeCategoryOnBackgroundThread(@NonNull SponsorSegment segment, @NonNull SegmentCategory categoryToVoteFor) { + voteOrRequestCategoryChange(segment, SegmentVote.CATEGORY_CHANGE, categoryToVoteFor); + } + + private static void voteOrRequestCategoryChange(@NonNull SponsorSegment segment, @NonNull SegmentVote voteOption, SegmentCategory categoryToVoteFor) { + Utils.runOnBackgroundThread(() -> { + try { + String segmentUuid = segment.UUID; + String uuid = SponsorBlockSettings.getSBPrivateUserID(); + HttpURLConnection connection = (voteOption == SegmentVote.CATEGORY_CHANGE) + ? getConnectionFromRoute(SBRoutes.VOTE_ON_SEGMENT_CATEGORY, uuid, segmentUuid, categoryToVoteFor.keyValue) + : getConnectionFromRoute(SBRoutes.VOTE_ON_SEGMENT_QUALITY, uuid, segmentUuid, String.valueOf(voteOption.apiVoteType)); + final int responseCode = connection.getResponseCode(); + + switch (responseCode) { + case HTTP_STATUS_CODE_SUCCESS: + Logger.printDebug(() -> "Vote success for segment: " + segment); + break; + case 403: + Utils.showToastLong( + str("revanced_sb_vote_failed_forbidden", Requester.parseErrorStringAndDisconnect(connection))); + break; + default: + Utils.showToastLong( + str("revanced_sb_vote_failed_unknown_error", responseCode, connection.getResponseMessage())); + break; + } + } catch (SocketTimeoutException ex) { + Utils.showToastShort(str("revanced_sb_vote_failed_timeout")); + } catch (IOException ex) { + Utils.showToastShort(str("revanced_sb_vote_failed_unknown_error", 0, ex.getMessage())); + } catch (Exception ex) { + Logger.printException(() -> "failed to vote for segment", ex); // should never happen + } + }); + } + + /** + * @return NULL, if stats fetch failed + */ + @Nullable + public static UserStats retrieveUserStats() { + Utils.verifyOffMainThread(); + try { + UserStats stats = new UserStats(getJSONObject(SBRoutes.GET_USER_STATS, SponsorBlockSettings.getSBPrivateUserID())); + Logger.printDebug(() -> "user stats: " + stats); + return stats; + } catch (IOException ex) { + Logger.printInfo(() -> "failed to retrieve user stats", ex); // info level, do not show a toast + } catch (Exception ex) { + Logger.printException(() -> "failure retrieving user stats", ex); // should never happen + } + return null; + } + + /** + * @return NULL if the call was successful. If unsuccessful, an error message is returned. + */ + @Nullable + public static String setUsername(@NonNull String username) { + Utils.verifyOffMainThread(); + try { + HttpURLConnection connection = getConnectionFromRoute(SBRoutes.CHANGE_USERNAME, SponsorBlockSettings.getSBPrivateUserID(), username); + final int responseCode = connection.getResponseCode(); + String responseMessage = connection.getResponseMessage(); + if (responseCode == HTTP_STATUS_CODE_SUCCESS) { + return null; + } + return str("revanced_sb_stats_username_change_unknown_error", responseCode, responseMessage); + } catch (Exception ex) { // should never happen + Logger.printInfo(() -> "failed to set username", ex); // do not toast + return str("revanced_sb_stats_username_change_unknown_error", 0, ex.getMessage()); + } + } + + public static void runVipCheckInBackgroundIfNeeded() { + if (!SponsorBlockSettings.userHasSBPrivateId()) { + return; // User cannot be a VIP. User has never voted, created any segments, or has imported a SB user id. + } + long now = System.currentTimeMillis(); + if (now < (Settings.SB_LAST_VIP_CHECK.get() + TimeUnit.DAYS.toMillis(3))) { + return; + } + Utils.runOnBackgroundThread(() -> { + try { + JSONObject json = getJSONObject(SBRoutes.IS_USER_VIP, SponsorBlockSettings.getSBPrivateUserID()); + boolean vip = json.getBoolean("vip"); + Settings.SB_USER_IS_VIP.save(vip); + Settings.SB_LAST_VIP_CHECK.save(now); + } catch (IOException ex) { + Logger.printInfo(() -> "Failed to check VIP (network error)", ex); // info, so no error toast is shown + } catch (Exception ex) { + Logger.printException(() -> "Failed to check VIP", ex); // should never happen + } + }); + } + + // helpers + + private static HttpURLConnection getConnectionFromRoute(@NonNull Route route, String... params) throws IOException { + HttpURLConnection connection = Requester.getConnectionFromRoute(Settings.SB_API_URL.get(), route, params); + connection.setConnectTimeout(TIMEOUT_TCP_DEFAULT_MILLISECONDS); + connection.setReadTimeout(TIMEOUT_HTTP_DEFAULT_MILLISECONDS); + return connection; + } + + private static JSONObject getJSONObject(@NonNull Route route, String... params) throws IOException, JSONException { + return Requester.parseJSONObject(getConnectionFromRoute(route, params)); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/CreateSegmentButtonController.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/CreateSegmentButtonController.java new file mode 100644 index 0000000000..44478658a7 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/CreateSegmentButtonController.java @@ -0,0 +1,89 @@ +package app.revanced.extension.youtube.sponsorblock.ui; + +import static app.revanced.extension.shared.utils.Utils.getChildView; + +import android.view.View; +import android.widget.ImageView; + +import java.lang.ref.WeakReference; +import java.util.Objects; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.overlaybutton.BottomControlButton; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.VideoInformation; + +@SuppressWarnings("unused") +public class CreateSegmentButtonController { + private static WeakReference buttonReference = new WeakReference<>(null); + private static boolean isVisible; + + + /** + * injection point + */ + public static void initialize(View youtubeControlsLayout) { + try { + ImageView imageView = Objects.requireNonNull(getChildView(youtubeControlsLayout, "revanced_sb_create_segment_button")); + imageView.setVisibility(View.GONE); + imageView.setOnClickListener(v -> SponsorBlockViewController.toggleNewSegmentLayoutVisibility()); + buttonReference = new WeakReference<>(imageView); + } catch (Exception ex) { + Logger.printException(() -> "Unable to set RelativeLayout", ex); + } + } + + public static void changeVisibility(boolean visible, boolean animation) { + ImageView imageView = buttonReference.get(); + if (imageView == null || isVisible == visible) return; + isVisible = visible; + + if (visible) { + imageView.clearAnimation(); + if (!shouldBeShown()) { + return; + } + if (animation) { + imageView.startAnimation(BottomControlButton.getButtonFadeIn()); + } + imageView.setVisibility(View.VISIBLE); + return; + } + if (imageView.getVisibility() == View.VISIBLE) { + imageView.clearAnimation(); + if (animation) { + imageView.startAnimation(BottomControlButton.getButtonFadeOut()); + } + imageView.setVisibility(View.GONE); + } + } + + public static void changeVisibilityNegatedImmediate() { + ImageView imageView = buttonReference.get(); + if (imageView == null) return; + if (!shouldBeShown()) return; + + imageView.clearAnimation(); + imageView.startAnimation(BottomControlButton.getButtonFadeOutImmediate()); + imageView.setVisibility(View.GONE); + } + + private static boolean shouldBeShown() { + return Settings.SB_ENABLED.get() && Settings.SB_CREATE_NEW_SEGMENT.get() + && !VideoInformation.isAtEndOfVideo(); + } + + public static void hide() { + if (!isVisible) { + return; + } + Utils.verifyOnMainThread(); + View v = buttonReference.get(); + if (v == null) { + return; + } + v.setVisibility(View.GONE); + isVisible = false; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/NewSegmentLayout.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/NewSegmentLayout.java new file mode 100644 index 0000000000..e0b6ae69dc --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/NewSegmentLayout.java @@ -0,0 +1,121 @@ +package app.revanced.extension.youtube.sponsorblock.ui; + +import static app.revanced.extension.shared.utils.ResourceUtils.getIdentifier; +import static app.revanced.extension.shared.utils.ResourceUtils.getLayoutIdentifier; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.drawable.RippleDrawable; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.widget.FrameLayout; +import android.widget.ImageButton; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.ResourceUtils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.VideoInformation; +import app.revanced.extension.youtube.sponsorblock.SponsorBlockUtils; + +public final class NewSegmentLayout extends FrameLayout { + private static final ColorStateList rippleColorStateList = new ColorStateList( + new int[][]{new int[]{android.R.attr.state_enabled}}, + new int[]{0x33ffffff} // sets the ripple color to white + ); + private final int rippleEffectId; + + public NewSegmentLayout(final Context context) { + this(context, null); + } + + public NewSegmentLayout(final Context context, final AttributeSet attributeSet) { + this(context, attributeSet, 0); + } + + public NewSegmentLayout(final Context context, final AttributeSet attributeSet, final int defStyleAttr) { + this(context, attributeSet, defStyleAttr, 0); + } + + public NewSegmentLayout(final Context context, final AttributeSet attributeSet, + final int defStyleAttr, final int defStyleRes) { + super(context, attributeSet, defStyleAttr, defStyleRes); + + LayoutInflater.from(context).inflate(getLayoutIdentifier("revanced_sb_new_segment"), this, true); + + + TypedValue rippleEffect = new TypedValue(); + context.getTheme().resolveAttribute(android.R.attr.selectableItemBackground, rippleEffect, true); + rippleEffectId = rippleEffect.resourceId; + + initializeButton( + context, + "revanced_sb_new_segment_rewind", + () -> VideoInformation.seekToRelative(-Settings.SB_CREATE_NEW_SEGMENT_STEP.get()), + "Rewind button clicked" + ); + + initializeButton( + context, + "revanced_sb_new_segment_forward", + () -> VideoInformation.seekToRelative(Settings.SB_CREATE_NEW_SEGMENT_STEP.get()), + "Forward button clicked" + ); + + initializeButton( + context, + "revanced_sb_new_segment_adjust", + SponsorBlockUtils::onMarkLocationClicked, + "Adjust button clicked" + ); + + initializeButton( + context, + "revanced_sb_new_segment_compare", + SponsorBlockUtils::onPreviewClicked, + "Compare button clicked" + ); + + initializeButton( + context, + "revanced_sb_new_segment_edit", + SponsorBlockUtils::onEditByHandClicked, + "Edit button clicked" + ); + + initializeButton( + context, + "revanced_sb_new_segment_publish", + SponsorBlockUtils::onPublishClicked, + "Publish button clicked" + ); + } + + /** + * Initializes a segment button with the given resource identifier name with the given handler and a ripple effect. + * + * @param context The context. + * @param resourceIdentifierName The resource identifier name for the button. + * @param handler The handler for the button's click event. + * @param debugMessage The debug message to print when the button is clicked. + */ + private void initializeButton(final Context context, final String resourceIdentifierName, + final ButtonOnClickHandlerFunction handler, final String debugMessage) { + final ImageButton button = findViewById(getIdentifier(resourceIdentifierName, ResourceUtils.ResourceType.ID, context)); + + // Add ripple effect + button.setBackgroundResource(rippleEffectId); + RippleDrawable rippleDrawable = (RippleDrawable) button.getBackground(); + rippleDrawable.setColor(rippleColorStateList); + + button.setOnClickListener((v) -> { + handler.apply(); + Logger.printDebug(() -> debugMessage); + }); + } + + @FunctionalInterface + public interface ButtonOnClickHandlerFunction { + void apply(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SkipSponsorButton.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SkipSponsorButton.java new file mode 100644 index 0000000000..43fe673eae --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SkipSponsorButton.java @@ -0,0 +1,65 @@ +package app.revanced.extension.youtube.sponsorblock.ui; + +import static app.revanced.extension.shared.utils.ResourceUtils.getDimension; +import static app.revanced.extension.shared.utils.ResourceUtils.getIdIdentifier; +import static app.revanced.extension.shared.utils.ResourceUtils.getLayoutIdentifier; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import java.util.Objects; + +import app.revanced.extension.youtube.sponsorblock.SegmentPlaybackController; +import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment; + +public class SkipSponsorButton extends FrameLayout { + private final TextView skipSponsorTextView; + private SponsorSegment segment; + + public SkipSponsorButton(Context context) { + this(context, null); + } + + public SkipSponsorButton(Context context, AttributeSet attributeSet) { + this(context, attributeSet, 0); + } + + public SkipSponsorButton(Context context, AttributeSet attributeSet, int defStyleAttr) { + this(context, attributeSet, defStyleAttr, 0); + } + + public SkipSponsorButton(Context context, AttributeSet attributeSet, int defStyleAttr, int defStyleRes) { + super(context, attributeSet, defStyleAttr, defStyleRes); + + LayoutInflater.from(context).inflate(getLayoutIdentifier("revanced_sb_skip_sponsor_button"), this, true); // layout:revanced_sb_skip_sponsor_button + setMinimumHeight(getDimension("ad_skip_ad_button_min_height")); // dimen:ad_skip_ad_button_min_height + final LinearLayout skipSponsorBtnContainer = (LinearLayout) Objects.requireNonNull((View) findViewById(getIdIdentifier("revanced_sb_skip_sponsor_button_container"))); // id:revanced_sb_skip_sponsor_button_container + skipSponsorTextView = (TextView) Objects.requireNonNull((View) findViewById(getIdIdentifier("revanced_sb_skip_sponsor_button_text"))); // id:revanced_sb_skip_sponsor_button_text; + + skipSponsorBtnContainer.setOnClickListener(v -> { + // The view controller handles hiding this button, but hide it here as well just in case something goofs. + setVisibility(View.GONE); + SegmentPlaybackController.onSkipSegmentClicked(segment); + }); + } + + /** + * @return true, if this button state was changed + */ + public boolean updateSkipButtonText(@NonNull SponsorSegment segment) { + this.segment = segment; + final String newText = segment.getSkipButtonText(); + if (newText.equals(skipSponsorTextView.getText().toString())) { + return false; + } + skipSponsorTextView.setText(newText); + return true; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SponsorBlockViewController.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SponsorBlockViewController.java new file mode 100644 index 0000000000..bccbbd3e6a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SponsorBlockViewController.java @@ -0,0 +1,240 @@ +package app.revanced.extension.youtube.sponsorblock.ui; + +import static app.revanced.extension.shared.utils.ResourceUtils.getDimension; +import static app.revanced.extension.shared.utils.ResourceUtils.getLayoutIdentifier; +import static app.revanced.extension.shared.utils.Utils.getChildView; +import static app.revanced.extension.youtube.utils.ExtendedUtils.isFullscreenHidden; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.RelativeLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.ref.WeakReference; +import java.util.Objects; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.player.PlayerPatch; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.PlayerType; +import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment; + +@SuppressWarnings("unused") +public class SponsorBlockViewController { + private static WeakReference inlineSponsorOverlayRef = new WeakReference<>(null); + private static WeakReference youtubeOverlaysLayoutRef = new WeakReference<>(null); + private static WeakReference skipHighlightButtonRef = new WeakReference<>(null); + private static WeakReference skipSponsorButtonRef = new WeakReference<>(null); + private static WeakReference newSegmentLayoutRef = new WeakReference<>(null); + private static boolean canShowViewElements; + private static boolean newSegmentLayoutVisible; + @Nullable + private static SponsorSegment skipHighlight; + @Nullable + private static SponsorSegment skipSegment; + private static final int ctaBottomMargin; + private static final int defaultBottomMargin; + private static final int hiddenBottomMargin; + + static { + PlayerType.getOnChange().addObserver((PlayerType type) -> { + playerTypeChanged(type); + return null; + }); + + defaultBottomMargin = getDimension("brand_interaction_default_bottom_margin"); + ctaBottomMargin = getDimension("brand_interaction_cta_bottom_margin") + PlayerPatch.getQuickActionsTopMargin(); + hiddenBottomMargin = (int) Math.round((ctaBottomMargin) * 0.5); + } + + public static Context getOverLaysViewGroupContext() { + ViewGroup group = youtubeOverlaysLayoutRef.get(); + if (group == null) { + return null; + } + return group.getContext(); + } + + /** + * Injection point. + */ + public static void initialize(ViewGroup viewGroup) { + try { + Logger.printDebug(() -> "initializing"); + + // hide any old components, just in case they somehow are still hanging around + hideAll(); + + Context context = Utils.getContext(); + RelativeLayout layout = new RelativeLayout(context); + layout.setLayoutParams(new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)); + LayoutInflater.from(context).inflate(getLayoutIdentifier("revanced_sb_inline_sponsor_overlay"), layout); + inlineSponsorOverlayRef = new WeakReference<>(layout); + + viewGroup.addView(layout); + viewGroup.setOnHierarchyChangeListener(new ViewGroup.OnHierarchyChangeListener() { + @Override + public void onChildViewAdded(View parent, View child) { + // ensure SB buttons and controls are always on top, otherwise the endscreen cards can cover the skip button + RelativeLayout layout = inlineSponsorOverlayRef.get(); + if (layout != null) { + layout.bringToFront(); + } + } + + @Override + public void onChildViewRemoved(View parent, View child) { + } + }); + youtubeOverlaysLayoutRef = new WeakReference<>(viewGroup); + + skipHighlightButtonRef = new WeakReference<>( + Objects.requireNonNull(getChildView(layout, "revanced_sb_skip_highlight_button"))); + skipSponsorButtonRef = new WeakReference<>( + Objects.requireNonNull(getChildView(layout, "revanced_sb_skip_sponsor_button"))); + newSegmentLayoutRef = new WeakReference<>( + Objects.requireNonNull(getChildView(layout, "revanced_sb_new_segment_view"))); + + newSegmentLayoutVisible = false; + skipHighlight = null; + skipSegment = null; + } catch (Exception ex) { + Logger.printException(() -> "initialize failure", ex); + } + } + + public static void hideAll() { + hideSkipHighlightButton(); + hideSkipSegmentButton(); + hideNewSegmentLayout(); + } + + public static void showSkipHighlightButton(@NonNull SponsorSegment segment) { + skipHighlight = Objects.requireNonNull(segment); + NewSegmentLayout newSegmentLayout = newSegmentLayoutRef.get(); + // don't show highlight button if create new segment is visible + final boolean buttonVisibility = newSegmentLayout == null || newSegmentLayout.getVisibility() != View.VISIBLE; + updateSkipButton(skipHighlightButtonRef.get(), segment, buttonVisibility); + } + + public static void showSkipSegmentButton(@NonNull SponsorSegment segment) { + skipSegment = Objects.requireNonNull(segment); + updateSkipButton(skipSponsorButtonRef.get(), segment, true); + } + + public static void hideSkipHighlightButton() { + skipHighlight = null; + updateSkipButton(skipHighlightButtonRef.get(), null, false); + } + + public static void hideSkipSegmentButton() { + skipSegment = null; + updateSkipButton(skipSponsorButtonRef.get(), null, false); + } + + private static void updateSkipButton(@Nullable SkipSponsorButton button, + @Nullable SponsorSegment segment, boolean visible) { + if (button == null) { + return; + } + if (segment != null) { + button.updateSkipButtonText(segment); + } + setViewVisibility(button, visible); + } + + public static void toggleNewSegmentLayoutVisibility() { + NewSegmentLayout newSegmentLayout = newSegmentLayoutRef.get(); + if (newSegmentLayout == null) { // should never happen + Logger.printException(() -> "toggleNewSegmentLayoutVisibility failure"); + return; + } + newSegmentLayoutVisible = (newSegmentLayout.getVisibility() != View.VISIBLE); + if (skipHighlight != null) { + setViewVisibility(skipHighlightButtonRef.get(), !newSegmentLayoutVisible); + } + setViewVisibility(newSegmentLayout, newSegmentLayoutVisible); + } + + public static void hideNewSegmentLayout() { + newSegmentLayoutVisible = false; + setViewVisibility(newSegmentLayoutRef.get(), false); + } + + private static void setViewVisibility(@Nullable View view, boolean visible) { + if (view == null) { + return; + } + visible &= canShowViewElements; + final int desiredVisibility = visible ? View.VISIBLE : View.GONE; + if (view.getVisibility() != desiredVisibility) { + view.setVisibility(desiredVisibility); + } + } + + private static void playerTypeChanged(@NonNull PlayerType playerType) { + try { + final boolean isWatchFullScreen = playerType == PlayerType.WATCH_WHILE_FULLSCREEN; + canShowViewElements = (isWatchFullScreen || playerType == PlayerType.WATCH_WHILE_MAXIMIZED); + + NewSegmentLayout newSegmentLayout = newSegmentLayoutRef.get(); + setNewSegmentLayoutMargins(newSegmentLayout, isWatchFullScreen); + setViewVisibility(newSegmentLayoutRef.get(), newSegmentLayoutVisible); + + SkipSponsorButton skipHighlightButton = skipHighlightButtonRef.get(); + setSkipButtonMargins(skipHighlightButton, isWatchFullScreen); + setViewVisibility(skipHighlightButton, skipHighlight != null); + + SkipSponsorButton skipSponsorButton = skipSponsorButtonRef.get(); + setSkipButtonMargins(skipSponsorButton, isWatchFullScreen); + setViewVisibility(skipSponsorButton, skipSegment != null); + } catch (Exception ex) { + Logger.printException(() -> "Player type changed failure", ex); + } + } + + private static void setNewSegmentLayoutMargins(@Nullable NewSegmentLayout layout, boolean fullScreen) { + if (layout != null) { + setLayoutMargins(layout, fullScreen); + } + } + + private static void setSkipButtonMargins(@Nullable SkipSponsorButton button, boolean fullScreen) { + if (button != null) { + setLayoutMargins(button, fullScreen); + } + } + + private static void setLayoutMargins(@NonNull View view, boolean fullScreen) { + RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) view.getLayoutParams(); + if (params == null) { + Logger.printException(() -> "Unable to setNewSegmentLayoutMargins (params are null)"); + return; + } + params.bottomMargin = fullScreen ? (isFullscreenHidden() ? hiddenBottomMargin : ctaBottomMargin) : defaultBottomMargin; + view.setLayoutParams(params); + } + + /** + * Injection point. + */ + public static void endOfVideoReached() { + try { + Logger.printDebug(() -> "endOfVideoReached"); + // the buttons automatically set themselves to visible when appropriate, + // but if buttons are showing when the end of the video is reached then they need + // to be forcefully hidden + if (!Settings.ALWAYS_REPEAT.get()) { + CreateSegmentButtonController.hide(); + VotingButtonController.hide(); + } + } catch (Exception ex) { + Logger.printException(() -> "endOfVideoReached failure", ex); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/VotingButtonController.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/VotingButtonController.java new file mode 100644 index 0000000000..4b8ab8e2e0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/VotingButtonController.java @@ -0,0 +1,92 @@ +package app.revanced.extension.youtube.sponsorblock.ui; + +import static app.revanced.extension.shared.utils.Utils.getChildView; +import static app.revanced.extension.youtube.sponsorblock.SegmentPlaybackController.videoHasSegments; + +import android.view.View; +import android.widget.ImageView; + +import java.lang.ref.WeakReference; +import java.util.Objects; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.overlaybutton.BottomControlButton; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.VideoInformation; +import app.revanced.extension.youtube.sponsorblock.SponsorBlockUtils; + +@SuppressWarnings("unused") +public class VotingButtonController { + private static WeakReference buttonReference = new WeakReference<>(null); + private static boolean isVisible; + + + /** + * injection point + */ + public static void initialize(View youtubeControlsLayout) { + try { + ImageView imageView = Objects.requireNonNull(getChildView(youtubeControlsLayout, "revanced_sb_voting_button")); + imageView.setVisibility(View.GONE); + imageView.setOnClickListener(v -> SponsorBlockUtils.onVotingClicked(v.getContext())); + buttonReference = new WeakReference<>(imageView); + } catch (Exception ex) { + Logger.printException(() -> "Unable to set RelativeLayout", ex); + } + } + + public static void changeVisibility(boolean visible, boolean animation) { + ImageView imageView = buttonReference.get(); + if (imageView == null || isVisible == visible) return; + isVisible = visible; + + if (visible) { + imageView.clearAnimation(); + if (!shouldBeShown()) { + return; + } + if (animation) { + imageView.startAnimation(BottomControlButton.getButtonFadeIn()); + } + imageView.setVisibility(View.VISIBLE); + return; + } + if (imageView.getVisibility() == View.VISIBLE) { + imageView.clearAnimation(); + if (animation) { + imageView.startAnimation(BottomControlButton.getButtonFadeOut()); + } + imageView.setVisibility(View.GONE); + } + } + + public static void changeVisibilityNegatedImmediate() { + ImageView imageView = buttonReference.get(); + if (imageView == null) return; + if (!shouldBeShown()) return; + + + imageView.clearAnimation(); + imageView.startAnimation(BottomControlButton.getButtonFadeOutImmediate()); + imageView.setVisibility(View.GONE); + } + + private static boolean shouldBeShown() { + return Settings.SB_ENABLED.get() && Settings.SB_VOTING_BUTTON.get() + && !VideoInformation.isAtEndOfVideo() && videoHasSegments(); + } + + public static void hide() { + if (!isVisible) { + return; + } + Utils.verifyOnMainThread(); + View v = buttonReference.get(); + if (v == null) { + return; + } + v.setVisibility(View.GONE); + isVisible = false; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/SwipeControlsConfigurationProvider.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/SwipeControlsConfigurationProvider.kt new file mode 100644 index 0000000000..3d3c5d83da --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/SwipeControlsConfigurationProvider.kt @@ -0,0 +1,161 @@ +package app.revanced.extension.youtube.swipecontrols + +import android.content.Context +import android.graphics.Color +import app.revanced.extension.youtube.settings.Settings +import app.revanced.extension.youtube.shared.LockModeState +import app.revanced.extension.youtube.shared.PlayerType +import app.revanced.extension.youtube.utils.ExtendedUtils.validateValue + +/** + * provider for configuration for volume and brightness swipe controls + * + * @param context the context to create in + */ +class SwipeControlsConfigurationProvider( + private val context: Context, +) { + // region swipe enable + + /** + * should swipe controls be enabled? (global setting) + */ + val enableSwipeControls: Boolean + get() = isFullscreenVideo && (enableVolumeControls || enableBrightnessControl) + + /** + * should swipe controls for volume be enabled? + */ + val enableVolumeControls: Boolean + get() = Settings.ENABLE_SWIPE_VOLUME.get() + + /** + * should swipe controls for volume be enabled? + */ + val enableBrightnessControl: Boolean + get() = Settings.ENABLE_SWIPE_BRIGHTNESS.get() + + /** + * is the video player currently in fullscreen mode? + */ + private val isFullscreenVideo: Boolean + get() = PlayerType.current == PlayerType.WATCH_WHILE_FULLSCREEN + + /** + * is the video player currently in lock mode? + */ + val isScreenLocked: Boolean + get() = LockModeState.current.isLocked() + + val enableSwipeControlsLockMode: Boolean + get() = Settings.SWIPE_LOCK_MODE.get() + + // endregion + + // region keys enable + + /** + * should volume key controls be overwritten? (global setting) + */ + val overwriteVolumeKeyControls: Boolean + get() = isFullscreenVideo && enableVolumeControls + + // endregion + + // region gesture adjustments + + /** + * should press-to-swipe be enabled? + */ + val shouldEnablePressToSwipe: Boolean + get() = Settings.ENABLE_SWIPE_PRESS_TO_ENGAGE.get() + + /** + * threshold for swipe detection + * this may be called rapidly in onScroll, so we have to load it once and then leave it constant + */ + val swipeMagnitudeThreshold: Int + get() = Settings.SWIPE_MAGNITUDE_THRESHOLD.get() + + /** + * swipe distances for brightness + */ + val brightnessDistance: Float + get() = validateValue( + Settings.SWIPE_BRIGHTNESS_SENSITIVITY, + 1, + 1000, + "revanced_swipe_brightness_sensitivity_invalid_toast" + ).toFloat() / 100 // 1f + + /** + * swipe distances for volume + */ + val volumeDistance: Float + get() = validateValue( + Settings.SWIPE_VOLUME_SENSITIVITY, + 1, + 1000, + "revanced_swipe_volume_sensitivity_invalid_toast" + ).toFloat() / 100 * 10 // 10f + + // endregion + + // region overlay adjustments + + /** + * should the overlay enable haptic feedback? + */ + val shouldEnableHapticFeedback: Boolean + get() = Settings.ENABLE_SWIPE_HAPTIC_FEEDBACK.get() + + /** + * how long the overlay should be shown on changes + */ + val overlayShowTimeoutMillis: Long + get() = Settings.SWIPE_OVERLAY_TIMEOUT.get() + + /** + * text size for the overlay, in sp + */ + val overlayTextSize: Int + get() = Settings.SWIPE_OVERLAY_TEXT_SIZE.get() + + /** + * get the background color for text on the overlay, as a color int + */ + val overlayTextBackgroundColor: Int + get() = Color.argb(Settings.SWIPE_OVERLAY_BACKGROUND_ALPHA.get(), 0, 0, 0) + + /** + * get the foreground color for text on the overlay, as a color int + */ + val overlayForegroundColor: Int + get() = Color.WHITE + + // endregion + + // region behaviour + + /** + * should the brightness be saved and restored when exiting or entering fullscreen + */ + val shouldSaveAndRestoreBrightness: Boolean + get() = Settings.ENABLE_SAVE_AND_RESTORE_BRIGHTNESS.get() + + /** + * should auto-brightness be enabled at the lowest value of the brightness gesture + */ + val shouldLowestValueEnableAutoBrightness: Boolean + get() = Settings.ENABLE_SWIPE_LOWEST_VALUE_AUTO_BRIGHTNESS.get() + + /** + * variable that stores the brightness gesture value in the settings + */ + var savedScreenBrightnessValue: Float + get() = Settings.SWIPE_BRIGHTNESS_VALUE.get() + set(value) = Settings.SWIPE_BRIGHTNESS_VALUE.save(value) + + // endregion + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/SwipeControlsHostActivity.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/SwipeControlsHostActivity.kt new file mode 100644 index 0000000000..3ebebc252f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/SwipeControlsHostActivity.kt @@ -0,0 +1,236 @@ +package app.revanced.extension.youtube.swipecontrols + +import android.app.Activity +import android.os.Build +import android.os.Bundle +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.ViewGroup +import app.revanced.extension.shared.utils.Logger.printDebug +import app.revanced.extension.shared.utils.Logger.printException +import app.revanced.extension.youtube.shared.PlayerType +import app.revanced.extension.youtube.swipecontrols.controller.AudioVolumeController +import app.revanced.extension.youtube.swipecontrols.controller.ScreenBrightnessController +import app.revanced.extension.youtube.swipecontrols.controller.SwipeZonesController +import app.revanced.extension.youtube.swipecontrols.controller.VolumeKeysController +import app.revanced.extension.youtube.swipecontrols.controller.gesture.ClassicSwipeController +import app.revanced.extension.youtube.swipecontrols.controller.gesture.PressToSwipeController +import app.revanced.extension.youtube.swipecontrols.controller.gesture.core.GestureController +import app.revanced.extension.youtube.swipecontrols.misc.Rectangle +import app.revanced.extension.youtube.swipecontrols.views.SwipeControlsOverlayLayout +import java.lang.ref.WeakReference + +/** + * The main controller for volume and brightness swipe controls. + * note that the superclass is overwritten to the superclass of the MainActivity at patch time + * + * @smali Lapp/revanced/integrations/youtube/swipecontrols/SwipeControlsHostActivity; + */ +class SwipeControlsHostActivity : Activity() { + /** + * current instance of [AudioVolumeController] + */ + var audio: AudioVolumeController? = null + + /** + * current instance of [ScreenBrightnessController] + */ + var screen: ScreenBrightnessController? = null + + /** + * current instance of [SwipeControlsConfigurationProvider] + */ + lateinit var config: SwipeControlsConfigurationProvider + + /** + * current instance of [SwipeControlsOverlayLayout] + */ + lateinit var overlay: SwipeControlsOverlayLayout + + /** + * current instance of [SwipeZonesController] + */ + lateinit var zones: SwipeZonesController + + /** + * main gesture controller + */ + private lateinit var gesture: GestureController + + /** + * main volume keys controller + */ + private lateinit var keys: VolumeKeysController + + /** + * current content view with id [android.R.id.content] + */ + private val contentRoot + get() = window.decorView.findViewById(android.R.id.content) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initialize() + } + + override fun onStart() { + super.onStart() + reAttachOverlays() + } + + override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { + ensureInitialized() + return if ((ev != null) && gesture.submitTouchEvent(ev)) { + true + } else { + super.dispatchTouchEvent(ev) + } + } + + override fun dispatchKeyEvent(ev: KeyEvent?): Boolean { + ensureInitialized() + return if ((ev != null) && keys.onKeyEvent(ev)) { + true + } else { + super.dispatchKeyEvent(ev) + } + } + + /** + * dispatch a touch event to downstream views + * + * @param event the event to dispatch + * @return was the event consumed? + */ + fun dispatchDownstreamTouchEvent(event: MotionEvent) = + super.dispatchTouchEvent(event) + + /** + * ensures that swipe controllers are initialized and attached. + * on some ROMs with SDK <= 23, [onCreate] and [onStart] may not be called correctly. + * see https://github.com/revanced/revanced-patches/issues/446 + */ + private fun ensureInitialized() { + if (!this::config.isInitialized) { + printException { + "swipe controls were not initialized in onCreate, initializing on-the-fly (SDK is ${Build.VERSION.SDK_INT})" + } + initialize() + reAttachOverlays() + } + } + + /** + * initializes controllers, only call once + */ + private fun initialize() { + // create controllers + printDebug { "initializing swipe controls controllers" } + config = SwipeControlsConfigurationProvider(this) + keys = VolumeKeysController(this) + audio = createAudioController() + screen = createScreenController() + + // create overlay + SwipeControlsOverlayLayout(this, config).let { + overlay = it + contentRoot.addView(it) + } + + // create swipe zone controller + zones = SwipeZonesController(this) { + Rectangle( + contentRoot.x.toInt(), + contentRoot.y.toInt(), + contentRoot.width, + contentRoot.height, + ) + } + + // create the gesture controller + gesture = createGestureController() + + // listen for changes in the player type + PlayerType.onChange += this::onPlayerTypeChanged + + // set current instance reference + currentHost = WeakReference(this) + } + + /** + * (re) attaches swipe overlays + */ + private fun reAttachOverlays() { + printDebug { "attaching swipe controls overlay" } + contentRoot.removeView(overlay) + contentRoot.addView(overlay) + } + + // Flag that indicates whether the brightness has been saved and restored default brightness + private var isBrightnessSaved = false + + /** + * called when the player type changes + * + * @param type the new player type + */ + private fun onPlayerTypeChanged(type: PlayerType) { + when { + // If saving and restoring brightness is enabled, and the player type is WATCH_WHILE_FULLSCREEN, + // and brightness has already been saved, then restore the screen brightness + config.shouldSaveAndRestoreBrightness && type == PlayerType.WATCH_WHILE_FULLSCREEN && isBrightnessSaved -> { + screen?.restore() + isBrightnessSaved = false + } + // If saving and restoring brightness is enabled, and brightness has not been saved, + // then save the current screen state, restore default brightness, and mark brightness as saved + config.shouldSaveAndRestoreBrightness && !isBrightnessSaved -> { + screen?.save() + screen?.restoreDefaultBrightness() + isBrightnessSaved = true + } + // If saving and restoring brightness is disabled, simply keep the default brightness + else -> screen?.restoreDefaultBrightness() + } + } + + /** + * create the audio volume controller + */ + private fun createAudioController() = + if (config.enableVolumeControls) { + AudioVolumeController(this) + } else { + null + } + + /** + * create the screen brightness controller instance + */ + private fun createScreenController() = + if (config.enableBrightnessControl) { + ScreenBrightnessController(this) + } else { + null + } + + /** + * create the gesture controller based on settings + */ + private fun createGestureController() = + if (config.shouldEnablePressToSwipe) { + PressToSwipeController(this, config) + } else { + ClassicSwipeController(this, config) + } + + companion object { + /** + * the currently active swipe controls host. + * the reference may be null! + */ + @JvmStatic + var currentHost: WeakReference = WeakReference(null) + private set + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/AudioVolumeController.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/AudioVolumeController.kt new file mode 100644 index 0000000000..dd4ff6463c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/AudioVolumeController.kt @@ -0,0 +1,79 @@ +package app.revanced.extension.youtube.swipecontrols.controller + +import android.content.Context +import android.media.AudioManager +import app.revanced.extension.shared.utils.Logger.printException +import app.revanced.extension.shared.utils.Utils.isSDKAbove +import app.revanced.extension.youtube.swipecontrols.misc.clamp +import kotlin.properties.Delegates + +/** + * controller to adjust the device volume level + * + * @param context the context to bind the audio service in + * @param targetStream the stream that is being controlled. Must be one of the STREAM_* constants in [AudioManager] + */ +class AudioVolumeController( + context: Context, + private val targetStream: Int = AudioManager.STREAM_MUSIC, +) { + + /** + * audio service connection + */ + private lateinit var audioManager: AudioManager + private var minimumVolumeIndex by Delegates.notNull() + private var maximumVolumeIndex by Delegates.notNull() + + init { + // bind audio service + val mgr = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + if (mgr == null) { + printException { "failed to acquire AUDIO_SERVICE" } + } else { + audioManager = mgr + maximumVolumeIndex = audioManager.getStreamMaxVolume(targetStream) + minimumVolumeIndex = + if (isSDKAbove(28)) { + audioManager.getStreamMinVolume( + targetStream, + ) + } else { + 0 + } + } + } + + /** + * the current volume, ranging from 0.0 to [maxVolume] + */ + var volume: Int + get() { + // check if initialized correctly + if (!this::audioManager.isInitialized) return 0 + + // get current volume + return currentVolumeIndex - minimumVolumeIndex + } + set(value) { + // check if initialized correctly + if (!this::audioManager.isInitialized) return + + // set new volume + currentVolumeIndex = + (value + minimumVolumeIndex).clamp(minimumVolumeIndex, maximumVolumeIndex) + } + + /** + * the maximum possible volume + */ + val maxVolume: Int + get() = maximumVolumeIndex - minimumVolumeIndex + + /** + * the current volume index of the target stream + */ + private var currentVolumeIndex: Int + get() = audioManager.getStreamVolume(targetStream) + set(value) = audioManager.setStreamVolume(targetStream, value, 0) +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/ScreenBrightnessController.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/ScreenBrightnessController.kt new file mode 100644 index 0000000000..abf0d0db8e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/ScreenBrightnessController.kt @@ -0,0 +1,67 @@ +package app.revanced.extension.youtube.swipecontrols.controller + +import android.view.WindowManager +import app.revanced.extension.youtube.swipecontrols.SwipeControlsHostActivity +import app.revanced.extension.youtube.swipecontrols.misc.clamp + +/** + * controller to adjust the screen brightness level + * + * @param host the host activity of which the brightness is adjusted, the main controller instance + */ +class ScreenBrightnessController( + val host: SwipeControlsHostActivity, +) { + + /** + * the current screen brightness in percent, ranging from 0.0 to 100.0 + */ + var screenBrightness: Double + get() = rawScreenBrightness * 100.0 + set(value) { + rawScreenBrightness = (value.toFloat() / 100f).clamp(0f, 1f) + } + + /** + * restore the screen brightness to the default device brightness + */ + fun restoreDefaultBrightness() { + rawScreenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + } + + // Flag that indicates whether the brightness has been restored + private var isBrightnessRestored = false + + /** + * save the current screen brightness into settings, to be brought back using [restore] + */ + fun save() { + if (isBrightnessRestored) { + // Saves the current screen brightness value into settings + host.config.savedScreenBrightnessValue = rawScreenBrightness + // Reset the flag + isBrightnessRestored = false + } + } + + /** + * restore the screen brightness from settings saved using [save] + */ + fun restore() { + // Restores the screen brightness value from the saved settings + rawScreenBrightness = host.config.savedScreenBrightnessValue + // Mark that brightness has been restored + isBrightnessRestored = true + } + + /** + * wrapper for the raw screen brightness in [WindowManager.LayoutParams.screenBrightness] + */ + private var rawScreenBrightness: Float + get() = host.window.attributes.screenBrightness + private set(value) { + val attr = host.window.attributes + attr.screenBrightness = value + host.window.attributes = attr + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/SwipeZonesController.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/SwipeZonesController.kt new file mode 100644 index 0000000000..d42e770d27 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/SwipeZonesController.kt @@ -0,0 +1,154 @@ +package app.revanced.extension.youtube.swipecontrols.controller + +import android.app.Activity +import android.util.TypedValue +import android.view.ViewGroup +import app.revanced.extension.shared.utils.ResourceUtils.ResourceType +import app.revanced.extension.shared.utils.ResourceUtils.getIdentifier +import app.revanced.extension.youtube.settings.Settings +import app.revanced.extension.youtube.swipecontrols.misc.Rectangle +import app.revanced.extension.youtube.swipecontrols.misc.applyDimension +import app.revanced.extension.youtube.utils.ExtendedUtils.validateValue +import kotlin.math.min + +/** + * Y- Axis: + * -------- 0 + * ^ + * dead | 40dp + * v + * -------- yDeadTop + * ^ + * swipe | + * v + * -------- yDeadBtm + * ^ + * dead | 80dp + * v + * -------- screenHeight + * + * X- Axis: + * 0 xBrigStart xBrigEnd xVolStart xVolEnd screenWidth + * | | | | | | + * | 20dp | 3/8 | 2/8 | 3/8 | 20dp | + * | <------> | <------> | <------> | <------> | <------> | + * | dead | brightness | dead | volume | dead | + * | <--------------------------------> | + * 1/1 + */ +@Suppress("PrivatePropertyName") +class SwipeZonesController( + private val host: Activity, + private val fallbackScreenRect: () -> Rectangle, +) { + + private val overlayRectSize = validateValue( + Settings.SWIPE_OVERLAY_RECT_SIZE, + 0, + 50, + "revanced_swipe_overlay_rect_size_invalid_toast" + ) + + /** + * 20dp, in pixels + */ + private val _20dp = 20.applyDimension(host, TypedValue.COMPLEX_UNIT_DIP) + + /** + * 40dp, in pixels + */ + private val _40dp = 40.applyDimension(host, TypedValue.COMPLEX_UNIT_DIP) + + /** + * 80dp, in pixels + */ + private val _80dp = 80.applyDimension(host, TypedValue.COMPLEX_UNIT_DIP) + + /** + * id for R.id.player_view + */ + private val playerViewId = getIdentifier("player_view", ResourceType.ID, host) + + /** + * current bounding rectangle of the player + */ + private var playerRect: Rectangle? = null + + /** + * rectangle of the area that is effectively usable for swipe controls + */ + private val effectiveSwipeRect: Rectangle + get() { + maybeAttachPlayerBoundsListener() + val p = if (playerRect != null) playerRect!! else fallbackScreenRect() + return Rectangle( + p.x + _20dp, + p.y + _40dp, + p.width - _20dp, + p.height - _20dp - _80dp, + ) + } + + /** + * the rectangle of the volume control zone + */ + val volume: Rectangle + get() { + val zoneWidth = effectiveSwipeRect.width * overlayRectSize / 100 + return Rectangle( + effectiveSwipeRect.right - zoneWidth, + effectiveSwipeRect.top, + zoneWidth, + effectiveSwipeRect.height, + ) + } + + /** + * the rectangle of the screen brightness control zone + */ + val brightness: Rectangle + get() { + val zoneWidth = effectiveSwipeRect.width * overlayRectSize / 100 + return Rectangle( + effectiveSwipeRect.left, + effectiveSwipeRect.top, + zoneWidth, + effectiveSwipeRect.height, + ) + } + + /** + * try to attach a listener to the player_view and update the player rectangle. + * once a listener is attached, this function does nothing + */ + private fun maybeAttachPlayerBoundsListener() { + if (playerRect != null) return + host.findViewById(playerViewId)?.let { + onPlayerViewLayout(it) + it.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + onPlayerViewLayout(it) + } + } + } + + /** + * update the player rectangle on player_view layout + * + * @param playerView the player view + */ + private fun onPlayerViewLayout(playerView: ViewGroup) { + playerView.getChildAt(0)?.let { playerSurface -> + // the player surface is centered in the player view + // figure out the width of the surface including the padding (same on the left and right side) + // and use that width for the player rectangle size + // this automatically excludes any engagement panel from the rect + val playerWidthWithPadding = playerSurface.width + (playerSurface.x.toInt() * 2) + playerRect = Rectangle( + playerView.x.toInt(), + playerView.y.toInt(), + min(playerView.width, playerWidthWithPadding), + playerView.height, + ) + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/VolumeKeysController.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/VolumeKeysController.kt new file mode 100644 index 0000000000..90aad8886f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/VolumeKeysController.kt @@ -0,0 +1,53 @@ +package app.revanced.extension.youtube.swipecontrols.controller + +import android.view.KeyEvent +import app.revanced.extension.youtube.swipecontrols.SwipeControlsHostActivity + +/** + * controller for custom volume button behaviour + * + * @param controller main controller instance + */ +class VolumeKeysController( + private val controller: SwipeControlsHostActivity, +) { + /** + * key event handler + * + * @param event the key event + * @return consume the event? + */ + fun onKeyEvent(event: KeyEvent): Boolean { + if (!controller.config.overwriteVolumeKeyControls) { + return false + } + + return when (event.keyCode) { + KeyEvent.KEYCODE_VOLUME_DOWN -> + handleVolumeKeyEvent(event, false) + + KeyEvent.KEYCODE_VOLUME_UP -> + handleVolumeKeyEvent(event, true) + + else -> false + } + } + + /** + * handle a volume up / down key event + * + * @param event the key event + * @param volumeUp was the key pressed the volume up key? + * @return consume the event? + */ + private fun handleVolumeKeyEvent(event: KeyEvent, volumeUp: Boolean): Boolean { + if (event.action == KeyEvent.ACTION_DOWN) { + controller.audio?.apply { + volume += if (volumeUp) 1 else -1 + controller.overlay.onVolumeChanged(volume, maxVolume) + } + } + + return true + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/ClassicSwipeController.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/ClassicSwipeController.kt new file mode 100644 index 0000000000..b3221c096b --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/ClassicSwipeController.kt @@ -0,0 +1,117 @@ +package app.revanced.extension.youtube.swipecontrols.controller.gesture + +import android.view.MotionEvent +import app.revanced.extension.youtube.shared.PlayerControlsVisibilityObserver +import app.revanced.extension.youtube.shared.PlayerControlsVisibilityObserverImpl +import app.revanced.extension.youtube.swipecontrols.SwipeControlsConfigurationProvider +import app.revanced.extension.youtube.swipecontrols.SwipeControlsHostActivity +import app.revanced.extension.youtube.swipecontrols.controller.gesture.core.BaseGestureController +import app.revanced.extension.youtube.swipecontrols.controller.gesture.core.SwipeDetector +import app.revanced.extension.youtube.swipecontrols.misc.contains +import app.revanced.extension.youtube.swipecontrols.misc.toPoint + +/** + * provides the classic swipe controls experience, as it was with 'XFenster' + * + * @param controller reference to the main swipe controller + */ +class ClassicSwipeController( + private val controller: SwipeControlsHostActivity, + private val config: SwipeControlsConfigurationProvider, +) : BaseGestureController(controller), + PlayerControlsVisibilityObserver by PlayerControlsVisibilityObserverImpl(controller) { + /** + * the last event captured in [onDown] + */ + private var lastOnDownEvent: MotionEvent? = null + + override val shouldForceInterceptEvents: Boolean + get() = currentSwipe == SwipeDetector.SwipeDirection.VERTICAL + + override fun isInSwipeZone(motionEvent: MotionEvent): Boolean { + val inVolumeZone = motionEvent.toPoint() in controller.zones.volume + val inBrightnessZone = motionEvent.toPoint() in controller.zones.brightness + + return inVolumeZone || inBrightnessZone + } + + override fun shouldDropMotion(motionEvent: MotionEvent): Boolean { + // ignore gestures with more than one pointer + // when such a gesture is detected, dispatch the first event of the gesture to downstream + if (motionEvent.pointerCount > 1) { + lastOnDownEvent?.let { + controller.dispatchDownstreamTouchEvent(it) + it.recycle() + } + lastOnDownEvent = null + return true + } + + // ignore gestures when player controls are visible + return arePlayerControlsVisible + } + + override fun onDown(motionEvent: MotionEvent): Boolean { + // save the event for later + lastOnDownEvent?.recycle() + lastOnDownEvent = MotionEvent.obtain(motionEvent) + + // must be inside swipe zone + return isInSwipeZone(motionEvent) + } + + override fun onSingleTapUp(motionEvent: MotionEvent): Boolean { + MotionEvent.obtain(motionEvent).let { + it.action = MotionEvent.ACTION_DOWN + controller.dispatchDownstreamTouchEvent(it) + it.recycle() + } + + return false + } + + override fun onDoubleTapEvent(motionEvent: MotionEvent): Boolean { + MotionEvent.obtain(motionEvent).let { + controller.dispatchDownstreamTouchEvent(it) + it.recycle() + } + + return super.onDoubleTapEvent(motionEvent) + } + + override fun onLongPress(motionEvent: MotionEvent) { + MotionEvent.obtain(motionEvent).let { + controller.dispatchDownstreamTouchEvent(it) + it.recycle() + } + + super.onLongPress(motionEvent) + } + + override fun onSwipe( + from: MotionEvent, + to: MotionEvent, + distanceX: Double, + distanceY: Double, + ): Boolean { + // cancel if locked + if (!config.enableSwipeControlsLockMode && config.isScreenLocked) + return false + // cancel if not vertical + if (currentSwipe != SwipeDetector.SwipeDirection.VERTICAL) + return false + return when (from.toPoint()) { + in controller.zones.volume -> { + scrollVolume(distanceY) + true + } + + in controller.zones.brightness -> { + scrollBrightness(distanceY) + true + } + + else -> false + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/PressToSwipeController.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/PressToSwipeController.kt new file mode 100644 index 0000000000..d6ab99c342 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/PressToSwipeController.kt @@ -0,0 +1,90 @@ +package app.revanced.extension.youtube.swipecontrols.controller.gesture + +import android.view.MotionEvent +import app.revanced.extension.youtube.patches.swipe.SwipeControlsPatch.isEngagementOverlayVisible +import app.revanced.extension.youtube.swipecontrols.SwipeControlsConfigurationProvider +import app.revanced.extension.youtube.swipecontrols.SwipeControlsHostActivity +import app.revanced.extension.youtube.swipecontrols.controller.gesture.core.BaseGestureController +import app.revanced.extension.youtube.swipecontrols.controller.gesture.core.SwipeDetector +import app.revanced.extension.youtube.swipecontrols.misc.contains +import app.revanced.extension.youtube.swipecontrols.misc.toPoint + +/** + * provides the press-to-swipe (PtS) swipe controls experience + * + * @param controller reference to the main swipe controller + */ +class PressToSwipeController( + private val controller: SwipeControlsHostActivity, + private val config: SwipeControlsConfigurationProvider, +) : BaseGestureController(controller) { + /** + * monitors if the user is currently in a swipe session. + */ + private var isInSwipeSession = false + + override val shouldForceInterceptEvents: Boolean + get() = currentSwipe == SwipeDetector.SwipeDirection.VERTICAL && isInSwipeSession + + override fun shouldDropMotion(motionEvent: MotionEvent): Boolean = false + + override fun isInSwipeZone(motionEvent: MotionEvent): Boolean { + val inVolumeZone = if (controller.config.enableVolumeControls) { + (motionEvent.toPoint() in controller.zones.volume) + } else { + false + } + val inBrightnessZone = if (controller.config.enableBrightnessControl) { + (motionEvent.toPoint() in controller.zones.brightness) + } else { + false + } + + return inVolumeZone || inBrightnessZone + } + + override fun onUp(motionEvent: MotionEvent) { + super.onUp(motionEvent) + isInSwipeSession = false + } + + override fun onLongPress(motionEvent: MotionEvent) { + // enter swipe session with feedback + isInSwipeSession = true + controller.overlay.onEnterSwipeSession() + + // send GestureDetector a ACTION_CANCEL event so it will handle further events + motionEvent.action = MotionEvent.ACTION_CANCEL + detector.onTouchEvent(motionEvent) + } + + override fun onSwipe( + from: MotionEvent, + to: MotionEvent, + distanceX: Double, + distanceY: Double, + ): Boolean { + // cancel if locked + if (!config.enableSwipeControlsLockMode && config.isScreenLocked) + return false + // cancel if not in swipe session or vertical + if (!isInSwipeSession || currentSwipe != SwipeDetector.SwipeDirection.VERTICAL) + return false + // ignore gestures when engagement overlay is visible + if (isEngagementOverlayVisible()) + return false + return when (from.toPoint()) { + in controller.zones.volume -> { + scrollVolume(distanceY) + true + } + + in controller.zones.brightness -> { + scrollBrightness(distanceY) + true + } + + else -> false + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/BaseGestureController.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/BaseGestureController.kt new file mode 100644 index 0000000000..ac995bfd73 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/BaseGestureController.kt @@ -0,0 +1,156 @@ +package app.revanced.extension.youtube.swipecontrols.controller.gesture.core + +import android.view.GestureDetector +import android.view.MotionEvent +import app.revanced.extension.youtube.swipecontrols.SwipeControlsHostActivity + +/** + * the common base of all [GestureController] classes. + * handles most of the boilerplate code needed for gesture detection + * + * @param controller reference to the main swipe controller + */ +abstract class BaseGestureController( + private val controller: SwipeControlsHostActivity, +) : GestureController, + GestureDetector.SimpleOnGestureListener(), + SwipeDetector by SwipeDetectorImpl( + controller.config.swipeMagnitudeThreshold.toDouble(), + ), + VolumeAndBrightnessScroller by VolumeAndBrightnessScrollerImpl( + controller, + controller.audio, + controller.screen, + controller.overlay, + controller.config.volumeDistance, + controller.config.brightnessDistance, + ) { + + /** + * the main gesture detector that powers everything + */ + @Suppress("LeakingThis") + protected val detector = GestureDetector(controller, this) + + /** + * were downstream event cancelled already? used in [onScroll] + */ + private var didCancelDownstream = false + + override fun submitTouchEvent(motionEvent: MotionEvent): Boolean { + // ignore if swipe is disabled + if (!controller.config.enableSwipeControls) { + return false + } + + // create a copy of the event so we can modify it + // without causing any issues downstream + val me = MotionEvent.obtain(motionEvent) + + // check if we should drop this motion + val dropped = shouldDropMotion(me) + if (dropped) { + me.action = MotionEvent.ACTION_CANCEL + } + + // send the event to the detector + // if we force intercept events, the event is always consumed + val consumed = detector.onTouchEvent(me) || shouldForceInterceptEvents + + // invoke the custom onUp handler + if (me.action == MotionEvent.ACTION_UP || me.action == MotionEvent.ACTION_CANCEL) { + onUp(me) + } + + // recycle the copy + me.recycle() + + // do not consume dropped events + // or events outside of any swipe zone + return !dropped && consumed && isInSwipeZone(me) + } + + /** + * custom handler for [MotionEvent.ACTION_UP] event, because GestureDetector doesn't offer that :| + * + * @param motionEvent the motion event + */ + open fun onUp(motionEvent: MotionEvent) { + didCancelDownstream = false + resetSwipe() + resetScroller() + } + + override fun onScroll( + from: MotionEvent?, + to: MotionEvent, + distanceX: Float, + distanceY: Float, + ): Boolean { + // submit to swipe detector + submitForSwipe(from!!, to, distanceX, distanceY) + + // call swipe callback if in a swipe + return if (currentSwipe != SwipeDetector.SwipeDirection.NONE) { + val consumed = onSwipe( + from, + to, + distanceX.toDouble(), + distanceY.toDouble(), + ) + + // if the swipe was consumed, cancel downstream events once + if (consumed && !didCancelDownstream) { + didCancelDownstream = true + MotionEvent.obtain(from).let { + it.action = MotionEvent.ACTION_CANCEL + controller.dispatchDownstreamTouchEvent(it) + it.recycle() + } + } + + consumed + } else { + false + } + } + + /** + * should [submitTouchEvent] force- intercept all touch events? + */ + abstract val shouldForceInterceptEvents: Boolean + + /** + * check if provided motion event is in any active swipe zone? + * + * @param motionEvent the event to check + * @return is the event in any active swipe zone? + */ + abstract fun isInSwipeZone(motionEvent: MotionEvent): Boolean + + /** + * check if a touch event should be dropped. + * when a event is dropped, the gesture detector received a [MotionEvent.ACTION_CANCEL] event and the event is not consumed + * + * @param motionEvent the event to check + * @return should the event be dropped? + */ + abstract fun shouldDropMotion(motionEvent: MotionEvent): Boolean + + /** + * handler for swipe events, once a swipe is detected. + * the direction of the swipe can be accessed in [currentSwipe] + * + * @param from start event of the swipe + * @param to end event of the swipe + * @param distanceX the horizontal distance of the swipe + * @param distanceY the vertical distance of the swipe + * @return was the event consumed? + */ + abstract fun onSwipe( + from: MotionEvent, + to: MotionEvent, + distanceX: Double, + distanceY: Double, + ): Boolean +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/GestureController.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/GestureController.kt new file mode 100644 index 0000000000..49da1f210e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/GestureController.kt @@ -0,0 +1,16 @@ +package app.revanced.extension.youtube.swipecontrols.controller.gesture.core + +import android.view.MotionEvent + +/** + * describes a class that accepts motion events and detects gestures + */ +interface GestureController { + /** + * accept a touch event and try to detect the desired gestures using it + * + * @param motionEvent the motion event that was submitted + * @return was a gesture detected? + */ + fun submitTouchEvent(motionEvent: MotionEvent): Boolean +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/SwipeDetector.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/SwipeDetector.kt new file mode 100644 index 0000000000..7d6fa4501f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/SwipeDetector.kt @@ -0,0 +1,94 @@ +package app.revanced.extension.youtube.swipecontrols.controller.gesture.core + +import android.view.MotionEvent +import kotlin.math.abs +import kotlin.math.pow + +/** + * describes a class that can detect swipes and their directionality + */ +interface SwipeDetector { + /** + * the currently detected swipe + */ + val currentSwipe: SwipeDirection + + /** + * submit a onScroll event for swipe detection + * + * @param from start event + * @param to end event + * @param distanceX horizontal scroll distance + * @param distanceY vertical scroll distance + */ + fun submitForSwipe( + from: MotionEvent, + to: MotionEvent, + distanceX: Float, + distanceY: Float, + ) + + /** + * reset the swipe detection + */ + fun resetSwipe() + + /** + * direction of a swipe + */ + enum class SwipeDirection { + /** + * swipe has no direction or no swipe + */ + NONE, + + /** + * swipe along the X- Axes + */ + HORIZONTAL, + + /** + * swipe along the Y- Axes + */ + VERTICAL, + } +} + +/** + * detector that can detect swipes and their directionality + * + * @param swipeMagnitudeThreshold minimum magnitude before a swipe is detected as such + */ +class SwipeDetectorImpl( + private val swipeMagnitudeThreshold: Double, +) : SwipeDetector { + override var currentSwipe = SwipeDetector.SwipeDirection.NONE + + override fun submitForSwipe( + from: MotionEvent, + to: MotionEvent, + distanceX: Float, + distanceY: Float, + ) { + if (currentSwipe == SwipeDetector.SwipeDirection.NONE) { + // no swipe direction was detected yet, try to detect one + // if the user did not swipe far enough, we cannot detect what direction they swiped + // so we wait until a greater distance was swiped + // NOTE: sqrt() can be high- cost, so using squared magnitudes here + val deltaX = abs(to.x - from.x) + val deltaY = abs(to.y - from.y) + val swipeMagnitudeSquared = deltaX.pow(2) + deltaY.pow(2) + if (swipeMagnitudeSquared > swipeMagnitudeThreshold.pow(2)) { + currentSwipe = if (deltaY > deltaX) { + SwipeDetector.SwipeDirection.VERTICAL + } else { + SwipeDetector.SwipeDirection.HORIZONTAL + } + } + } + } + + override fun resetSwipe() { + currentSwipe = SwipeDetector.SwipeDirection.NONE + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/VolumeAndBrightnessScroller.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/VolumeAndBrightnessScroller.kt new file mode 100644 index 0000000000..4cf7303bad --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/VolumeAndBrightnessScroller.kt @@ -0,0 +1,103 @@ +package app.revanced.extension.youtube.swipecontrols.controller.gesture.core + +import android.content.Context +import android.util.TypedValue +import app.revanced.extension.youtube.swipecontrols.controller.AudioVolumeController +import app.revanced.extension.youtube.swipecontrols.controller.ScreenBrightnessController +import app.revanced.extension.youtube.swipecontrols.misc.ScrollDistanceHelper +import app.revanced.extension.youtube.swipecontrols.misc.SwipeControlsOverlay +import app.revanced.extension.youtube.swipecontrols.misc.applyDimension + +/** + * describes a class that controls volume and brightness based on scrolling events + */ +interface VolumeAndBrightnessScroller { + /** + * submit a scroll for volume adjustment + * + * @param distance the scroll distance + */ + fun scrollVolume(distance: Double) + + /** + * submit a scroll for brightness adjustment + * + * @param distance the scroll distance + */ + fun scrollBrightness(distance: Double) + + /** + * reset all scroll distances to zero + */ + fun resetScroller() +} + +/** + * handles scrolling of volume and brightness, adjusts them using the provided controllers and updates the overlay + * + * @param context context to create the scrollers in + * @param volumeController volume controller instance. if null, volume control is disabled + * @param screenController screen brightness controller instance. if null, brightness control is disabled + * @param overlayController overlay controller instance + * @param volumeDistance unit distance for volume scrolling, in dp + * @param brightnessDistance unit distance for brightness scrolling, in dp + */ +class VolumeAndBrightnessScrollerImpl( + context: Context, + private val volumeController: AudioVolumeController?, + private val screenController: ScreenBrightnessController?, + private val overlayController: SwipeControlsOverlay, + volumeDistance: Float = 10.0f, + brightnessDistance: Float = 1.0f, +) : VolumeAndBrightnessScroller { + + // region volume + private val volumeScroller = + ScrollDistanceHelper( + volumeDistance.applyDimension( + context, + TypedValue.COMPLEX_UNIT_DIP, + ), + ) { _, _, direction -> + volumeController?.run { + volume += direction + overlayController.onVolumeChanged(volume, maxVolume) + } + } + + override fun scrollVolume(distance: Double) = volumeScroller.add(distance) + //endregion + + //region brightness + private val brightnessScroller = + ScrollDistanceHelper( + brightnessDistance.applyDimension( + context, + TypedValue.COMPLEX_UNIT_DIP, + ), + ) { _, _, direction -> + screenController?.run { + val shouldAdjustBrightness = + if (host.config.shouldLowestValueEnableAutoBrightness) { + screenBrightness > 0 || direction > 0 + } else { + screenBrightness >= 0 || direction >= 0 + } + + if (shouldAdjustBrightness) { + screenBrightness += direction + } else { + restoreDefaultBrightness() + } + overlayController.onBrightnessChanged(screenBrightness) + } + } + + override fun scrollBrightness(distance: Double) = brightnessScroller.add(distance) + //endregion + + override fun resetScroller() { + volumeScroller.reset() + brightnessScroller.reset() + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/Point.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/Point.kt new file mode 100644 index 0000000000..8400fedaf6 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/Point.kt @@ -0,0 +1,17 @@ +package app.revanced.extension.youtube.swipecontrols.misc + +import android.view.MotionEvent + +/** + * a simple 2D point class + */ +data class Point( + val x: Int, + val y: Int, +) + +/** + * convert the motion event coordinates to a point + */ +fun MotionEvent.toPoint(): Point = + Point(x.toInt(), y.toInt()) diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/Rectangle.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/Rectangle.kt new file mode 100644 index 0000000000..723834318f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/Rectangle.kt @@ -0,0 +1,22 @@ +package app.revanced.extension.youtube.swipecontrols.misc + +/** + * a simple rectangle class + */ +data class Rectangle( + val x: Int, + val y: Int, + val width: Int, + val height: Int, +) { + val left = x + val right = x + width + val top = y + val bottom = y + height +} + +/** + * is the point within this rectangle? + */ +operator fun Rectangle.contains(p: Point): Boolean = + p.x in left..right && p.y in top..bottom diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/ScrollDistanceHelper.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/ScrollDistanceHelper.kt new file mode 100644 index 0000000000..09a74c229b --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/ScrollDistanceHelper.kt @@ -0,0 +1,56 @@ +package app.revanced.extension.youtube.swipecontrols.misc + +import kotlin.math.abs +import kotlin.math.sign + +/** + * helper for scaling onScroll handler + * + * @param unitDistance absolute distance after which the callback is invoked + * @param callback callback function for when unit distance is reached + */ +class ScrollDistanceHelper( + private val unitDistance: Double, + private val callback: (oldDistance: Double, newDistance: Double, direction: Int) -> Unit, +) { + + /** + * total distance scrolled + */ + private var scrolledDistance: Double = 0.0 + + /** + * add a scrolled distance to the total. + * if the [unitDistance] is reached, this function will also invoke the callback + * + * @param distance the distance to add + */ + fun add(distance: Double) { + scrolledDistance += distance + + // invoke the callback if we scrolled far enough + while (abs(scrolledDistance) >= unitDistance) { + val oldDistance = scrolledDistance + subtractUnitDistance() + callback.invoke( + oldDistance, + scrolledDistance, + sign(scrolledDistance).toInt(), + ) + } + } + + /** + * reset the distance scrolled to zero + */ + fun reset() { + scrolledDistance = 0.0 + } + + /** + * subtract the [unitDistance] from the total [scrolledDistance] + */ + private fun subtractUnitDistance() { + scrolledDistance -= (unitDistance * sign(scrolledDistance)) + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/SwipeControlsOverlay.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/SwipeControlsOverlay.kt new file mode 100644 index 0000000000..5e863a3c58 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/SwipeControlsOverlay.kt @@ -0,0 +1,26 @@ +package app.revanced.extension.youtube.swipecontrols.misc + +/** + * Interface for all overlays for swipe controls + */ +interface SwipeControlsOverlay { + /** + * called when the currently set volume level was changed + * + * @param newVolume the new volume level + * @param maximumVolume the maximum volume index + */ + fun onVolumeChanged(newVolume: Int, maximumVolume: Int) + + /** + * called when the currently set screen brightness was changed + * + * @param brightness the new screen brightness, in percent (range 0.0 - 100.0) + */ + fun onBrightnessChanged(brightness: Double) + + /** + * called when a new swipe- session has started + */ + fun onEnterSwipeSession() +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/SwipeControlsUtils.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/SwipeControlsUtils.kt new file mode 100644 index 0000000000..74b1e777d1 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/SwipeControlsUtils.kt @@ -0,0 +1,33 @@ +package app.revanced.extension.youtube.swipecontrols.misc + +import android.content.Context +import android.util.TypedValue +import kotlin.math.roundToInt + +fun Float.clamp(min: Float, max: Float): Float { + if (this < min) return min + if (this > max) return max + return this +} + +fun Int.clamp(min: Int, max: Int): Int { + if (this < min) return min + if (this > max) return max + return this +} + +fun Int.applyDimension(context: Context, unit: Int): Int { + return TypedValue.applyDimension( + unit, + this.toFloat(), + context.resources.displayMetrics, + ).roundToInt() +} + +fun Float.applyDimension(context: Context, unit: Int): Double { + return TypedValue.applyDimension( + unit, + this, + context.resources.displayMetrics, + ).toDouble() +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/views/SwipeControlsOverlayLayout.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/views/SwipeControlsOverlayLayout.kt new file mode 100644 index 0000000000..6d2cef6063 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/views/SwipeControlsOverlayLayout.kt @@ -0,0 +1,147 @@ +package app.revanced.extension.youtube.swipecontrols.views + +import android.content.Context +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.os.Handler +import android.os.Looper +import android.util.TypedValue +import android.view.HapticFeedbackConstants +import android.view.View +import android.view.ViewGroup +import android.widget.RelativeLayout +import android.widget.TextView +import app.revanced.extension.shared.utils.ResourceUtils.ResourceType +import app.revanced.extension.shared.utils.ResourceUtils.getIdentifier +import app.revanced.extension.shared.utils.StringRef.str +import app.revanced.extension.youtube.swipecontrols.SwipeControlsConfigurationProvider +import app.revanced.extension.youtube.swipecontrols.misc.SwipeControlsOverlay +import app.revanced.extension.youtube.swipecontrols.misc.applyDimension +import kotlin.math.round + +/** + * main overlay layout for volume and brightness swipe controls + * + * @param context context to create in + */ +class SwipeControlsOverlayLayout( + context: Context, + private val config: SwipeControlsConfigurationProvider, +) : RelativeLayout(context), SwipeControlsOverlay { + /** + * DO NOT use this, for tools only + */ + constructor(context: Context) : this(context, SwipeControlsConfigurationProvider(context)) + + private val feedbackTextView: TextView + private val autoBrightnessIcon: Drawable + private val manualBrightnessIcon: Drawable + private val mutedVolumeIcon: Drawable + private val normalVolumeIcon: Drawable + + private fun getDrawable(name: String, width: Int, height: Int): Drawable { + return resources.getDrawable( + getIdentifier(name, ResourceType.DRAWABLE, context), + context.theme + ).apply { + setTint(config.overlayForegroundColor) + setBounds( + 0, + 0, + width, + height, + ) + } + } + + init { + // init views + val feedbackYTextViewPadding = 5.applyDimension(context, TypedValue.COMPLEX_UNIT_DIP) + val feedbackXTextViewPadding = 12.applyDimension(context, TypedValue.COMPLEX_UNIT_DIP) + val compoundIconPadding = 4.applyDimension(context, TypedValue.COMPLEX_UNIT_DIP) + feedbackTextView = TextView(context).apply { + layoutParams = LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + addRule(CENTER_IN_PARENT, TRUE) + setPadding( + feedbackXTextViewPadding, + feedbackYTextViewPadding, + feedbackXTextViewPadding, + feedbackYTextViewPadding + ) + } + background = GradientDrawable().apply { + cornerRadius = 30f + setColor(config.overlayTextBackgroundColor) + } + setTextColor(config.overlayForegroundColor) + setTextSize(TypedValue.COMPLEX_UNIT_SP, config.overlayTextSize.toFloat()) + compoundDrawablePadding = compoundIconPadding + visibility = GONE + } + addView(feedbackTextView) + + // get icons scaled, assuming square icons + val iconHeight = round(feedbackTextView.lineHeight * .8).toInt() + autoBrightnessIcon = getDrawable("ic_sc_brightness_auto", iconHeight, iconHeight) + manualBrightnessIcon = getDrawable("ic_sc_brightness_manual", iconHeight, iconHeight) + mutedVolumeIcon = getDrawable("ic_sc_volume_mute", iconHeight, iconHeight) + normalVolumeIcon = getDrawable("ic_sc_volume_normal", iconHeight, iconHeight) + } + + private val feedbackHideHandler = Handler(Looper.getMainLooper()) + private val feedbackHideCallback = Runnable { + feedbackTextView.visibility = View.GONE + } + + /** + * show the feedback view for a given time + * + * @param message the message to show + * @param icon the icon to use + */ + private fun showFeedbackView(message: String, icon: Drawable) { + feedbackHideHandler.removeCallbacks(feedbackHideCallback) + feedbackHideHandler.postDelayed(feedbackHideCallback, config.overlayShowTimeoutMillis) + feedbackTextView.apply { + text = message + setCompoundDrawablesRelative( + icon, + null, + null, + null, + ) + visibility = VISIBLE + } + } + + override fun onVolumeChanged(newVolume: Int, maximumVolume: Int) { + showFeedbackView( + "$newVolume", + if (newVolume > 0) normalVolumeIcon else mutedVolumeIcon, + ) + } + + override fun onBrightnessChanged(brightness: Double) { + if (config.shouldLowestValueEnableAutoBrightness && brightness <= 0) { + showFeedbackView( + str("revanced_swipe_lowest_value_auto_brightness_overlay_text"), + autoBrightnessIcon, + ) + } else if (brightness >= 0) { + showFeedbackView("${round(brightness).toInt()}%", manualBrightnessIcon) + } + } + + @Suppress("DEPRECATION") + override fun onEnterSwipeSession() { + if (config.shouldEnableHapticFeedback) { + performHapticFeedback( + HapticFeedbackConstants.LONG_PRESS, + HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING, + ) + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/ExtendedUtils.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/ExtendedUtils.java new file mode 100644 index 0000000000..321e28de0e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/ExtendedUtils.java @@ -0,0 +1,111 @@ +package app.revanced.extension.youtube.utils; + +import static app.revanced.extension.shared.utils.StringRef.str; + +import androidx.annotation.NonNull; + +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.shared.settings.FloatSetting; +import app.revanced.extension.shared.settings.IntegerSetting; +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.utils.PackageUtils; +import app.revanced.extension.youtube.settings.Settings; + +public class ExtendedUtils extends PackageUtils { + + public static int validateValue(IntegerSetting settings, int min, int max, String message) { + int value = settings.get(); + + if (value < min || value > max) { + showToastShort(str(message)); + showToastShort(str("revanced_extended_reset_to_default_toast")); + settings.resetToDefault(); + value = settings.defaultValue; + } + + return value; + } + + public static float validateValue(FloatSetting settings, float min, float max, String message) { + float value = settings.get(); + + if (value < min || value > max) { + showToastShort(str(message)); + showToastShort(str("revanced_extended_reset_to_default_toast")); + settings.resetToDefault(); + value = settings.defaultValue; + } + + return value; + } + + public static boolean isFullscreenHidden() { + return Settings.DISABLE_ENGAGEMENT_PANEL.get() || Settings.HIDE_QUICK_ACTIONS.get(); + } + + public static boolean isSpoofingToLessThan(@NonNull String versionName) { + if (!Settings.SPOOF_APP_VERSION.get()) + return false; + + return isVersionToLessThan(Settings.SPOOF_APP_VERSION_TARGET.get(), versionName); + } + + public static void setCommentPreviewSettings() { + final boolean enabled = Settings.HIDE_PREVIEW_COMMENT.get(); + final boolean newMethod = Settings.HIDE_PREVIEW_COMMENT_TYPE.get(); + + Settings.HIDE_PREVIEW_COMMENT_OLD_METHOD.save(enabled && !newMethod); + Settings.HIDE_PREVIEW_COMMENT_NEW_METHOD.save(enabled && newMethod); + } + + private static final Setting[] additionalSettings = { + Settings.HIDE_PLAYER_FLYOUT_MENU_AMBIENT, + Settings.HIDE_PLAYER_FLYOUT_MENU_HELP, + Settings.HIDE_PLAYER_FLYOUT_MENU_LOOP, + Settings.HIDE_PLAYER_FLYOUT_MENU_PIP, + Settings.HIDE_PLAYER_FLYOUT_MENU_PREMIUM_CONTROLS, + Settings.HIDE_PLAYER_FLYOUT_MENU_SLEEP_TIMER, + Settings.HIDE_PLAYER_FLYOUT_MENU_STABLE_VOLUME, + Settings.HIDE_PLAYER_FLYOUT_MENU_STATS_FOR_NERDS, + Settings.HIDE_PLAYER_FLYOUT_MENU_WATCH_IN_VR, + Settings.HIDE_PLAYER_FLYOUT_MENU_YT_MUSIC, + Settings.SPOOF_APP_VERSION, + Settings.SPOOF_APP_VERSION_TARGET + }; + + public static boolean anyMatchSetting(Setting setting) { + for (Setting s : additionalSettings) { + if (setting == s) return true; + } + return false; + } + + public static void setPlayerFlyoutMenuAdditionalSettings() { + Settings.HIDE_PLAYER_FLYOUT_MENU_ADDITIONAL_SETTINGS.save(isAdditionalSettingsEnabled()); + } + + private static boolean isAdditionalSettingsEnabled() { + // In the old player flyout panels, the video quality icon and additional quality icon are the same + // Therefore, additional Settings should not be blocked in old player flyout panels + if (isSpoofingToLessThan("18.22.00")) + return false; + + boolean additionalSettingsEnabled = true; + final BooleanSetting[] additionalSettings = { + Settings.HIDE_PLAYER_FLYOUT_MENU_AMBIENT, + Settings.HIDE_PLAYER_FLYOUT_MENU_HELP, + Settings.HIDE_PLAYER_FLYOUT_MENU_LOOP, + Settings.HIDE_PLAYER_FLYOUT_MENU_PIP, + Settings.HIDE_PLAYER_FLYOUT_MENU_PREMIUM_CONTROLS, + Settings.HIDE_PLAYER_FLYOUT_MENU_SLEEP_TIMER, + Settings.HIDE_PLAYER_FLYOUT_MENU_STABLE_VOLUME, + Settings.HIDE_PLAYER_FLYOUT_MENU_STATS_FOR_NERDS, + Settings.HIDE_PLAYER_FLYOUT_MENU_WATCH_IN_VR, + Settings.HIDE_PLAYER_FLYOUT_MENU_YT_MUSIC, + }; + for (BooleanSetting s : additionalSettings) { + additionalSettingsEnabled &= s.get(); + } + return additionalSettingsEnabled; + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/ThemeUtils.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/ThemeUtils.java new file mode 100644 index 0000000000..aafdc17cbe --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/ThemeUtils.java @@ -0,0 +1,119 @@ +package app.revanced.extension.youtube.utils; + +import static app.revanced.extension.shared.utils.ResourceUtils.getColor; +import static app.revanced.extension.shared.utils.ResourceUtils.getDrawable; +import static app.revanced.extension.shared.utils.ResourceUtils.getStyleIdentifier; +import static app.revanced.extension.shared.utils.Utils.getResources; + +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; + +import app.revanced.extension.shared.utils.BaseThemeUtils; +import app.revanced.extension.shared.utils.Logger; + +@SuppressWarnings({"unused", "SameParameterValue"}) +public class ThemeUtils extends BaseThemeUtils { + + public static int getThemeId() { + final String themeName = isDarkTheme() + ? "Theme.YouTube.Settings.Dark" + : "Theme.YouTube.Settings"; + + return getStyleIdentifier(themeName); + } + + public static Drawable getBackButtonDrawable() { + final String drawableName = isDarkTheme() + ? "yt_outline_arrow_left_white_24" + : "yt_outline_arrow_left_black_24"; + + return getDrawable(drawableName); + } + + public static Drawable getTrashButtonDrawable() { + final String drawableName = isDarkTheme() + ? "yt_outline_trash_can_white_24" + : "yt_outline_trash_can_black_24"; + + return getDrawable(drawableName); + } + + /** + * Since {@link android.widget.Toolbar} is used instead of {@link android.support.v7.widget.Toolbar}, + * We have to manually specify the toolbar background. + * + * @return toolbar background color. + */ + public static int getToolbarBackgroundColor() { + final String colorName = isDarkTheme() + ? "yt_black3" // Color names used in the light theme + : "yt_white1"; // Color names used in the dark theme + + return getColor(colorName); + } + + public static GradientDrawable getSearchViewShape() { + GradientDrawable shape = new GradientDrawable(); + + String currentHex = getBackgroundColorHexString(); + String defaultHex = isDarkTheme() ? "#1A1A1A" : "#E5E5E5"; + + String finalHex; + if (currentThemeColorIsBlackOrWhite()) { + shape.setColor(Color.parseColor(defaultHex)); // stock black/white color + finalHex = defaultHex; + } else { + // custom color theme + String adjustedColor = isDarkTheme() + ? lightenColor(currentHex, 15) + : darkenColor(currentHex, 15); + shape.setColor(Color.parseColor(adjustedColor)); + finalHex = adjustedColor; + } + Logger.printDebug(() -> "searchbar color: " + finalHex); + + shape.setCornerRadius(30 * getResources().getDisplayMetrics().density); + + return shape; + } + + private static boolean currentThemeColorIsBlackOrWhite() { + final int color = isDarkTheme() + ? getDarkColor() + : getLightColor(); + + return getBackgroundColor() == color; + } + + // Convert HEX to RGB + private static int[] hexToRgb(String hex) { + int r = Integer.valueOf(hex.substring(1, 3), 16); + int g = Integer.valueOf(hex.substring(3, 5), 16); + int b = Integer.valueOf(hex.substring(5, 7), 16); + return new int[]{r, g, b}; + } + + // Convert RGB to HEX + private static String rgbToHex(int r, int g, int b) { + return String.format("#%02x%02x%02x", r, g, b); + } + + // Darken color by percentage + private static String darkenColor(String hex, double percentage) { + int[] rgb = hexToRgb(hex); + int r = (int) (rgb[0] * (1 - percentage / 100)); + int g = (int) (rgb[1] * (1 - percentage / 100)); + int b = (int) (rgb[2] * (1 - percentage / 100)); + return rgbToHex(r, g, b); + } + + // Lighten color by percentage + private static String lightenColor(String hex, double percentage) { + int[] rgb = hexToRgb(hex); + int r = (int) (rgb[0] + (255 - rgb[0]) * (percentage / 100)); + int g = (int) (rgb[1] + (255 - rgb[1]) * (percentage / 100)); + int b = (int) (rgb[2] + (255 - rgb[2]) * (percentage / 100)); + return rgbToHex(r, g, b); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/VideoUtils.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/VideoUtils.java new file mode 100644 index 0000000000..485d64cd23 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/VideoUtils.java @@ -0,0 +1,244 @@ +package app.revanced.extension.youtube.utils; + +import static app.revanced.extension.shared.utils.ResourceUtils.getStringArray; +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.youtube.patches.video.PlaybackSpeedPatch.userSelectedPlaybackSpeed; + +import android.app.AlertDialog; +import android.content.Context; +import android.media.AudioManager; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Arrays; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; + +import app.revanced.extension.shared.settings.IntegerSetting; +import app.revanced.extension.shared.utils.IntentUtils; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.youtube.patches.video.CustomPlaybackSpeedPatch; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.settings.preference.ExternalDownloaderPlaylistPreference; +import app.revanced.extension.youtube.settings.preference.ExternalDownloaderVideoPreference; +import app.revanced.extension.youtube.shared.PlaylistIdPrefix; +import app.revanced.extension.youtube.shared.VideoInformation; + +@SuppressWarnings("unused") +public class VideoUtils extends IntentUtils { + private static final String PLAYLIST_URL = "https://www.youtube.com/playlist?list="; + private static final String VIDEO_URL = "https://youtu.be/"; + private static final String VIDEO_SCHEME_FORMAT = "vnd.youtube://%s?start=%d"; + private static final AtomicBoolean isExternalDownloaderLaunched = new AtomicBoolean(false); + + private static String getPlaylistUrl(String playlistId) { + return PLAYLIST_URL + playlistId; + } + + private static String getVideoUrl(String videoId) { + return getVideoUrl(videoId, false); + } + + private static String getVideoUrl(boolean withTimestamp) { + return getVideoUrl(VideoInformation.getVideoId(), withTimestamp); + } + + private static String getVideoUrl(String videoId, boolean withTimestamp) { + StringBuilder builder = new StringBuilder(VIDEO_URL); + builder.append(videoId); + final long currentVideoTimeInSeconds = VideoInformation.getVideoTimeInSeconds(); + if (withTimestamp && currentVideoTimeInSeconds > 0) { + builder.append("?t="); + builder.append(currentVideoTimeInSeconds); + } + return builder.toString(); + } + + private static String getVideoScheme() { + return getVideoScheme(VideoInformation.getVideoId()); + } + + private static String getVideoScheme(String videoId) { + return String.format(Locale.ENGLISH, VIDEO_SCHEME_FORMAT, videoId, VideoInformation.getVideoTimeInSeconds()); + } + + public static void copyUrl(boolean withTimestamp) { + setClipboard(getVideoUrl(withTimestamp), withTimestamp + ? str("revanced_share_copy_url_timestamp_success") + : str("revanced_share_copy_url_success") + ); + } + + public static void copyTimeStamp() { + final String timeStamp = getTimeStamp(VideoInformation.getVideoTime()); + setClipboard(timeStamp, str("revanced_share_copy_timestamp_success", timeStamp)); + } + + public static void launchVideoExternalDownloader() { + launchVideoExternalDownloader(VideoInformation.getVideoId()); + } + + public static void launchVideoExternalDownloader(@NonNull String videoId) { + try { + final String downloaderPackageName = ExternalDownloaderVideoPreference.getExternalDownloaderPackageName(); + if (ExternalDownloaderVideoPreference.checkPackageIsDisabled()) { + return; + } + + isExternalDownloaderLaunched.compareAndSet(false, true); + launchExternalDownloader(getVideoUrl(videoId), downloaderPackageName); + } catch (Exception ex) { + Logger.printException(() -> "launchExternalDownloader failure", ex); + } finally { + runOnMainThreadDelayed(() -> isExternalDownloaderLaunched.compareAndSet(true, false), 500); + } + } + + public static void launchPlaylistExternalDownloader(@NonNull String playlistId) { + try { + final String downloaderPackageName = ExternalDownloaderPlaylistPreference.getExternalDownloaderPackageName(); + if (ExternalDownloaderPlaylistPreference.checkPackageIsDisabled()) { + return; + } + + isExternalDownloaderLaunched.compareAndSet(false, true); + launchExternalDownloader(getPlaylistUrl(playlistId), downloaderPackageName); + } catch (Exception ex) { + Logger.printException(() -> "launchPlaylistExternalDownloader failure", ex); + } finally { + runOnMainThreadDelayed(() -> isExternalDownloaderLaunched.compareAndSet(true, false), 500); + } + } + + public static void openVideo() { + openVideo(VideoInformation.getVideoId()); + } + + public static void openVideo(@NonNull String videoId) { + openVideo(getVideoScheme(videoId), ""); + } + + public static void openVideo(@NonNull PlaylistIdPrefix prefixId) { + openVideo(getVideoScheme(), prefixId.prefixId); + } + + /** + * Create playlist with all channel videos. + */ + public static void openVideo(@NonNull String videoScheme, @NonNull String prefixId) { + if (!TextUtils.isEmpty(prefixId)) { + final String channelId = VideoInformation.getChannelId(); + // Channel id always starts with `UC` prefix + if (!channelId.startsWith("UC")) { + showToastShort(str("revanced_overlay_button_play_all_not_available_toast")); + return; + } + videoScheme += "&list=" + prefixId + channelId.substring(2); + } + final String finalVideoScheme = videoScheme; + Logger.printInfo(() -> finalVideoScheme); + + launchView(videoScheme, getContext().getPackageName()); + } + + /** + * Pause the media by changing audio focus. + */ + public static void pauseMedia() { + if (context != null && context.getApplicationContext().getSystemService(Context.AUDIO_SERVICE) instanceof AudioManager audioManager) { + audioManager.requestAudioFocus(null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); + } + } + + public static void showPlaybackSpeedDialog(@NonNull Context context) { + final String[] playbackSpeedEntries = CustomPlaybackSpeedPatch.getTrimmedListEntries(); + final String[] playbackSpeedEntryValues = CustomPlaybackSpeedPatch.getTrimmedListEntryValues(); + + final float playbackSpeed = VideoInformation.getPlaybackSpeed(); + final int index = Arrays.binarySearch(playbackSpeedEntryValues, String.valueOf(playbackSpeed)); + + new AlertDialog.Builder(context) + .setSingleChoiceItems(playbackSpeedEntries, index, (mDialog, mIndex) -> { + final float selectedPlaybackSpeed = Float.parseFloat(playbackSpeedEntryValues[mIndex] + "f"); + VideoInformation.overridePlaybackSpeed(selectedPlaybackSpeed); + userSelectedPlaybackSpeed(selectedPlaybackSpeed); + mDialog.dismiss(); + }) + .show(); + } + + private static int mClickedDialogEntryIndex; + + public static void showShortsRepeatDialog(@NonNull Context context) { + final IntegerSetting setting = Settings.CHANGE_SHORTS_REPEAT_STATE; + final String settingsKey = setting.key; + + final String entryKey = settingsKey + "_entries"; + final String entryValueKey = settingsKey + "_entry_values"; + final String[] mEntries = getStringArray(entryKey); + final String[] mEntryValues = getStringArray(entryValueKey); + + final int findIndex = Arrays.binarySearch(mEntryValues, String.valueOf(setting.get())); + mClickedDialogEntryIndex = findIndex >= 0 ? findIndex : setting.defaultValue; + + new AlertDialog.Builder(context) + .setTitle(str(settingsKey + "_title")) + .setSingleChoiceItems(mEntries, mClickedDialogEntryIndex, (dialog, id) -> { + mClickedDialogEntryIndex = id; + setting.save(id); + dialog.dismiss(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + public static void showFlyoutMenu() { + if (Settings.APPEND_TIME_STAMP_INFORMATION_TYPE.get()) { + showVideoQualityFlyoutMenu(); + } else { + showPlaybackSpeedFlyoutMenu(); + } + } + + public static String getFormattedQualityString(@Nullable String prefix) { + final String qualityString = VideoInformation.getVideoQualityString(); + + return prefix == null ? qualityString : String.format("%s\u2009•\u2009%s", prefix, qualityString); + } + + public static String getFormattedSpeedString(@Nullable String prefix) { + final float playbackSpeed = VideoInformation.getPlaybackSpeed(); + + final String playbackSpeedString = isRightToLeftTextLayout() + ? "\u2066x\u2069" + playbackSpeed + : playbackSpeed + "x"; + + return prefix == null ? playbackSpeedString : String.format("%s\u2009•\u2009%s", prefix, playbackSpeedString); + } + + /** + * Injection point. + * Disable PiP mode when an external downloader Intent is started. + */ + public static boolean getExternalDownloaderLaunchedState(boolean original) { + return !isExternalDownloaderLaunched.get() && original; + } + + /** + * Rest of the implementation added by patch. + */ + public static void showPlaybackSpeedFlyoutMenu() { + Logger.printDebug(() -> "Playback speed flyout menu opened"); + } + + /** + * Rest of the implementation added by patch. + */ + public static void showVideoQualityFlyoutMenu() { + // These instructions are ignored by patch. + Log.d("Extended: VideoUtils", "Video quality flyout menu opened"); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/whitelist/VideoChannel.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/whitelist/VideoChannel.java new file mode 100644 index 0000000000..50db765926 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/whitelist/VideoChannel.java @@ -0,0 +1,21 @@ +package app.revanced.extension.youtube.whitelist; + +import java.io.Serializable; + +public final class VideoChannel implements Serializable { + private final String channelName; + private final String channelId; + + public VideoChannel(String channelName, String channelId) { + this.channelName = channelName; + this.channelId = channelId; + } + + public String getChannelName() { + return channelName; + } + + public String getChannelId() { + return channelId; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/whitelist/Whitelist.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/whitelist/Whitelist.java new file mode 100644 index 0000000000..06ad3a7d0f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/whitelist/Whitelist.java @@ -0,0 +1,309 @@ +package app.revanced.extension.youtube.whitelist; + +import static app.revanced.extension.shared.utils.StringRef.str; +import static app.revanced.extension.shared.utils.Utils.isSDKAbove; +import static app.revanced.extension.shared.utils.Utils.showToastShort; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.graphics.ColorFilter; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.drawable.Drawable; +import android.widget.Button; + +import androidx.annotation.NonNull; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.EnumMap; +import java.util.Iterator; +import java.util.Map; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.InflaterInputStream; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.ResourceUtils; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.patches.utils.PatchStatus; +import app.revanced.extension.youtube.shared.VideoInformation; +import app.revanced.extension.youtube.utils.ThemeUtils; + +@SuppressWarnings("deprecation") +public class Whitelist { + private static final String ZERO_WIDTH_SPACE_CHARACTER = "\u200B"; + private static final Map> whitelistMap = parseWhitelist(); + + private static final WhitelistType whitelistTypePlaybackSpeed = WhitelistType.PLAYBACK_SPEED; + private static final WhitelistType whitelistTypeSponsorBlock = WhitelistType.SPONSOR_BLOCK; + private static final String whitelistIncluded = str("revanced_whitelist_included"); + private static final String whitelistExcluded = str("revanced_whitelist_excluded"); + private static Drawable playbackSpeedDrawable; + private static Drawable sponsorBlockDrawable; + + static { + final Resources resource = Utils.getResources(); + + final int playbackSpeedDrawableId = ResourceUtils.getDrawableIdentifier("yt_outline_play_arrow_half_circle_black_24"); + if (playbackSpeedDrawableId != 0) { + playbackSpeedDrawable = resource.getDrawable(playbackSpeedDrawableId); + } + + final int sponsorBlockDrawableId = ResourceUtils.getDrawableIdentifier("revanced_sb_logo"); + if (sponsorBlockDrawableId != 0) { + sponsorBlockDrawable = resource.getDrawable(sponsorBlockDrawableId); + } + } + + public static boolean isChannelWhitelistedSponsorBlock(String channelId) { + return isWhitelisted(whitelistTypeSponsorBlock, channelId); + } + + public static boolean isChannelWhitelistedPlaybackSpeed(String channelId) { + return isWhitelisted(whitelistTypePlaybackSpeed, channelId); + } + + public static void showWhitelistDialog(Context context) { + final String channelId = VideoInformation.getChannelId(); + final String channelName = VideoInformation.getChannelName(); + + if (channelId.isEmpty() || channelName.isEmpty()) { + Utils.showToastShort(str("revanced_whitelist_failure_generic")); + return; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(channelName); + + StringBuilder sb = new StringBuilder("\n"); + + if (PatchStatus.RememberPlaybackSpeed()) { + appendStringBuilder(sb, whitelistTypePlaybackSpeed, channelId, false); + builder.setNeutralButton(ZERO_WIDTH_SPACE_CHARACTER, + (dialog, id) -> whitelistListener( + whitelistTypePlaybackSpeed, + channelId, + channelName + ) + ); + } + + if (PatchStatus.SponsorBlock()) { + appendStringBuilder(sb, whitelistTypeSponsorBlock, channelId, true); + builder.setPositiveButton(ZERO_WIDTH_SPACE_CHARACTER, + (dialog, id) -> whitelistListener( + whitelistTypeSponsorBlock, + channelId, + channelName + ) + ); + } + + builder.setMessage(sb.toString()); + + AlertDialog dialog = builder.show(); + + final ColorFilter cf = new PorterDuffColorFilter(ThemeUtils.getForegroundColor(), PorterDuff.Mode.SRC_ATOP); + Button sponsorBlockButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + Button playbackSpeedButton = dialog.getButton(AlertDialog.BUTTON_NEUTRAL); + if (sponsorBlockButton != null && sponsorBlockDrawable != null) { + sponsorBlockDrawable.setColorFilter(cf); + sponsorBlockButton.setCompoundDrawablesWithIntrinsicBounds(null, null, sponsorBlockDrawable, null); + } + if (playbackSpeedButton != null && playbackSpeedDrawable != null) { + playbackSpeedDrawable.setColorFilter(cf); + playbackSpeedButton.setCompoundDrawablesWithIntrinsicBounds(playbackSpeedDrawable, null, null, null); + } + } + + private static void appendStringBuilder(StringBuilder sb, WhitelistType whitelistType, + String channelId, boolean eol) { + final String status = isWhitelisted(whitelistType, channelId) + ? whitelistIncluded + : whitelistExcluded; + sb.append(whitelistType.getFriendlyName()); + sb.append(":\n"); + sb.append(status); + sb.append("\n"); + if (!eol) sb.append("\n"); + } + + private static void whitelistListener(WhitelistType whitelistType, String channelId, String channelName) { + try { + if (isWhitelisted(whitelistType, channelId)) { + removeFromWhitelist(whitelistType, channelId); + } else { + addToWhitelist(whitelistType, channelId, channelName); + } + } catch (Exception ex) { + Logger.printException(() -> "whitelistListener failure", ex); + } + } + + /** + * @noinspection unchecked + */ + private static Map> parseWhitelist() { + WhitelistType[] whitelistTypes = WhitelistType.values(); + Map> whitelistMap = new EnumMap<>(WhitelistType.class); + + for (WhitelistType whitelistType : whitelistTypes) { + SharedPreferences preferences = getPreferences(whitelistType.getPreferencesName()); + String serializedChannels = preferences.getString("channels", null); + if (serializedChannels == null) { + whitelistMap.put(whitelistType, new ArrayList<>()); + continue; + } + try { + Object channelsObject = deserialize(serializedChannels); + ArrayList deserializedChannels = (ArrayList) channelsObject; + whitelistMap.put(whitelistType, deserializedChannels); + } catch (Exception ex) { + Logger.printException(() -> "parseWhitelist failure", ex); + } + } + return whitelistMap; + } + + private static boolean isWhitelisted(WhitelistType whitelistType, String channelId) { + for (VideoChannel channel : getWhitelistedChannels(whitelistType)) { + if (channel.getChannelId().equals(channelId)) { + return true; + } + } + return false; + } + + private static void addToWhitelist(WhitelistType whitelistType, String channelId, String channelName) { + final VideoChannel channel = new VideoChannel(channelName, channelId); + ArrayList whitelisted = getWhitelistedChannels(whitelistType); + for (VideoChannel whitelistedChannel : whitelisted) { + if (whitelistedChannel.getChannelId().equals(channel.getChannelId())) + return; + } + whitelisted.add(channel); + String friendlyName = whitelistType.getFriendlyName(); + if (updateWhitelist(whitelistType, whitelisted)) { + showToastShort(str("revanced_whitelist_added", channelName, friendlyName)); + } else { + showToastShort(str("revanced_whitelist_add_failed", channelName, friendlyName)); + } + } + + public static void removeFromWhitelist(WhitelistType whitelistType, String channelId) { + ArrayList whitelisted = getWhitelistedChannels(whitelistType); + Iterator iterator = whitelisted.iterator(); + String channelName = ""; + while (iterator.hasNext()) { + VideoChannel channel = iterator.next(); + if (channel.getChannelId().equals(channelId)) { + channelName = channel.getChannelName(); + iterator.remove(); + break; + } + } + String friendlyName = whitelistType.getFriendlyName(); + if (updateWhitelist(whitelistType, whitelisted)) { + showToastShort(str("revanced_whitelist_removed", channelName, friendlyName)); + } else { + showToastShort(str("revanced_whitelist_remove_failed", channelName, friendlyName)); + } + } + + private static boolean updateWhitelist(WhitelistType whitelistType, ArrayList channels) { + SharedPreferences.Editor editor = getPreferences(whitelistType.getPreferencesName()).edit(); + + final String channelName = serialize(channels); + if (channelName != null && !channelName.isEmpty()) { + editor.putString("channels", channelName); + editor.apply(); + return true; + } + return false; + } + + public static ArrayList getWhitelistedChannels(WhitelistType whitelistType) { + return whitelistMap.get(whitelistType); + } + + private static SharedPreferences getPreferences(@NonNull String prefName) { + final Context context = Utils.getContext(); + return context.getSharedPreferences(prefName, Context.MODE_PRIVATE); + } + + private static String serialize(Serializable obj) { + try { + if (obj != null) { + ByteArrayOutputStream serialObj = new ByteArrayOutputStream(); + Deflater def = new Deflater(Deflater.BEST_COMPRESSION); + ObjectOutputStream objStream = + new ObjectOutputStream(new DeflaterOutputStream(serialObj, def)); + objStream.writeObject(obj); + objStream.close(); + return encodeBytes(serialObj.toByteArray()); + } + } catch (IOException ex) { + Logger.printException(() -> "Serialization error: " + ex.getMessage(), ex); + } + return null; + } + + private static Object deserialize(@NonNull String str) { + try { + final ByteArrayInputStream serialObj = new ByteArrayInputStream(decodeBytes(str)); + final ObjectInputStream objStream = new ObjectInputStream(new InflaterInputStream(serialObj)); + return objStream.readObject(); + } catch (ClassNotFoundException | IOException ex) { + Logger.printException(() -> "Deserialization error: " + ex.getMessage(), ex); + } + return null; + } + + private static String encodeBytes(byte[] bytes) { + if (isSDKAbove(26)) { + return Base64.getEncoder().encodeToString(bytes); + } else { + return new String(bytes, StandardCharsets.UTF_8); + } + } + + private static byte[] decodeBytes(String str) { + if (isSDKAbove(26)) { + return Base64.getDecoder().decode(str.getBytes(StandardCharsets.UTF_8)); + } else { + return str.getBytes(StandardCharsets.UTF_8); + } + } + + public enum WhitelistType { + PLAYBACK_SPEED(), + SPONSOR_BLOCK(); + + private final String friendlyName; + private final String preferencesName; + + WhitelistType() { + String name = name().toLowerCase(); + this.friendlyName = str("revanced_whitelist_" + name); + this.preferencesName = "whitelist_" + name; + } + + public String getFriendlyName() { + return friendlyName; + } + + public String getPreferencesName() { + return preferencesName; + } + } +} diff --git a/extensions/shared/src/main/java/com/google/android/apps/youtube/app/settings/videoquality/VideoQualitySettingsActivity.java b/extensions/shared/src/main/java/com/google/android/apps/youtube/app/settings/videoquality/VideoQualitySettingsActivity.java new file mode 100644 index 0000000000..1d1468478e --- /dev/null +++ b/extensions/shared/src/main/java/com/google/android/apps/youtube/app/settings/videoquality/VideoQualitySettingsActivity.java @@ -0,0 +1,184 @@ +package com.google.android.apps.youtube.app.settings.videoquality; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.util.TypedValue; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.SearchView; +import android.widget.SearchView.OnQueryTextListener; +import android.widget.TextView; +import android.widget.Toolbar; + +import java.lang.ref.WeakReference; +import java.lang.reflect.Field; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.ResourceUtils; +import app.revanced.extension.shared.utils.Utils; +import app.revanced.extension.youtube.settings.preference.ReVancedPreferenceFragment; +import app.revanced.extension.youtube.utils.ThemeUtils; + +@SuppressWarnings("deprecation") +public class VideoQualitySettingsActivity extends Activity { + + private static final String rvxSettingsLabel = ResourceUtils.getString("revanced_extended_settings_title"); + private static final String searchLabel = ResourceUtils.getString("revanced_extended_settings_search_title"); + private static WeakReference searchViewRef = new WeakReference<>(null); + private ReVancedPreferenceFragment fragment; + + private final OnQueryTextListener onQueryTextListener = new OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + filterPreferences(query); + return true; + } + + @Override + public boolean onQueryTextChange(String newText) { + filterPreferences(newText); + return true; + } + }; + + @Override + protected void attachBaseContext(Context base) { + super.attachBaseContext(Utils.getLocalizedContextAndSetResources(base)); + } + + @Override + protected void onCreate(Bundle bundle) { + super.onCreate(bundle); + try { + // Set fragment theme + setTheme(ThemeUtils.getThemeId()); + + // Set content + setContentView(ResourceUtils.getLayoutIdentifier("revanced_settings_with_toolbar")); + + String dataString = getIntent().getDataString(); + if (dataString == null) { + Logger.printException(() -> "DataString is null"); + return; + } else if (dataString.equals("revanced_extended_settings_intent")) { + fragment = new ReVancedPreferenceFragment(); + } else { + Logger.printException(() -> "Unknown setting: " + dataString); + return; + } + + // Set toolbar + setToolbar(); + + getFragmentManager() + .beginTransaction() + .replace(ResourceUtils.getIdIdentifier("revanced_settings_fragments"), fragment) + .commit(); + + setSearchView(); + } catch (Exception ex) { + Logger.printException(() -> "onCreate failure", ex); + } + } + + private void filterPreferences(String query) { + if (fragment == null) return; + fragment.filterPreferences(query); + } + + private void setToolbar() { + if (!(findViewById(ResourceUtils.getIdIdentifier("revanced_toolbar_parent")) instanceof ViewGroup toolBarParent)) + return; + + // Remove dummy toolbar. + for (int i = 0; i < toolBarParent.getChildCount(); i++) { + View view = toolBarParent.getChildAt(i); + if (view != null) { + toolBarParent.removeView(view); + } + } + + Toolbar toolbar = new Toolbar(toolBarParent.getContext()); + toolbar.setBackgroundColor(ThemeUtils.getToolbarBackgroundColor()); + toolbar.setNavigationIcon(ThemeUtils.getBackButtonDrawable()); + toolbar.setNavigationOnClickListener(view -> VideoQualitySettingsActivity.this.onBackPressed()); + toolbar.setTitle(rvxSettingsLabel); + int margin = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16, getResources().getDisplayMetrics()); + toolbar.setTitleMarginStart(margin); + toolbar.setTitleMarginEnd(margin); + TextView toolbarTextView = Utils.getChildView(toolbar, view -> view instanceof TextView); + if (toolbarTextView != null) { + toolbarTextView.setTextColor(ThemeUtils.getForegroundColor()); + } + toolBarParent.addView(toolbar, 0); + } + + private void setSearchView() { + SearchView searchView = findViewById(ResourceUtils.getIdIdentifier("search_view")); + + // region compose search hint + + // if the translation is missing the %s, then it + // will use the default search hint for that language + String finalSearchHint = String.format(searchLabel, rvxSettingsLabel); + + searchView.setQueryHint(finalSearchHint); + + // endregion + + // region set the font size + + try { + // 'android.widget.SearchView' has been deprecated quite a long time ago + // So access the SearchView's EditText via reflection + Field field = searchView.getClass().getDeclaredField("mSearchSrcTextView"); + field.setAccessible(true); + + // Set the font size + if (field.get(searchView) instanceof EditText searchEditText) { + searchEditText.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16); + } + } catch (NoSuchFieldException | IllegalAccessException ex) { + Logger.printException(() -> "Reflection error accessing mSearchSrcTextView", ex); + } + + // endregion + + // region SearchView dimensions + + // Get the current layout parameters of the SearchView + ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) searchView.getLayoutParams(); + + // Set the margins (in pixels) + int margin = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 20, getResources().getDisplayMetrics()); // for example, 10dp + layoutParams.setMargins(margin, layoutParams.topMargin, margin, layoutParams.bottomMargin); + + // Apply the layout parameters to the SearchView + searchView.setLayoutParams(layoutParams); + + // endregion + + // region SearchView color + + searchView.setBackground(ThemeUtils.getSearchViewShape()); + + // endregion + + // Set the listener for query text changes + searchView.setOnQueryTextListener(onQueryTextListener); + + // Keep a weak reference to the SearchView + searchViewRef = new WeakReference<>(searchView); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + SearchView searchView = searchViewRef.get(); + if (!hasFocus && searchView != null && searchView.getQuery().length() == 0) { + searchView.clearFocus(); + } + } +} diff --git a/extensions/shared/stub/build.gradle.kts b/extensions/shared/stub/build.gradle.kts new file mode 100644 index 0000000000..ea7fb8015b --- /dev/null +++ b/extensions/shared/stub/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id(libs.plugins.android.library.get().pluginId) +} + +android { + namespace = "app.revanced.extension" + compileSdk = 34 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} diff --git a/extensions/shared/stub/src/main/AndroidManifest.xml b/extensions/shared/stub/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..568741e54f --- /dev/null +++ b/extensions/shared/stub/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/extensions/shared/stub/src/main/java/android/support/v7/widget/RecyclerView.java b/extensions/shared/stub/src/main/java/android/support/v7/widget/RecyclerView.java new file mode 100644 index 0000000000..225d1c565e --- /dev/null +++ b/extensions/shared/stub/src/main/java/android/support/v7/widget/RecyclerView.java @@ -0,0 +1,19 @@ +package android.support.v7.widget; + +import android.content.Context; +import android.view.ViewGroup; + +/** + * "CompileOnly" class + *

+ * This class will not be included and "replaced" by the real package's class. + */ +public class RecyclerView extends ViewGroup { + public RecyclerView(Context context) { + super(context); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + } +} diff --git a/extensions/shared/stub/src/main/java/android/support/v7/widget/Toolbar.java b/extensions/shared/stub/src/main/java/android/support/v7/widget/Toolbar.java new file mode 100644 index 0000000000..96e027cb47 --- /dev/null +++ b/extensions/shared/stub/src/main/java/android/support/v7/widget/Toolbar.java @@ -0,0 +1,19 @@ +package android.support.v7.widget; + +import android.content.Context; +import android.view.ViewGroup; + +/** + * "CompileOnly" class + *

+ * This class will not be included and "replaced" by the real package's class. + */ +public class Toolbar extends ViewGroup { + public Toolbar(Context context) { + super(context); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + } +} diff --git a/extensions/shared/stub/src/main/java/androidx/coordinatorlayout/widget/CoordinatorLayout.java b/extensions/shared/stub/src/main/java/androidx/coordinatorlayout/widget/CoordinatorLayout.java new file mode 100644 index 0000000000..fa927116fc --- /dev/null +++ b/extensions/shared/stub/src/main/java/androidx/coordinatorlayout/widget/CoordinatorLayout.java @@ -0,0 +1,24 @@ +package androidx.coordinatorlayout.widget; + +import android.content.Context; +import android.view.ViewGroup; + +/** + * "CompileOnly" class + *

+ * This class will not be included and "replaced" by the real package's class. + */ +public class CoordinatorLayout extends ViewGroup { + public CoordinatorLayout(Context context) { + super(context); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + } + + @Override + public void setVisibility(int visibility) { + super.setVisibility(visibility); + } +} diff --git a/extensions/shared/stub/src/main/java/com/airbnb/lottie/LottieAnimationView.java b/extensions/shared/stub/src/main/java/com/airbnb/lottie/LottieAnimationView.java new file mode 100644 index 0000000000..239b4321e6 --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/airbnb/lottie/LottieAnimationView.java @@ -0,0 +1,15 @@ +package com.airbnb.lottie; + +import android.content.Context; +import android.widget.ImageView; + +public class LottieAnimationView extends ImageView { + + public LottieAnimationView(Context context) { + super(context); + } + + @SuppressWarnings("unused") + public void setAnimation(final int rawRes) { + } +} \ No newline at end of file diff --git a/extensions/shared/stub/src/main/java/com/google/android/apps/youtube/app/application/Shell_SettingsActivity.java b/extensions/shared/stub/src/main/java/com/google/android/apps/youtube/app/application/Shell_SettingsActivity.java new file mode 100644 index 0000000000..32ce1d8c9e --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/google/android/apps/youtube/app/application/Shell_SettingsActivity.java @@ -0,0 +1,4 @@ +package com.google.android.apps.youtube.app.application; + +public class Shell_SettingsActivity { +} \ No newline at end of file diff --git a/extensions/shared/stub/src/main/java/com/google/android/apps/youtube/app/settings/SettingsActivity.java b/extensions/shared/stub/src/main/java/com/google/android/apps/youtube/app/settings/SettingsActivity.java new file mode 100644 index 0000000000..0bbe502126 --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/google/android/apps/youtube/app/settings/SettingsActivity.java @@ -0,0 +1,4 @@ +package com.google.android.apps.youtube.app.settings; + +public class SettingsActivity { +} \ No newline at end of file diff --git a/extensions/shared/stub/src/main/java/com/google/android/libraries/youtube/rendering/ui/pivotbar/PivotBar.java b/extensions/shared/stub/src/main/java/com/google/android/libraries/youtube/rendering/ui/pivotbar/PivotBar.java new file mode 100644 index 0000000000..f275effdb9 --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/google/android/libraries/youtube/rendering/ui/pivotbar/PivotBar.java @@ -0,0 +1,10 @@ +package com.google.android.libraries.youtube.rendering.ui.pivotbar; + +import android.content.Context; +import android.widget.HorizontalScrollView; + +public class PivotBar extends HorizontalScrollView { + public PivotBar(Context context) { + super(context); + } +} diff --git a/extensions/shared/stub/src/main/java/com/google/android/material/textfield/TextInputLayout.java b/extensions/shared/stub/src/main/java/com/google/android/material/textfield/TextInputLayout.java new file mode 100644 index 0000000000..d1d3d63a00 --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/google/android/material/textfield/TextInputLayout.java @@ -0,0 +1,11 @@ +package com.google.android.material.textfield; + +import android.content.Context; +import android.widget.LinearLayout; + +public class TextInputLayout extends LinearLayout { + + public TextInputLayout(Context context) { + super(context); + } +} \ No newline at end of file diff --git a/extensions/shared/stub/src/main/java/com/reddit/domain/model/ILink.java b/extensions/shared/stub/src/main/java/com/reddit/domain/model/ILink.java new file mode 100644 index 0000000000..f9cbb955cb --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/reddit/domain/model/ILink.java @@ -0,0 +1,7 @@ +package com.reddit.domain.model; + +public class ILink { + public boolean getPromoted() { + throw new UnsupportedOperationException("Stub"); + } +} diff --git a/extensions/shared/stub/src/main/java/org/chromium/net/UrlRequest.java b/extensions/shared/stub/src/main/java/org/chromium/net/UrlRequest.java new file mode 100644 index 0000000000..565fc22274 --- /dev/null +++ b/extensions/shared/stub/src/main/java/org/chromium/net/UrlRequest.java @@ -0,0 +1,4 @@ +package org.chromium.net; + +public abstract class UrlRequest { +} diff --git a/extensions/shared/stub/src/main/java/org/chromium/net/UrlResponseInfo.java b/extensions/shared/stub/src/main/java/org/chromium/net/UrlResponseInfo.java new file mode 100644 index 0000000000..8e341247dc --- /dev/null +++ b/extensions/shared/stub/src/main/java/org/chromium/net/UrlResponseInfo.java @@ -0,0 +1,12 @@ +package org.chromium.net; + +//dummy class +public abstract class UrlResponseInfo { + + public abstract String getUrl(); + + public abstract int getHttpStatusCode(); + + // Add additional existing methods, if needed. + +} diff --git a/extensions/shared/stub/src/main/java/org/chromium/net/impl/CronetUrlRequest.java b/extensions/shared/stub/src/main/java/org/chromium/net/impl/CronetUrlRequest.java new file mode 100644 index 0000000000..fa0dcacd98 --- /dev/null +++ b/extensions/shared/stub/src/main/java/org/chromium/net/impl/CronetUrlRequest.java @@ -0,0 +1,11 @@ +package org.chromium.net.impl; + +import org.chromium.net.UrlRequest; + +public abstract class CronetUrlRequest extends UrlRequest { + + /** + * Method is added by patch. + */ + public abstract String getHookedUrl(); +} diff --git a/gradle.properties b/gradle.properties index fb02b8c60f..552fd30410 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,8 @@ -org.gradle.parallel = true org.gradle.caching = true +org.gradle.jvmargs = -Xms1024M -Xmx4096M +org.gradle.parallel = true +android.useAndroidX = true kotlin.code.style = official -version = 4.16.1 +kotlin.jvm.target.validation.mode = IGNORE +version = 5.0.0 + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a719a55525..169ef71992 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,13 +1,19 @@ [versions] -revanced-patcher = "19.3.1" +revanced-patcher = "21.0.0" +# Tracking https://github.com/google/smali/issues/64. +#noinspection GradleDependency smali = "3.0.5" gson = "2.11.0" -kotlin = "2.0.20" +agp = "8.2.2" +annotation = "1.9.1" +lang3 = "3.17.0" +preference = "1.2.1" [libraries] -revanced-patcher = { module = "app.revanced:revanced-patcher", version.ref = "revanced-patcher" } -smali = { module = "com.android.tools.smali:smali", version.ref = "smali" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } +lang3 = { module = "org.apache.commons:commons-lang3", version.ref = "lang3" } +preference = { module = "androidx.preference:preference", version.ref = "preference" } [plugins] -kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +android-library = { id = "com.android.library", version.ref = "agp" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7e0b101d5c..c67622290d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,103 +8,45 @@ "@saithodev/semantic-release-backmerge": "^4.0.1", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", - "gradle-semantic-release-plugin": "^1.9.2", - "semantic-release": "^24.1.0" + "gradle-semantic-release-plugin": "^1.10.1", + "semantic-release": "^24.1.2" } }, "node_modules/@babel/code-frame": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", - "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", + "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/highlight": "^7.23.4", - "chalk": "^2.4.2" + "@babel/highlight": "^7.24.7", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/code-frame/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/code-frame/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/code-frame/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", + "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/highlight": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", + "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-validator-identifier": "^7.24.7", "chalk": "^2.4.2", - "js-tokens": "^4.0.0" + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" @@ -115,6 +57,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^1.9.0" }, @@ -127,6 +70,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -141,6 +85,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "1.1.3" } @@ -149,13 +94,15 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@babel/highlight/node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -165,6 +112,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^3.0.0" }, @@ -177,6 +125,7 @@ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", "dev": true, + "license": "MIT", "optional": true, "engines": { "node": ">=0.1.90" @@ -187,6 +136,7 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -200,6 +150,7 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, + "license": "MIT", "engines": { "node": ">= 8" } @@ -209,6 +160,7 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -218,149 +170,158 @@ } }, "node_modules/@octokit/auth-token": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", - "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.1.tgz", + "integrity": "sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 18" } }, "node_modules/@octokit/core": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.1.0.tgz", - "integrity": "sha512-BDa2VAMLSh3otEiaMJ/3Y36GU4qf6GI+VivQ/P41NC6GHcdxpKlqV0ikSZ5gdQsmS3ojXeRx5vasgNTinF0Q4g==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.2.tgz", + "integrity": "sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg==", "dev": true, + "license": "MIT", "dependencies": { - "@octokit/auth-token": "^4.0.0", - "@octokit/graphql": "^7.0.0", - "@octokit/request": "^8.0.2", - "@octokit/request-error": "^5.0.0", - "@octokit/types": "^12.0.0", - "before-after-hook": "^2.2.0", - "universal-user-agent": "^6.0.0" + "@octokit/auth-token": "^5.0.0", + "@octokit/graphql": "^8.0.0", + "@octokit/request": "^9.0.0", + "@octokit/request-error": "^6.0.1", + "@octokit/types": "^13.0.0", + "before-after-hook": "^3.0.2", + "universal-user-agent": "^7.0.0" }, "engines": { "node": ">= 18" } }, "node_modules/@octokit/endpoint": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.4.tgz", - "integrity": "sha512-DWPLtr1Kz3tv8L0UvXTDP1fNwM0S+z6EJpRcvH66orY6Eld4XBMCSYsaWp4xIm61jTWxK68BrR7ibO+vSDnZqw==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", + "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", "dev": true, + "license": "MIT", "dependencies": { - "@octokit/types": "^12.0.0", - "universal-user-agent": "^6.0.0" + "@octokit/types": "^13.0.0", + "universal-user-agent": "^7.0.2" }, "engines": { "node": ">= 18" } }, "node_modules/@octokit/graphql": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.0.2.tgz", - "integrity": "sha512-OJ2iGMtj5Tg3s6RaXH22cJcxXRi7Y3EBqbHTBRq+PQAqfaS8f/236fUrWhfSn8P4jovyzqucxme7/vWSSZBX2Q==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.1.1.tgz", + "integrity": "sha512-ukiRmuHTi6ebQx/HFRCXKbDlOh/7xEV6QUXaE7MJEKGNAncGI/STSbOkl12qVXZrfZdpXctx5O9X1AIaebiDBg==", "dev": true, + "license": "MIT", "dependencies": { - "@octokit/request": "^8.0.1", - "@octokit/types": "^12.0.0", - "universal-user-agent": "^6.0.0" + "@octokit/request": "^9.0.0", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^7.0.0" }, "engines": { "node": ">= 18" } }, "node_modules/@octokit/openapi-types": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-19.1.0.tgz", - "integrity": "sha512-6G+ywGClliGQwRsjvqVYpklIfa7oRPA0vyhPQG/1Feh+B+wU0vGH1JiJ5T25d3g1JZYBHzR2qefLi9x8Gt+cpw==", - "dev": true + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", + "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==", + "dev": true, + "license": "MIT" }, "node_modules/@octokit/plugin-paginate-rest": { - "version": "9.1.5", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.1.5.tgz", - "integrity": "sha512-WKTQXxK+bu49qzwv4qKbMMRXej1DU2gq017euWyKVudA6MldaSSQuxtz+vGbhxV4CjxpUxjZu6rM2wfc1FiWVg==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.3.5.tgz", + "integrity": "sha512-cgwIRtKrpwhLoBi0CUNuY83DPGRMaWVjqVI/bGKsLJ4PzyWZNaEmhHroI2xlrVXkk6nFv0IsZpOp+ZWSWUS2AQ==", "dev": true, + "license": "MIT", "dependencies": { - "@octokit/types": "^12.4.0" + "@octokit/types": "^13.6.0" }, "engines": { "node": ">= 18" }, "peerDependencies": { - "@octokit/core": ">=5" + "@octokit/core": ">=6" } }, "node_modules/@octokit/plugin-retry": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-6.0.1.tgz", - "integrity": "sha512-SKs+Tz9oj0g4p28qkZwl/topGcb0k0qPNX/i7vBKmDsjoeqnVfFUquqrE/O9oJY7+oLzdCtkiWSXLpLjvl6uog==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-7.1.2.tgz", + "integrity": "sha512-XOWnPpH2kJ5VTwozsxGurw+svB2e61aWlmk5EVIYZPwFK5F9h4cyPyj9CIKRyMXMHSwpIsI3mPOdpMmrRhe7UQ==", "dev": true, + "license": "MIT", "dependencies": { - "@octokit/request-error": "^5.0.0", - "@octokit/types": "^12.0.0", + "@octokit/request-error": "^6.0.0", + "@octokit/types": "^13.0.0", "bottleneck": "^2.15.3" }, "engines": { "node": ">= 18" }, "peerDependencies": { - "@octokit/core": ">=5" + "@octokit/core": ">=6" } }, "node_modules/@octokit/plugin-throttling": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-8.1.3.tgz", - "integrity": "sha512-pfyqaqpc0EXh5Cn4HX9lWYsZ4gGbjnSmUILeu4u2gnuM50K/wIk9s1Pxt3lVeVwekmITgN/nJdoh43Ka+vye8A==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-9.3.1.tgz", + "integrity": "sha512-Qd91H4liUBhwLB2h6jZ99bsxoQdhgPk6TdwnClPyTBSDAdviGPceViEgUwj+pcQDmB/rfAXAXK7MTochpHM3yQ==", "dev": true, + "license": "MIT", "dependencies": { - "@octokit/types": "^12.2.0", + "@octokit/types": "^13.0.0", "bottleneck": "^2.15.3" }, "engines": { "node": ">= 18" }, "peerDependencies": { - "@octokit/core": "^5.0.0" + "@octokit/core": "^6.0.0" } }, "node_modules/@octokit/request": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.2.0.tgz", - "integrity": "sha512-exPif6x5uwLqv1N1irkLG1zZNJkOtj8bZxuVHd71U5Ftuxf2wGNvAJyNBcPbPC+EBzwYEbBDdSFb8EPcjpYxPQ==", + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.1.3.tgz", + "integrity": "sha512-V+TFhu5fdF3K58rs1pGUJIDH5RZLbZm5BI+MNF+6o/ssFNT4vWlCh/tVpF3NxGtP15HUxTTMUbsG5llAuU2CZA==", "dev": true, + "license": "MIT", "dependencies": { - "@octokit/endpoint": "^9.0.0", - "@octokit/request-error": "^5.0.0", - "@octokit/types": "^12.0.0", - "universal-user-agent": "^6.0.0" + "@octokit/endpoint": "^10.0.0", + "@octokit/request-error": "^6.0.1", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^7.0.2" }, "engines": { "node": ">= 18" } }, "node_modules/@octokit/request-error": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.0.1.tgz", - "integrity": "sha512-X7pnyTMV7MgtGmiXBwmO6M5kIPrntOXdyKZLigNfQWSEQzVxR4a4vo49vJjTWX70mPndj8KhfT4Dx+2Ng3vnBQ==", + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.5.tgz", + "integrity": "sha512-IlBTfGX8Yn/oFPMwSfvugfncK2EwRLjzbrpifNaMY8o/HTEAFqCA1FZxjD9cWvSKBHgrIhc4CSBIzMxiLsbzFQ==", "dev": true, + "license": "MIT", "dependencies": { - "@octokit/types": "^12.0.0", - "deprecation": "^2.0.0", - "once": "^1.4.0" + "@octokit/types": "^13.0.0" }, "engines": { "node": ">= 18" } }, "node_modules/@octokit/types": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.4.0.tgz", - "integrity": "sha512-FLWs/AvZllw/AGVs+nJ+ELCDZZJk+kY0zMen118xhL2zD0s1etIUHm1odgjP7epxYU1ln7SZxEUWYop5bhsdgQ==", + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.6.0.tgz", + "integrity": "sha512-CrooV/vKCXqwLa+osmHLIMUb87brpgUqlqkPGc6iE2wCkUvTrHiXFMhAKoDDaAAYJrtKtrFTgSQTg5nObBEaew==", "dev": true, + "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^19.1.0" + "@octokit/openapi-types": "^22.2.0" } }, "node_modules/@pnpm/config.env-replace": { @@ -368,6 +329,7 @@ "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", "dev": true, + "license": "MIT", "engines": { "node": ">=12.22.0" } @@ -377,6 +339,7 @@ "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "4.2.10" }, @@ -388,13 +351,15 @@ "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/@pnpm/npm-conf": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.2.2.tgz", - "integrity": "sha512-UA91GwWPhFExt3IizW6bOeY/pQ0BkuNwKjk9iQW9KqxluGCrg4VenZ0/L+2Y0+ZOtme72EVvg6v0zo3AMQRCeA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.3.1.tgz", + "integrity": "sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==", "dev": true, + "license": "MIT", "dependencies": { "@pnpm/config.env-replace": "^1.1.0", "@pnpm/network.ca-file": "^1.0.1", @@ -409,6 +374,7 @@ "resolved": "https://registry.npmjs.org/@saithodev/semantic-release-backmerge/-/semantic-release-backmerge-4.0.1.tgz", "integrity": "sha512-WDsU28YrXSLx0xny7FgFlEk8DCKGcj6OOhA+4Q9k3te1jJD1GZuqY8sbIkVQaw9cqJ7CT+fCZUN6QDad8JW4Dg==", "dev": true, + "license": "MIT", "dependencies": { "@semantic-release/error": "^3.0.0", "aggregate-error": "^3.1.0", @@ -418,92 +384,567 @@ "semantic-release": "^22.0.7" } }, - "node_modules/@saithodev/semantic-release-backmerge/node_modules/clean-stack": { + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/auth-token": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", + "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/core": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", - "integrity": "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ==", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.0.tgz", + "integrity": "sha512-1LFfa/qnMQvEOAdzlQymH0ulepxbxnCYAKJZfMci/5XJyIHWgEYnDmgnKakbTh7CH2tFQ5O60oYDvns4i9RAIg==", "dev": true, + "license": "MIT", "dependencies": { - "escape-string-regexp": "5.0.0" + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.3.1", + "@octokit/request-error": "^5.1.0", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" }, "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 18" } }, - "node_modules/@saithodev/semantic-release-backmerge/node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/endpoint": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.5.tgz", + "integrity": "sha512-ekqR4/+PCLkEBF6qgj8WqJfvDq65RH85OAgrtnVp1mSxaXF03u2xW/hUdweGS5654IlC0wkNYC18Z50tSYTAFw==", "dev": true, + "license": "MIT", "dependencies": { - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "node": ">= 18" } }, - "node_modules/@saithodev/semantic-release-backmerge/node_modules/env-ci": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-10.0.0.tgz", - "integrity": "sha512-U4xcd/utDYFgMh0yWj07R1H6L5fwhVbmxBCpnL0DbVSDZVnsC82HONw0wxtxNkIAcua3KtbomQvIk5xFZGAQJw==", + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/graphql": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.0.tgz", + "integrity": "sha512-r+oZUH7aMFui1ypZnAvZmn0KSqAUgE1/tUXIWaqUCa1758ts/Jio84GZuzsvUkme98kv0WFY8//n0J1Z+vsIsQ==", "dev": true, + "license": "MIT", "dependencies": { - "execa": "^8.0.0", - "java-properties": "^1.0.2" + "@octokit/request": "^8.3.0", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" }, "engines": { - "node": "^18.17 || >=20.6.1" + "node": ">= 18" } }, - "node_modules/@saithodev/semantic-release-backmerge/node_modules/env-ci/node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/openapi-types": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", + "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/plugin-paginate-rest": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.2.1.tgz", + "integrity": "sha512-wfGhE/TAkXZRLjksFXuDZdmGnJQHvtU/joFQdweXUgzo1XwvBCD4o4+75NtFfjfLK5IwLf9vHTfSiU3sLRYpRw==", "dev": true, + "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" + "@octokit/types": "^12.6.0" }, "engines": { - "node": ">=16.17" + "node": ">= 18" }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "peerDependencies": { + "@octokit/core": "5" } }, - "node_modules/@saithodev/semantic-release-backmerge/node_modules/env-ci/node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^20.0.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/plugin-retry": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-6.0.1.tgz", + "integrity": "sha512-SKs+Tz9oj0g4p28qkZwl/topGcb0k0qPNX/i7vBKmDsjoeqnVfFUquqrE/O9oJY7+oLzdCtkiWSXLpLjvl6uog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^5.0.0", + "@octokit/types": "^12.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=5" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/plugin-retry/node_modules/@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^20.0.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/plugin-throttling": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-8.2.0.tgz", + "integrity": "sha512-nOpWtLayKFpgqmgD0y3GqXafMFuKcA4tRPZIfu7BArd2lEZeb1988nhWhwx4aZWmjDmUfdgVf7W+Tt4AmvRmMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^12.2.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "^5.0.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/plugin-throttling/node_modules/@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^20.0.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/request": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.0.tgz", + "integrity": "sha512-9Bb014e+m2TgBeEJGEbdplMVWwPmL1FPtggHQRkV+WVsMggPtEkLKPlcVYm/o8xKLkpJ7B+6N8WfQMtDLX2Dpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^9.0.1", + "@octokit/request-error": "^5.1.0", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/request-error": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.0.tgz", + "integrity": "sha512-GETXfE05J0+7H2STzekpKObFe765O5dlAKUTLNGeH+x47z7JjXHfsHKo5z21D/o/IOZTUEI6nyWyR+bZVP/n5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/commit-analyzer": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@semantic-release/commit-analyzer/-/commit-analyzer-11.1.0.tgz", + "integrity": "sha512-cXNTbv3nXR2hlzHjAMgbuiQVtvWHTlwwISt60B+4NZv01y/QRY7p2HcJm8Eh2StzcTJoNnflvKjHH/cjFS7d5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "conventional-changelog-angular": "^7.0.0", + "conventional-commits-filter": "^4.0.0", + "conventional-commits-parser": "^5.0.0", + "debug": "^4.0.0", + "import-from-esm": "^1.0.3", + "lodash-es": "^4.17.21", + "micromatch": "^4.0.2" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/github": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-9.2.6.tgz", + "integrity": "sha512-shi+Lrf6exeNZF+sBhK+P011LSbhmIAoUEgEY6SsxF8irJ+J2stwI5jkyDQ+4gzYyDImzV6LCKdYB9FXnQRWKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^5.0.0", + "@octokit/plugin-paginate-rest": "^9.0.0", + "@octokit/plugin-retry": "^6.0.0", + "@octokit/plugin-throttling": "^8.0.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "debug": "^4.3.4", + "dir-glob": "^3.0.1", + "globby": "^14.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "issue-parser": "^6.0.0", + "lodash-es": "^4.17.21", + "mime": "^4.0.0", + "p-filter": "^4.0.0", + "url-join": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/github/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/github/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/npm": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-11.0.3.tgz", + "integrity": "sha512-KUsozQGhRBAnoVg4UMZj9ep436VEGwT536/jwSqB7vcEfA6oncCUU7UIYTRdLx7GvTtqn0kBjnkfLVkcnBa2YQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "execa": "^8.0.0", + "fs-extra": "^11.0.0", + "lodash-es": "^4.17.21", + "nerf-dart": "^1.0.0", + "normalize-url": "^8.0.0", + "npm": "^10.5.0", + "rc": "^1.2.8", + "read-pkg": "^9.0.0", + "registry-auth-token": "^5.0.0", + "semver": "^7.1.2", + "tempy": "^3.0.0" + }, + "engines": { + "node": "^18.17 || >=20" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/npm/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/npm/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/npm/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/npm/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/release-notes-generator": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-12.1.0.tgz", + "integrity": "sha512-g6M9AjUKAZUZnxaJZnouNBeDNTCUrJ5Ltj+VJ60gJeDaRRahcHsry9HW8yKrnKkKNkx5lbWiEP1FPMqVNQz8Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "conventional-changelog-angular": "^7.0.0", + "conventional-changelog-writer": "^7.0.0", + "conventional-commits-filter": "^4.0.0", + "conventional-commits-parser": "^5.0.0", + "debug": "^4.0.0", + "get-stream": "^7.0.0", + "import-from-esm": "^1.0.3", + "into-stream": "^7.0.0", + "lodash-es": "^4.17.21", + "read-pkg-up": "^11.0.0" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/release-notes-generator/node_modules/get-stream": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-7.0.1.tgz", + "integrity": "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/ansi-escapes": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz", + "integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/clean-stack": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", + "integrity": "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/conventional-changelog-angular": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", + "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/conventional-changelog-writer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-7.0.1.tgz", + "integrity": "sha512-Uo+R9neH3r/foIvQ0MKcsXkX642hdm9odUp7TqgFS7BsalTcjzRlIfWZrZR1gbxOozKucaKt5KAbjW8J8xRSmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "conventional-commits-filter": "^4.0.0", + "handlebars": "^4.7.7", + "json-stringify-safe": "^5.0.1", + "meow": "^12.0.1", + "semver": "^7.5.2", + "split2": "^4.0.0" + }, + "bin": { + "conventional-changelog-writer": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/conventional-commits-filter": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-4.0.0.tgz", + "integrity": "sha512-rnpnibcSOdFcdclpFwWa+pPlZJhXE7l+XK04zxhbWrhgpR96h33QLz8hITTXbcYICxVr3HZFtbtUAQ+4LdBo9A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/conventional-commits-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-text-path": "^2.0.0", + "JSONStream": "^1.3.5", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "conventional-commits-parser": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/env-ci": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-10.0.0.tgz", + "integrity": "sha512-U4xcd/utDYFgMh0yWj07R1H6L5fwhVbmxBCpnL0DbVSDZVnsC82HONw0wxtxNkIAcua3KtbomQvIk5xFZGAQJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^8.0.0", + "java-properties": "^1.0.2" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/env-ci/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/env-ci/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, @@ -512,6 +953,23 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/find-versions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-5.1.0.tgz", + "integrity": "sha512-+iwzCJ7C5v5KgcBuueqVoNiHVoQpwiUK5XFLjf0affFTep+Wcw93tPvmb8tqujDNmzhBDPddnWV/qgWSXgq+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver-regex": "^4.0.5" + }, "engines": { "node": ">=12" }, @@ -519,11 +977,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, "node_modules/@saithodev/semantic-release-backmerge/node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=16.17.0" } @@ -533,6 +1005,7 @@ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -545,6 +1018,7 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true, + "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -552,11 +1026,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/issue-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-6.0.0.tgz", + "integrity": "sha512-zKa/Dxq2lGsBIXQ7CUZWTHfvxPC2ej0KfO7fIPqLlHB9J2hJ7rGhZ5rilhuufylr4RXYPzJUeFjKxz305OsNlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.capitalize": "^4.2.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.uniqby": "^4.7.0" + }, + "engines": { + "node": ">=10.13" + } + }, "node_modules/@saithodev/semantic-release-backmerge/node_modules/marked": { "version": "9.1.6", "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", "dev": true, + "license": "MIT", "bin": { "marked": "bin/marked.js" }, @@ -569,6 +1061,7 @@ "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-6.2.0.tgz", "integrity": "sha512-ubWhwcBFHnXsjYNsu+Wndpg0zhY4CahSpPlA70PlO0rR9r2sZpkyU+rkCsOWH+KMEkx847UpALON+HWgxowFtw==", "dev": true, + "license": "MIT", "dependencies": { "ansi-escapes": "^6.2.0", "cardinal": "^2.1.1", @@ -584,11 +1077,25 @@ "marked": ">=1 <12" } }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/meow": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@saithodev/semantic-release-backmerge/node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -597,10 +1104,11 @@ } }, "node_modules/@saithodev/semantic-release-backmerge/node_modules/npm-run-path": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz", - "integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^4.0.0" }, @@ -616,6 +1124,7 @@ "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", "dev": true, + "license": "MIT", "dependencies": { "mimic-fn": "^4.0.0" }, @@ -631,6 +1140,7 @@ "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", "integrity": "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -643,6 +1153,7 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -655,6 +1166,7 @@ "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-22.0.12.tgz", "integrity": "sha512-0mhiCR/4sZb00RVFJIUlMuiBkW3NMpVIW2Gse7noqEMoFGkvfPPAImEQbkBV8xga4KOPP4FdTRYuLLy32R1fPw==", "dev": true, + "license": "MIT", "dependencies": { "@semantic-release/commit-analyzer": "^11.0.0", "@semantic-release/error": "^4.0.0", @@ -698,6 +1210,7 @@ "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" } @@ -707,6 +1220,7 @@ "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", "dev": true, + "license": "MIT", "dependencies": { "clean-stack": "^5.2.0", "indent-string": "^5.0.0" @@ -723,6 +1237,7 @@ "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", "dev": true, + "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", @@ -746,6 +1261,7 @@ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", "dev": true, + "license": "MIT", "engines": { "node": ">=16" }, @@ -758,6 +1274,7 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, + "license": "ISC", "engines": { "node": ">=14" }, @@ -770,6 +1287,7 @@ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -777,17 +1295,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@semantic-release/changelog": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@semantic-release/changelog/-/changelog-6.0.3.tgz", "integrity": "sha512-dZuR5qByyfe3Y03TpmCvAxCyTnp7r5XwtHRf/8vD9EAn4ZWbavUX8adMtXYzE86EVh0gyLA7lm5yW4IV30XUag==", "dev": true, + "license": "MIT", "dependencies": { "@semantic-release/error": "^3.0.0", "aggregate-error": "^3.0.0", @@ -802,21 +1329,23 @@ } }, "node_modules/@semantic-release/commit-analyzer": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@semantic-release/commit-analyzer/-/commit-analyzer-11.1.0.tgz", - "integrity": "sha512-cXNTbv3nXR2hlzHjAMgbuiQVtvWHTlwwISt60B+4NZv01y/QRY7p2HcJm8Eh2StzcTJoNnflvKjHH/cjFS7d5g==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/commit-analyzer/-/commit-analyzer-13.0.0.tgz", + "integrity": "sha512-KtXWczvTAB1ZFZ6B4O+w8HkfYm/OgQb1dUGNFZtDgQ0csggrmkq8sTxhd+lwGF8kMb59/RnG9o4Tn7M/I8dQ9Q==", "dev": true, + "license": "MIT", "dependencies": { - "conventional-changelog-angular": "^7.0.0", - "conventional-commits-filter": "^4.0.0", - "conventional-commits-parser": "^5.0.0", + "conventional-changelog-angular": "^8.0.0", + "conventional-changelog-writer": "^8.0.0", + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.0.0", "debug": "^4.0.0", "import-from-esm": "^1.0.3", "lodash-es": "^4.17.21", "micromatch": "^4.0.2" }, "engines": { - "node": "^18.17 || >=20.6.1" + "node": ">=20.8.1" }, "peerDependencies": { "semantic-release": ">=20.1.0" @@ -827,6 +1356,7 @@ "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-3.0.0.tgz", "integrity": "sha512-5hiM4Un+tpl4cKw3lV4UgzJj+SmfNIDCLLw0TepzQxz9ZGV5ixnqkzIVF+3tp0ZHgcMKE+VNGHJjEeyFG2dcSw==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.17" } @@ -836,6 +1366,7 @@ "resolved": "https://registry.npmjs.org/@semantic-release/git/-/git-10.0.1.tgz", "integrity": "sha512-eWrx5KguUcU2wUPaO6sfvZI0wPafUKAMNC18aXY4EnNcrZL86dEmpNVnC9uMpGZkmZJ9EfCVJBQx4pV4EMGT1w==", "dev": true, + "license": "MIT", "dependencies": { "@semantic-release/error": "^3.0.0", "aggregate-error": "^3.0.0", @@ -854,15 +1385,16 @@ } }, "node_modules/@semantic-release/github": { - "version": "9.2.6", - "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-9.2.6.tgz", - "integrity": "sha512-shi+Lrf6exeNZF+sBhK+P011LSbhmIAoUEgEY6SsxF8irJ+J2stwI5jkyDQ+4gzYyDImzV6LCKdYB9FXnQRWKA==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-11.0.0.tgz", + "integrity": "sha512-Uon6G6gJD8U1JNvPm7X0j46yxNRJ8Ui6SgK4Zw5Ktu8RgjEft3BGn+l/RX1TTzhhO3/uUcKuqM+/9/ETFxWS/Q==", "dev": true, + "license": "MIT", "dependencies": { - "@octokit/core": "^5.0.0", - "@octokit/plugin-paginate-rest": "^9.0.0", - "@octokit/plugin-retry": "^6.0.0", - "@octokit/plugin-throttling": "^8.0.0", + "@octokit/core": "^6.0.0", + "@octokit/plugin-paginate-rest": "^11.0.0", + "@octokit/plugin-retry": "^7.0.0", + "@octokit/plugin-throttling": "^9.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "debug": "^4.3.4", @@ -870,17 +1402,17 @@ "globby": "^14.0.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", - "issue-parser": "^6.0.0", + "issue-parser": "^7.0.0", "lodash-es": "^4.17.21", "mime": "^4.0.0", "p-filter": "^4.0.0", "url-join": "^5.0.0" }, "engines": { - "node": ">=18" + "node": ">=20.8.1" }, "peerDependencies": { - "semantic-release": ">=20.1.0" + "semantic-release": ">=24.1.0" } }, "node_modules/@semantic-release/github/node_modules/@semantic-release/error": { @@ -888,6 +1420,7 @@ "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" } @@ -897,6 +1430,7 @@ "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", "dev": true, + "license": "MIT", "dependencies": { "clean-stack": "^5.2.0", "indent-string": "^5.0.0" @@ -913,6 +1447,7 @@ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", "integrity": "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ==", "dev": true, + "license": "MIT", "dependencies": { "escape-string-regexp": "5.0.0" }, @@ -928,6 +1463,7 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -940,6 +1476,7 @@ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -948,19 +1485,20 @@ } }, "node_modules/@semantic-release/npm": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-11.0.2.tgz", - "integrity": "sha512-owtf3RjyPvRE63iUKZ5/xO4uqjRpVQDUB9+nnXj0xwfIeM9pRl+cG+zGDzdftR4m3f2s4Wyf3SexW+kF5DFtWA==", + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-12.0.1.tgz", + "integrity": "sha512-/6nntGSUGK2aTOI0rHPwY3ZjgY9FkXmEHbW9Kr+62NVOsyqpKKeP0lrCH+tphv+EsNdJNmqqwijTEnVWUMQ2Nw==", "dev": true, + "license": "MIT", "dependencies": { "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", - "execa": "^8.0.0", + "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", "nerf-dart": "^1.0.0", "normalize-url": "^8.0.0", - "npm": "^10.0.0", + "npm": "^10.5.0", "rc": "^1.2.8", "read-pkg": "^9.0.0", "registry-auth-token": "^5.0.0", @@ -968,7 +1506,7 @@ "tempy": "^3.0.0" }, "engines": { - "node": "^18.17 || >=20" + "node": ">=20.8.1" }, "peerDependencies": { "semantic-release": ">=20.1.0" @@ -979,8 +1517,22 @@ "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/npm/node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@semantic-release/npm/node_modules/aggregate-error": { @@ -988,6 +1540,7 @@ "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", "dev": true, + "license": "MIT", "dependencies": { "clean-stack": "^5.2.0", "indent-string": "^5.0.0" @@ -1004,6 +1557,7 @@ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", "integrity": "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ==", "dev": true, + "license": "MIT", "dependencies": { "escape-string-regexp": "5.0.0" }, @@ -1019,6 +1573,7 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -1027,47 +1582,57 @@ } }, "node_modules/@semantic-release/npm/node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.4.0.tgz", + "integrity": "sha512-yKHlle2YGxZE842MERVIplWwNH5VYmqqcPFgtnlU//K8gxuFFXu0pwd/CrfXTumFpeEiufsP7+opT/bPJa1yVw==", "dev": true, + "license": "MIT", "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.0", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.0.0", "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.0.0" }, "engines": { - "node": ">=16.17" + "node": "^18.19.0 || >=20.5.0" }, "funding": { "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, "node_modules/@semantic-release/npm/node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, "engines": { - "node": ">=16" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@semantic-release/npm/node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.0.tgz", + "integrity": "sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==", "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=16.17.0" + "node": ">=18.18.0" } }, "node_modules/@semantic-release/npm/node_modules/indent-string": { @@ -1075,6 +1640,7 @@ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -1082,55 +1648,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/npm/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/npm/node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/npm/node_modules/npm-run-path": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz", - "integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==", + "node_modules/@semantic-release/npm/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, + "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/npm/node_modules/onetime": { + "node_modules/@semantic-release/npm/node_modules/npm-run-path": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", "dev": true, + "license": "MIT", "dependencies": { - "mimic-fn": "^4.0.0" + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -1141,6 +1683,7 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -1153,6 +1696,7 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, + "license": "ISC", "engines": { "node": ">=14" }, @@ -1161,36 +1705,51 @@ } }, "node_modules/@semantic-release/npm/node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", "dev": true, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@semantic-release/release-notes-generator": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-12.1.0.tgz", - "integrity": "sha512-g6M9AjUKAZUZnxaJZnouNBeDNTCUrJ5Ltj+VJ60gJeDaRRahcHsry9HW8yKrnKkKNkx5lbWiEP1FPMqVNQz8Kg==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.0.1.tgz", + "integrity": "sha512-K0w+5220TM4HZTthE5dDpIuFrnkN1NfTGPidJFm04ULT1DEZ9WG89VNXN7F0c+6nMEpWgqmPvb7vY7JkB2jyyA==", "dev": true, + "license": "MIT", "dependencies": { - "conventional-changelog-angular": "^7.0.0", - "conventional-changelog-writer": "^7.0.0", - "conventional-commits-filter": "^4.0.0", - "conventional-commits-parser": "^5.0.0", + "conventional-changelog-angular": "^8.0.0", + "conventional-changelog-writer": "^8.0.0", + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.0.0", "debug": "^4.0.0", "get-stream": "^7.0.0", "import-from-esm": "^1.0.3", "into-stream": "^7.0.0", "lodash-es": "^4.17.21", - "read-pkg-up": "^11.0.0" + "read-package-up": "^11.0.0" }, "engines": { - "node": "^18.17 || >=20.6.1" + "node": ">=20.8.1" }, "peerDependencies": { "semantic-release": ">=20.1.0" @@ -1201,6 +1760,7 @@ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-7.0.1.tgz", "integrity": "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=16" }, @@ -1213,6 +1773,7 @@ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -1221,10 +1782,11 @@ } }, "node_modules/@sindresorhus/merge-streams": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.2.0.tgz", - "integrity": "sha512-UTce8mUwUW0RikMb/eseJ7ys0BRkZVFB86orHzrfW12ZmFtym5zua8joZ4L7okH2dDFHkcFjqnZ5GocWBXOFtA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -1236,19 +1798,22 @@ "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/agent-base": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", - "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.3.4" }, @@ -1261,6 +1826,7 @@ "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", "dev": true, + "license": "MIT", "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" @@ -1270,15 +1836,16 @@ } }, "node_modules/ansi-escapes": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.0.tgz", - "integrity": "sha512-kzRaCqXnpzWs+3z5ABPQiVke+iq0KXkHo8xiWV4RPTi5Yli0l97BEQuhXV1s7+aSU/fu1kUuxgS4MsQ0fRuygw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", "dev": true, + "license": "MIT", "dependencies": { - "type-fest": "^3.0.0" + "environment": "^1.0.0" }, "engines": { - "node": ">=14.16" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -1289,6 +1856,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -1298,6 +1866,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -1312,51 +1881,59 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz", "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "dev": true, + "license": "Python-2.0" }, "node_modules/argv-formatter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/argv-formatter/-/argv-formatter-1.0.0.tgz", "integrity": "sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/array-ify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/before-after-hook": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", - "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", - "dev": true + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", + "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", + "dev": true, + "license": "Apache-2.0" }, "node_modules/bottleneck": { "version": "2.19.5", "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, + "license": "MIT", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -1367,6 +1944,7 @@ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -1376,6 +1954,7 @@ "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz", "integrity": "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==", "dev": true, + "license": "MIT", "dependencies": { "ansicolors": "~0.3.2", "redeyed": "~2.1.0" @@ -1389,6 +1968,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", "dev": true, + "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -1401,6 +1981,7 @@ "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" } @@ -1410,6 +1991,7 @@ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -1419,6 +2001,7 @@ "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", "dev": true, + "license": "ISC", "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", @@ -1440,6 +2023,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -1456,6 +2040,7 @@ "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "dev": true, + "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -1467,6 +2052,7 @@ "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "dev": true, + "license": "MIT", "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", @@ -1485,15 +2071,17 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, + "license": "ISC", "engines": { "node": ">=10" } }, "node_modules/cli-table3": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz", - "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", "dev": true, + "license": "MIT", "dependencies": { "string-width": "^4.2.0" }, @@ -1509,6 +2097,7 @@ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, + "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -1523,6 +2112,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -1534,13 +2124,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/compare-func": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", "dev": true, + "license": "MIT", "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" @@ -1551,68 +2143,69 @@ "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", "dev": true, + "license": "MIT", "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "node_modules/conventional-changelog-angular": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", - "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.0.0.tgz", + "integrity": "sha512-CLf+zr6St0wIxos4bmaKHRXWAcsCXrJU6F4VdNDrGRK3B8LDLKoX3zuMV5GhtbGkVR/LohZ6MT6im43vZLSjmA==", "dev": true, + "license": "ISC", "dependencies": { "compare-func": "^2.0.0" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/conventional-changelog-writer": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-7.0.1.tgz", - "integrity": "sha512-Uo+R9neH3r/foIvQ0MKcsXkX642hdm9odUp7TqgFS7BsalTcjzRlIfWZrZR1gbxOozKucaKt5KAbjW8J8xRSmA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.0.0.tgz", + "integrity": "sha512-TQcoYGRatlAnT2qEWDON/XSfnVG38JzA7E0wcGScu7RElQBkg9WWgZd1peCWFcWDh1xfb2CfsrcvOn1bbSzztA==", "dev": true, + "license": "MIT", "dependencies": { - "conventional-commits-filter": "^4.0.0", + "@types/semver": "^7.5.5", + "conventional-commits-filter": "^5.0.0", "handlebars": "^4.7.7", - "json-stringify-safe": "^5.0.1", - "meow": "^12.0.1", - "semver": "^7.5.2", - "split2": "^4.0.0" + "meow": "^13.0.0", + "semver": "^7.5.2" }, "bin": { - "conventional-changelog-writer": "cli.mjs" + "conventional-changelog-writer": "dist/cli/index.js" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/conventional-commits-filter": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-4.0.0.tgz", - "integrity": "sha512-rnpnibcSOdFcdclpFwWa+pPlZJhXE7l+XK04zxhbWrhgpR96h33QLz8hITTXbcYICxVr3HZFtbtUAQ+4LdBo9A==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", + "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", "dev": true, + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/conventional-commits-parser": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", - "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.0.0.tgz", + "integrity": "sha512-TbsINLp48XeMXR8EvGjTnKGsZqBemisPoyWESlpRyR8lif0lcwzqz+NMtYSj1ooF/WYjSuu7wX0CtdeeMEQAmA==", "dev": true, + "license": "MIT", "dependencies": { - "is-text-path": "^2.0.0", - "JSONStream": "^1.3.5", - "meow": "^12.0.1", - "split2": "^4.0.0" + "meow": "^13.0.0" }, "bin": { - "conventional-commits-parser": "cli.mjs" + "conventional-commits-parser": "dist/cli/index.js" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/convert-hrtime": { @@ -1620,6 +2213,7 @@ "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -1631,13 +2225,15 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cosmiconfig": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, + "license": "MIT", "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -1664,6 +2260,7 @@ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1678,6 +2275,7 @@ "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^1.0.1" }, @@ -1693,6 +2291,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -1701,12 +2300,13 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -1722,6 +2322,7 @@ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "dev": true, + "license": "MIT", "engines": { "node": ">=4.0.0" } @@ -1730,13 +2331,15 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", "dev": true, + "license": "MIT", "dependencies": { "path-type": "^4.0.0" }, @@ -1749,6 +2352,7 @@ "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", "dev": true, + "license": "MIT", "dependencies": { "is-obj": "^2.0.0" }, @@ -1761,6 +2365,7 @@ "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "readable-stream": "^2.0.2" } @@ -1769,19 +2374,22 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/emojilib": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/env-ci": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.0.0.tgz", - "integrity": "sha512-apikxMgkipkgTvMdRT9MNqWx5VLOci79F4VBd7Op/7OPjjoanjdAvn6fglMCCEf/1bAh8eOiuEVCUs4V3qP3nQ==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.1.0.tgz", + "integrity": "sha512-Z8dnwSDbV1XYM9SBF2J0GcNVvmfmfh3a49qddGIROhBoVro6MZVTji15z/sJbQ2ko2ei8n988EU1wzoLU/tF+g==", "dev": true, + "license": "MIT", "dependencies": { "execa": "^8.0.0", "java-properties": "^1.0.2" @@ -1795,6 +2403,7 @@ "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", "dev": true, + "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", @@ -1818,6 +2427,7 @@ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", "dev": true, + "license": "MIT", "engines": { "node": ">=16" }, @@ -1830,6 +2440,7 @@ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=16.17.0" } @@ -1839,6 +2450,7 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true, + "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -1851,6 +2463,7 @@ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -1859,10 +2472,11 @@ } }, "node_modules/env-ci/node_modules/npm-run-path": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz", - "integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^4.0.0" }, @@ -1878,6 +2492,7 @@ "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", "dev": true, + "license": "MIT", "dependencies": { "mimic-fn": "^4.0.0" }, @@ -1893,6 +2508,7 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -1905,6 +2521,7 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, + "license": "ISC", "engines": { "node": ">=14" }, @@ -1917,6 +2534,7 @@ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -1929,24 +2547,40 @@ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", "dev": true, + "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" } }, "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -1956,6 +2590,7 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.0" } @@ -1965,6 +2600,7 @@ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, + "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -1978,6 +2614,7 @@ "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "dev": true, + "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", @@ -2001,6 +2638,7 @@ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -2017,6 +2655,7 @@ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", "dev": true, + "license": "ISC", "dependencies": { "reusify": "^1.0.4" } @@ -2026,6 +2665,7 @@ "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", "dev": true, + "license": "MIT", "dependencies": { "is-unicode-supported": "^2.0.0" }, @@ -2037,10 +2677,11 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -2053,6 +2694,7 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", "dev": true, + "license": "MIT", "dependencies": { "locate-path": "^2.0.0" }, @@ -2065,6 +2707,7 @@ "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.0.tgz", "integrity": "sha512-q7Us7kcjj2VMePAa02hDAF6d+MzsdsAWEwYyOpwUtlerRBkOEPBCRZrAV4XfcSN8fHAgaD0hP7miwoay6DCprw==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -2073,15 +2716,17 @@ } }, "node_modules/find-versions": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-5.1.0.tgz", - "integrity": "sha512-+iwzCJ7C5v5KgcBuueqVoNiHVoQpwiUK5XFLjf0affFTep+Wcw93tPvmb8tqujDNmzhBDPddnWV/qgWSXgq+Hg==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-6.0.0.tgz", + "integrity": "sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==", "dev": true, + "license": "MIT", "dependencies": { - "semver-regex": "^4.0.5" + "semver-regex": "^4.0.5", + "super-regex": "^1.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -2092,6 +2737,7 @@ "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", "dev": true, + "license": "MIT", "dependencies": { "inherits": "^2.0.1", "readable-stream": "^2.0.0" @@ -2102,6 +2748,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -2111,20 +2758,12 @@ "node": ">=14.14" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/function-timeout": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", "integrity": "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -2137,6 +2776,7 @@ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, + "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -2146,6 +2786,7 @@ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -2154,17 +2795,18 @@ } }, "node_modules/git-log-parser": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/git-log-parser/-/git-log-parser-1.2.0.tgz", - "integrity": "sha512-rnCVNfkTL8tdNryFuaY0fYiBWEBcgF748O6ZI61rslBvr2o7U65c2/6npCRqH40vuAhtgtDiqLTJjBVdrejCzA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/git-log-parser/-/git-log-parser-1.2.1.tgz", + "integrity": "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ==", "dev": true, + "license": "MIT", "dependencies": { "argv-formatter": "~1.0.0", "spawn-error-forwarder": "~1.0.0", "split2": "~1.0.0", "stream-combiner2": "~1.1.1", "through2": "~2.0.0", - "traverse": "~0.6.6" + "traverse": "0.6.8" } }, "node_modules/git-log-parser/node_modules/split2": { @@ -2172,6 +2814,7 @@ "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz", "integrity": "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==", "dev": true, + "license": "ISC", "dependencies": { "through2": "~2.0.0" } @@ -2181,6 +2824,7 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, + "license": "ISC", "dependencies": { "is-glob": "^4.0.1" }, @@ -2189,10 +2833,11 @@ } }, "node_modules/globby": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.1.tgz", - "integrity": "sha512-jOMLD2Z7MAhyG8aJpNOpmziMOP4rPLcc95oQPKXBazW82z+CEgPFBQvEpRUa1KeIMUJo4Wsm+q6uzO/Q/4BksQ==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.2.tgz", + "integrity": "sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==", "dev": true, + "license": "MIT", "dependencies": { "@sindresorhus/merge-streams": "^2.1.0", "fast-glob": "^3.3.2", @@ -2213,6 +2858,7 @@ "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -2224,12 +2870,13 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/gradle-semantic-release-plugin": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/gradle-semantic-release-plugin/-/gradle-semantic-release-plugin-1.9.2.tgz", - "integrity": "sha512-8qpf4GYFPQ+UMUymYBy/VchOOwLILAWzZMrZX1R0RR3JMgJBMN2R0tJn92R/3rXmxx4OAqwUFH6Np51eFoxr3w==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/gradle-semantic-release-plugin/-/gradle-semantic-release-plugin-1.10.1.tgz", + "integrity": "sha512-Q4dLAFICjPouUyRRHEKK8cXNB75nraXoioYZDZlVQOg4sYKudnTDZ3ohLmV3k4cPGiiMCh1ckXETkx9JnuyKmA==", "dev": true, "funding": [ { @@ -2237,6 +2884,7 @@ "url": "https://github.com/sponsors/KengoTODA" } ], + "license": "MIT", "dependencies": { "promisified-properties": "^3.0.0", "split2": "^4.1.0" @@ -2253,6 +2901,7 @@ "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", "dev": true, + "license": "MIT", "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", @@ -2274,27 +2923,17 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/hasown": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz", - "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": "*" } @@ -2304,6 +2943,7 @@ "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-3.0.0.tgz", "integrity": "sha512-jHRQzjSDzMtFy34AGj1DN+vq54WVuhSvKgrHf0OMiFQTwDD4L/qqofVEWjLOBMTn5+lCD3fPg32W9yOfnEJTTw==", "dev": true, + "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -2312,22 +2952,24 @@ } }, "node_modules/hosted-git-info": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.1.tgz", - "integrity": "sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.0.0.tgz", + "integrity": "sha512-4nw3vOVR+vHUOT8+U4giwe2tcGv+R3pwwRidUe67DoMBTjhrfr6rZYJVVwdkBE+Um050SG+X9tf0Jo4fOpn01w==", "dev": true, + "license": "ISC", "dependencies": { "lru-cache": "^10.0.1" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/http-proxy-agent": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.1.tgz", - "integrity": "sha512-My1KCEPs6A0hb4qCVzYp8iEvA8j8YqcvXLZZH8C9OFuTYpYjHE7N2dtG3mRl1HMD4+VGXpF3XcDVcxGBT7yDZQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, + "license": "MIT", "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -2337,10 +2979,11 @@ } }, "node_modules/https-proxy-agent": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.3.tgz", - "integrity": "sha512-kCnwztfX0KZJSLOBrcL0emLeFako55NWMovvyPP2AjsghNk9RB1yjSI+jVumPHYZsNXegNoqupSW9IY3afSH8w==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", "dev": true, + "license": "MIT", "dependencies": { "agent-base": "^7.0.2", "debug": "4" @@ -2354,15 +2997,17 @@ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=10.17.0" } }, "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4" } @@ -2372,6 +3017,7 @@ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", "dev": true, + "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -2388,15 +3034,17 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/import-from-esm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-1.3.3.tgz", - "integrity": "sha512-U3Qt/CyfFpTUv6LOP2jRTLYjphH6zg3okMfHbyqRa/W2w6hr8OsJWVggNlR4jxuojQy81TgTJTxgSkyoteRGMQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-1.3.4.tgz", + "integrity": "sha512-7EyUlPFC0HOlBDpUFGfYstsU7XHxZJKAAMzCT8wZ0hMW7b+hG51LIKTDcsgtz8Pu6YC0HqRVbX+rVUtsGMUKvg==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.3.4", "import-meta-resolve": "^4.0.0" @@ -2406,10 +3054,11 @@ } }, "node_modules/import-meta-resolve": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.0.0.tgz", - "integrity": "sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", + "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", "dev": true, + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -2420,6 +3069,7 @@ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2429,6 +3079,7 @@ "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-0.1.2.tgz", "integrity": "sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -2440,19 +3091,22 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/into-stream": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-7.0.0.tgz", "integrity": "sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==", "dev": true, + "license": "MIT", "dependencies": { "from2": "^2.3.0", "p-is-promise": "^3.0.0" @@ -2468,25 +3122,15 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true - }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "dev": true, - "dependencies": { - "hasown": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -2496,6 +3140,7 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2505,6 +3150,7 @@ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, + "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, @@ -2517,6 +3163,7 @@ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -2526,6 +3173,7 @@ "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2535,6 +3183,7 @@ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -2547,6 +3196,7 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -2559,6 +3209,7 @@ "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", "dev": true, + "license": "MIT", "dependencies": { "text-extensions": "^2.0.0" }, @@ -2567,10 +3218,11 @@ } }, "node_modules/is-unicode-supported": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.0.0.tgz", - "integrity": "sha512-FRdAyx5lusK1iHG0TWpVtk9+1i+GjrzRffhDg4ovQ7mcidMQ6mj+MhKPmvh7Xwyv5gIS06ns49CA7Sqg7lC22Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -2582,19 +3234,22 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/issue-parser": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-6.0.0.tgz", - "integrity": "sha512-zKa/Dxq2lGsBIXQ7CUZWTHfvxPC2ej0KfO7fIPqLlHB9J2hJ7rGhZ5rilhuufylr4RXYPzJUeFjKxz305OsNlA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", + "integrity": "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==", "dev": true, + "license": "MIT", "dependencies": { "lodash.capitalize": "^4.2.1", "lodash.escaperegexp": "^4.1.2", @@ -2603,7 +3258,7 @@ "lodash.uniqby": "^4.7.0" }, "engines": { - "node": ">=10.13" + "node": "^18.17 || >=20.6.1" } }, "node_modules/java-properties": { @@ -2611,6 +3266,7 @@ "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", "integrity": "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6.0" } @@ -2619,13 +3275,15 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -2637,25 +3295,29 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "dev": true, + "license": "MIT", "dependencies": { "universalify": "^2.0.0" }, @@ -2670,13 +3332,15 @@ "dev": true, "engines": [ "node >= 0.2.0" - ] + ], + "license": "MIT" }, "node_modules/JSONStream": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", "dev": true, + "license": "(MIT OR Apache-2.0)", "dependencies": { "jsonparse": "^1.2.0", "through": ">=2.2.7 <3" @@ -2692,13 +3356,15 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/load-json-file": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^4.0.0", @@ -2714,6 +3380,7 @@ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", "dev": true, + "license": "MIT", "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" @@ -2727,6 +3394,7 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", "dev": true, + "license": "MIT", "dependencies": { "p-locate": "^2.0.0", "path-exists": "^3.0.0" @@ -2739,58 +3407,64 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash-es": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.capitalize": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz", "integrity": "sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.isstring": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.uniqby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "engines": { - "node": "14 || >=16.14" - } + "license": "ISC" }, "node_modules/marked": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.0.tgz", - "integrity": "sha512-Vkwtq9rLqXryZnWaQc86+FHLC6tr/fycMfYAhiOIXkrNmeGAyhSxjqu0Rs1i0bBqw5u0S7+lV9fdH2ZSVaoa0w==", + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", "dev": true, + "license": "MIT", "bin": { "marked": "bin/marked.js" }, @@ -2799,15 +3473,16 @@ } }, "node_modules/marked-terminal": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-7.0.0.tgz", - "integrity": "sha512-sNEx8nn9Ktcm6pL0TnRz8tnXq/mSS0Q1FRSwJOAqw4lAB4l49UeDf85Gm1n9RPFm5qurCPjwi1StAQT2XExhZw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-7.1.0.tgz", + "integrity": "sha512-+pvwa14KZL74MVXjYdPR3nSInhGhNvPce/3mqLVZT2oUvt654sL1XImFuLZ1pkA866IYZ3ikDTOFUIC7XzpZZg==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-escapes": "^6.2.0", + "ansi-escapes": "^7.0.0", "chalk": "^5.3.0", "cli-highlight": "^2.1.11", - "cli-table3": "^0.6.3", + "cli-table3": "^0.6.5", "node-emoji": "^2.1.3", "supports-hyperlinks": "^3.0.0" }, @@ -2815,16 +3490,17 @@ "node": ">=16.0.0" }, "peerDependencies": { - "marked": ">=1 <13" + "marked": ">=1 <14" } }, "node_modules/meow": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", - "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=16.10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -2834,24 +3510,27 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 8" } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -2859,13 +3538,14 @@ } }, "node_modules/mime": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.1.tgz", - "integrity": "sha512-5lZ5tyrIfliMXzFtkYyekWbtRXObT9OWa8IwQ5uxTBDHucNNwniRqo0yInflj+iYi5CBa6qxadGzGarDfuEOxA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.4.tgz", + "integrity": "sha512-v8yqInVjhXyqP6+Kw4fV3ZzeMRqEW6FotRsKXjRS5VMTNIuXsdRoAvklpoRgSqXm6o9VNH4/C0mgedko9DdLsQ==", "dev": true, "funding": [ "https://github.com/sponsors/broofa" ], + "license": "MIT", "bin": { "mime": "bin/cli.js" }, @@ -2878,6 +3558,7 @@ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -2887,21 +3568,24 @@ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", "dev": true, + "license": "MIT", "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", @@ -2912,19 +3596,22 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/nerf-dart": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/nerf-dart/-/nerf-dart-1.0.0.tgz", "integrity": "sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/node-emoji": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.1.3.tgz", "integrity": "sha512-E2WEOVsgs7O16zsURJ/eH8BqhF029wGpEOnv7Urwdo2wmQanOACwJQh0devF9D9RhoZru0+9JXIS0dBXIAz+lA==", "dev": true, + "license": "MIT", "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", @@ -2936,13 +3623,13 @@ } }, "node_modules/normalize-package-data": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.0.tgz", - "integrity": "sha512-UL7ELRVxYBHBgYEtZCXjxuD5vPxnmvMGq0jp/dGPKKrN7tfsBh2IY7TlJ15WWwdjRWD3RJbnsygUurTK3xkPkg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "hosted-git-info": "^7.0.0", - "is-core-module": "^2.8.1", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" }, @@ -2950,11 +3637,25 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, "node_modules/normalize-url": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.0.tgz", - "integrity": "sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.1.tgz", + "integrity": "sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.16" }, @@ -2963,9 +3664,9 @@ } }, "node_modules/npm": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/npm/-/npm-10.8.2.tgz", - "integrity": "sha512-x/AIjFIKRllrhcb48dqUNAAZl0ig9+qMuN91RpZo3Cb2+zuibfh+KISl6+kVVyktDz230JKc208UkQwwMqyB+w==", + "version": "10.8.3", + "resolved": "https://registry.npmjs.org/npm/-/npm-10.8.3.tgz", + "integrity": "sha512-0IQlyAYvVtQ7uOhDFYZCGK8kkut2nh8cpAdA9E6FvRSJaTgtZRZgNjlC5ZCct//L73ygrpY93CxXpRJDtNqPVg==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -3037,6 +3738,14 @@ "write-file-atomic" ], "dev": true, + "license": "Artistic-2.0", + "workspaces": [ + "docs", + "smoke-tests", + "mock-globals", + "mock-registry", + "workspaces/*" + ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/arborist": "^7.5.4", @@ -3050,13 +3759,13 @@ "@sigstore/tuf": "^2.3.4", "abbrev": "^2.0.0", "archy": "~1.0.0", - "cacache": "^18.0.3", + "cacache": "^18.0.4", "chalk": "^5.3.0", "ci-info": "^4.0.0", "cli-columns": "^4.0.0", "fastest-levenshtein": "^1.0.16", "fs-minipass": "^3.0.3", - "glob": "^10.4.2", + "glob": "^10.4.5", "graceful-fs": "^4.2.11", "hosted-git-info": "^7.0.2", "ini": "^4.1.3", @@ -3065,7 +3774,7 @@ "json-parse-even-better-errors": "^3.0.2", "libnpmaccess": "^8.0.6", "libnpmdiff": "^6.1.4", - "libnpmexec": "^8.1.3", + "libnpmexec": "^8.1.4", "libnpmfund": "^5.0.12", "libnpmhook": "^10.0.5", "libnpmorg": "^6.0.6", @@ -3079,12 +3788,12 @@ "minipass": "^7.1.1", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", - "node-gyp": "^10.1.0", + "node-gyp": "^10.2.0", "nopt": "^7.2.1", "normalize-package-data": "^6.0.2", "npm-audit-report": "^5.0.0", "npm-install-checks": "^6.3.0", - "npm-package-arg": "^11.0.2", + "npm-package-arg": "^11.0.3", "npm-pick-manifest": "^9.1.0", "npm-profile": "^10.0.0", "npm-registry-fetch": "^17.1.0", @@ -3095,7 +3804,7 @@ "proc-log": "^4.2.0", "qrcode-terminal": "^0.12.0", "read": "^3.0.1", - "semver": "^7.6.2", + "semver": "^7.6.3", "spdx-expression-parse": "^4.0.0", "ssri": "^10.0.6", "supports-color": "^9.4.0", @@ -3120,6 +3829,7 @@ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.0.0" }, @@ -3665,7 +4375,7 @@ } }, "node_modules/npm/node_modules/cacache": { - "version": "18.0.3", + "version": "18.0.4", "dev": true, "inBundle": true, "license": "ISC", @@ -3832,7 +4542,7 @@ } }, "node_modules/npm/node_modules/debug": { - "version": "4.3.5", + "version": "4.3.6", "dev": true, "inBundle": true, "license": "MIT", @@ -3916,7 +4626,7 @@ } }, "node_modules/npm/node_modules/foreground-child": { - "version": "3.2.1", + "version": "3.3.0", "dev": true, "inBundle": true, "license": "ISC", @@ -3944,7 +4654,7 @@ } }, "node_modules/npm/node_modules/glob": { - "version": "10.4.2", + "version": "10.4.5", "dev": true, "inBundle": true, "license": "ISC", @@ -3959,9 +4669,6 @@ "bin": { "glob": "dist/esm/bin.mjs" }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -4145,16 +4852,13 @@ "license": "ISC" }, "node_modules/npm/node_modules/jackspeak": { - "version": "3.4.0", + "version": "3.4.3", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, - "engines": { - "node": ">=14" - }, "funding": { "url": "https://github.com/sponsors/isaacs" }, @@ -4240,7 +4944,7 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "8.1.3", + "version": "8.1.4", "dev": true, "inBundle": true, "license": "ISC", @@ -4374,13 +5078,10 @@ } }, "node_modules/npm/node_modules/lru-cache": { - "version": "10.2.2", + "version": "10.4.3", "dev": true, "inBundle": true, - "license": "ISC", - "engines": { - "node": "14 || >=16.14" - } + "license": "ISC" }, "node_modules/npm/node_modules/make-fetch-happen": { "version": "13.0.1", @@ -4592,7 +5293,7 @@ } }, "node_modules/npm/node_modules/node-gyp": { - "version": "10.1.0", + "version": "10.2.0", "dev": true, "inBundle": true, "license": "MIT", @@ -4603,9 +5304,9 @@ "graceful-fs": "^4.2.6", "make-fetch-happen": "^13.0.0", "nopt": "^7.0.0", - "proc-log": "^3.0.0", + "proc-log": "^4.1.0", "semver": "^7.3.5", - "tar": "^6.1.2", + "tar": "^6.2.1", "which": "^4.0.0" }, "bin": { @@ -4615,15 +5316,6 @@ "node": "^16.14.0 || >=18.0.0" } }, - "node_modules/npm/node_modules/node-gyp/node_modules/proc-log": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/npm/node_modules/nopt": { "version": "7.2.1", "dev": true, @@ -4696,7 +5388,7 @@ } }, "node_modules/npm/node_modules/npm-package-arg": { - "version": "11.0.2", + "version": "11.0.3", "dev": true, "inBundle": true, "license": "ISC", @@ -4870,7 +5562,7 @@ } }, "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "6.1.0", + "version": "6.1.2", "dev": true, "inBundle": true, "license": "MIT", @@ -5008,7 +5700,7 @@ "optional": true }, "node_modules/npm/node_modules/semver": { - "version": "7.6.2", + "version": "7.6.3", "dev": true, "inBundle": true, "license": "ISC", @@ -5531,6 +6223,7 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -5540,6 +6233,7 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, + "license": "ISC", "dependencies": { "wrappy": "1" } @@ -5549,6 +6243,7 @@ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "dev": true, + "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" }, @@ -5564,6 +6259,7 @@ "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", "integrity": "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -5576,6 +6272,7 @@ "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-4.1.0.tgz", "integrity": "sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==", "dev": true, + "license": "MIT", "dependencies": { "p-map": "^7.0.1" }, @@ -5591,6 +6288,7 @@ "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -5600,6 +6298,7 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", "dev": true, + "license": "MIT", "dependencies": { "p-try": "^1.0.0" }, @@ -5612,6 +6311,7 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", "dev": true, + "license": "MIT", "dependencies": { "p-limit": "^1.1.0" }, @@ -5620,10 +6320,11 @@ } }, "node_modules/p-map": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.1.tgz", - "integrity": "sha512-2wnaR0XL/FDOj+TgpDuRb2KTjLnu3Fma6b1ZUwGY7LcqenMcvP/YFpjpbPKY6WVGsbuJZRuoUz8iPrt8ORnAFw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.2.tgz", + "integrity": "sha512-z4cYYMMdKHzw4O5UkWJImbZynVIo0lSGTXc7bzB1e/rrDqkgGUNysK/o4bTr+0+xKvvLoTyGqYC4Fgljy9qe1Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -5636,6 +6337,7 @@ "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-2.1.0.tgz", "integrity": "sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -5645,6 +6347,7 @@ "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -5654,6 +6357,7 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, + "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -5666,6 +6370,7 @@ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -5684,6 +6389,7 @@ "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -5695,13 +6401,15 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/parse5-htmlparser2-tree-adapter": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", "dev": true, + "license": "MIT", "dependencies": { "parse5": "^6.0.1" } @@ -5710,19 +6418,22 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/parsimmon": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/parsimmon/-/parsimmon-1.18.1.tgz", "integrity": "sha512-u7p959wLfGAhJpSDJVYXoyMCXWYwHia78HhRBWqk7AIbxdmlrfdp5wX0l3xv/iTSH5HvhN9K7o26hwwpgS5Nmw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -5732,6 +6443,7 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -5741,15 +6453,24 @@ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/picocolors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.6" }, @@ -5762,6 +6483,7 @@ "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -5771,6 +6493,7 @@ "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-2.1.0.tgz", "integrity": "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==", "dev": true, + "license": "MIT", "dependencies": { "find-up": "^2.0.0", "load-json-file": "^4.0.0" @@ -5784,6 +6507,7 @@ "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.1.0.tgz", "integrity": "sha512-o1piW0n3tgKIKCwk2vpM/vOV13zjJzvP37Ioze54YlTHE06m4tjEbzg9WsKkvTuyYln2DHjo5pY4qrZGI0otpw==", "dev": true, + "license": "MIT", "dependencies": { "parse-ms": "^4.0.0" }, @@ -5798,13 +6522,15 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/promisified-properties": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/promisified-properties/-/promisified-properties-3.0.0.tgz", "integrity": "sha512-ARteuBuUpPg/+spsMhcKHvdtOW/q8btyyVYYxxegGgx+7u9ix9at8DjP2KM2t8+4SuI8wBLt+3X876FMQx91yQ==", "dev": true, + "license": "MIT", "dependencies": { "parsimmon": "^1.13.0" }, @@ -5817,7 +6543,8 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/queue-microtask": { "version": "1.2.3", @@ -5837,13 +6564,15 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -5859,6 +6588,7 @@ "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", "dev": true, + "license": "MIT", "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", @@ -5871,23 +6601,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-package-up/node_modules/type-fest": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.25.0.tgz", - "integrity": "sha512-bRkIGlXsnGBRBQRAY56UXBm//9qH4bmJfFvq83gSz41N282df+fjy8ofcEgc1sM8geNt5cl6mC2g9Fht1cs8Aw==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/read-pkg": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", "dev": true, + "license": "MIT", "dependencies": { "@types/normalize-package-data": "^2.4.3", "normalize-package-data": "^6.0.0", @@ -5908,25 +6627,14 @@ "integrity": "sha512-LOVbvF1Q0SZdjClSefZ0Nz5z8u+tIE7mV5NibzmE9VYmDe9CaBbAVtz1veOSZbofrdsilxuDAYnFenukZVp8/Q==", "deprecated": "Renamed to read-package-up", "dev": true, + "license": "MIT", "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg-up/node_modules/type-fest": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.10.2.tgz", - "integrity": "sha512-anpAG63wSpdEbLwOqH8L84urkL6PiVIov3EMmgIhhThevh9aiMQov+6Btx0wldNcvm4wV+e2/Rt1QdDwKHFbHw==", - "dev": true, + }, "engines": { - "node": ">=16" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5937,6 +6645,7 @@ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.1.0.tgz", "integrity": "sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.22.13", "index-to-position": "^0.1.2", @@ -5949,23 +6658,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg/node_modules/type-fest": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.10.2.tgz", - "integrity": "sha512-anpAG63wSpdEbLwOqH8L84urkL6PiVIov3EMmgIhhThevh9aiMQov+6Btx0wldNcvm4wV+e2/Rt1QdDwKHFbHw==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, + "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -5981,6 +6679,7 @@ "resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz", "integrity": "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==", "dev": true, + "license": "MIT", "dependencies": { "esprima": "~4.0.0" } @@ -5990,6 +6689,7 @@ "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.0.2.tgz", "integrity": "sha512-o/3ikDxtXaA59BmZuZrJZDJv8NMDGSj+6j6XaeBmHw8eY1i1qd9+6H+LjVvQXx3HN6aRCGa1cUdJ9RaJZUugnQ==", "dev": true, + "license": "MIT", "dependencies": { "@pnpm/npm-conf": "^2.1.0" }, @@ -6002,6 +6702,7 @@ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -6011,6 +6712,7 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -6020,6 +6722,7 @@ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "dev": true, + "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -6044,6 +6747,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } @@ -6052,17 +6756,19 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/semantic-release": { - "version": "24.1.0", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-24.1.0.tgz", - "integrity": "sha512-FwaE2hKDHQn9G6GA7xmqsc9WnsjaFD/ppLM5PUg56Do9oKSCf+vH6cPeb3hEBV/m06n8Sh9vbVqPjHu/1onzQw==", + "version": "24.1.2", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-24.1.2.tgz", + "integrity": "sha512-hvEJ7yI97pzJuLsDZCYzJgmRxF8kiEJvNZhf0oiZQcexw+Ycjy4wbdsn/sVMURgNCu8rwbAXJdBRyIxM4pe32g==", "dev": true, + "license": "MIT", "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", - "@semantic-release/github": "^10.0.0", + "@semantic-release/github": "^11.0.0", "@semantic-release/npm": "^12.0.0", "@semantic-release/release-notes-generator": "^14.0.0-beta.1", "aggregate-error": "^5.0.0", @@ -6075,294 +6781,36 @@ "get-stream": "^6.0.0", "git-log-parser": "^1.2.0", "hook-std": "^3.0.0", - "hosted-git-info": "^7.0.0", + "hosted-git-info": "^8.0.0", "import-from-esm": "^1.3.1", "lodash-es": "^4.17.21", "marked": "^12.0.0", "marked-terminal": "^7.0.0", "micromatch": "^4.0.2", "p-each-series": "^3.0.0", - "p-reduce": "^3.0.0", - "read-package-up": "^11.0.0", - "resolve-from": "^5.0.0", - "semver": "^7.3.2", - "semver-diff": "^4.0.0", - "signale": "^1.2.1", - "yargs": "^17.5.1" - }, - "bin": { - "semantic-release": "bin/semantic-release.js" - }, - "engines": { - "node": ">=20.8.1" - } - }, - "node_modules/semantic-release/node_modules/@octokit/auth-token": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.1.tgz", - "integrity": "sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA==", - "dev": true, - "engines": { - "node": ">= 18" - } - }, - "node_modules/semantic-release/node_modules/@octokit/core": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.2.tgz", - "integrity": "sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg==", - "dev": true, - "dependencies": { - "@octokit/auth-token": "^5.0.0", - "@octokit/graphql": "^8.0.0", - "@octokit/request": "^9.0.0", - "@octokit/request-error": "^6.0.1", - "@octokit/types": "^13.0.0", - "before-after-hook": "^3.0.2", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/semantic-release/node_modules/@octokit/endpoint": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", - "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", - "dev": true, - "dependencies": { - "@octokit/types": "^13.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/semantic-release/node_modules/@octokit/graphql": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.1.1.tgz", - "integrity": "sha512-ukiRmuHTi6ebQx/HFRCXKbDlOh/7xEV6QUXaE7MJEKGNAncGI/STSbOkl12qVXZrfZdpXctx5O9X1AIaebiDBg==", - "dev": true, - "dependencies": { - "@octokit/request": "^9.0.0", - "@octokit/types": "^13.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/semantic-release/node_modules/@octokit/openapi-types": { - "version": "22.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", - "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==", - "dev": true - }, - "node_modules/semantic-release/node_modules/@octokit/plugin-paginate-rest": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.3.3.tgz", - "integrity": "sha512-o4WRoOJZlKqEEgj+i9CpcmnByvtzoUYC6I8PD2SA95M+BJ2x8h7oLcVOg9qcowWXBOdcTRsMZiwvM3EyLm9AfA==", - "dev": true, - "dependencies": { - "@octokit/types": "^13.5.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } - }, - "node_modules/semantic-release/node_modules/@octokit/plugin-retry": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-7.1.1.tgz", - "integrity": "sha512-G9Ue+x2odcb8E1XIPhaFBnTTIrrUDfXN05iFXiqhR+SeeeDMMILcAnysOsxUpEWcQp2e5Ft397FCXTcPkiPkLw==", - "dev": true, - "dependencies": { - "@octokit/request-error": "^6.0.0", - "@octokit/types": "^13.0.0", - "bottleneck": "^2.15.3" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } - }, - "node_modules/semantic-release/node_modules/@octokit/plugin-throttling": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-9.3.1.tgz", - "integrity": "sha512-Qd91H4liUBhwLB2h6jZ99bsxoQdhgPk6TdwnClPyTBSDAdviGPceViEgUwj+pcQDmB/rfAXAXK7MTochpHM3yQ==", - "dev": true, - "dependencies": { - "@octokit/types": "^13.0.0", - "bottleneck": "^2.15.3" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": "^6.0.0" - } - }, - "node_modules/semantic-release/node_modules/@octokit/request": { - "version": "9.1.3", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.1.3.tgz", - "integrity": "sha512-V+TFhu5fdF3K58rs1pGUJIDH5RZLbZm5BI+MNF+6o/ssFNT4vWlCh/tVpF3NxGtP15HUxTTMUbsG5llAuU2CZA==", - "dev": true, - "dependencies": { - "@octokit/endpoint": "^10.0.0", - "@octokit/request-error": "^6.0.1", - "@octokit/types": "^13.1.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/semantic-release/node_modules/@octokit/request-error": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.4.tgz", - "integrity": "sha512-VpAhIUxwhWZQImo/dWAN/NpPqqojR6PSLgLYAituLM6U+ddx9hCioFGwBr5Mi+oi5CLeJkcAs3gJ0PYYzU6wUg==", - "dev": true, - "dependencies": { - "@octokit/types": "^13.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/semantic-release/node_modules/@octokit/types": { - "version": "13.5.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.5.0.tgz", - "integrity": "sha512-HdqWTf5Z3qwDVlzCrP8UJquMwunpDiMPt5er+QjGzL4hqr/vBVY/MauQgS1xWxCDT1oMx1EULyqxncdCY/NVSQ==", - "dev": true, - "dependencies": { - "@octokit/openapi-types": "^22.2.0" - } - }, - "node_modules/semantic-release/node_modules/@semantic-release/commit-analyzer": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/commit-analyzer/-/commit-analyzer-13.0.0.tgz", - "integrity": "sha512-KtXWczvTAB1ZFZ6B4O+w8HkfYm/OgQb1dUGNFZtDgQ0csggrmkq8sTxhd+lwGF8kMb59/RnG9o4Tn7M/I8dQ9Q==", - "dev": true, - "dependencies": { - "conventional-changelog-angular": "^8.0.0", - "conventional-changelog-writer": "^8.0.0", - "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.0.0", - "debug": "^4.0.0", - "import-from-esm": "^1.0.3", - "lodash-es": "^4.17.21", - "micromatch": "^4.0.2" - }, - "engines": { - "node": ">=20.8.1" - }, - "peerDependencies": { - "semantic-release": ">=20.1.0" - } - }, - "node_modules/semantic-release/node_modules/@semantic-release/error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", - "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", - "dev": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/semantic-release/node_modules/@semantic-release/github": { - "version": "10.1.7", - "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-10.1.7.tgz", - "integrity": "sha512-QnhP4k1eqzYLz6a4kpWrUQeKJYXqHggveMykvUFbSquq07GF85BXvr/QLhpOD7bpDcmEfL8VnphRA7KT5i9lzQ==", - "dev": true, - "dependencies": { - "@octokit/core": "^6.0.0", - "@octokit/plugin-paginate-rest": "^11.0.0", - "@octokit/plugin-retry": "^7.0.0", - "@octokit/plugin-throttling": "^9.0.0", - "@semantic-release/error": "^4.0.0", - "aggregate-error": "^5.0.0", - "debug": "^4.3.4", - "dir-glob": "^3.0.1", - "globby": "^14.0.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "issue-parser": "^7.0.0", - "lodash-es": "^4.17.21", - "mime": "^4.0.0", - "p-filter": "^4.0.0", - "url-join": "^5.0.0" - }, - "engines": { - "node": ">=20.8.1" - }, - "peerDependencies": { - "semantic-release": ">=20.1.0" - } - }, - "node_modules/semantic-release/node_modules/@semantic-release/npm": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-12.0.1.tgz", - "integrity": "sha512-/6nntGSUGK2aTOI0rHPwY3ZjgY9FkXmEHbW9Kr+62NVOsyqpKKeP0lrCH+tphv+EsNdJNmqqwijTEnVWUMQ2Nw==", - "dev": true, - "dependencies": { - "@semantic-release/error": "^4.0.0", - "aggregate-error": "^5.0.0", - "execa": "^9.0.0", - "fs-extra": "^11.0.0", - "lodash-es": "^4.17.21", - "nerf-dart": "^1.0.0", - "normalize-url": "^8.0.0", - "npm": "^10.5.0", - "rc": "^1.2.8", - "read-pkg": "^9.0.0", - "registry-auth-token": "^5.0.0", - "semver": "^7.1.2", - "tempy": "^3.0.0" - }, - "engines": { - "node": ">=20.8.1" - }, - "peerDependencies": { - "semantic-release": ">=20.1.0" - } - }, - "node_modules/semantic-release/node_modules/@semantic-release/release-notes-generator": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.0.1.tgz", - "integrity": "sha512-K0w+5220TM4HZTthE5dDpIuFrnkN1NfTGPidJFm04ULT1DEZ9WG89VNXN7F0c+6nMEpWgqmPvb7vY7JkB2jyyA==", - "dev": true, - "dependencies": { - "conventional-changelog-angular": "^8.0.0", - "conventional-changelog-writer": "^8.0.0", - "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.0.0", - "debug": "^4.0.0", - "get-stream": "^7.0.0", - "import-from-esm": "^1.0.3", - "into-stream": "^7.0.0", - "lodash-es": "^4.17.21", - "read-package-up": "^11.0.0" + "p-reduce": "^3.0.0", + "read-package-up": "^11.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.3.2", + "semver-diff": "^4.0.0", + "signale": "^1.2.1", + "yargs": "^17.5.1" + }, + "bin": { + "semantic-release": "bin/semantic-release.js" }, "engines": { "node": ">=20.8.1" - }, - "peerDependencies": { - "semantic-release": ">=20.1.0" } }, - "node_modules/semantic-release/node_modules/@semantic-release/release-notes-generator/node_modules/get-stream": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-7.0.1.tgz", - "integrity": "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ==", + "node_modules/semantic-release/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, "node_modules/semantic-release/node_modules/@sindresorhus/merge-streams": { @@ -6370,6 +6818,7 @@ "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -6382,6 +6831,7 @@ "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", "dev": true, + "license": "MIT", "dependencies": { "clean-stack": "^5.2.0", "indent-string": "^5.0.0" @@ -6393,17 +6843,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/before-after-hook": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", - "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", - "dev": true - }, "node_modules/semantic-release/node_modules/clean-stack": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", "integrity": "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ==", "dev": true, + "license": "MIT", "dependencies": { "escape-string-regexp": "5.0.0" }, @@ -6414,66 +6859,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/conventional-changelog-angular": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.0.0.tgz", - "integrity": "sha512-CLf+zr6St0wIxos4bmaKHRXWAcsCXrJU6F4VdNDrGRK3B8LDLKoX3zuMV5GhtbGkVR/LohZ6MT6im43vZLSjmA==", - "dev": true, - "dependencies": { - "compare-func": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/semantic-release/node_modules/conventional-changelog-writer": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.0.0.tgz", - "integrity": "sha512-TQcoYGRatlAnT2qEWDON/XSfnVG38JzA7E0wcGScu7RElQBkg9WWgZd1peCWFcWDh1xfb2CfsrcvOn1bbSzztA==", - "dev": true, - "dependencies": { - "@types/semver": "^7.5.5", - "conventional-commits-filter": "^5.0.0", - "handlebars": "^4.7.7", - "meow": "^13.0.0", - "semver": "^7.5.2" - }, - "bin": { - "conventional-changelog-writer": "dist/cli/index.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/semantic-release/node_modules/conventional-commits-filter": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", - "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", - "dev": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/semantic-release/node_modules/conventional-commits-parser": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.0.0.tgz", - "integrity": "sha512-TbsINLp48XeMXR8EvGjTnKGsZqBemisPoyWESlpRyR8lif0lcwzqz+NMtYSj1ooF/WYjSuu7wX0CtdeeMEQAmA==", - "dev": true, - "dependencies": { - "meow": "^13.0.0" - }, - "bin": { - "conventional-commits-parser": "dist/cli/index.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/semantic-release/node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -6482,10 +6873,11 @@ } }, "node_modules/semantic-release/node_modules/execa": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.3.1.tgz", - "integrity": "sha512-gdhefCCNy/8tpH/2+ajP9IQc14vXchNdd0weyzSJEFURhRMGncQ+zKFxwjAufIewPEJm9BPOaJnvg2UtlH2gPQ==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.4.0.tgz", + "integrity": "sha512-yKHlle2YGxZE842MERVIplWwNH5VYmqqcPFgtnlU//K8gxuFFXu0pwd/CrfXTumFpeEiufsP7+opT/bPJa1yVw==", "dev": true, + "license": "MIT", "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.3", @@ -6494,7 +6886,7 @@ "human-signals": "^8.0.0", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", - "npm-run-path": "^5.2.0", + "npm-run-path": "^6.0.0", "pretty-ms": "^9.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", @@ -6512,6 +6904,7 @@ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", "dev": true, + "license": "MIT", "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" @@ -6523,27 +6916,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/find-versions": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-6.0.0.tgz", - "integrity": "sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==", - "dev": true, - "dependencies": { - "semver-regex": "^4.0.5", - "super-regex": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/semantic-release/node_modules/human-signals": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.0.tgz", "integrity": "sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=18.18.0" } @@ -6553,6 +6931,7 @@ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -6565,34 +6944,7 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semantic-release/node_modules/issue-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", - "integrity": "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==", - "dev": true, - "dependencies": { - "lodash.capitalize": "^4.2.1", - "lodash.escaperegexp": "^4.1.2", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.uniqby": "^4.7.0" - }, - "engines": { - "node": "^18.17 || >=20.6.1" - } - }, - "node_modules/semantic-release/node_modules/meow": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", - "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", - "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -6601,15 +6953,17 @@ } }, "node_modules/semantic-release/node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", "dev": true, + "license": "MIT", "dependencies": { - "path-key": "^4.0.0" + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6620,6 +6974,7 @@ "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", "integrity": "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -6632,6 +6987,7 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -6644,6 +7000,7 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, + "license": "ISC", "engines": { "node": ">=14" }, @@ -6656,6 +7013,7 @@ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -6663,20 +7021,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/universal-user-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", - "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==", - "dev": true + "node_modules/semantic-release/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -6689,6 +7052,7 @@ "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-4.0.0.tgz", "integrity": "sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==", "dev": true, + "license": "MIT", "dependencies": { "semver": "^7.3.5" }, @@ -6704,6 +7068,7 @@ "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-4.0.5.tgz", "integrity": "sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -6711,23 +7076,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, + "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -6740,6 +7094,7 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -6748,13 +7103,15 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/signale": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/signale/-/signale-1.4.0.tgz", "integrity": "sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^2.3.2", "figures": "^2.0.0", @@ -6769,6 +7126,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^1.9.0" }, @@ -6781,6 +7139,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -6795,6 +7154,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "1.1.3" } @@ -6803,13 +7163,15 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/signale/node_modules/figures": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", "integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==", "dev": true, + "license": "MIT", "dependencies": { "escape-string-regexp": "^1.0.5" }, @@ -6822,6 +7184,7 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -6831,6 +7194,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^3.0.0" }, @@ -6843,6 +7207,7 @@ "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", "dev": true, + "license": "MIT", "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" }, @@ -6855,6 +7220,7 @@ "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.16" }, @@ -6867,6 +7233,7 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -6875,45 +7242,51 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/spawn-error-forwarder/-/spawn-error-forwarder-1.0.0.tgz", "integrity": "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", "dev": true, + "license": "Apache-2.0", "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" } }, "node_modules/spdx-exceptions": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.4.0.tgz", - "integrity": "sha512-hcjppoJ68fhxA/cjbN4T8N6uCUejN8yFw69ttpqtBeCbF3u13n7mb31NB9jKwGTTWWnt9IbRA/mf1FprYS8wfw==", - "dev": true + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "dev": true, + "license": "MIT", "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "node_modules/spdx-license-ids": { - "version": "3.0.17", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz", - "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==", - "dev": true + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.20.tgz", + "integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==", + "dev": true, + "license": "CC0-1.0" }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "dev": true, + "license": "ISC", "engines": { "node": ">= 10.x" } @@ -6923,6 +7296,7 @@ "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", "integrity": "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==", "dev": true, + "license": "MIT", "dependencies": { "duplexer2": "~0.1.0", "readable-stream": "^2.0.2" @@ -6933,6 +7307,7 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, + "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" } @@ -6942,6 +7317,7 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -6956,6 +7332,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -6968,6 +7345,7 @@ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -6977,6 +7355,7 @@ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -6986,6 +7365,7 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -6995,6 +7375,7 @@ "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.0.0.tgz", "integrity": "sha512-CY8u7DtbvucKuquCmOFEKhr9Besln7n9uN8eFbwcoGYWXOMW07u2o8njWaiXt11ylS3qoGF55pILjRmPlbodyg==", "dev": true, + "license": "MIT", "dependencies": { "function-timeout": "^1.0.1", "time-span": "^5.1.0" @@ -7011,6 +7392,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -7019,16 +7401,20 @@ } }, "node_modules/supports-hyperlinks": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.0.0.tgz", - "integrity": "sha512-QBDPHyPQDRTy9ku4URNGY5Lah8PAaXs6tAAwp55sL5WCsSW7GIfdf6W5ixfziW+t7wh3GVvHyHHyQ1ESsoRvaA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.1.0.tgz", + "integrity": "sha512-2rn0BZ+/f7puLOHZm1HOJfwBggfaHXUpPUSSG/SWM4TWp5KCfmNYwnC3hruy2rZlMnmWZ+QAGpZfchu3f3695A==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" }, "engines": { "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/temp-dir": { @@ -7036,6 +7422,7 @@ "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.16" } @@ -7045,6 +7432,7 @@ "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.1.0.tgz", "integrity": "sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==", "dev": true, + "license": "MIT", "dependencies": { "is-stream": "^3.0.0", "temp-dir": "^3.0.0", @@ -7063,6 +7451,7 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true, + "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -7075,6 +7464,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=12.20" }, @@ -7087,6 +7477,7 @@ "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -7099,6 +7490,7 @@ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", "dev": true, + "license": "MIT", "dependencies": { "any-promise": "^1.0.0" } @@ -7108,6 +7500,7 @@ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", "dev": true, + "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" }, @@ -7119,13 +7512,15 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", "dev": true, + "license": "MIT", "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" @@ -7136,6 +7531,7 @@ "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", "dev": true, + "license": "MIT", "dependencies": { "convert-hrtime": "^5.0.0" }, @@ -7151,6 +7547,7 @@ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -7163,6 +7560,7 @@ "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz", "integrity": "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -7171,22 +7569,24 @@ } }, "node_modules/type-fest": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", - "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.1.tgz", + "integrity": "sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=14.16" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/uglify-js": { - "version": "3.17.4", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", - "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", "dev": true, + "license": "BSD-2-Clause", "optional": true, "bin": { "uglifyjs": "bin/uglifyjs" @@ -7200,6 +7600,7 @@ "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -7209,6 +7610,7 @@ "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -7221,6 +7623,7 @@ "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", "dev": true, + "license": "MIT", "dependencies": { "crypto-random-string": "^4.0.0" }, @@ -7232,16 +7635,18 @@ } }, "node_modules/universal-user-agent": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", - "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", - "dev": true + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", + "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==", + "dev": true, + "license": "ISC" }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 10.0.0" } @@ -7251,6 +7656,7 @@ "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", "dev": true, + "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } @@ -7259,13 +7665,15 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "dev": true, + "license": "Apache-2.0", "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" @@ -7276,6 +7684,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -7290,13 +7699,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -7313,13 +7724,15 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.4" } @@ -7329,21 +7742,17 @@ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, + "license": "ISC", "engines": { "node": ">=10" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, + "license": "MIT", "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -7362,6 +7771,7 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, + "license": "ISC", "engines": { "node": ">=12" } @@ -7371,6 +7781,7 @@ "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, diff --git a/package.json b/package.json index 827688e6b3..105a5ca879 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "@saithodev/semantic-release-backmerge": "^4.0.1", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", - "gradle-semantic-release-plugin": "^1.9.2", - "semantic-release": "^24.1.0" + "gradle-semantic-release-plugin": "^1.10.1", + "semantic-release": "^24.1.2" } } diff --git a/patches.json b/patches.json index 1d595f23c3..b1e01c9602 100644 --- a/patches.json +++ b/patches.json @@ -1 +1 @@ -[{"name":"Alternative thumbnails","description":"Adds options to replace video thumbnails using the DeArrow API or image captures from the video.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Ambient mode control","description":"Adds options to disable Ambient mode and to bypass Ambient mode restrictions.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Amoled","description":"Applies a pure black theme to some components.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Bitrate default value","description":"Sets the audio quality to \u0027Always High\u0027 when you first install the app.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Bypass image region restrictions","description":"Adds an option to use a different host for static images, so that images blocked in some countries can be received.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Bypass image region restrictions","description":"Adds an option to use a different host for static images, so that images blocked in some countries can be received.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Certificate spoof","description":"Enables YouTube Music to work with Android Auto by spoofing the YouTube Music certificate.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Change package name","description":"Changes the package name for Reddit to the name specified in options.json.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":false,"requiresIntegrations":false,"options":[{"key":"PackageNameReddit","default":"com.reddit.frontpage","values":{"Clone":"com.reddit.frontpage.revanced","Default":"com.reddit.frontpage.rvx","Original":"com.reddit.frontpage"},"title":"Package name of Reddit","description":"The name of the package to rename the app to.","required":true}]},{"name":"Change player flyout menu toggles","description":"Adds an option to use text toggles instead of switch toggles within the additional settings menu.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Change share sheet","description":"Add option to change from in-app share sheet to system share sheet.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Change share sheet","description":"Add option to change from in-app share sheet to system share sheet.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Change start page","description":"Adds an option to set which page the app opens in instead of the homepage.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Change start page","description":"Adds an option to set which page the app opens in instead of the homepage.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Change version code","description":"Changes the version code of the app to the value specified in options.json. Except when mounting, this can prevent app stores from updating the app and allow the app to be installed over an existing installation that has a higher version code. By default, the highest version code is set.","compatiblePackages":null,"use":false,"requiresIntegrations":false,"options":[{"key":"ChangeVersionCode","default":false,"values":null,"title":"Change version code","description":"Changes the version code of the app.","required":true},{"key":"VersionCode","default":"2147483647","values":null,"title":"Version code","description":"The version code to use. (1 ~ 2147483647)","required":true}]},{"name":"Custom Shorts action buttons","description":"Changes, at compile time, the icon of the action buttons of the Shorts player.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[{"key":"IconType","default":"cairo","values":{"Cairo":"cairo","Outline":"outline","OutlineCircle":"outlinecircle","Round":"round","YoutubeOutline":"youtubeoutline","YouTube":"youtube"},"title":"Shorts icon style ","description":"The style of the icons for the action buttons in the Shorts player.","required":true}]},{"name":"Custom branding icon for YouTube","description":"Changes the YouTube app icon to the icon specified in options.json.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[{"key":"AppIcon","default":"revancify_blue","values":{"AFN Blue":"afn_blue","AFN Red":"afn_red","MMT":"mmt","Revancify Blue":"revancify_blue","Revancify Red":"revancify_red","YouTube":"youtube"},"title":"App icon","description":"The icon to apply to the app.\n\nIf a path to a folder is provided, the folder must contain the following folders:\n\n- mipmap-xxxhdpi\n- mipmap-xxhdpi\n- mipmap-xhdpi\n- mipmap-hdpi\n- mipmap-mdpi\n\nEach of these folders must contain the following files:\n\n- adaptiveproduct_youtube_background_color_108.png\n- adaptiveproduct_youtube_foreground_color_108.png\n- ic_launcher.png\n- ic_launcher_round.png","required":true},{"key":"ChangeSplashIcon","default":true,"values":null,"title":"Change splash icons","description":"Apply the custom branding icon to the splash screen.","required":true},{"key":"RestoreOldSplashAnimation","default":true,"values":null,"title":"Restore old splash animation","description":"Restore the old style splash animation.","required":true}]},{"name":"Custom branding icon for YouTube Music","description":"Changes the YouTube Music app icon to the icon specified in options.json.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[{"key":"AppIcon","default":"revancify_blue","values":{"AFN Blue":"afn_blue","AFN Red":"afn_red","MMT":"mmt","Revancify Blue":"revancify_blue","Revancify Red":"revancify_red","YouTube Music":"youtube_music"},"title":"App icon","description":"The icon to apply to the app.\n\nIf a path to a folder is provided, the folder must contain the following folders:\n\n- mipmap-xxxhdpi\n- mipmap-xxhdpi\n- mipmap-xhdpi\n- mipmap-hdpi\n- mipmap-mdpi\n\nEach of these folders must contain the following files:\n\n- adaptiveproduct_youtube_music_background_color_108.png\n- adaptiveproduct_youtube_music_foreground_color_108.png\n- ic_launcher_release.png","required":true},{"key":"ChangeSplashIcon","default":true,"values":null,"title":"Change splash icons","description":"Apply the custom branding icon to the splash screen.","required":true},{"key":"RestoreOldSplashIcon","default":false,"values":null,"title":"Restore old splash icon","description":"Restore the old style splash icon.\n\nIf you enable both the old style splash icon and the Cairo splash animation,\n\nOld style splash icon will appear first and then the Cairo splash animation will start.","required":true}]},{"name":"Custom branding name for Reddit","description":"Renames the Reddit app to the name specified in options.json.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":false,"requiresIntegrations":false,"options":[{"key":"AppName","default":"Reddit","values":{"Default":"RVX Reddit","Original":"Reddit"},"title":"App name","description":"The name of the app.","required":true}]},{"name":"Custom branding name for YouTube","description":"Renames the YouTube app to the name specified in options.json.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[{"key":"AppName","default":"RVX","values":{"ReVanced Extended":"ReVanced Extended","RVX":"RVX","YouTube RVX":"YouTube RVX","YouTube":"YouTube"},"title":"App name","description":"The name of the app.","required":true}]},{"name":"Custom branding name for YouTube Music","description":"Renames the YouTube Music app to the name specified in options.json.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[{"key":"AppNameNotification","default":"RVX Music","values":{"ReVanced Extended Music":"ReVanced Extended Music","RVX Music":"RVX Music","YouTube Music":"YouTube Music","YT Music":"YT Music"},"title":"App name in notification panel","description":"The name of the app as it appears in the notification panel.","required":true},{"key":"AppNameLauncher","default":"RVX Music","values":{"ReVanced Extended Music":"ReVanced Extended Music","RVX Music":"RVX Music","YouTube Music":"YouTube Music","YT Music":"YT Music"},"title":"App name in launcher","description":"The name of the app as it appears in the launcher.","required":true}]},{"name":"Custom double tap length","description":"Adds Double-tap to seek values that are specified in options.json.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[{"key":"DoubleTapLengthArrays","default":"3, 5, 10, 15, 20, 30, 60, 120, 180","values":null,"title":"Double-tap to seek values","description":"A list of custom Double-tap to seek lengths to be added, separated by commas.","required":true}]},{"name":"Custom header for YouTube","description":"Applies a custom header in the top left corner within the app.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":false,"requiresIntegrations":false,"options":[{"key":"CustomHeader","default":"custom_branding_icon","values":{"Custom branding icon":"custom_branding_icon"},"title":"Custom header","description":"The header to apply to the app.\n\nPatch option \u0027Custom branding icon\u0027 applies only when:\n\n1. Patch \u0027Custom branding icon for YouTube\u0027 is included.\n2. Patch option for \u0027Custom branding icon for YouTube\u0027 is selected from the preset.\n\nIf a path to a folder is provided, the folder must contain one or more of the following folders, depending on the DPI of the device:\n\n- drawable-xxxhdpi\n- drawable-xxhdpi\n- drawable-xhdpi\n- drawable-hdpi\n- drawable-mdpi\n\nEach of the folders must contain all of the following files:\n\n[Generic header]\n\n- yt_wordmark_header_light.png\n- yt_wordmark_header_dark.png\n\nThe image dimensions must be as follows:\n\n- drawable-xxxhdpi: 488px x 192px\n- drawable-xxhdpi: 366px x 144px\n- drawable-xhdpi: 244px x 96px\n- drawable-hdpi: 184px x 72px\n- drawable-mdpi: 122px x 48px\n\n[Premium header]\n\n- yt_premium_wordmark_header_light.png\n- yt_premium_wordmark_header_dark.png\n\nThe image dimensions must be as follows:\n- drawable-xxxhdpi: 516px x 192px\n- drawable-xxhdpi: 387px x 144px\n- drawable-xhdpi: 258px x 96px\n- drawable-hdpi: 194px x 72px\n- drawable-mdpi: 129px x 48px","required":true}]},{"name":"Custom header for YouTube Music","description":"Applies a custom header in the top left corner within the app.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":false,"requiresIntegrations":false,"options":[{"key":"CustomHeader","default":"custom_branding_icon","values":{"Custom branding icon":"custom_branding_icon"},"title":"Custom header","description":"The header to apply to the app.\n\nPatch option \u0027Custom branding icon\u0027 applies only when:\n\n1. Patch \u0027Custom branding icon for YouTube Music\u0027 is included.\n2. Patch option for \u0027Custom branding icon for YouTube Music\u0027 is selected from the preset.\n\nIf a path to a folder is provided, the folder must contain one or more of the following folders, depending on the DPI of the device:\n\n- drawable-xxxhdpi\n- drawable-xxhdpi\n- drawable-xhdpi\n- drawable-hdpi\n- drawable-mdpi\n\nEach of the folders must contain all of the following files:\n\n- action_bar_logo.png\n- logo_music.png\n- ytm_logo.png\n\nThe image \u0027action_bar_logo.png\u0027 dimensions must be as follows:\n\n- drawable-xxxhdpi: 320px x 96px\n- drawable-xxhdpi: 240px x 72px\n- drawable-xhdpi: 160px x 48px\n- drawable-hdpi: 121px x 36px\n- drawable-mdpi: 80px x 24px\n\nThe image \u0027logo_music.png\u0027 dimensions must be as follows:\n\n- drawable-xxxhdpi: 576px x 200px\n- drawable-xxhdpi: 432px x 150px\n- drawable-xhdpi: 288px x 100px\n- drawable-hdpi: 217px x 76px\n- drawable-mdpi: 144px x 50px\n\nThe image \u0027ytm_logo.png\u0027 dimensions must be as follows:\n\n- drawable-xxxhdpi: 412px x 144px\n- drawable-xxhdpi: 309px x 108px\n- drawable-xhdpi: 206px x 72px\n- drawable-hdpi: 155px x 54px\n- drawable-mdpi: 103px x 36px","required":true}]},{"name":"Description components","description":"Adds options to hide and disable description components.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Disable Cairo splash animation","description":"Adds an option to disable Cairo splash animation.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["7.06.54","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Disable QUIC protocol","description":"Adds an option to disable CronetEngine\u0027s QUIC protocol.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Disable auto audio tracks","description":"Adds an option to disable audio tracks from being automatically enabled.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Disable auto captions","description":"Adds an option to disable captions from being automatically enabled.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Disable auto captions","description":"Adds an option to disable captions from being automatically enabled.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Disable dislike redirection","description":"Adds an option to disable redirection to the next track when clicking the Dislike button.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Disable haptic feedback","description":"Adds options to disable haptic feedback when swiping in the video player.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Disable resuming Shorts on startup","description":"Adds an option to disable the Shorts player from resuming on app startup when Shorts were last being watched.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Disable screenshot popup","description":"Adds an option to disable the popup that appears when taking a screenshot.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Disable splash animation","description":"Adds an option to disable the splash animation on app startup.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Enable OPUS codec","description":"Adds an options to enable the OPUS audio codec if the player response includes.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Enable OPUS codec","description":"Adds an options to enable the OPUS audio codec if the player response includes.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Enable debug logging","description":"Adds an option to enable debug logging.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Enable debug logging","description":"Adds an option to enable debug logging.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Enable external browser","description":"Adds an option to always open links in your browser instead of in the in-app-browser.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Enable gradient loading screen","description":"Adds an option to enable the gradient loading screen.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Enable landscape mode","description":"Adds an option to enable landscape mode when rotating the screen on phones.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Enable open links directly","description":"Adds an option to skip over redirection URLs in external links.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Flyout menu components","description":"Adds options to hide or change flyout menu components.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Force hide player buttons background","description":"Removes, at compile time, the dark background surrounding the video player controls.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":false,"requiresIntegrations":false,"options":[]},{"name":"Fullscreen components","description":"Adds options to hide or change components related to fullscreen.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"GmsCore support","description":"Allows patched Google apps to run without root and under a different package name by using GmsCore instead of Google Play Services.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":true,"options":[{"key":"GmsCoreVendorGroupId","default":"app.revanced","values":{"ReVanced":"app.revanced"},"title":"GmsCore vendor group ID","description":"The vendor\u0027s group ID for GmsCore.","required":true},{"key":"CheckGmsCore","default":true,"values":null,"title":"Check GmsCore","description":"Check if GmsCore is installed on the device and has battery optimizations disabled when the app starts. \n\nIf GmsCore is not installed the app will not work, so disabling this is not recommended.","required":true},{"key":"DisableGmsServiceBroker","default":false,"values":null,"title":"Disable GmsService Broker","description":"Disabling GmsServiceBroker will somewhat improve crashes caused by unimplemented GmsCore services.\n\nFor YouTube, the \u0027Spoof streaming data\u0027 setting is required.","required":true},{"key":"PackageNameYouTube","default":"app.rvx.android.youtube","values":{"Clone":"com.rvx.android.youtube","Default":"app.rvx.android.youtube"},"title":"Package name of YouTube","description":"The name of the package to use in GmsCore support.","required":true},{"key":"PackageNameYouTubeMusic","default":"app.rvx.android.apps.youtube.music","values":{"Clone":"com.rvx.android.apps.youtube.music","Default":"app.rvx.android.apps.youtube.music"},"title":"Package name of YouTube Music","description":"The name of the package to use in GmsCore support.","required":true}]},{"name":"GmsCore support","description":"Allows patched Google apps to run without root and under a different package name by using GmsCore instead of Google Play Services.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":true,"options":[{"key":"GmsCoreVendorGroupId","default":"app.revanced","values":{"ReVanced":"app.revanced"},"title":"GmsCore vendor group ID","description":"The vendor\u0027s group ID for GmsCore.","required":true},{"key":"CheckGmsCore","default":true,"values":null,"title":"Check GmsCore","description":"Check if GmsCore is installed on the device and has battery optimizations disabled when the app starts. \n\nIf GmsCore is not installed the app will not work, so disabling this is not recommended.","required":true},{"key":"DisableGmsServiceBroker","default":false,"values":null,"title":"Disable GmsService Broker","description":"Disabling GmsServiceBroker will somewhat improve crashes caused by unimplemented GmsCore services.\n\nFor YouTube, the \u0027Spoof streaming data\u0027 setting is required.","required":true},{"key":"PackageNameYouTube","default":"app.rvx.android.youtube","values":{"Clone":"com.rvx.android.youtube","Default":"app.rvx.android.youtube"},"title":"Package name of YouTube","description":"The name of the package to use in GmsCore support.","required":true},{"key":"PackageNameYouTubeMusic","default":"app.rvx.android.apps.youtube.music","values":{"Clone":"com.rvx.android.apps.youtube.music","Default":"app.rvx.android.apps.youtube.music"},"title":"Package name of YouTube Music","description":"The name of the package to use in GmsCore support.","required":true}]},{"name":"Hide Recently Visited shelf","description":"Adds an option to hide the Recently Visited shelf in the sidebar.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Hide Shorts dimming","description":"Removes, at compile time, the dimming effect at the top and bottom of Shorts videos.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":false,"requiresIntegrations":false,"options":[]},{"name":"Hide account components","description":"Adds options to hide components related to the account menu.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Hide action bar components","description":"Adds options to hide action bar components and replace the offline download button with an external download button.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Hide action buttons","description":"Adds options to hide action buttons under videos.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Hide ads","description":"Adds options to hide ads.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Hide ads","description":"Adds options to hide ads.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"requiresIntegrations":true,"options":[]},{"name":"Hide ads","description":"Adds options to hide ads.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Hide comments components","description":"Adds options to hide components related to comments.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Hide feed components","description":"Adds options to hide components related to feeds.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Hide feed flyout menu","description":"Adds the ability to hide feed flyout menu components using a custom filter.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Hide layout components","description":"Adds options to hide general layout components.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Hide layout components","description":"Adds options to hide general layout components.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Hide navigation buttons","description":"Adds options to hide buttons in the navigation bar.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Hide overlay filter","description":"Removes, at compile time, the dark overlay that appears when player flyout menus are open.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":false,"requiresIntegrations":false,"options":[]},{"name":"Hide player buttons","description":"Adds options to hide buttons in the video player.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Hide player flyout menu","description":"Adds options to hide player flyout menu components.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Hide player overlay filter","description":"Removes, at compile time, the dark overlay that appears when single-tapping in the player.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":false,"requiresIntegrations":false,"options":[]},{"name":"Hide recommended communities shelf","description":"Adds an option to hide the recommended communities shelves in subreddits.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Hide shortcuts","description":"Remove, at compile time, the app shortcuts that appears when app icon is long pressed.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":false,"requiresIntegrations":false,"options":[{"key":"Explore","default":false,"values":null,"title":"Hide Explore","description":"Hide Explore from shortcuts.","required":true},{"key":"Subscriptions","default":false,"values":null,"title":"Hide Subscriptions","description":"Hide Subscriptions from shortcuts.","required":true},{"key":"Search","default":false,"values":null,"title":"Hide Search","description":"Hide Search from shortcuts.","required":true},{"key":"Shorts","default":true,"values":null,"title":"Hide Shorts","description":"Hide Shorts from shortcuts.","required":true}]},{"name":"Hook YouTube Music actions","description":"Adds support for opening music in RVX Music using the in-app YouTube Music button.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Hook download actions","description":"Adds support to download videos with an external downloader app using the in-app download button.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Layout switch","description":"Adds an option to spoof the dpi in order to use a tablet or phone layout.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"MaterialYou","description":"Applies the MaterialYou theme for Android 12+ devices.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":false,"requiresIntegrations":false,"options":[]},{"name":"Miniplayer","description":"Adds options to change the in app minimized player, and if patching target 19.16+ adds options to use modern miniplayers.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Navigation bar components","description":"Adds options to hide or change components related to the navigation bar.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Navigation bar components","description":"Adds options to hide or change components related to the navigation bar.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Open links directly","description":"Adds an option to skip over redirection URLs in external links.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Open links externally","description":"Adds an option to always open links in your browser instead of in the in-app-browser.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Overlay buttons","description":"Adds options to display overlay buttons in the video player.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[{"key":"IconType","default":"bold","values":{"Bold":"bold","Rounded":"rounded","Thin":"thin"},"title":"Icon type","description":"The icon type.","required":true},{"key":"BottomMargin","default":"2.5dip","values":{"Default":"2.5dip","None":"0.0dip","Wider":"5.0dip"},"title":"Bottom margin","description":"The bottom margin for the overlay buttons and timestamp.","required":true},{"key":"WiderButtonsSpace","default":false,"values":null,"title":"Wider between-buttons space","description":"Prevent adjacent button presses by increasing the horizontal spacing between buttons.","required":true},{"key":"ChangeTopButtons","default":false,"values":null,"title":"Change top buttons","description":"Change the icons at the top of the player.","required":true}]},{"name":"Player components","description":"Adds options to hide or change components related to the player.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Player components","description":"Adds options to hide or change components related to the video player.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Premium icon","description":"Unlocks premium app icons.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Remove background playback restrictions","description":"Removes restrictions on background playback, including for kids videos.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Remove background playback restrictions","description":"Removes restrictions on background playback, including for music and kids videos.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Remove subreddit dialog","description":"Adds options to remove the NSFW community warning and notifications suggestion dialogs by dismissing them automatically.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Remove viewer discretion dialog","description":"Adds an option to remove the dialog that appears when opening a video that has been age-restricted by accepting it automatically. This does not bypass the age restriction.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Remove viewer discretion dialog","description":"Adds an option to remove the dialog that appears when opening a video that has been age-restricted by accepting it automatically. This does not bypass the age restriction.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Restore old style library shelf","description":"Adds an option to return the Library tab to the old style.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Return YouTube Dislike","description":"Adds an option to show the dislike count of songs using the Return YouTube Dislike API.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Return YouTube Dislike","description":"Adds an option to show the dislike count of videos using the Return YouTube Dislike API.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Return YouTube Username","description":"Adds an option to replace YouTube handles with usernames in comments using YouTube Data API v3.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":false,"requiresIntegrations":false,"options":[]},{"name":"Return YouTube Username","description":"Adds an option to replace YouTube handles with usernames in comments using YouTube Data API v3.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":false,"requiresIntegrations":false,"options":[]},{"name":"Sanitize sharing links","description":"Adds an option to remove tracking query parameters from URLs when sharing links.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Sanitize sharing links","description":"Adds an option to remove tracking query parameters from URLs when sharing links.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Sanitize sharing links","description":"Adds an option to remove tracking query parameters from URLs when sharing links.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Seekbar components","description":"Adds options to hide or change components related to the seekbar.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Settings for Reddit","description":"Applies mandatory patches to implement ReVanced Extended settings into the application.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"requiresIntegrations":true,"options":[{"key":"RVXSettingsMenuName","default":"ReVanced Extended","values":null,"title":"RVX settings menu name","description":"The name of the RVX settings menu.","required":true}]},{"name":"Settings for YouTube","description":"Applies mandatory patches to implement ReVanced Extended settings into the application.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":true,"options":[{"key":"InsertPosition","default":"@string/about_key","values":{"Parent settings":"@string/parent_tools_key","General":"@string/general_key","Account":"@string/account_switcher_key","Data saving":"@string/data_saving_settings_key","Autoplay":"@string/auto_play_key","Video quality preferences":"@string/video_quality_settings_key","Background":"@string/offline_key","Watch on TV":"@string/pair_with_tv_key","Manage all history":"@string/history_key","Your data in YouTube":"@string/your_data_key","Privacy":"@string/privacy_key","History \u0026 privacy":"@string/privacy_key","Try experimental new features":"@string/premium_early_access_browse_page_key","Purchases and memberships":"@string/subscription_product_setting_key","Billing \u0026 payments":"@string/billing_and_payment_key","Billing and payments":"@string/billing_and_payment_key","Notifications":"@string/notification_key","Connected apps":"@string/connected_accounts_browse_page_key","Live chat":"@string/live_chat_key","Captions":"@string/captions_key","Accessibility":"@string/accessibility_settings_key","About":"@string/about_key"},"title":"Insert position","description":"The settings menu name that the RVX settings menu should be above.","required":true},{"key":"RVXSettingsMenuName","default":"ReVanced Extended","values":null,"title":"RVX settings menu name","description":"The name of the RVX settings menu.","required":true}]},{"name":"Settings for YouTube Music","description":"Applies mandatory patches to implement ReVanced Extended settings into the application.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":true,"options":[{"key":"RVXSettingsMenuName","default":"ReVanced Extended","values":null,"title":"RVX settings menu name","description":"The name of the RVX settings menu.","required":true}]},{"name":"Shorts components","description":"Adds options to hide or change components related to YouTube Shorts.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"SponsorBlock","description":"Adds options to enable and configure SponsorBlock, which can skip undesired video segments, such as non-music sections.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"SponsorBlock","description":"Adds options to enable and configure SponsorBlock, which can skip undesired video segments, such as sponsored content.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[{"key":"OutlineIcon","default":false,"values":null,"title":"Outline icons","description":"Apply the outline icon.","required":true}]},{"name":"Spoof app version","description":"Adds options to spoof the YouTube Music client version. This can remove the radio mode restriction in Canadian regions or disable real-time lyrics.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Spoof app version","description":"Adds options to spoof the YouTube client version. This can be used to restore old UI elements and features.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Spoof streaming data","description":"Adds options to spoof the streaming data to allow video playback.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Swipe controls","description":"Adds options for controlling volume and brightness with swiping, and whether to enter fullscreen when swiping down below the player.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Theme","description":"Changes the app\u0027s theme to the values specified in options.json.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[{"key":"DarkThemeBackgroundColor","default":"@android:color/black","values":{"Amoled Black":"@android:color/black","Catppuccin (Mocha)":"#FF181825","Dark Pink":"#FF290025","Dark Blue":"#FF001029","Dark Green":"#FF002905","Dark Yellow":"#FF282900","Dark Orange":"#FF291800","Dark Red":"#FF290000"},"title":"Dark theme background color","description":"Can be a hex color (#AARRGGBB) or a color resource reference.","required":true},{"key":"LightThemeBackgroundColor","default":"@android:color/white","values":{"White":"@android:color/white","Catppuccin (Latte)":"#FFE6E9EF","Light Pink":"#FFFCCFF3","Light Blue":"#FFD1E0FF","Light Green":"#FFCCFFCC","Light Yellow":"#FFFDFFCC","Light Orange":"#FFFFE6CC","Light Red":"#FFFFD6D6"},"title":"Light theme background color","description":"Can be a hex color (#AARRGGBB) or a color resource reference.","required":true}]},{"name":"Toolbar components","description":"Adds options to hide or change components located on the toolbar, such as toolbar buttons, search bar, and header.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Translations for YouTube","description":"Add translations or remove string resources.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[{"key":"CustomTranslations","default":"","values":null,"title":"Custom translations","description":"The path to the \u0027strings.xml\u0027 file.\nPlease note that applying the \u0027strings.xml\u0027 file will overwrite all existing translations.","required":true},{"key":"SelectedTranslations","default":"ar, bg-rBG, de-rDE, el-rGR, es-rES, fr-rFR, hu-rHU, it-rIT, ja-rJP, ko-rKR, pl-rPL, pt-rBR, ru-rRU, tr-rTR, uk-rUA, vi-rVN, zh-rCN, zh-rTW","values":null,"title":"Translations to add","description":"A list of translations to be added for the RVX settings, separated by commas.","required":true},{"key":"SelectedStringResources","default":"af, am, ar, ar-rXB, as, az, b+es+419, b+sr+Latn, be, bg, bn, bs, ca, cs, da, de, el, en-rAU, en-rCA, en-rGB, en-rIN, en-rXA, en-rXC, es, es-rUS, et, eu, fa, fi, fr, fr-rCA, gl, gu, hi, hr, hu, hy, id, in, is, it, iw, ja, ka, kk, km, kn, ko, ky, lo, lt, lv, mk, ml, mn, mr, ms, my, nb, ne, nl, no, or, pa, pl, pt, pt-rBR, pt-rPT, ro, ru, si, sk, sl, sq, sr, sv, sw, ta, te, th, tl, tr, uk, ur, uz, vi, zh, zh-rCN, zh-rHK, zh-rTW, zu","values":null,"title":"String resources to keep","description":"A list of string resources to be kept, separated by commas.\nString resources not in the list will be removed from the app.\n\nDefault string resource, English, is not removed.","required":true}]},{"name":"Translations for YouTube Music","description":"Add translations or remove string resources.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[{"key":"CustomTranslations","default":"","values":null,"title":"Custom translations","description":"The path to the \u0027strings.xml\u0027 file.\nPlease note that applying the \u0027strings.xml\u0027 file will overwrite all existing language translations.","required":true},{"key":"SelectedTranslations","default":"bg-rBG, bn, cs-rCZ, el-rGR, es-rES, fr-rFR, hu-rHU, id-rID, in, it-rIT, ja-rJP, ko-rKR, nl-rNL, pl-rPL, pt-rBR, ro-rRO, ru-rRU, tr-rTR, uk-rUA, vi-rVN, zh-rCN, zh-rTW","values":null,"title":"Translations to add","description":"A list of translations to be added for the RVX settings, separated by commas.","required":true},{"key":"SelectedStringResources","default":"af, am, ar, ar-rXB, as, az, b+es+419, b+sr+Latn, be, bg, bn, bs, ca, cs, da, de, el, en-rAU, en-rCA, en-rGB, en-rIN, en-rXA, en-rXC, es, es-rUS, et, eu, fa, fi, fr, fr-rCA, gl, gu, hi, hr, hu, hy, id, in, is, it, iw, ja, ka, kk, km, kn, ko, ky, lo, lt, lv, mk, ml, mn, mr, ms, my, nb, ne, nl, no, or, pa, pl, pt, pt-rBR, pt-rPT, ro, ru, si, sk, sl, sq, sr, sv, sw, ta, te, th, tl, tr, uk, ur, uz, vi, zh, zh-rCN, zh-rHK, zh-rTW, zu","values":null,"title":"String resources to keep","description":"A list of string resources to be kept, separated by commas.\nString resources not in the list will be removed from the app.\n\nDefault string resource, English, is not removed.","required":true}]},{"name":"Video playback","description":"Adds options to customize settings related to video playback, such as default video quality and playback speed.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Video playback","description":"Adds options to customize settings related to video playback, such as default video quality and playback speed.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]},{"name":"Visual preferences icons for YouTube","description":"Adds icons to specific preferences in the settings.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[{"key":"RVXSettingsMenuIcon","default":"extension","values":{"Custom branding icon":"custom_branding_icon","Extension":"extension","Gear":"gear","YT alt":"yt_alt","ReVanced":"revanced","ReVanced Colored":"revanced_colored"},"title":"RVX settings menu icon","description":"The icon for the RVX settings menu.","required":true},{"key":"ApplyToAll","default":false,"values":null,"title":"Apply to all settings menu","description":"Whether to apply Visual preferences icons to all settings menus.\n\nIf true: icons are applied to the parent PreferenceScreen of YouTube settings, the parent PreferenceScreen of RVX settings and the RVX sub-settings (if supported).\n\nIf false: icons are applied only to the parent PreferenceScreen of YouTube settings and RVX settings.","required":true}]},{"name":"Visual preferences icons for YouTube Music","description":"Adds icons to specific preferences in the settings.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"requiresIntegrations":false,"options":[{"key":"RVXSettingsMenuIcon","default":"extension","values":{"Custom branding icon":"custom_branding_icon","Extension":"extension","Gear":"gear","ReVanced":"revanced","ReVanced Colored":"revanced_colored"},"title":"RVX settings menu icon","description":"The icon for the RVX settings menu.","required":true}]},{"name":"Watch history","description":"Adds an option to change the domain of the watch history or check its status.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"requiresIntegrations":false,"options":[]}] \ No newline at end of file +[{"name":"Alternative thumbnails","description":"Adds options to replace video thumbnails using the DeArrow API or image captures from the video.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Ambient mode control","description":"Adds options to disable Ambient mode and to bypass Ambient mode restrictions.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Amoled","description":"Applies a pure black theme to some components.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Bitrate default value","description":"Sets the audio quality to \u0027Always High\u0027 when you first install the app.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Bypass image region restrictions","description":"Adds an option to use a different host for static images, so that images blocked in some countries can be received.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Bypass image region restrictions","description":"Adds an option to use a different host for static images, so that images blocked in some countries can be received.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Certificate spoof","description":"Enables YouTube Music to work with Android Auto by spoofing the YouTube Music certificate.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Change package name","description":"Changes the package name for Reddit to the name specified in patch options.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":false,"options":[{"key":"packageNameReddit","default":"com.reddit.frontpage","values":{"Clone":"com.reddit.frontpage.revanced","Default":"com.reddit.frontpage.rvx","Original":"com.reddit.frontpage"},"title":"Package name of Reddit","description":"The name of the package to rename the app to.","required":true}]},{"name":"Change player flyout menu toggles","description":"Adds an option to use text toggles instead of switch toggles within the additional settings menu.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Change share sheet","description":"Add option to change from in-app share sheet to system share sheet.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Change share sheet","description":"Add option to change from in-app share sheet to system share sheet.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Change start page","description":"Adds an option to set which page the app opens in instead of the homepage.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Change start page","description":"Adds an option to set which page the app opens in instead of the homepage.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Change version code","description":"Changes the version code of the app to the value specified in patch options. Except when mounting, this can prevent app stores from updating the app and allow the app to be installed over an existing installation that has a higher version code. By default, the highest version code is set.","compatiblePackages":null,"use":false,"options":[{"key":"changeVersionCode","default":false,"values":null,"title":"Change version code","description":"Changes the version code of the app.","required":true},{"key":"versionCode","default":"2147483647","values":{"Lowest":"1","Highest":"2147483647"},"title":"Version code","description":"The version code to use. (1 ~ 2147483647)","required":true}]},{"name":"Custom Shorts action buttons","description":"Changes, at compile time, the icon of the action buttons of the Shorts player.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[{"key":"iconType","default":"cairo","values":{"Cairo":"cairo","Outline":"outline","OutlineCircle":"outlinecircle","Round":"round","YoutubeOutline":"youtubeoutline","YouTube":"youtube"},"title":"Shorts icon style ","description":"The style of the icons for the action buttons in the Shorts player.","required":true}]},{"name":"Custom branding icon for YouTube","description":"Changes the YouTube app icon to the icon specified in patch options.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[{"key":"appIcon","default":"revancify_blue","values":{"AFN Blue":"afn_blue","AFN Red":"afn_red","MMT":"mmt","Revancify Blue":"revancify_blue","Revancify Red":"revancify_red","YouTube":"youtube"},"title":"App icon","description":"The icon to apply to the app.\n\nIf a path to a folder is provided, the folder must contain the following folders:\n\n- mipmap-xxxhdpi\n- mipmap-xxhdpi\n- mipmap-xhdpi\n- mipmap-hdpi\n- mipmap-mdpi\n\nEach of these folders must contain the following files:\n\n- adaptiveproduct_youtube_background_color_108.png\n- adaptiveproduct_youtube_foreground_color_108.png\n- ic_launcher.png\n- ic_launcher_round.png","required":true},{"key":"changeSplashIcon","default":true,"values":null,"title":"Change splash icons","description":"Apply the custom branding icon to the splash screen.","required":true},{"key":"restoreOldSplashAnimation","default":true,"values":null,"title":"Restore old splash animation","description":"Restore the old style splash animation.","required":true}]},{"name":"Custom branding icon for YouTube Music","description":"Changes the YouTube Music app icon to the icon specified in patch options.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[{"key":"appIcon","default":"revancify_blue","values":{"AFN Blue":"afn_blue","AFN Red":"afn_red","MMT":"mmt","Revancify Blue":"revancify_blue","Revancify Red":"revancify_red","YouTube Music":"youtube_music"},"title":"App icon","description":"The icon to apply to the app.\n\nIf a path to a folder is provided, the folder must contain the following folders:\n\n- mipmap-xxxhdpi\n- mipmap-xxhdpi\n- mipmap-xhdpi\n- mipmap-hdpi\n- mipmap-mdpi\n\nEach of these folders must contain the following files:\n\n- adaptiveproduct_youtube_music_background_color_108.png\n- adaptiveproduct_youtube_music_foreground_color_108.png\n- ic_launcher_release.png","required":true},{"key":"changeSplashIcon","default":true,"values":null,"title":"Change splash icons","description":"Apply the custom branding icon to the splash screen.","required":true},{"key":"restoreOldSplashIcon","default":false,"values":null,"title":"Restore old splash icon","description":"Restore the old style splash icon.\n\nIf you enable both the old style splash icon and the Cairo splash animation,\n\nOld style splash icon will appear first and then the Cairo splash animation will start.","required":true}]},{"name":"Custom branding name for Reddit","description":"Renames the Reddit app to the name specified in patch options.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":false,"options":[{"key":"appName","default":"Reddit","values":{"Default":"RVX Reddit","Original":"Reddit"},"title":"App name","description":"The name of the app.","required":true}]},{"name":"Custom branding name for YouTube","description":"Renames the YouTube app to the name specified in patch options.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[{"key":"appName","default":"RVX","values":{"ReVanced Extended":"ReVanced Extended","RVX":"RVX","YouTube RVX":"YouTube RVX","YouTube":"YouTube"},"title":"App name","description":"The name of the app.","required":true}]},{"name":"Custom branding name for YouTube Music","description":"Renames the YouTube Music app to the name specified in patch options.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[{"key":"appNameNotification","default":"RVX Music","values":{"ReVanced Extended Music":"ReVanced Extended Music","RVX Music":"RVX Music","YouTube Music":"YouTube Music","YT Music":"YT Music"},"title":"App name in notification panel","description":"The name of the app as it appears in the notification panel.","required":true},{"key":"appNameLauncher","default":"RVX Music","values":{"ReVanced Extended Music":"ReVanced Extended Music","RVX Music":"RVX Music","YouTube Music":"YouTube Music","YT Music":"YT Music"},"title":"App name in launcher","description":"The name of the app as it appears in the launcher.","required":true}]},{"name":"Custom double tap length","description":"Adds Double-tap to seek values that are specified in patch options.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[{"key":"doubleTapLengthArrays","default":"3, 5, 10, 15, 20, 30, 60, 120, 180","values":null,"title":"Double-tap to seek values","description":"A list of custom Double-tap to seek lengths to be added, separated by commas.","required":true}]},{"name":"Custom header for YouTube","description":"Applies a custom header in the top left corner within the app.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":false,"options":[{"key":"customHeader","default":"custom_branding_icon","values":{"Custom branding icon":"custom_branding_icon"},"title":"Custom header","description":"The header to apply to the app.\n\nPatch option \u0027Custom branding icon\u0027 applies only when:\n\n1. Patch \u0027Custom branding icon for YouTube\u0027 is included.\n2. Patch option for \u0027Custom branding icon for YouTube\u0027 is selected from the preset.\n\nIf a path to a folder is provided, the folder must contain one or more of the following folders, depending on the DPI of the device:\n\n- drawable-xxxhdpi\n- drawable-xxhdpi\n- drawable-xhdpi\n- drawable-hdpi\n- drawable-mdpi\n\nEach of the folders must contain all of the following files:\n\n[Generic header]\n\n- yt_wordmark_header_light.png\n- yt_wordmark_header_dark.png\n\nThe image dimensions must be as follows:\n\n- drawable-xxxhdpi: 488px x 192px\n- drawable-xxhdpi: 366px x 144px\n- drawable-xhdpi: 244px x 96px\n- drawable-hdpi: 184px x 72px\n- drawable-mdpi: 122px x 48px\n\n[Premium header]\n\n- yt_premium_wordmark_header_light.png\n- yt_premium_wordmark_header_dark.png\n\nThe image dimensions must be as follows:\n- drawable-xxxhdpi: 516px x 192px\n- drawable-xxhdpi: 387px x 144px\n- drawable-xhdpi: 258px x 96px\n- drawable-hdpi: 194px x 72px\n- drawable-mdpi: 129px x 48px","required":true}]},{"name":"Custom header for YouTube Music","description":"Applies a custom header in the top left corner within the app.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":false,"options":[{"key":"customHeader","default":"custom_branding_icon","values":{"Custom branding icon":"custom_branding_icon"},"title":"Custom header","description":"The header to apply to the app.\n\nPatch option \u0027Custom branding icon\u0027 applies only when:\n\n1. Patch \u0027Custom branding icon for YouTube Music\u0027 is included.\n2. Patch option for \u0027Custom branding icon for YouTube Music\u0027 is selected from the preset.\n\nIf a path to a folder is provided, the folder must contain one or more of the following folders, depending on the DPI of the device:\n\n- drawable-xxxhdpi\n- drawable-xxhdpi\n- drawable-xhdpi\n- drawable-hdpi\n- drawable-mdpi\n\nEach of the folders must contain all of the following files:\n\n- action_bar_logo.png\n- logo_music.png\n- ytm_logo.png\n\nThe image \u0027action_bar_logo.png\u0027 dimensions must be as follows:\n\n- drawable-xxxhdpi: 320px x 96px\n- drawable-xxhdpi: 240px x 72px\n- drawable-xhdpi: 160px x 48px\n- drawable-hdpi: 121px x 36px\n- drawable-mdpi: 80px x 24px\n\nThe image \u0027logo_music.png\u0027 dimensions must be as follows:\n\n- drawable-xxxhdpi: 576px x 200px\n- drawable-xxhdpi: 432px x 150px\n- drawable-xhdpi: 288px x 100px\n- drawable-hdpi: 217px x 76px\n- drawable-mdpi: 144px x 50px\n\nThe image \u0027ytm_logo.png\u0027 dimensions must be as follows:\n\n- drawable-xxxhdpi: 412px x 144px\n- drawable-xxhdpi: 309px x 108px\n- drawable-xhdpi: 206px x 72px\n- drawable-hdpi: 155px x 54px\n- drawable-mdpi: 103px x 36px","required":true}]},{"name":"Description components","description":"Adds options to hide and disable description components.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Disable Cairo splash animation","description":"Adds an option to disable Cairo splash animation.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["7.06.54","7.16.53"]}],"use":true,"options":[]},{"name":"Disable QUIC protocol","description":"Adds an option to disable CronetEngine\u0027s QUIC protocol.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Disable auto audio tracks","description":"Adds an option to disable audio tracks from being automatically enabled.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Disable auto captions","description":"Adds an option to disable captions from being automatically enabled.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Disable auto captions","description":"Adds an option to disable captions from being automatically enabled.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Disable dislike redirection","description":"Adds an option to disable redirection to the next track when clicking the Dislike button.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Disable haptic feedback","description":"Adds options to disable haptic feedback when swiping in the video player.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Disable resuming Shorts on startup","description":"Adds an option to disable the Shorts player from resuming on app startup when Shorts were last being watched.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Disable screenshot popup","description":"Adds an option to disable the popup that appears when taking a screenshot.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"options":[]},{"name":"Disable splash animation","description":"Adds an option to disable the splash animation on app startup.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Enable OPUS codec","description":"Adds an options to enable the OPUS audio codec if the player response includes.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Enable OPUS codec","description":"Adds an options to enable the OPUS audio codec if the player response includes.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Enable debug logging","description":"Adds an option to enable debug logging.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Enable debug logging","description":"Adds an option to enable debug logging.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Enable external browser","description":"Adds an option to always open links in your browser instead of in the in-app-browser.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Enable gradient loading screen","description":"Adds an option to enable the gradient loading screen.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Enable landscape mode","description":"Adds an option to enable landscape mode when rotating the screen on phones.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Enable open links directly","description":"Adds an option to skip over redirection URLs in external links.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Flyout menu components","description":"Adds options to hide or change flyout menu components.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Force hide player buttons background","description":"Removes, at compile time, the dark background surrounding the video player controls.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":false,"options":[]},{"name":"Fullscreen components","description":"Adds options to hide or change components related to fullscreen.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"GmsCore support","description":"Allows patched Google apps to run without root and under a different package name by using GmsCore instead of Google Play Services.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[{"key":"gmsCoreVendorGroupId","default":"app.revanced","values":{"ReVanced":"app.revanced"},"title":"GmsCore vendor group ID","description":"The vendor\u0027s group ID for GmsCore.","required":true},{"key":"checkGmsCore","default":true,"values":null,"title":"Check GmsCore","description":"Check if GmsCore is installed on the device and has battery optimizations disabled when the app starts. \n\nIf GmsCore is not installed the app will not work, so disabling this is not recommended.","required":true},{"key":"packageNameYouTube","default":"app.rvx.android.youtube","values":{"Clone":"com.rvx.android.youtube","Default":"app.rvx.android.youtube"},"title":"Package name of YouTube","description":"The name of the package to use in GmsCore support.","required":true},{"key":"packageNameYouTubeMusic","default":"app.rvx.android.apps.youtube.music","values":{"Clone":"com.rvx.android.apps.youtube.music","Default":"app.rvx.android.apps.youtube.music"},"title":"Package name of YouTube Music","description":"The name of the package to use in GmsCore support.","required":true}]},{"name":"GmsCore support","description":"Allows patched Google apps to run without root and under a different package name by using GmsCore instead of Google Play Services.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[{"key":"gmsCoreVendorGroupId","default":"app.revanced","values":{"ReVanced":"app.revanced"},"title":"GmsCore vendor group ID","description":"The vendor\u0027s group ID for GmsCore.","required":true},{"key":"checkGmsCore","default":true,"values":null,"title":"Check GmsCore","description":"Check if GmsCore is installed on the device and has battery optimizations disabled when the app starts. \n\nIf GmsCore is not installed the app will not work, so disabling this is not recommended.","required":true},{"key":"packageNameYouTube","default":"app.rvx.android.youtube","values":{"Clone":"com.rvx.android.youtube","Default":"app.rvx.android.youtube"},"title":"Package name of YouTube","description":"The name of the package to use in GmsCore support.","required":true},{"key":"packageNameYouTubeMusic","default":"app.rvx.android.apps.youtube.music","values":{"Clone":"com.rvx.android.apps.youtube.music","Default":"app.rvx.android.apps.youtube.music"},"title":"Package name of YouTube Music","description":"The name of the package to use in GmsCore support.","required":true}]},{"name":"Hide Recently Visited shelf","description":"Adds an option to hide the Recently Visited shelf in the sidebar.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"options":[]},{"name":"Hide Shorts dimming","description":"Removes, at compile time, the dimming effect at the top and bottom of Shorts videos.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":false,"options":[]},{"name":"Hide account components","description":"Adds options to hide components related to the account menu.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Hide action bar components","description":"Adds options to hide action bar components and replace the offline download button with an external download button.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Hide action buttons","description":"Adds options to hide action buttons under videos.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Hide ads","description":"Adds options to hide ads.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Hide ads","description":"Adds options to hide ads.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"options":[]},{"name":"Hide ads","description":"Adds options to hide ads.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Hide comments components","description":"Adds options to hide components related to comments.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Hide feed components","description":"Adds options to hide components related to feeds.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Hide feed flyout menu","description":"Adds the ability to hide feed flyout menu components using a custom filter.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Hide layout components","description":"Adds options to hide general layout components.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Hide layout components","description":"Adds options to hide general layout components.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Hide navigation buttons","description":"Adds options to hide buttons in the navigation bar.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"options":[]},{"name":"Hide overlay filter","description":"Removes, at compile time, the dark overlay that appears when player flyout menus are open.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":false,"options":[]},{"name":"Hide player buttons","description":"Adds options to hide buttons in the video player.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Hide player flyout menu","description":"Adds options to hide player flyout menu components.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Hide player overlay filter","description":"Removes, at compile time, the dark overlay that appears when single-tapping in the player.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":false,"options":[]},{"name":"Hide recommended communities shelf","description":"Adds an option to hide the recommended communities shelves in subreddits.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"options":[]},{"name":"Hide shortcuts","description":"Remove, at compile time, the app shortcuts that appears when app icon is long pressed.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":false,"options":[{"key":"explore","default":false,"values":null,"title":"Hide Explore","description":"Hide Explore from shortcuts.","required":true},{"key":"subscriptions","default":false,"values":null,"title":"Hide Subscriptions","description":"Hide Subscriptions from shortcuts.","required":true},{"key":"search","default":false,"values":null,"title":"Hide Search","description":"Hide Search from shortcuts.","required":true},{"key":"shorts","default":true,"values":null,"title":"Hide Shorts","description":"Hide Shorts from shortcuts.","required":true}]},{"name":"Hook YouTube Music actions","description":"Adds support for opening music in RVX Music using the in-app YouTube Music button.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Hook download actions","description":"Adds support to download videos with an external downloader app using the in-app download button.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Layout switch","description":"Adds an option to spoof the dpi in order to use a tablet or phone layout.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"MaterialYou","description":"Applies the MaterialYou theme for Android 12+ devices.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":false,"options":[]},{"name":"Miniplayer","description":"Adds options to change the in app minimized player, and if patching target 19.16+ adds options to use modern miniplayers.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Navigation bar components","description":"Adds options to hide or change components related to the navigation bar.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Navigation bar components","description":"Adds options to hide or change components related to the navigation bar.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Open links directly","description":"Adds an option to skip over redirection URLs in external links.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"options":[]},{"name":"Open links externally","description":"Adds an option to always open links in your browser instead of in the in-app-browser.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"options":[]},{"name":"Overlay buttons","description":"Adds options to display overlay buttons in the video player.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[{"key":"iconType","default":"bold","values":{"Bold":"bold","Rounded":"rounded","Thin":"thin"},"title":"Icon type","description":"The icon type.","required":true},{"key":"bottomMargin","default":"2.5dip","values":{"Default":"2.5dip","None":"0.0dip","Wider":"5.0dip"},"title":"Bottom margin","description":"The bottom margin for the overlay buttons and timestamp.","required":true},{"key":"widerButtonsSpace","default":false,"values":null,"title":"Wider between-buttons space","description":"Prevent adjacent button presses by increasing the horizontal spacing between buttons.","required":true},{"key":"changeTopButtons","default":false,"values":null,"title":"Change top buttons","description":"Change the icons at the top of the player.","required":true}]},{"name":"Player components","description":"Adds options to hide or change components related to the player.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Player components","description":"Adds options to hide or change components related to the video player.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Premium icon","description":"Unlocks premium app icons.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"options":[]},{"name":"Remove background playback restrictions","description":"Removes restrictions on background playback, including for kids videos.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Remove background playback restrictions","description":"Removes restrictions on background playback, including for music and kids videos.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Remove subreddit dialog","description":"Adds options to remove the NSFW community warning and notifications suggestion dialogs by dismissing them automatically.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"options":[]},{"name":"Remove viewer discretion dialog","description":"Adds an option to remove the dialog that appears when opening a video that has been age-restricted by accepting it automatically. This does not bypass the age restriction.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Remove viewer discretion dialog","description":"Adds an option to remove the dialog that appears when opening a video that has been age-restricted by accepting it automatically. This does not bypass the age restriction.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Restore old style library shelf","description":"Adds an option to return the Library tab to the old style.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Return YouTube Dislike","description":"Adds an option to show the dislike count of songs using the Return YouTube Dislike API.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Return YouTube Dislike","description":"Adds an option to show the dislike count of videos using the Return YouTube Dislike API.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Return YouTube Username","description":"Adds an option to replace YouTube handles with usernames in comments using YouTube Data API v3.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":false,"options":[]},{"name":"Return YouTube Username","description":"Adds an option to replace YouTube handles with usernames in comments using YouTube Data API v3.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":false,"options":[]},{"name":"Sanitize sharing links","description":"Adds an option to remove tracking query parameters from URLs when sharing links.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Sanitize sharing links","description":"Adds an option to remove tracking query parameters from URLs when sharing links.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"options":[]},{"name":"Sanitize sharing links","description":"Adds an option to remove tracking query parameters from URLs when sharing links.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Seekbar components","description":"Adds options to hide or change components related to the seekbar.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Settings for Reddit","description":"Applies mandatory patches to implement ReVanced Extended settings into the application.","compatiblePackages":[{"name":"com.reddit.frontpage","versions":["2023.12.0","2024.17.0"]}],"use":true,"options":[{"key":"settingsLabel","default":"ReVanced Extended","values":null,"title":"RVX settings menu name","description":"The name of the RVX settings menu.","required":true}]},{"name":"Settings for YouTube","description":"Applies mandatory patches to implement ReVanced Extended settings into the application.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[{"key":"insertPosition","default":"@string/about_key","values":{"Parent settings":"@string/parent_tools_key","General":"@string/general_key","Account":"@string/account_switcher_key","Data saving":"@string/data_saving_settings_key","Autoplay":"@string/auto_play_key","Video quality preferences":"@string/video_quality_settings_key","Background":"@string/offline_key","Watch on TV":"@string/pair_with_tv_key","Manage all history":"@string/history_key","Your data in YouTube":"@string/your_data_key","Privacy":"@string/privacy_key","History \u0026 privacy":"@string/privacy_key","Try experimental new features":"@string/premium_early_access_browse_page_key","Purchases and memberships":"@string/subscription_product_setting_key","Billing \u0026 payments":"@string/billing_and_payment_key","Billing and payments":"@string/billing_and_payment_key","Notifications":"@string/notification_key","Connected apps":"@string/connected_accounts_browse_page_key","Live chat":"@string/live_chat_key","Captions":"@string/captions_key","Accessibility":"@string/accessibility_settings_key","About":"@string/about_key"},"title":"Insert position","description":"The settings menu name that the RVX settings menu should be above.","required":true},{"key":"settingsLabel","default":"ReVanced Extended","values":null,"title":"RVX settings label","description":"The name of the RVX settings menu.","required":true}]},{"name":"Settings for YouTube Music","description":"Applies mandatory patches to implement ReVanced Extended settings into the application.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[{"key":"settingsLabel","default":"ReVanced Extended","values":null,"title":"RVX settings label","description":"The name of the RVX settings menu.","required":true}]},{"name":"Shorts components","description":"Adds options to hide or change components related to YouTube Shorts.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"SponsorBlock","description":"Adds options to enable and configure SponsorBlock, which can skip undesired video segments, such as non-music sections.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"SponsorBlock","description":"Adds options to enable and configure SponsorBlock, which can skip undesired video segments, such as sponsored content.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[{"key":"outlineIcon","default":false,"values":null,"title":"Outline icons","description":"Apply the outline icon.","required":true}]},{"name":"Spoof app version","description":"Adds options to spoof the YouTube Music client version. This can remove the radio mode restriction in Canadian regions or disable real-time lyrics.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Spoof app version","description":"Adds options to spoof the YouTube client version. This can be used to restore old UI elements and features.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Spoof streaming data","description":"Adds options to spoof the streaming data to allow video playback.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Swipe controls","description":"Adds options for controlling volume and brightness with swiping, and whether to enter fullscreen when swiping down below the player.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Theme","description":"Changes the app\u0027s theme to the values specified in patch options.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[{"key":"darkThemeBackgroundColor","default":"@android:color/black","values":{"Amoled Black":"@android:color/black","Classic (Old YouTube)":"#FF212121","Catppuccin (Mocha)":"#FF181825","Dark Pink":"#FF290025","Dark Blue":"#FF001029","Dark Green":"#FF002905","Dark Yellow":"#FF282900","Dark Orange":"#FF291800","Dark Red":"#FF290000"},"title":"Dark theme background color","description":"Can be a hex color (#AARRGGBB) or a color resource reference.","required":false},{"key":"lightThemeBackgroundColor","default":"@android:color/white","values":{"White":"@android:color/white","Catppuccin (Latte)":"#FFE6E9EF","Light Pink":"#FFFCCFF3","Light Blue":"#FFD1E0FF","Light Green":"#FFCCFFCC","Light Yellow":"#FFFDFFCC","Light Orange":"#FFFFE6CC","Light Red":"#FFFFD6D6"},"title":"Light theme background color","description":"Can be a hex color (#AARRGGBB) or a color resource reference.","required":false}]},{"name":"Toolbar components","description":"Adds options to hide or change components located on the toolbar, such as toolbar buttons, search bar, and header.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Translations for YouTube","description":"Add translations or remove string resources.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[{"key":"customTranslations","default":"","values":null,"title":"Custom translations","description":"The path to the \u0027strings.xml\u0027 file.\nPlease note that applying the \u0027strings.xml\u0027 file will overwrite all existing translations.","required":true},{"key":"selectedTranslations","default":"ar, bg-rBG, de-rDE, el-rGR, es-rES, fr-rFR, hu-rHU, it-rIT, ja-rJP, ko-rKR, pl-rPL, pt-rBR, ru-rRU, tr-rTR, uk-rUA, vi-rVN, zh-rCN, zh-rTW","values":null,"title":"Translations to add","description":"A list of translations to be added for the RVX settings, separated by commas.","required":true},{"key":"selectedStringResources","default":"af, am, ar, ar-rXB, as, az, b+es+419, b+sr+Latn, be, bg, bn, bs, ca, cs, da, de, el, en-rAU, en-rCA, en-rGB, en-rIN, en-rXA, en-rXC, es, es-rUS, et, eu, fa, fi, fr, fr-rCA, gl, gu, hi, hr, hu, hy, id, in, is, it, iw, ja, ka, kk, km, kn, ko, ky, lo, lt, lv, mk, ml, mn, mr, ms, my, nb, ne, nl, no, or, pa, pl, pt, pt-rBR, pt-rPT, ro, ru, si, sk, sl, sq, sr, sv, sw, ta, te, th, tl, tr, uk, ur, uz, vi, zh, zh-rCN, zh-rHK, zh-rTW, zu","values":null,"title":"String resources to keep","description":"A list of string resources to be kept, separated by commas.\nString resources not in the list will be removed from the app.\n\nDefault string resource, English, is not removed.","required":true}]},{"name":"Translations for YouTube Music","description":"Add translations or remove string resources.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[{"key":"customTranslations","default":"","values":null,"title":"Custom translations","description":"The path to the \u0027strings.xml\u0027 file.\nPlease note that applying the \u0027strings.xml\u0027 file will overwrite all existing translations.","required":true},{"key":"selectedTranslations","default":"bg-rBG, bn, cs-rCZ, el-rGR, es-rES, fr-rFR, hu-rHU, id-rID, in, it-rIT, ja-rJP, ko-rKR, nl-rNL, pl-rPL, pt-rBR, ro-rRO, ru-rRU, tr-rTR, uk-rUA, vi-rVN, zh-rCN, zh-rTW","values":null,"title":"Translations to add","description":"A list of translations to be added for the RVX settings, separated by commas.","required":true},{"key":"selectedStringResources","default":"af, am, ar, ar-rXB, as, az, b+es+419, b+sr+Latn, be, bg, bn, bs, ca, cs, da, de, el, en-rAU, en-rCA, en-rGB, en-rIN, en-rXA, en-rXC, es, es-rUS, et, eu, fa, fi, fr, fr-rCA, gl, gu, hi, hr, hu, hy, id, in, is, it, iw, ja, ka, kk, km, kn, ko, ky, lo, lt, lv, mk, ml, mn, mr, ms, my, nb, ne, nl, no, or, pa, pl, pt, pt-rBR, pt-rPT, ro, ru, si, sk, sl, sq, sr, sv, sw, ta, te, th, tl, tr, uk, ur, uz, vi, zh, zh-rCN, zh-rHK, zh-rTW, zu","values":null,"title":"String resources to keep","description":"A list of string resources to be kept, separated by commas.\nString resources not in the list will be removed from the app.\n\nDefault string resource, English, is not removed.","required":true}]},{"name":"Video playback","description":"Adds options to customize settings related to video playback, such as default video quality and playback speed.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[]},{"name":"Video playback","description":"Adds options to customize settings related to video playback, such as default video quality and playback speed.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]},{"name":"Visual preferences icons for YouTube","description":"Adds icons to specific preferences in the settings.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[{"key":"settingsMenuIcon","default":"extension","values":{"Custom branding icon":"custom_branding_icon","Extension":"extension","Gear":"gear","YT alt":"yt_alt","ReVanced":"revanced","ReVanced Colored":"revanced_colored"},"title":"RVX settings menu icon","description":"The icon for the RVX settings menu.","required":true},{"key":"applyToAll","default":false,"values":null,"title":"Apply to all settings menu","description":"Whether to apply Visual preferences icons to all settings menus.\n\nIf true: icons are applied to the parent PreferenceScreen of YouTube settings, the parent PreferenceScreen of RVX settings and the RVX sub-settings (if supported).\n\nIf false: icons are applied only to the parent PreferenceScreen of YouTube settings and RVX settings.","required":true}]},{"name":"Visual preferences icons for YouTube Music","description":"Adds icons to specific preferences in the settings.","compatiblePackages":[{"name":"com.google.android.apps.youtube.music","versions":["6.20.51","6.29.59","6.42.55","6.51.53","7.16.53"]}],"use":true,"options":[{"key":"settingsMenuIcon","default":"extension","values":{"Custom branding icon":"custom_branding_icon","Extension":"extension","Gear":"gear","ReVanced":"revanced","ReVanced Colored":"revanced_colored"},"title":"RVX settings menu icon","description":"The icon for the RVX settings menu.","required":true}]},{"name":"Watch history","description":"Adds an option to change the domain of the watch history or check its status.","compatiblePackages":[{"name":"com.google.android.youtube","versions":["18.29.38","18.33.40","18.38.44","18.48.39","19.05.36","19.16.39"]}],"use":true,"options":[]}] \ No newline at end of file diff --git a/patches/api/patches.api b/patches/api/patches.api new file mode 100644 index 0000000000..e4c49bf530 --- /dev/null +++ b/patches/api/patches.api @@ -0,0 +1,1119 @@ +public final class app/revanced/generator/MainKt { + public static synthetic fun main ([Ljava/lang/String;)V +} + +public final class app/revanced/patches/all/misc/versioncode/ChangeVersionCodePatchKt { + public static final fun getChangeVersionCodePatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/music/account/components/AccountComponentsPatchKt { + public static final fun getAccountComponentsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/actionbar/components/ActionBarComponentsPatchKt { + public static final fun getActionBarComponentsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/ads/general/AdsPatchKt { + public static final fun getAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/flyoutmenu/components/FlyoutMenuComponentsPatchKt { + public static final fun getFlyoutMenuComponentsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/general/amoled/AmoledPatchKt { + public static final fun getAmoledPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/music/general/autocaptions/AutoCaptionsPatchKt { + public static final fun getAutoCaptionsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/general/components/FingerprintsKt { + public static final fun indexOfVisibilityInstruction (Lcom/android/tools/smali/dexlib2/iface/Method;)I +} + +public final class app/revanced/patches/music/general/components/LayoutComponentsPatchKt { + public static final fun getLayoutComponentsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/general/dialog/ViewerDiscretionDialogPatchKt { + public static final fun getViewerDiscretionDialogPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/general/landscapemode/LandScapeModePatchKt { + public static final fun getLandScapeModePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/general/oldstylelibraryshelf/OldStyleLibraryShelfPatchKt { + public static final fun getOldStyleLibraryShelfPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/general/redirection/DislikeRedirectionPatchKt { + public static final fun getDislikeRedirectionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/general/spoofappversion/SpoofAppVersionPatchKt { + public static final fun getSpoofAppVersionPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/music/general/startpage/ChangeStartPagePatchKt { + public static final fun getChangeStartPagePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/layout/branding/icon/CustomBrandingIconPatchKt { + public static final fun getCustomBrandingIconPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/music/layout/branding/name/CustomBrandingNamePatchKt { + public static final fun getCustomBrandingNamePatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/music/layout/header/ChangeHeaderPatchKt { + public static final fun getChangeHeaderPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/music/layout/overlayfilter/OverlayFilterPatchKt { + public static final fun getOverlayFilterPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/music/layout/playeroverlay/PlayerOverlayFilterPatchKt { + public static final fun getPlayerOverlayFilterPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/music/layout/translations/TranslationsPatchKt { + public static final fun getTranslationsPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/music/layout/visual/VisualPreferencesIconsPatchKt { + public static final fun getVisualPreferencesIconsPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/music/misc/backgroundplayback/BackgroundPlaybackPatchKt { + public static final fun getBackgroundPlaybackPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/misc/bitrate/BitrateDefaultValuePatchKt { + public static final fun getBitrateDefaultValuePatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/music/misc/codecs/OpusCodecPatchKt { + public static final fun getOpusCodecPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/music/misc/debugging/DebuggingPatchKt { + public static final fun getDebuggingPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/music/misc/share/ShareSheetPatchKt { + public static final fun getShareSheetPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/misc/splash/CairoSplashAnimationPatchKt { + public static final fun getCairoSplashAnimationPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/misc/thumbnails/BypassImageRegionRestrictionsPatchKt { + public static final fun getBypassImageRegionRestrictionsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/misc/tracking/SanitizeUrlQueryPatchKt { + public static final fun getSanitizeUrlQueryPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/navigation/components/NavigationBarComponentsPatchKt { + public static final fun getNavigationBarComponentsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/player/components/FingerprintsKt { + public static final field AUDIO_VIDEO_SWITCH_TOGGLE_VISIBILITY Ljava/lang/String; +} + +public final class app/revanced/patches/music/player/components/PlayerComponentsPatchKt { + public static final fun getPlayerComponentsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/utils/extension/SharedExtensionPatchKt { + public static final fun getSharedExtensionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/utils/fix/androidauto/AndroidAutoCertificatePatchKt { + public static final fun getAndroidAutoCertificatePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/utils/fix/fileprovider/FileProviderPatchKt { + public static final fun fileProviderPatch (Ljava/lang/String;Ljava/lang/String;)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/utils/flyoutmenu/FlyoutMenuHookPatchKt { + public static final fun getFlyoutMenuHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/utils/gms/GmsCoreSupportPatchKt { + public static final fun getGmsCoreSupportPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/utils/mainactivity/MainActivityResolvePatchKt { + public static final fun getMainActivityResolvePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/utils/playertype/PlayerTypeHookPatchKt { + public static final fun getPlayerTypeHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/utils/playservice/VersionCheckPatchKt { + public static final fun getVersionCheckPatch ()Lapp/revanced/patcher/patch/ResourcePatch; + public static final fun is_6_27_or_greater ()Z + public static final fun is_6_36_or_greater ()Z + public static final fun is_6_42_or_greater ()Z + public static final fun is_7_06_or_greater ()Z + public static final fun is_7_18_or_greater ()Z + public static final fun is_7_20_or_greater ()Z + public static final fun is_7_23_or_greater ()Z +} + +public final class app/revanced/patches/music/utils/resourceid/SharedResourceIdPatchKt { + public static final fun getAccountSwitcherAccessibility ()J + public static final fun getBottomSheetRecyclerView ()J + public static final fun getButtonContainer ()J + public static final fun getButtonIconPaddingMedium ()J + public static final fun getChipCloud ()J + public static final fun getColorGrey ()J + public static final fun getDarkBackground ()J + public static final fun getDesignBottomSheetDialog ()J + public static final fun getEndButtonsContainer ()J + public static final fun getFloatingLayout ()J + public static final fun getHistoryMenuItem ()J + public static final fun getInlineTimeBarAdBreakMarkerColor ()J + public static final fun getInterstitialsContainer ()J + public static final fun getLikeDislikeContainer ()J + public static final fun getMainActivityLaunchAnimation ()J + public static final fun getMenuEntry ()J + public static final fun getMiniPlayerDefaultText ()J + public static final fun getMiniPlayerMdxPlaying ()J + public static final fun getMiniPlayerPlayPauseReplayButton ()J + public static final fun getMiniPlayerViewPager ()J + public static final fun getMusicNotifierShelf ()J + public static final fun getMusicTasteBuilderShelf ()J + public static final fun getNamesInactiveAccountThumbnailSize ()J + public static final fun getOfflineSettingsMenuItem ()J + public static final fun getPlayerOverlayChip ()J + public static final fun getPlayerViewPager ()J + public static final fun getPrivacyTosFooter ()J + public static final fun getQualityAuto ()J + public static final fun getRemixGenericButtonSize ()J + public static final fun getSlidingDialogAnimation ()J + public static final fun getTapBloomView ()J + public static final fun getText1 ()J + public static final fun getToolTipContentView ()J + public static final fun getTopBarMenuItemImageView ()J + public static final fun getTopEnd ()J + public static final fun getTopStart ()J + public static final fun getTosFooter ()J + public static final fun getTouchOutside ()J + public static final fun getTrimSilenceSwitch ()J + public static final fun getVarispeedUnavailableTitle ()J + public static final fun isTablet ()J +} + +public final class app/revanced/patches/music/utils/returnyoutubedislike/ReturnYouTubeDislikePatchKt { + public static final fun getReturnYouTubeDislikePatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/music/utils/returnyoutubedislike/Vote : java/lang/Enum { + public static final field DISLIKE Lapp/revanced/patches/music/utils/returnyoutubedislike/Vote; + public static final field LIKE Lapp/revanced/patches/music/utils/returnyoutubedislike/Vote; + public static final field REMOVE_LIKE Lapp/revanced/patches/music/utils/returnyoutubedislike/Vote; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getValue ()I + public static fun valueOf (Ljava/lang/String;)Lapp/revanced/patches/music/utils/returnyoutubedislike/Vote; + public static fun values ()[Lapp/revanced/patches/music/utils/returnyoutubedislike/Vote; +} + +public final class app/revanced/patches/music/utils/returnyoutubeusername/ReturnYouTubeUsernamePatchKt { + public static final fun getReturnYouTubeUsernamePatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/music/utils/settings/SettingsPatchKt { + public static final fun getSettingsPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/music/utils/sponsorblock/SponsorBlockPatchKt { + public static final fun getSponsorBlockPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/music/utils/videotype/VideoTypeHookPatchKt { + public static final fun getVideoTypeHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/video/information/VideoInformationPatchKt { + public static final fun getVideoInformationPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/video/playback/VideoPlaybackPatchKt { + public static final fun getVideoPlaybackPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/ad/AdsPatchKt { + public static final fun getAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/layout/branding/name/CustomBrandingNamePatchKt { + public static final fun getCustomBrandingNamePatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/reddit/layout/branding/packagename/ChangePackageNamePatchKt { + public static final fun getChangePackageNamePatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/reddit/layout/communities/RecommendedCommunitiesPatchKt { + public static final fun getRecommendedCommunitiesPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/layout/navigation/NavigationButtonsPatchKt { + public static final fun getNavigationButtonsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/layout/premiumicon/PremiumIconPatchKt { + public static final fun getPremiumIconPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/layout/recentlyvisited/RecentlyVisitedShelfPatchKt { + public static final fun getRecentlyVisitedShelfPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/layout/screenshotpopup/ScreenshotPopupPatchKt { + public static final fun getScreenshotPopupPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/layout/subredditdialog/SubRedditDialogPatchKt { + public static final fun getSubRedditDialogPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/layout/toolbar/ToolBarButtonPatchKt { + public static final fun getToolBarButtonPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/misc/openlink/OpenLinksDirectlyPatchKt { + public static final fun getOpenLinksDirectlyPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/misc/openlink/OpenLinksExternallyPatchKt { + public static final fun getOpenLinksExternallyPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/misc/tracking/url/SanitizeUrlQueryPatchKt { + public static final fun getSanitizeUrlQueryPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/utils/extension/SharedExtensionPatchKt { + public static final fun getSharedExtensionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/utils/resourceid/SharedResourceIdPatchKt { + public static final fun getCancelButton ()J + public static final fun getLabelAcknowledgements ()J + public static final fun getScreenShotShareBanner ()J + public static final fun getTextAppearanceRedditBaseOldButtonColored ()J + public static final fun getToolBarNavSearchCtaContainer ()J +} + +public final class app/revanced/patches/reddit/utils/settings/SettingsPatchKt { + public static final fun getSettingsPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/shared/FingerprintsKt { + public static final field SPANNABLE_STRING_REFERENCE Ljava/lang/String; + public static final fun indexOfModelInstruction (Lcom/android/tools/smali/dexlib2/iface/Method;)I + public static final fun indexOfReleaseInstruction (Lcom/android/tools/smali/dexlib2/iface/Method;)I + public static final fun indexOfSpannableStringInstruction (Lcom/android/tools/smali/dexlib2/iface/Method;)I +} + +public final class app/revanced/patches/shared/ads/BaseAdsPatchKt { + public static final fun baseAdsPatch (Ljava/lang/String;Ljava/lang/String;)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/captions/BaseAutoCaptionsPatchKt { + public static final fun getBaseAutoCaptionsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/customspeed/CustomPlaybackSpeedPatchKt { + public static final fun customPlaybackSpeedPatch (Ljava/lang/String;F)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/dialog/BaseViewerDiscretionDialogPatchKt { + public static final fun baseViewerDiscretionDialogPatch (Ljava/lang/String;Z)Lapp/revanced/patcher/patch/BytecodePatch; + public static synthetic fun baseViewerDiscretionDialogPatch$default (Ljava/lang/String;ZILjava/lang/Object;)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/drawable/DrawableColorHookPatchKt { + public static final fun getDrawableColorHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/extension/ExtensionHook { + public final fun getFingerprint ()Lapp/revanced/patcher/Fingerprint; + public final fun invoke (Lapp/revanced/patcher/patch/BytecodePatchContext;Ljava/lang/String;)V +} + +public final class app/revanced/patches/shared/extension/SharedExtensionPatchKt { + public static final fun extensionHook (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lapp/revanced/patches/shared/extension/ExtensionHook; + public static synthetic fun extensionHook$default (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lapp/revanced/patches/shared/extension/ExtensionHook; + public static final fun sharedExtensionPatch ([Lapp/revanced/patches/shared/extension/ExtensionHook;)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/gms/FingerprintsKt { + public static final field GET_GMS_CORE_VENDOR_GROUP_ID_METHOD_NAME Ljava/lang/String; + public static final fun indexOfGetPackageNameInstruction (Lcom/android/tools/smali/dexlib2/iface/Method;)I +} + +public final class app/revanced/patches/shared/gms/GmsCoreSupportPatchKt { + public static final fun gmsCoreSupportPatch (Ljava/lang/String;Lapp/revanced/patcher/Fingerprint;Lapp/revanced/patcher/patch/Patch;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lapp/revanced/patcher/patch/BytecodePatch; + public static synthetic fun gmsCoreSupportPatch$default (Ljava/lang/String;Lapp/revanced/patcher/Fingerprint;Lapp/revanced/patcher/patch/Patch;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lapp/revanced/patcher/patch/BytecodePatch; + public static final fun gmsCoreSupportResourcePatch (Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patcher/patch/Option;Lapp/revanced/patcher/patch/Option;Lapp/revanced/patcher/patch/Option;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lapp/revanced/patcher/patch/ResourcePatch; + public static synthetic fun gmsCoreSupportResourcePatch$default (Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patcher/patch/Option;Lapp/revanced/patcher/patch/Option;Lapp/revanced/patcher/patch/Option;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/shared/imageurl/CronetImageUrlHookPatchKt { + public static final fun cronetImageUrlHookPatch (Z)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/litho/LithoFilterPatchKt { + public static final fun getLithoFilterPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/mainactivity/BaseMainActivityResolvePatchKt { + public static final fun baseMainActivityResolvePatch (Lkotlin/Pair;)Lapp/revanced/patcher/patch/BytecodePatch; + public static final fun getMainActivityMutableClass ()Lapp/revanced/patcher/util/proxy/mutableTypes/MutableClass; + public static final fun getOnConfigurationChangedMethod ()Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod; + public static final fun getOnCreateMethod ()Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod; +} + +public final class app/revanced/patches/shared/mapping/ResourceElement { + public fun (Ljava/lang/String;Ljava/lang/String;J)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()J + public final fun copy (Ljava/lang/String;Ljava/lang/String;J)Lapp/revanced/patches/shared/mapping/ResourceElement; + public static synthetic fun copy$default (Lapp/revanced/patches/shared/mapping/ResourceElement;Ljava/lang/String;Ljava/lang/String;JILjava/lang/Object;)Lapp/revanced/patches/shared/mapping/ResourceElement; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()J + public final fun getName ()Ljava/lang/String; + public final fun getType ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class app/revanced/patches/shared/mapping/ResourceMappingPatchKt { + public static final fun get (Ljava/util/List;Lapp/revanced/patches/shared/mapping/ResourceType;Ljava/lang/String;)J + public static final fun get (Ljava/util/List;Ljava/lang/String;Ljava/lang/String;)J + public static final fun getResourceMappingPatch ()Lapp/revanced/patcher/patch/ResourcePatch; + public static final fun getResourceMappings ()Ljava/util/List; +} + +public final class app/revanced/patches/shared/mapping/ResourceType : java/lang/Enum { + public static final field ATTR Lapp/revanced/patches/shared/mapping/ResourceType; + public static final field BOOL Lapp/revanced/patches/shared/mapping/ResourceType; + public static final field COLOR Lapp/revanced/patches/shared/mapping/ResourceType; + public static final field DIMEN Lapp/revanced/patches/shared/mapping/ResourceType; + public static final field DRAWABLE Lapp/revanced/patches/shared/mapping/ResourceType; + public static final field ID Lapp/revanced/patches/shared/mapping/ResourceType; + public static final field INTEGER Lapp/revanced/patches/shared/mapping/ResourceType; + public static final field LAYOUT Lapp/revanced/patches/shared/mapping/ResourceType; + public static final field STRING Lapp/revanced/patches/shared/mapping/ResourceType; + public static final field STYLE Lapp/revanced/patches/shared/mapping/ResourceType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getValue ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lapp/revanced/patches/shared/mapping/ResourceType; + public static fun values ()[Lapp/revanced/patches/shared/mapping/ResourceType; +} + +public final class app/revanced/patches/shared/opus/BaseOpusCodecsPatchKt { + public static final fun baseOpusCodecsPatch (Ljava/lang/String;)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/returnyoutubeusername/BaseReturnYouTubeUsernamePatchKt { + public static final fun getBaseReturnYouTubeUsernamePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/settingmenu/SettingsMenuPatchKt { + public static final fun getSettingsMenuPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/spans/InclusiveSpanPatchKt { + public static final fun getInclusiveSpanPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/spoof/appversion/BaseSpoofAppVersionPatchKt { + public static final fun baseSpoofAppVersionPatch (Ljava/lang/String;)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/spoof/useragent/BaseSpoofUserAgentPatchKt { + public static final fun baseSpoofUserAgentPatch (Ljava/lang/String;)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/textcomponent/TextComponentPatchKt { + public static final fun getTextComponentPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/tracking/BaseSanitizeUrlQueryPatchKt { + public static final fun getBaseSanitizeUrlQueryPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public abstract interface class app/revanced/patches/shared/transformation/IMethodCall { + public abstract fun getDefinedClassName ()Ljava/lang/String; + public abstract fun getMethodName ()Ljava/lang/String; + public abstract fun getMethodParams ()[Ljava/lang/String; + public abstract fun getReturnType ()Ljava/lang/String; + public abstract fun replaceInvokeVirtualWithExtension (Ljava/lang/String;Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Lcom/android/tools/smali/dexlib2/iface/instruction/formats/Instruction35c;I)V +} + +public final class app/revanced/patches/shared/transformation/IMethodCall$DefaultImpls { + public static fun replaceInvokeVirtualWithExtension (Lapp/revanced/patches/shared/transformation/IMethodCall;Ljava/lang/String;Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Lcom/android/tools/smali/dexlib2/iface/instruction/formats/Instruction35c;I)V +} + +public final class app/revanced/patches/shared/transformation/TransformInstructionsPatchKt { + public static final fun transformInstructionsPatch (Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function2;)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/translations/BaseTranslationsPatchKt { + public static final fun baseTranslationsPatch (Lapp/revanced/patcher/patch/ResourcePatchContext;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;Ljava/lang/String;)V + public static final fun getAPP_LANGUAGES ()[Ljava/lang/String; +} + +public final class app/revanced/patches/shared/viewgroup/ViewGroupMarginLayoutParamsHookPatchKt { + public static final fun getViewGroupMarginLayoutParamsHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/ads/general/AdsPatchKt { + public static final fun getAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/alternative/thumbnails/AlternativeThumbnailsPatchKt { + public static final fun getAlternativeThumbnailsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/alternative/thumbnails/BypassImageRegionRestrictionsPatchKt { + public static final fun getBypassImageRegionRestrictionsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/feed/components/FeedComponentsPatchKt { + public static final fun getFeedComponentsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/feed/flyoutmenu/FeedFlyoutMenuPatchKt { + public static final fun getFeedFlyoutMenuPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/general/audiotracks/AudioTracksPatchKt { + public static final fun getAudioTracksPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/general/autocaptions/AutoCaptionsPatchKt { + public static final fun getAutoCaptionsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/general/components/LayoutComponentsPatchKt { + public static final fun getLayoutComponentsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/general/dialog/ViewerDiscretionDialogPatchKt { + public static final fun getViewerDiscretionDialogPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/general/downloads/DownloadActionsPatchKt { + public static final fun getDownloadActionsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/general/layoutswitch/LayoutSwitchPatchKt { + public static final fun getLayoutSwitchPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/general/loadingscreen/GradientLoadingScreenPatchKt { + public static final fun getGradientLoadingScreenPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/general/miniplayer/MiniplayerPatchKt { + public static final fun getMiniplayerPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/general/music/YouTubeMusicActionsPatchKt { + public static final fun getYoutubeMusicActionsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/general/navigation/NavigationBarComponentsPatchKt { + public static final fun getNavigationBarComponentsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/general/splashanimation/SplashAnimationPatchKt { + public static final fun getSplashAnimationPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/general/spoofappversion/SpoofAppVersionPatchKt { + public static final fun getSpoofAppVersionPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/general/startpage/ChangeStartPagePatchKt { + public static final fun getChangeStartPagePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/general/toolbar/ToolBarComponentsPatchKt { + public static final fun getToolBarComponentsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/actionbuttons/ShortsActionButtonsPatchKt { + public static final fun getShortsActionButtonsPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/layout/branding/icon/CustomBrandingIconPatchKt { + public static final fun getCustomBrandingIconPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/layout/branding/name/CustomBrandingNamePatchKt { + public static final fun getCustomBrandingNamePatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/layout/dimming/ShortsDimmingPatchKt { + public static final fun getShortsDimmingPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/layout/doubletaplength/DoubleTapLengthPatchKt { + public static final fun getDoubleTapLengthPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/layout/header/ChangeHeaderPatchKt { + public static final fun getChangeHeaderPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/layout/playerbuttonbg/PlayerButtonBackgroundPatchKt { + public static final fun getPlayerButtonBackgroundPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/layout/shortcut/ShortcutPatchKt { + public static final fun getShortcutPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/layout/theme/MaterialYouPatchKt { + public static final fun getMaterialYouPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/layout/theme/SharedThemePatchKt { + public static final fun getSharedThemePatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/layout/theme/ThemePatchKt { + public static final fun getThemePatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/layout/translations/TranslationsPatchKt { + public static final fun getTranslationsPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/layout/visual/VisualPreferencesIconsPatchKt { + public static final fun getIntentIcon ()Ljava/util/Map; + public static final fun getVisualPreferencesIconsPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/misc/backgroundplayback/BackgroundPlaybackPatchKt { + public static final fun getBackgroundPlaybackPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/codecs/OpusCodecPatchKt { + public static final fun getOpusCodecPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/debugging/DebuggingPatchKt { + public static final fun getDebuggingPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/externalbrowser/OpenLinksExternallyPatchKt { + public static final fun getOpenLinksExternallyPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/openlinksdirectly/OpenLinksDirectlyPatchKt { + public static final fun getOpenLinksDirectlyPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/quic/QUICProtocolPatchKt { + public static final fun getQuicProtocolPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/share/ShareSheetPatchKt { + public static final fun getShareSheetPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/tracking/SanitizeUrlQueryPatchKt { + public static final fun getSanitizeUrlQueryPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/watchhistory/WatchHistoryPatchKt { + public static final fun getWatchHistoryPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/player/action/ActionButtonsPatchKt { + public static final fun getActionButtonsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/player/ambientmode/AmbientModeSwitchPatchKt { + public static final fun getAmbientModeSwitchPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/player/buttons/PlayerButtonsPatchKt { + public static final fun getPlayerButtonsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/player/comments/CommentsComponentPatchKt { + public static final fun getCommentsComponentPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/player/components/PlayerComponentsPatchKt { + public static final fun getPlayerComponentsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/player/descriptions/DescriptionComponentsPatchKt { + public static final fun getDescriptionComponentsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/player/flyoutmenu/hide/PlayerFlyoutMenuPatchKt { + public static final fun getPlayerFlyoutMenuPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/player/flyoutmenu/toggle/ChangeTogglePatchKt { + public static final fun getChangeTogglePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/player/fullscreen/FullscreenComponentsPatchKt { + public static final fun getFullscreenComponentsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/player/hapticfeedback/HapticFeedbackPatchKt { + public static final fun getHapticFeedbackPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/player/overlaybuttons/OverlayButtonsPatchKt { + public static final fun getOverlayButtonsPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/player/seekbar/SeekbarComponentsPatchKt { + public static final fun getSeekbarComponentsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/shorts/components/ShortsComponentPatchKt { + public static final fun getShortsComponentPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/shorts/startupshortsreset/ResumingShortsOnStartupPatchKt { + public static final fun getResumingShortsOnStartupPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/swipe/controls/SwipeControlsPatchKt { + public static final fun getSwipeControlsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/FingerprintsKt { + public static final field PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR Ljava/lang/String; +} + +public final class app/revanced/patches/youtube/utils/bottomsheet/BottomSheetHookPatchKt { + public static final fun getBottomSheetHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/castbutton/CastButtonPatchKt { + public static final fun getCastButtonPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/controlsoverlay/ControlsOverlayConfigPatchKt { + public static final fun getControlsOverlayConfigPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/extension/SharedExtensionPatchKt { + public static final fun getSharedExtensionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/fix/bottomui/CfBottomUIPatchKt { + public static final fun getCfBottomUIPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/fix/cairo/CairoSettingsPatchKt { + public static final fun getCairoSettingsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/fix/doublebacktoclose/DoubleBackToClosePatchKt { + public static final fun getDoubleBackToClosePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/fix/shortsplayback/ShortsPlaybackPatchKt { + public static final fun getShortsPlaybackPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/fix/streamingdata/SpoofStreamingDataPatchKt { + public static final fun getSpoofStreamingDataPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/fix/suggestedvideoendscreen/SuggestedVideoEndScreenPatchKt { + public static final fun getSuggestedVideoEndScreenPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/fix/swiperefresh/SwipeRefreshPatchKt { + public static final fun getSwipeRefreshPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/flyoutmenu/FlyoutMenuHookPatchKt { + public static final fun getFlyoutMenuHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/gms/GmsCoreSupportPatchKt { + public static final fun getGmsCoreSupportPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/lockmodestate/LockModeStateHookPatchKt { + public static final fun getLockModeStateHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/lottie/LottieAnimationViewHookPatchKt { + public static final fun getLottieAnimationViewHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/mainactivity/MainActivityResolvePatchKt { + public static final fun getMainActivityResolvePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/navigation/NavigationBarHookPatchKt { + public static field hookNavigationButtonCreated Lkotlin/jvm/functions/Function1; + public static final fun addBottomBarContainerHook (Ljava/lang/String;)V + public static final fun getHookNavigationButtonCreated ()Lkotlin/jvm/functions/Function1; + public static final fun getNavigationBarHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; + public static final fun setHookNavigationButtonCreated (Lkotlin/jvm/functions/Function1;)V +} + +public final class app/revanced/patches/youtube/utils/pip/PiPStateHookPatchKt { + public static final fun getPipStateHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/playercontrols/PlayerControlsPatchKt { + public static field changeVisibilityMethod Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod; + public static field changeVisibilityNegatedImmediatelyMethod Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod; + public static field initializeBottomControlButtonMethod Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod; + public static field initializeTopControlButtonMethod Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod; + public static final fun getChangeVisibilityMethod ()Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod; + public static final fun getChangeVisibilityNegatedImmediatelyMethod ()Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod; + public static final fun getInitializeBottomControlButtonMethod ()Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod; + public static final fun getInitializeTopControlButtonMethod ()Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod; + public static final fun getPlayerControlsPatch ()Lapp/revanced/patcher/patch/ResourcePatch; + public static final fun hookBottomControlButton (Ljava/lang/String;)V + public static final fun hookTopControlButton (Ljava/lang/String;)V + public static final fun setChangeVisibilityMethod (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;)V + public static final fun setChangeVisibilityNegatedImmediatelyMethod (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;)V + public static final fun setInitializeBottomControlButtonMethod (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;)V + public static final fun setInitializeTopControlButtonMethod (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;)V +} + +public final class app/revanced/patches/youtube/utils/playertype/PlayerTypeHookPatchKt { + public static final fun getPlayerTypeHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/playservice/VersionCheckPatchKt { + public static final fun getVersionCheckPatch ()Lapp/revanced/patcher/patch/ResourcePatch; + public static final fun is_18_31_or_greater ()Z + public static final fun is_18_34_or_greater ()Z + public static final fun is_18_39_or_greater ()Z + public static final fun is_18_42_or_greater ()Z + public static final fun is_18_49_or_greater ()Z + public static final fun is_19_02_or_greater ()Z + public static final fun is_19_15_or_greater ()Z + public static final fun is_19_23_or_greater ()Z + public static final fun is_19_25_or_greater ()Z + public static final fun is_19_28_or_greater ()Z + public static final fun is_19_32_or_greater ()Z + public static final fun is_19_44_or_greater ()Z +} + +public final class app/revanced/patches/youtube/utils/recyclerview/BottomSheetRecyclerViewPatchKt { + public static final fun bottomSheetRecyclerViewHook (Ljava/lang/String;)V + public static final fun getBottomSheetRecyclerViewPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/resourceid/SharedResourceIdPatchKt { + public static final fun getAccountSwitcherAccessibility ()J + public static final fun getActionBarRingo ()J + public static final fun getActionBarRingoBackground ()J + public static final fun getActionBarSearchResultsViewMic ()J + public static final fun getAdAttribution ()J + public static final fun getAppRelatedEndScreenResults ()J + public static final fun getAppearance ()J + public static final fun getAutoNavPreviewStub ()J + public static final fun getAutoNavToggle ()J + public static final fun getBackgroundCategory ()J + public static final fun getBadgeLabel ()J + public static final fun getBar ()J + public static final fun getBarContainerHeight ()J + public static final fun getBottomBarContainer ()J + public static final fun getBottomSheetFooterText ()J + public static final fun getBottomSheetRecyclerView ()J + public static final fun getBottomUiContainerStub ()J + public static final fun getCaptionToggleContainer ()J + public static final fun getCastMediaRouteButton ()J + public static final fun getCfFullscreenButton ()J + public static final fun getChannelListSubMenu ()J + public static final fun getCompactLink ()J + public static final fun getCompactListItem ()J + public static final fun getComponentLongClickListener ()J + public static final fun getContentPill ()J + public static final fun getControlsLayoutStub ()J + public static final fun getDarkBackground ()J + public static final fun getDarkSplashAnimation ()J + public static final fun getDesignBottomSheet ()J + public static final fun getDonationCompanion ()J + public static final fun getDrawerContentView ()J + public static final fun getDrawerResults ()J + public static final fun getEasySeekEduContainer ()J + public static final fun getEditSettingsAction ()J + public static final fun getEmojiPickerIcon ()J + public static final fun getEndScreenElementLayoutCircle ()J + public static final fun getEndScreenElementLayoutIcon ()J + public static final fun getEndScreenElementLayoutVideo ()J + public static final fun getExpandButtonDown ()J + public static final fun getFab ()J + public static final fun getFadeDurationFast ()J + public static final fun getFilterBarHeight ()J + public static final fun getFloatyBarTopMargin ()J + public static final fun getFullScreenButton ()J + public static final fun getFullScreenEngagementOverlay ()J + public static final fun getFullScreenEngagementPanel ()J + public static final fun getHorizontalCardList ()J + public static final fun getImageOnlyTab ()J + public static final fun getInlineTimeBarColorizedBarPlayedColorDark ()J + public static final fun getInlineTimeBarPlayedNotHighlightedColor ()J + public static final fun getInsetOverlayViewLayout ()J + public static final fun getInterstitialsContainer ()J + public static final fun getMenuItemView ()J + public static final fun getMetaPanel ()J + public static final fun getModernMiniPlayerClose ()J + public static final fun getModernMiniPlayerExpand ()J + public static final fun getModernMiniPlayerForwardButton ()J + public static final fun getModernMiniPlayerRewindButton ()J + public static final fun getMusicAppDeeplinkButtonView ()J + public static final fun getNotice ()J + public static final fun getNotificationBigPictureIconWidth ()J + public static final fun getOfflineActionsVideoDeletedUndoSnackbarText ()J + public static final fun getPlayerCollapseButton ()J + public static final fun getPlayerVideoTitleView ()J + public static final fun getPosterArtWidthDefault ()J + public static final fun getQualityAuto ()J + public static final fun getQuickActionsElementContainer ()J + public static final fun getReelDynRemix ()J + public static final fun getReelDynShare ()J + public static final fun getReelFeedbackLike ()J + public static final fun getReelFeedbackPause ()J + public static final fun getReelFeedbackPlay ()J + public static final fun getReelForcedMuteButton ()J + public static final fun getReelPlayerFooter ()J + public static final fun getReelPlayerRightPivotV2Size ()J + public static final fun getReelRightDislikeIcon ()J + public static final fun getReelRightLikeIcon ()J + public static final fun getReelTimeBarPlayedColor ()J + public static final fun getReelVodTimeStampsContainer ()J + public static final fun getReelWatchPlayer ()J + public static final fun getRelatedChipCloudMargin ()J + public static final fun getRightComment ()J + public static final fun getScrimOverlay ()J + public static final fun getScrubbing ()J + public static final fun getSeekEasyHorizontalTouchOffsetToStartScrubbing ()J + public static final fun getSeekUndoEduOverlayStub ()J + public static final fun getSlidingDialogAnimation ()J + public static final fun getSubtitleMenuSettingsFooterInfo ()J + public static final fun getSuggestedAction ()J + public static final fun getTapBloomView ()J + public static final fun getTitleAnchor ()J + public static final fun getToolTipContentView ()J + public static final fun getTotalTime ()J + public static final fun getTouchArea ()J + public static final fun getVarispeedUnavailableTitle ()J + public static final fun getVideoQualityBottomSheet ()J + public static final fun getVideoQualityUnavailableAnnouncement ()J + public static final fun getVideoZoomSnapIndicator ()J + public static final fun getVoiceSearch ()J + public static final fun getYouTubeControlsOverlaySubtitleButton ()J + public static final fun getYouTubeLogo ()J + public static final fun getYtOutlinePictureInPictureWhite ()J + public static final fun getYtOutlineVideoCamera ()J + public static final fun getYtOutlineXWhite ()J + public static final fun getYtPremiumWordMarkHeader ()J + public static final fun getYtWordMarkHeader ()J +} + +public final class app/revanced/patches/youtube/utils/returnyoutubedislike/ReturnYouTubeDislikePatchKt { + public static final fun getReturnYouTubeDislikePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/returnyoutubedislike/Vote : java/lang/Enum { + public static final field DISLIKE Lapp/revanced/patches/youtube/utils/returnyoutubedislike/Vote; + public static final field LIKE Lapp/revanced/patches/youtube/utils/returnyoutubedislike/Vote; + public static final field REMOVE_LIKE Lapp/revanced/patches/youtube/utils/returnyoutubedislike/Vote; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getValue ()I + public static fun valueOf (Ljava/lang/String;)Lapp/revanced/patches/youtube/utils/returnyoutubedislike/Vote; + public static fun values ()[Lapp/revanced/patches/youtube/utils/returnyoutubedislike/Vote; +} + +public final class app/revanced/patches/youtube/utils/returnyoutubeusername/ReturnYouTubeUsernamePatchKt { + public static final fun getReturnYouTubeUsernamePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/settings/SettingsPatchKt { + public static final fun getSettingsPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/utils/sponsorblock/SponsorBlockPatchKt { + public static final fun getSponsorBlockBytecodePatch ()Lapp/revanced/patcher/patch/BytecodePatch; + public static final fun getSponsorBlockPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/utils/toolbar/ToolBarHookPatchKt { + public static final fun getToolBarHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/utils/trackingurlhook/TrackingUrlHookPatchKt { + public static final fun getTrackingUrlHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/video/information/FingerprintsKt { + public static final fun indexOfPlayerResponseModelInterfaceInstruction (Lcom/android/tools/smali/dexlib2/iface/Method;)I +} + +public final class app/revanced/patches/youtube/video/information/VideoInformationPatchKt { + public static final fun getVideoInformationPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/video/playback/VideoPlaybackPatchKt { + public static final fun getVideoPlaybackPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public abstract class app/revanced/patches/youtube/video/playerresponse/Hook { + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun toString ()Ljava/lang/String; +} + +public final class app/revanced/patches/youtube/video/playerresponse/Hook$PlayerParameter : app/revanced/patches/youtube/video/playerresponse/Hook { + public fun (Ljava/lang/String;)V +} + +public final class app/revanced/patches/youtube/video/playerresponse/Hook$PlayerParameterBeforeVideoId : app/revanced/patches/youtube/video/playerresponse/Hook { + public fun (Ljava/lang/String;)V +} + +public final class app/revanced/patches/youtube/video/playerresponse/Hook$VideoId : app/revanced/patches/youtube/video/playerresponse/Hook { + public fun (Ljava/lang/String;)V +} + +public final class app/revanced/patches/youtube/video/playerresponse/PlayerResponseMethodHookPatchKt { + public static final fun addPlayerResponseMethodHook (Lapp/revanced/patches/youtube/video/playerresponse/Hook;)V + public static final fun getPlayerResponseMethodHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/video/videoid/VideoIdPatchKt { + public static final fun getVideoIdPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/util/BytecodeUtilsKt { + public static final field REGISTER_TEMPLATE_REPLACEMENT Ljava/lang/String; + public static final fun addStaticFieldToExtension (Lapp/revanced/patcher/patch/BytecodePatchContext;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V + public static synthetic fun addStaticFieldToExtension$default (Lapp/revanced/patcher/patch/BytecodePatchContext;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)V + public static final fun cloneMutable (Lcom/android/tools/smali/dexlib2/iface/Method;IZLjava/lang/String;ILjava/util/List;Ljava/lang/String;)Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod; + public static synthetic fun cloneMutable$default (Lcom/android/tools/smali/dexlib2/iface/Method;IZLjava/lang/String;ILjava/util/List;Ljava/lang/String;ILjava/lang/Object;)Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod; + public static final fun containsLiteralInstruction (Lcom/android/tools/smali/dexlib2/iface/Method;J)Z + public static final fun findInstructionIndicesReversed (Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/Opcode;)Ljava/util/List; + public static final fun findInstructionIndicesReversed (Lcom/android/tools/smali/dexlib2/iface/Method;Lkotlin/jvm/functions/Function1;)Ljava/util/List; + public static final fun findInstructionIndicesReversedOrThrow (Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/Opcode;)Ljava/util/List; + public static final fun findInstructionIndicesReversedOrThrow (Lcom/android/tools/smali/dexlib2/iface/Method;Lkotlin/jvm/functions/Function1;)Ljava/util/List; + public static final fun findMethodOrThrow (Lapp/revanced/patcher/patch/BytecodePatchContext;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod; + public static synthetic fun findMethodOrThrow$default (Lapp/revanced/patcher/patch/BytecodePatchContext;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod; + public static final fun findMethodsOrThrow (Lapp/revanced/patcher/patch/BytecodePatchContext;Ljava/lang/String;)Ljava/util/Set; + public static final fun findMutableMethodOf (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableClass;Lcom/android/tools/smali/dexlib2/iface/Method;)Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod; + public static final fun forEachLiteralValueInstruction (Lapp/revanced/patcher/patch/BytecodePatchContext;JLkotlin/jvm/functions/Function2;)V + public static final fun getFiveRegisters (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;I)Ljava/lang/String; + public static final fun getWalkerMethod (Lapp/revanced/patcher/patch/BytecodePatchContext;Lapp/revanced/patcher/Match;I)Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod; + public static final fun getWalkerMethod (Lapp/revanced/patcher/patch/BytecodePatchContext;Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;I)Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod; + public static final fun indexOfFirstInstruction (Lcom/android/tools/smali/dexlib2/iface/Method;ILcom/android/tools/smali/dexlib2/Opcode;)I + public static final fun indexOfFirstInstruction (Lcom/android/tools/smali/dexlib2/iface/Method;ILkotlin/jvm/functions/Function1;)I + public static final fun indexOfFirstInstruction (Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/Opcode;)I + public static synthetic fun indexOfFirstInstruction$default (Lcom/android/tools/smali/dexlib2/iface/Method;ILcom/android/tools/smali/dexlib2/Opcode;ILjava/lang/Object;)I + public static synthetic fun indexOfFirstInstruction$default (Lcom/android/tools/smali/dexlib2/iface/Method;ILkotlin/jvm/functions/Function1;ILjava/lang/Object;)I + public static final fun indexOfFirstInstructionOrThrow (Lcom/android/tools/smali/dexlib2/iface/Method;ILcom/android/tools/smali/dexlib2/Opcode;)I + public static final fun indexOfFirstInstructionOrThrow (Lcom/android/tools/smali/dexlib2/iface/Method;ILkotlin/jvm/functions/Function1;)I + public static final fun indexOfFirstInstructionOrThrow (Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/Opcode;)I + public static synthetic fun indexOfFirstInstructionOrThrow$default (Lcom/android/tools/smali/dexlib2/iface/Method;ILcom/android/tools/smali/dexlib2/Opcode;ILjava/lang/Object;)I + public static synthetic fun indexOfFirstInstructionOrThrow$default (Lcom/android/tools/smali/dexlib2/iface/Method;ILkotlin/jvm/functions/Function1;ILjava/lang/Object;)I + public static final fun indexOfFirstInstructionReversed (Lcom/android/tools/smali/dexlib2/iface/Method;Ljava/lang/Integer;Lcom/android/tools/smali/dexlib2/Opcode;)I + public static final fun indexOfFirstInstructionReversed (Lcom/android/tools/smali/dexlib2/iface/Method;Ljava/lang/Integer;Lkotlin/jvm/functions/Function1;)I + public static synthetic fun indexOfFirstInstructionReversed$default (Lcom/android/tools/smali/dexlib2/iface/Method;Ljava/lang/Integer;Lcom/android/tools/smali/dexlib2/Opcode;ILjava/lang/Object;)I + public static synthetic fun indexOfFirstInstructionReversed$default (Lcom/android/tools/smali/dexlib2/iface/Method;Ljava/lang/Integer;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)I + public static final fun indexOfFirstInstructionReversedOrThrow (Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/Opcode;)I + public static final fun indexOfFirstInstructionReversedOrThrow (Lcom/android/tools/smali/dexlib2/iface/Method;Ljava/lang/Integer;Lcom/android/tools/smali/dexlib2/Opcode;)I + public static final fun indexOfFirstInstructionReversedOrThrow (Lcom/android/tools/smali/dexlib2/iface/Method;Ljava/lang/Integer;Lkotlin/jvm/functions/Function1;)I + public static synthetic fun indexOfFirstInstructionReversedOrThrow$default (Lcom/android/tools/smali/dexlib2/iface/Method;Ljava/lang/Integer;Lcom/android/tools/smali/dexlib2/Opcode;ILjava/lang/Object;)I + public static synthetic fun indexOfFirstInstructionReversedOrThrow$default (Lcom/android/tools/smali/dexlib2/iface/Method;Ljava/lang/Integer;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)I + public static final fun indexOfFirstLiteralInstruction (Lcom/android/tools/smali/dexlib2/iface/Method;J)I + public static final fun indexOfFirstLiteralInstructionOrThrow (Lcom/android/tools/smali/dexlib2/iface/Method;J)I + public static final fun indexOfFirstLiteralInstructionReversed (Lcom/android/tools/smali/dexlib2/iface/Method;J)I + public static final fun indexOfFirstLiteralInstructionReversedOrThrow (Lcom/android/tools/smali/dexlib2/iface/Method;J)I + public static final fun indexOfFirstResourceId (Lcom/android/tools/smali/dexlib2/iface/Method;Ljava/lang/String;)I + public static final fun indexOfFirstResourceIdOrThrow (Lcom/android/tools/smali/dexlib2/iface/Method;Ljava/lang/String;)I + public static final fun indexOfFirstStringInstruction (Lcom/android/tools/smali/dexlib2/iface/Method;Ljava/lang/String;)I + public static final fun indexOfFirstStringInstructionOrThrow (Lcom/android/tools/smali/dexlib2/iface/Method;Ljava/lang/String;)I + public static final fun injectHideViewCall (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;IILjava/lang/String;Ljava/lang/String;)V + public static final fun injectLiteralInstructionViewCall (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;JLjava/lang/String;)V + public static final fun literal (Lapp/revanced/patcher/FingerprintBuilder;Lkotlin/jvm/functions/Function0;)V + public static final fun or (ILcom/android/tools/smali/dexlib2/AccessFlags;)I + public static final fun or (Lcom/android/tools/smali/dexlib2/AccessFlags;I)I + public static final fun or (Lcom/android/tools/smali/dexlib2/AccessFlags;Lcom/android/tools/smali/dexlib2/AccessFlags;)I + public static final fun parametersEqual (Ljava/lang/Iterable;Ljava/lang/Iterable;)Z + public static final fun replaceLiteralInstructionCall (Lapp/revanced/patcher/patch/BytecodePatchContext;JLjava/lang/String;)V + public static final fun returnEarly (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Z)V + public static synthetic fun returnEarly$default (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;ZILjava/lang/Object;)V + public static final fun transformMethods (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableClass;Lkotlin/jvm/functions/Function1;)V + public static final fun traverseClassHierarchy (Lapp/revanced/patcher/patch/BytecodePatchContext;Lapp/revanced/patcher/util/proxy/mutableTypes/MutableClass;Lkotlin/jvm/functions/Function1;)V + public static final fun updatePatchStatus (Lapp/revanced/patcher/patch/BytecodePatchContext;Ljava/lang/String;Ljava/lang/String;)V +} + +public final class app/revanced/util/ResourceGroup { + public fun (Ljava/lang/String;[Ljava/lang/String;)V + public final fun getResourceDirectoryName ()Ljava/lang/String; + public final fun getResources ()[Ljava/lang/String; +} + +public final class app/revanced/util/ResourceUtilsKt { + public static final fun addEntryValues (Lapp/revanced/patcher/patch/ResourcePatchContext;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V + public static synthetic fun addEntryValues$default (Lapp/revanced/patcher/patch/ResourcePatchContext;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)V + public static final fun adoptChild (Lorg/w3c/dom/Node;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public static final fun appendAppVersion (Lapp/revanced/patcher/patch/ResourcePatchContext;Ljava/lang/String;)V + public static final fun asSequence (Lorg/w3c/dom/NodeList;)Lkotlin/sequences/Sequence; + public static final fun childElementsSequence (Lorg/w3c/dom/Node;)Lkotlin/sequences/Sequence; + public static final fun cloneNodes (Lorg/w3c/dom/Node;Lorg/w3c/dom/Node;)V + public static final fun copyFile (Lapp/revanced/patcher/patch/ResourcePatchContext;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;)Z + public static final fun copyResources (Lapp/revanced/patcher/patch/ResourcePatchContext;Ljava/lang/String;[Lapp/revanced/util/ResourceGroup;Z)V + public static synthetic fun copyResources$default (Lapp/revanced/patcher/patch/ResourcePatchContext;Ljava/lang/String;[Lapp/revanced/util/ResourceGroup;ZILjava/lang/Object;)V + public static final fun copyXmlNode (Lapp/revanced/patcher/patch/ResourcePatchContext;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lkotlin/Unit; + public static final fun copyXmlNode (Ljava/lang/String;Lapp/revanced/patcher/util/Document;Lapp/revanced/patcher/util/Document;)Ljava/lang/AutoCloseable; + public static final fun doRecursively (Lorg/w3c/dom/Node;Lkotlin/jvm/functions/Function1;)V + public static final fun forEachChildElement (Lorg/w3c/dom/Node;Lkotlin/jvm/functions/Function1;)V + public static final fun getResourceGroup (Ljava/util/List;[Ljava/lang/String;)Ljava/util/List; + public static final fun getStringOptionValue (Lapp/revanced/patcher/patch/Patch;Ljava/lang/String;)Lapp/revanced/patcher/patch/Option; + public static final fun insertFirst (Lorg/w3c/dom/Node;Lorg/w3c/dom/Node;)V + public static final fun insertNode (Lorg/w3c/dom/Node;Ljava/lang/String;Lorg/w3c/dom/Node;Lkotlin/jvm/functions/Function1;)V + public static final fun iterateXmlNodeChildren (Lapp/revanced/patcher/patch/ResourcePatchContext;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public static final fun lowerCaseOrThrow (Lapp/revanced/patcher/patch/Option;)Ljava/lang/String; + public static final fun removeOverlayBackground (Lapp/revanced/patcher/patch/ResourcePatchContext;[Ljava/lang/String;[Ljava/lang/String;)V + public static final fun removeStringsElements (Lapp/revanced/patcher/patch/ResourcePatchContext;[Ljava/lang/String;)V + public static final fun removeStringsElements (Lapp/revanced/patcher/patch/ResourcePatchContext;[Ljava/lang/String;[Ljava/lang/String;)V + public static final fun startsWithAny (Ljava/lang/String;[Ljava/lang/String;)Z + public static final fun underBarOrThrow (Lapp/revanced/patcher/patch/Option;)Ljava/lang/String; + public static final fun valueOrThrow (Lapp/revanced/patcher/patch/Option;)I + public static final fun valueOrThrow (Lapp/revanced/patcher/patch/Option;)Ljava/lang/String; +} + +public final class app/revanced/util/fingerprint/LegacyFingerprintKt { + public static final fun injectLiteralInstructionBooleanCall (Lapp/revanced/patcher/patch/BytecodePatchContext;Lkotlin/Pair;JLjava/lang/String;)V + public static final fun injectLiteralInstructionViewCall (Lapp/revanced/patcher/patch/BytecodePatchContext;Lkotlin/Pair;JLjava/lang/String;)V +} + diff --git a/patches/build.gradle.kts b/patches/build.gradle.kts new file mode 100644 index 0000000000..27f36e191a --- /dev/null +++ b/patches/build.gradle.kts @@ -0,0 +1,55 @@ +group = "app.revanced" + +patches { + about { + name = "ReVanced Patches" + description = "Patches for ReVanced" + source = "git@github.com:revanced/revanced-patches.git" + author = "ReVanced" + contact = "contact@revanced.app" + website = "https://revanced.app" + license = "GNU General Public License v3.0" + } +} + +dependencies { + // Used by JsonGenerator. + implementation(libs.gson) +} + +tasks { + jar { + exclude("app/revanced/generator") + } + register("generatePatchesFiles") { + description = "Generate patches files" + + dependsOn(build) + + classpath = sourceSets["main"].runtimeClasspath + mainClass.set("app.revanced.generator.MainKt") + } + // Used by gradle-semantic-release-plugin. + publish { + dependsOn("generatePatchesFiles") + } +} + +kotlin { + compilerOptions { + freeCompilerArgs = listOf("-Xcontext-receivers") + } +} + +publishing { + repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/inotia00/revanced-patches") + credentials { + username = System.getenv("GITHUB_ACTOR") + password = System.getenv("GITHUB_TOKEN") + } + } + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/generator/JsonPatchesFileGenerator.kt b/patches/src/main/kotlin/app/revanced/generator/JsonPatchesFileGenerator.kt new file mode 100644 index 0000000000..dab126698d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/generator/JsonPatchesFileGenerator.kt @@ -0,0 +1,62 @@ +package app.revanced.generator + +import app.revanced.patcher.patch.Package +import app.revanced.patcher.patch.Patch +import com.google.gson.GsonBuilder +import java.io.File + +internal class JsonPatchesFileGenerator : PatchesFileGenerator { + override fun generate(patches: Set>) { + val patchesJson = File("../patches.json") + patches.sortedBy { it.name }.map { + JsonPatch( + it.name!!, + it.description, + it.compatiblePackages, + it.use, + it.options.values.map { option -> + JsonPatch.Option( + option.key, + option.default, + option.values, + option.title, + option.description, + option.required, + ) + }, + ) + }.let { + patchesJson.writeText(GsonBuilder().serializeNulls().create().toJson(it)) + } + + patchesJson.writeText( + patchesJson.readText() + .replace( + "\"first\":", + "\"name\":" + ).replace( + "\"second\":", + "\"versions\":" + ) + ) + } + + @Suppress("unused") + private class JsonPatch( + val name: String? = null, + val description: String? = null, + val compatiblePackages: Set? = null, + val use: Boolean = true, + val options: List

\n") + appendLine(tableHeader) + patches.sortedBy { it.name }.forEach { patch -> + val supportedVersionArray = + patch.compatiblePackages?.lastOrNull()?.second + val supportedVersion = + if (supportedVersionArray?.isNotEmpty() == true) { + val minVersion = supportedVersionArray.elementAt(0) + val maxVersion = + supportedVersionArray.elementAt(supportedVersionArray.size - 1) + if (minVersion == maxVersion) + maxVersion + else + "$minVersion ~ $maxVersion" + } else if (exception.containsKey(pkg)) + exception[pkg] + "+" + else + "ALL" + + appendLine( + "| `${patch.name}` " + + "| ${patch.description} " + + "| $supportedVersion |" + ) + } + appendLine("
\n") + } + } + + // copy the contents of the temp file to 'README.md' + StringBuilder(readMeFile.readText()) + .replace(Regex("\\{\\{\\s?table\\s?}}"), output.toString()) + .let(readMeFile::writeText) + } + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/all/misc/versioncode/ChangeVersionCodePatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/versioncode/ChangeVersionCodePatch.kt new file mode 100644 index 0000000000..370200a11b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/versioncode/ChangeVersionCodePatch.kt @@ -0,0 +1,70 @@ +package app.revanced.patches.all.misc.versioncode + +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.booleanOption +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.util.getNode +import app.revanced.util.valueOrThrow +import org.w3c.dom.Element + +private const val MAX_VALUE = Int.MAX_VALUE.toString() + +@Suppress("unused") +val changeVersionCodePatch = resourcePatch( + name = "Change version code", + description = "Changes the version code of the app to the value specified in patch options. " + + "Except when mounting, this can prevent app stores from updating the app and allow " + + "the app to be installed over an existing installation that has a higher version code. " + + "By default, the highest version code is set.", + use = false, +) { + val changeVersionCode by booleanOption( + key = "changeVersionCode", + default = false, + title = "Change version code", + description = "Changes the version code of the app.", + required = true + ) + + val versionCodeOption = stringOption( + key = "versionCode", + default = MAX_VALUE, + values = mapOf( + "Lowest" to "1", + "Highest" to MAX_VALUE, + ), + title = "Version code", + description = "The version code to use. (1 ~ $MAX_VALUE)", + required = true, + ) + + execute { + if (changeVersionCode == false) { + println("INFO: Version code will remain unchanged as 'ChangeVersionCode' is false.") + return@execute + } + fun throwVersionCodeException(versionCodeString: String): PatchException = + PatchException( + "Invalid versionCode: $versionCodeString, " + + "Version code should be larger than 1 and smaller than $MAX_VALUE." + ) + val versionCodeString = versionCodeOption.valueOrThrow() + val versionCode: Int + + try { + versionCode = Integer.parseInt(versionCodeString) + } catch (e: NumberFormatException) { + throw throwVersionCodeException(versionCodeString) + } + + if (versionCode < 1) { + throw throwVersionCodeException(versionCodeString) + } + + document("AndroidManifest.xml").use { document -> + val manifestElement = document.getNode("manifest") as Element + manifestElement.setAttribute("android:versionCode", "$versionCode") + } + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/account/components/AccountComponentsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/account/components/AccountComponentsPatch.kt new file mode 100644 index 0000000000..f15dc184b3 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/account/components/AccountComponentsPatch.kt @@ -0,0 +1,160 @@ +package app.revanced.patches.music.account.components + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.ACCOUNT_CLASS_DESCRIPTOR +import app.revanced.patches.music.utils.patch.PatchList.HIDE_ACCOUNT_COMPONENTS +import app.revanced.patches.music.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addPreferenceWithIntent +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +@Suppress("unused") +val accountComponentsPatch = bytecodePatch( + HIDE_ACCOUNT_COMPONENTS.title, + HIDE_ACCOUNT_COMPONENTS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + sharedResourceIdPatch, + settingsPatch, + ) + + execute { + + // region patch for hide account menu + + menuEntryFingerprint.methodOrThrow().apply { + val textIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "setText" + } + val viewIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "addView" + } + + val textRegister = getInstruction(textIndex).registerD + val viewRegister = getInstruction(viewIndex).registerD + + addInstruction( + textIndex + 1, + "invoke-static {v$textRegister, v$viewRegister}, " + + "$ACCOUNT_CLASS_DESCRIPTOR->hideAccountMenu(Ljava/lang/CharSequence;Landroid/view/View;)V" + ) + } + + // endregion + + // region patch for hide handle + + // account menu + accountSwitcherAccessibilityLabelFingerprint.methodOrThrow().apply { + val textColorIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "setTextColor" + } + val setVisibilityIndex = indexOfFirstInstructionOrThrow(textColorIndex) { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "setVisibility" + } + val textViewInstruction = + getInstruction(setVisibilityIndex) + + replaceInstruction( + setVisibilityIndex, + "invoke-static {v${textViewInstruction.registerC}, v${textViewInstruction.registerD}}, " + + "$ACCOUNT_CLASS_DESCRIPTOR->hideHandle(Landroid/widget/TextView;I)V" + ) + } + + // account switcher + namesInactiveAccountThumbnailSizeFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = it.patternMatch!!.startIndex + val targetRegister = getInstruction(targetIndex).registerA + + addInstructions( + targetIndex, """ + invoke-static {v$targetRegister}, $ACCOUNT_CLASS_DESCRIPTOR->hideHandle(Z)Z + move-result v$targetRegister + """ + ) + } + } + + // endregion + + // region patch for hide terms container + + termsOfServiceFingerprint.methodOrThrow().apply { + val insertIndex = indexOfFirstInstructionOrThrow { + val reference = getReference() + opcode == Opcode.INVOKE_VIRTUAL && + reference?.name == "setVisibility" && + reference.definingClass.endsWith("/PrivacyTosFooter;") + } + val visibilityRegister = + getInstruction(insertIndex).registerD + + addInstruction( + insertIndex + 1, + "const/4 v$visibilityRegister, 0x0" + ) + addInstructions( + insertIndex, """ + invoke-static {}, $ACCOUNT_CLASS_DESCRIPTOR->hideTermsContainer()I + move-result v$visibilityRegister + """ + ) + + } + + // endregion + + addSwitchPreference( + CategoryType.ACCOUNT, + "revanced_hide_account_menu", + "false" + ) + addPreferenceWithIntent( + CategoryType.ACCOUNT, + "revanced_hide_account_menu_filter_strings", + "revanced_hide_account_menu" + ) + addSwitchPreference( + CategoryType.ACCOUNT, + "revanced_hide_account_menu_empty_component", + "false", + "revanced_hide_account_menu" + ) + addSwitchPreference( + CategoryType.ACCOUNT, + "revanced_hide_handle", + "true" + ) + addSwitchPreference( + CategoryType.ACCOUNT, + "revanced_hide_terms_container", + "false" + ) + + updatePatchStatus(HIDE_ACCOUNT_COMPONENTS) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/account/components/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/account/components/Fingerprints.kt new file mode 100644 index 0000000000..8af7146110 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/account/components/Fingerprints.kt @@ -0,0 +1,47 @@ +package app.revanced.patches.music.account.components + +import app.revanced.patches.music.utils.resourceid.accountSwitcherAccessibility +import app.revanced.patches.music.utils.resourceid.menuEntry +import app.revanced.patches.music.utils.resourceid.namesInactiveAccountThumbnailSize +import app.revanced.patches.music.utils.resourceid.tosFooter +import app.revanced.util.fingerprint.legacyFingerprint +import com.android.tools.smali.dexlib2.Opcode + +internal val accountSwitcherAccessibilityLabelFingerprint = legacyFingerprint( + name = "accountSwitcherAccessibilityLabelFingerprint", + returnType = "V", + parameters = listOf("L", "Ljava/lang/Object;"), + literals = listOf(accountSwitcherAccessibility) +) + +internal val menuEntryFingerprint = legacyFingerprint( + name = "menuEntryFingerprint", + returnType = "V", + literals = listOf(menuEntry) +) + +internal val namesInactiveAccountThumbnailSizeFingerprint = legacyFingerprint( + name = "namesInactiveAccountThumbnailSizeFingerprint", + returnType = "V", + parameters = listOf("L", "Ljava/lang/Object;"), + opcodes = listOf( + Opcode.IF_NEZ, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.GOTO, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IF_EQZ + ), + literals = listOf(namesInactiveAccountThumbnailSize) +) + +internal val termsOfServiceFingerprint = legacyFingerprint( + name = "termsOfServiceFingerprint", + returnType = "Landroid/view/View;", + literals = listOf(tosFooter) +) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/actionbar/components/ActionBarComponentsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/actionbar/components/ActionBarComponentsPatch.kt new file mode 100644 index 0000000000..3fcca843d8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/actionbar/components/ActionBarComponentsPatch.kt @@ -0,0 +1,193 @@ +package app.revanced.patches.music.actionbar.components + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.ACTIONBAR_CLASS_DESCRIPTOR +import app.revanced.patches.music.utils.patch.PatchList.HIDE_ACTION_BAR_COMPONENTS +import app.revanced.patches.music.utils.resourceid.likeDislikeContainer +import app.revanced.patches.music.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addPreferenceWithIntent +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.music.video.information.videoInformationPatch +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import kotlin.math.min + +@Suppress("unused") +val actionBarComponentsPatch = bytecodePatch( + HIDE_ACTION_BAR_COMPONENTS.title, + HIDE_ACTION_BAR_COMPONENTS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + settingsPatch, + sharedResourceIdPatch, + videoInformationPatch, + ) + + execute { + + actionBarComponentFingerprint.matchOrThrow().let { + it.method.apply { + // hook download button + val addViewIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "addView" + } + val addViewRegister = + getInstruction(addViewIndex).registerD + + addInstruction( + addViewIndex + 1, + "invoke-static {v$addViewRegister}, $ACTIONBAR_CLASS_DESCRIPTOR->inAppDownloadButtonOnClick(Landroid/view/View;)V" + ) + + // hide action button label + val noLabelIndex = indexOfFirstInstructionOrThrow { + val reference = (this as? ReferenceInstruction)?.reference.toString() + opcode == Opcode.INVOKE_DIRECT && + reference.endsWith("(Landroid/content/Context;)V") && + !reference.contains("Lcom/google/android/libraries/youtube/common/ui/YouTubeButton;") + } - 2 + val replaceIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_DIRECT && + (this as? ReferenceInstruction)?.reference.toString() + .endsWith("Lcom/google/android/libraries/youtube/common/ui/YouTubeButton;->(Landroid/content/Context;)V") + } - 2 + val replaceInstruction = getInstruction(replaceIndex) + val replaceReference = getInstruction(replaceIndex).reference + + addInstructionsWithLabels( + replaceIndex + 1, """ + invoke-static {}, $ACTIONBAR_CLASS_DESCRIPTOR->hideActionBarLabel()Z + move-result v${replaceInstruction.registerA} + if-nez v${replaceInstruction.registerA}, :hidden + iget-object v${replaceInstruction.registerA}, v${replaceInstruction.registerB}, $replaceReference + """, ExternalLabel("hidden", getInstruction(noLabelIndex)) + ) + removeInstruction(replaceIndex) + + // hide action button + val hasNextIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_INTERFACE && + getReference()?.name == "hasNext" + } + val freeRegister = min(implementation!!.registerCount - parameters.size - 2, 15) + + val spannedIndex = indexOfFirstInstructionOrThrow { + getReference()?.returnType == "Landroid/text/Spanned;" + } + val spannedRegister = + getInstruction(spannedIndex).registerC + val spannedReference = getInstruction(spannedIndex).reference + + addInstructionsWithLabels( + spannedIndex + 1, """ + invoke-static {}, $ACTIONBAR_CLASS_DESCRIPTOR->hideActionButton()Z + move-result v$freeRegister + if-nez v$freeRegister, :hidden + invoke-static {v$spannedRegister}, $spannedReference + """, ExternalLabel("hidden", getInstruction(hasNextIndex)) + ) + removeInstruction(spannedIndex) + + // set action button identifier + val buttonTypeDownloadIndex = it.patternMatch!!.startIndex + 1 + val buttonTypeDownloadRegister = + getInstruction(buttonTypeDownloadIndex).registerA + + val buttonTypeIndex = it.patternMatch!!.endIndex - 1 + val buttonTypeRegister = + getInstruction(buttonTypeIndex).registerA + + addInstruction( + buttonTypeIndex + 2, + "invoke-static {v$buttonTypeRegister}, $ACTIONBAR_CLASS_DESCRIPTOR->setButtonType(Ljava/lang/Object;)V" + ) + + addInstruction( + buttonTypeDownloadIndex, + "invoke-static {v$buttonTypeDownloadRegister}, $ACTIONBAR_CLASS_DESCRIPTOR->setButtonTypeDownload(I)V" + ) + } + } + + likeDislikeContainerFingerprint.methodOrThrow().apply { + val insertIndex = + indexOfFirstLiteralInstructionOrThrow(likeDislikeContainer) + 2 + val insertRegister = getInstruction(insertIndex).registerA + + addInstruction( + insertIndex + 1, + "invoke-static {v$insertRegister}, $ACTIONBAR_CLASS_DESCRIPTOR->hideLikeDislikeButton(Landroid/view/View;)V" + ) + } + + addSwitchPreference( + CategoryType.ACTION_BAR, + "revanced_hide_action_button_like_dislike", + "false" + ) + addSwitchPreference( + CategoryType.ACTION_BAR, + "revanced_hide_action_button_comment", + "false" + ) + addSwitchPreference( + CategoryType.ACTION_BAR, + "revanced_hide_action_button_add_to_playlist", + "false" + ) + addSwitchPreference( + CategoryType.ACTION_BAR, + "revanced_hide_action_button_download", + "false" + ) + addSwitchPreference( + CategoryType.ACTION_BAR, + "revanced_hide_action_button_share", + "false" + ) + addSwitchPreference( + CategoryType.ACTION_BAR, + "revanced_hide_action_button_radio", + "false" + ) + addSwitchPreference( + CategoryType.ACTION_BAR, + "revanced_hide_action_button_label", + "false" + ) + addSwitchPreference( + CategoryType.ACTION_BAR, + "revanced_external_downloader_action", + "false" + ) + addPreferenceWithIntent( + CategoryType.ACTION_BAR, + "revanced_external_downloader_package_name", + "revanced_external_downloader_action" + ) + + updatePatchStatus(HIDE_ACTION_BAR_COMPONENTS) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/actionbar/components/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/actionbar/components/Fingerprints.kt new file mode 100644 index 0000000000..e7e9eb934a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/actionbar/components/Fingerprints.kt @@ -0,0 +1,30 @@ +package app.revanced.patches.music.actionbar.components + +import app.revanced.patches.music.utils.resourceid.likeDislikeContainer +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val actionBarComponentFingerprint = legacyFingerprint( + name = "actionBarComponentFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "L"), + opcodes = listOf( + Opcode.AND_INT_LIT16, + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, + Opcode.IF_NEZ, + Opcode.SGET_OBJECT, + Opcode.SGET_OBJECT + ), + literals = listOf(99180L), +) + +internal val likeDislikeContainerFingerprint = legacyFingerprint( + name = "likeDislikeContainerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + literals = listOf(likeDislikeContainer) +) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/ads/general/AdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/ads/general/AdsPatch.kt new file mode 100644 index 0000000000..d967d7241e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/ads/general/AdsPatch.kt @@ -0,0 +1,190 @@ +package app.revanced.patches.music.ads.general + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.navigation.components.navigationBarComponentsPatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.ADS_PATH +import app.revanced.patches.music.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.music.utils.patch.PatchList.HIDE_ADS +import app.revanced.patches.music.utils.resourceid.buttonContainer +import app.revanced.patches.music.utils.resourceid.floatingLayout +import app.revanced.patches.music.utils.resourceid.interstitialsContainer +import app.revanced.patches.music.utils.resourceid.privacyTosFooter +import app.revanced.patches.music.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.shared.ads.baseAdsPatch +import app.revanced.patches.shared.ads.hookLithoFullscreenAds +import app.revanced.patches.shared.ads.hookNonLithoFullscreenAds +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +private const val ADS_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/AdsFilter;" + +private const val PREMIUM_PROMOTION_POP_UP_CLASS_DESCRIPTOR = + "$ADS_PATH/PremiumPromotionPatch;" + +private const val PREMIUM_PROMOTION_BANNER_CLASS_DESCRIPTOR = + "$ADS_PATH/PremiumRenewalPatch;" + +@Suppress("unused") +val adsPatch = bytecodePatch( + HIDE_ADS.title, + HIDE_ADS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + baseAdsPatch("$ADS_PATH/MusicAdsPatch;", "hideMusicAds"), + lithoFilterPatch, + navigationBarComponentsPatch, // for 'Hide upgrade button' setting + sharedResourceIdPatch, + settingsPatch, + ) + + execute { + + // region patch for hide fullscreen ads + + // non-litho view, used in some old clients + interstitialsContainerFingerprint + .methodOrThrow() + .hookNonLithoFullscreenAds(interstitialsContainer) + + // litho view, used in 'ShowDialogCommandOuterClass' in innertube + showDialogCommandFingerprint + .matchOrThrow() + .hookLithoFullscreenAds() + + // endregion + + // region patch for hide premium promotion popup + + floatingLayoutFingerprint.methodOrThrow().apply { + val targetIndex = indexOfFirstLiteralInstructionOrThrow(floatingLayout) + 2 + val targetRegister = getInstruction(targetIndex).registerA + + addInstruction( + targetIndex + 1, + "invoke-static {v$targetRegister}, $PREMIUM_PROMOTION_POP_UP_CLASS_DESCRIPTOR->hidePremiumPromotion(Landroid/view/View;)V" + ) + } + + // endregion + + // region patch for hide premium renewal banner + + notifierShelfFingerprint.methodOrThrow().apply { + val linearLayoutIndex = + indexOfFirstLiteralInstructionOrThrow(buttonContainer) + 3 + val linearLayoutRegister = + getInstruction(linearLayoutIndex).registerA + + addInstruction( + linearLayoutIndex + 1, + "invoke-static {v$linearLayoutRegister}, $PREMIUM_PROMOTION_BANNER_CLASS_DESCRIPTOR->hidePremiumRenewal(Landroid/widget/LinearLayout;)V" + ) + } + + // endregion + + // region patch for hide get premium + + // get premium button at the top of the account switching menu + getPremiumTextViewFingerprint.matchOrThrow().let { + it.method.apply { + val insertIndex = it.patternMatch!!.startIndex + val register = getInstruction(insertIndex).registerA + + addInstruction( + insertIndex + 1, + "const/4 v$register, 0x0" + ) + } + } + + // get premium button at the bottom of the account switching menu + accountMenuFooterFingerprint.methodOrThrow().apply { + val constIndex = + indexOfFirstLiteralInstructionOrThrow(privacyTosFooter) + val walkerIndex = + indexOfFirstInstructionOrThrow(constIndex + 2, Opcode.INVOKE_VIRTUAL) + val viewIndex = indexOfFirstInstructionOrThrow(constIndex, Opcode.IGET_OBJECT) + val viewReference = + getInstruction(viewIndex).reference.toString() + + val walkerMethod = getWalkerMethod(walkerIndex) + walkerMethod.apply { + val insertIndex = indexOfFirstInstructionOrThrow { + getReference()?.toString() == viewReference + } + val nullCheckIndex = + indexOfFirstInstructionOrThrow(insertIndex - 1, Opcode.IF_NEZ) + val nullCheckRegister = + getInstruction(nullCheckIndex).registerA + + addInstruction( + nullCheckIndex, + "const/4 v$nullCheckRegister, 0x0" + ) + } + } + + addLithoFilter(ADS_FILTER_CLASS_DESCRIPTOR) + + addSwitchPreference( + CategoryType.ADS, + "revanced_hide_fullscreen_ads", + "true" + ) + addSwitchPreference( + CategoryType.ADS, + "revanced_hide_general_ads", + "true" + ) + addSwitchPreference( + CategoryType.ADS, + "revanced_hide_music_ads", + "true" + ) + addSwitchPreference( + CategoryType.ADS, + "revanced_hide_paid_promotion_label", + "true" + ) + addSwitchPreference( + CategoryType.ADS, + "revanced_hide_premium_promotion", + "true" + ) + addSwitchPreference( + CategoryType.ADS, + "revanced_hide_premium_renewal", + "true" + ) + addSwitchPreference( + CategoryType.ADS, + "revanced_hide_promotion_alert_banner", + "true" + ) + + updatePatchStatus(HIDE_ADS) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/ads/general/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/ads/general/Fingerprints.kt new file mode 100644 index 0000000000..1c06ae47dd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/ads/general/Fingerprints.kt @@ -0,0 +1,87 @@ +package app.revanced.patches.music.ads.general + +import app.revanced.patches.music.utils.resourceid.buttonContainer +import app.revanced.patches.music.utils.resourceid.floatingLayout +import app.revanced.patches.music.utils.resourceid.interstitialsContainer +import app.revanced.patches.music.utils.resourceid.musicNotifierShelf +import app.revanced.patches.music.utils.resourceid.privacyTosFooter +import app.revanced.patches.music.utils.resourceid.slidingDialogAnimation +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val accountMenuFooterFingerprint = legacyFingerprint( + name = "accountMenuFooterFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + opcodes = listOf( + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + Opcode.INVOKE_VIRTUAL, + Opcode.IGET_OBJECT + ), + literals = listOf(privacyTosFooter) +) + +internal val floatingLayoutFingerprint = legacyFingerprint( + name = "floatingLayoutFingerprint", + returnType = "Landroid/view/View;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(floatingLayout) +) + +internal val getPremiumTextViewFingerprint = legacyFingerprint( + name = "getPremiumTextViewFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf( + Opcode.IGET_BOOLEAN, + Opcode.CONST_4, + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_STATIC + ), + strings = listOf("FEmusic_history") +) + +internal val interstitialsContainerFingerprint = legacyFingerprint( + name = "interstitialsContainerFingerprint", + returnType = "V", + strings = listOf("overlay_controller_param"), + literals = listOf(interstitialsContainer) +) + +internal val notifierShelfFingerprint = legacyFingerprint( + name = "notifierShelfFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + literals = listOf(musicNotifierShelf, buttonContainer) +) + +internal val showDialogCommandFingerprint = legacyFingerprint( + name = "showDialogCommandFingerprint", + returnType = "V", + opcodes = listOf( + Opcode.IF_EQ, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.IGET, // get dialog code + ), + literals = listOf(slidingDialogAnimation), + // 6.26 and earlier has a different first parameter. + // Since this fingerprint is somewhat weak, work around by checking for both method parameter signatures. + customFingerprint = custom@{ method, _ -> + // 6.26 and earlier parameters are: "L", "L" + // 6.27+ parameters are "[B", "L" + val parameterTypes = method.parameterTypes + + parameterTypes.size == 2 && parameterTypes[1].startsWith("L") + }, +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/flyoutmenu/components/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/flyoutmenu/components/Fingerprints.kt new file mode 100644 index 0000000000..2130cc7f20 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/flyoutmenu/components/Fingerprints.kt @@ -0,0 +1,78 @@ +package app.revanced.patches.music.flyoutmenu.components + +import app.revanced.patches.music.utils.resourceid.endButtonsContainer +import app.revanced.patches.music.utils.resourceid.touchOutside +import app.revanced.patches.music.utils.resourceid.trimSilenceSwitch +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionReversed +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val endButtonsContainerFingerprint = legacyFingerprint( + name = "endButtonsContainerFingerprint", + returnType = "V", + literals = listOf(endButtonsContainer) +) + +internal val menuItemFingerprint = legacyFingerprint( + name = "menuItemFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf( + Opcode.CHECK_CAST, + Opcode.INVOKE_DIRECT, + Opcode.MOVE_RESULT_OBJECT + ), + strings = listOf("toggleMenuItemMutations") +) + +internal val screenWidthFingerprint = legacyFingerprint( + name = "screenWidthFingerprint", + returnType = "Z", + parameters = listOf("L"), + opcodes = listOf(Opcode.IF_LT), + literals = listOf(600L) +) + +internal val screenWidthParentFingerprint = legacyFingerprint( + name = "screenWidthParentFingerprint", + returnType = "Landroid/graphics/Bitmap;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("Landroid/app/Activity;", "I"), + customFingerprint = { method, _ -> + method.indexOfFirstInstructionReversed { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "destroyDrawingCache" + } >= 0 + } +) + +internal val sleepTimerFingerprint = legacyFingerprint( + name = "sleepTimerFingerprint", + returnType = "Z", + parameters = emptyList(), + literals = listOf(45372767L) +) + +internal val touchOutsideFingerprint = legacyFingerprint( + name = "touchOutsideFingerprint", + returnType = "Landroid/view/View;", + literals = listOf(touchOutside) +) + +internal val trimSilenceConfigFingerprint = legacyFingerprint( + name = "trimSilenceConfigFingerprint", + returnType = "Z", + literals = listOf(45619123L) +) + +internal val trimSilenceSwitchFingerprint = legacyFingerprint( + name = "trimSilenceSwitchFingerprint", + returnType = "Landroid/view/View;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + literals = listOf(trimSilenceSwitch) +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/flyoutmenu/components/FlyoutMenuComponentsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/flyoutmenu/components/FlyoutMenuComponentsPatch.kt new file mode 100644 index 0000000000..da0c53faaf --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/flyoutmenu/components/FlyoutMenuComponentsPatch.kt @@ -0,0 +1,454 @@ +package app.revanced.patches.music.flyoutmenu.components + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.music.utils.extension.Constants.FLYOUT_CLASS_DESCRIPTOR +import app.revanced.patches.music.utils.flyoutmenu.flyoutMenuHookPatch +import app.revanced.patches.music.utils.patch.PatchList.FLYOUT_MENU_COMPONENTS +import app.revanced.patches.music.utils.playservice.is_6_36_or_greater +import app.revanced.patches.music.utils.playservice.versionCheckPatch +import app.revanced.patches.music.utils.resourceid.endButtonsContainer +import app.revanced.patches.music.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.music.utils.resourceid.trimSilenceSwitch +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.music.utils.videotype.videoTypeHookPatch +import app.revanced.patches.music.video.information.videoInformationPatch +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.util.ResourceGroup +import app.revanced.util.copyResources +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.resolvable +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private val resourceFileArray = arrayOf( + "yt_outline_play_arrow_half_circle_black_24" +).map { "$it.png" }.toTypedArray() + +private val flyoutMenuComponentsResourcePatch = resourcePatch( + description = "flyoutMenuComponentsResourcePatch" +) { + execute { + arrayOf("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi") + .map { "drawable-$it" } + .map { directory -> + ResourceGroup( + directory, *resourceFileArray + ) + } + .let { resourceGroups -> + resourceGroups.forEach { + copyResources("music/flyout", it) + } + } + } +} + +private const val FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/PlayerFlyoutMenuFilter;" + +@Suppress("unused") +val flyoutMenuComponentsPatch = bytecodePatch( + FLYOUT_MENU_COMPONENTS.title, + FLYOUT_MENU_COMPONENTS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + flyoutMenuComponentsResourcePatch, + flyoutMenuHookPatch, + lithoFilterPatch, + sharedResourceIdPatch, + settingsPatch, + versionCheckPatch, + videoInformationPatch, + videoTypeHookPatch, + ) + + execute { + var trimSilenceIncluded = false + + // region patch for enable compact dialog + + screenWidthFingerprint.matchOrThrow(screenWidthParentFingerprint).let { + it.method.apply { + val index = it.patternMatch!!.startIndex + val register = getInstruction(index).registerA + + addInstructions( + index, """ + invoke-static {v$register}, $FLYOUT_CLASS_DESCRIPTOR->enableCompactDialog(I)I + move-result v$register + """ + ) + } + } + + // endregion + + // region patch for enable trim silence + + if (trimSilenceConfigFingerprint.resolvable()) { + trimSilenceConfigFingerprint.injectLiteralInstructionBooleanCall( + 45619123L, + "$FLYOUT_CLASS_DESCRIPTOR->enableTrimSilence(Z)Z" + ) + + trimSilenceSwitchFingerprint.methodOrThrow().apply { + val constIndex = + indexOfFirstLiteralInstructionOrThrow(trimSilenceSwitch) + val onCheckedChangedListenerIndex = + indexOfFirstInstructionOrThrow(constIndex, Opcode.INVOKE_DIRECT) + val onCheckedChangedListenerReference = + getInstruction(onCheckedChangedListenerIndex).reference + val onCheckedChangedListenerDefiningClass = + (onCheckedChangedListenerReference as MethodReference).definingClass + + findMethodOrThrow(onCheckedChangedListenerDefiningClass) { + name == "onCheckedChanged" + }.apply { + val onCheckedChangedWalkerIndex = + indexOfFirstInstructionOrThrow { + val reference = getReference() + opcode == Opcode.INVOKE_VIRTUAL && + reference?.returnType == "V" && + reference.parameterTypes.size == 1 && + reference.parameterTypes[0] == "Z" + } + + getWalkerMethod(onCheckedChangedWalkerIndex).apply { + val insertIndex = indexOfFirstInstructionOrThrow(Opcode.MOVE_RESULT) + val insertRegister = + getInstruction(insertIndex).registerA + + addInstructions( + insertIndex + 1, """ + invoke-static {v$insertRegister}, $FLYOUT_CLASS_DESCRIPTOR->enableTrimSilenceSwitch(Z)Z + move-result v$insertRegister + """ + ) + } + } + } + trimSilenceIncluded = true + } + + // endregion + + // region patch for hide flyout menu components and replace menu + + menuItemFingerprint.matchOrThrow().let { + it.method.apply { + val freeIndex = indexOfFirstInstructionOrThrow(Opcode.OR_INT_LIT16) + val textViewIndex = it.patternMatch!!.startIndex + val imageViewIndex = it.patternMatch!!.endIndex + + val freeRegister = + getInstruction(freeIndex).registerA + val textViewRegister = + getInstruction(textViewIndex).registerA + val imageViewRegister = + getInstruction(imageViewIndex).registerA + + val enumIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_STATIC + && (this as? ReferenceInstruction)?.reference.toString() + .contains("(I)L") + } + 1 + val enumRegister = getInstruction(enumIndex).registerA + + addInstructionsWithLabels( + enumIndex + 1, + """ + invoke-static {v$enumRegister, v$textViewRegister, v$imageViewRegister}, $FLYOUT_CLASS_DESCRIPTOR->replaceComponents(Ljava/lang/Enum;Landroid/widget/TextView;Landroid/widget/ImageView;)V + invoke-static {v$enumRegister}, $FLYOUT_CLASS_DESCRIPTOR->hideComponents(Ljava/lang/Enum;)Z + move-result v$freeRegister + if-nez v$freeRegister, :hide + """, + ExternalLabel("hide", getInstruction(implementation!!.instructions.lastIndex)) + ) + } + } + + touchOutsideFingerprint.methodOrThrow().apply { + val setOnClickListenerIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "setOnClickListener" + } + val setOnClickListenerRegister = + getInstruction(setOnClickListenerIndex).registerC + + addInstruction( + setOnClickListenerIndex + 1, + "invoke-static {v$setOnClickListenerRegister}, $FLYOUT_CLASS_DESCRIPTOR->setTouchOutSideView(Landroid/view/View;)V" + ) + } + + endButtonsContainerFingerprint.methodOrThrow().apply { + val startIndex = + indexOfFirstLiteralInstructionOrThrow(endButtonsContainer) + val targetIndex = + indexOfFirstInstructionOrThrow(startIndex, Opcode.MOVE_RESULT_OBJECT) + val targetRegister = getInstruction(targetIndex).registerA + + addInstruction( + targetIndex + 1, + "invoke-static {v$targetRegister}, $FLYOUT_CLASS_DESCRIPTOR->hideLikeDislikeContainer(Landroid/view/View;)V" + ) + } + + // endregion + + // region patch for enable sleep timer + + /** + * Forces sleep timer menu to be enabled. + * This method may be desperate in the future. + */ + if (sleepTimerFingerprint.resolvable()) { + sleepTimerFingerprint.methodOrThrow().apply { + val insertIndex = implementation!!.instructions.lastIndex + val targetRegister = getInstruction(insertIndex).registerA + + addInstruction( + insertIndex, + "const/4 v$targetRegister, 0x1" + ) + } + } + + // endregion + + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_enable_compact_dialog", + "true" + ) + if (trimSilenceIncluded) { + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_enable_trim_silence", + "false" + ) + } + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_like_dislike", + "false", + false + ) + if (is_6_36_or_greater) { + addLithoFilter(FILTER_CLASS_DESCRIPTOR) + + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_3_column_component", + "false", + false + ) + } + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_add_to_queue", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_captions", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_delete_playlist", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_dismiss_queue", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_download", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_edit_playlist", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_go_to_album", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_go_to_artist", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_go_to_episode", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_go_to_podcast", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_help", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_play_next", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_quality", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_remove_from_library", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_remove_from_playlist", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_report", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_save_episode_for_later", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_save_to_library", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_save_to_playlist", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_share", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_shuffle_play", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_sleep_timer", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_start_radio", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_stats_for_nerds", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_subscribe", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_hide_flyout_menu_view_song_credit", + "false", + false + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_replace_flyout_menu_dismiss_queue", + "false" + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_replace_flyout_menu_dismiss_queue_continue_watch", + "true", + "revanced_replace_flyout_menu_dismiss_queue" + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_replace_flyout_menu_report", + "true" + ) + addSwitchPreference( + CategoryType.FLYOUT, + "revanced_replace_flyout_menu_report_only_player", + "true", + "revanced_replace_flyout_menu_report" + ) + + updatePatchStatus(FLYOUT_MENU_COMPONENTS) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/general/amoled/AmoledPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/general/amoled/AmoledPatch.kt new file mode 100644 index 0000000000..310578ff1d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/general/amoled/AmoledPatch.kt @@ -0,0 +1,46 @@ +package app.revanced.patches.music.general.amoled + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.UTILS_PATH +import app.revanced.patches.music.utils.patch.PatchList.AMOLED +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.shared.drawable.addDrawableColorHook +import app.revanced.patches.shared.drawable.drawableColorHookPatch +import org.w3c.dom.Element + +@Suppress("unused") +val amoledPatch = resourcePatch( + AMOLED.title, + AMOLED.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + drawableColorHookPatch, + settingsPatch + ) + + execute { + addDrawableColorHook("$UTILS_PATH/DrawableColorPatch;->getColor(I)I") + + document("res/values/colors.xml").use { document -> + val resourcesNode = document.getElementsByTagName("resources").item(0) as Element + + for (i in 0 until resourcesNode.childNodes.length) { + val node = resourcesNode.childNodes.item(i) as? Element ?: continue + + node.textContent = when (node.getAttribute("name")) { + "yt_black0", "yt_black1", "yt_black1_opacity95", "yt_black1_opacity98", "yt_black2", "yt_black3", + "yt_black4", "yt_status_bar_background_dark", "ytm_color_grey_12", "material_grey_850" -> "@android:color/black" + + else -> continue + } + } + } + + updatePatchStatus(AMOLED) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/general/autocaptions/AutoCaptionsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/general/autocaptions/AutoCaptionsPatch.kt new file mode 100644 index 0000000000..b79c38e237 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/general/autocaptions/AutoCaptionsPatch.kt @@ -0,0 +1,34 @@ +package app.revanced.patches.music.general.autocaptions + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.patch.PatchList.DISABLE_AUTO_CAPTIONS +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.shared.captions.baseAutoCaptionsPatch + +@Suppress("unused") +val autoCaptionsPatch = bytecodePatch( + DISABLE_AUTO_CAPTIONS.title, + DISABLE_AUTO_CAPTIONS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + baseAutoCaptionsPatch, + settingsPatch + ) + + execute { + addSwitchPreference( + CategoryType.GENERAL, + "revanced_disable_auto_captions", + "false" + ) + + updatePatchStatus(DISABLE_AUTO_CAPTIONS) + + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/general/components/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/general/components/Fingerprints.kt new file mode 100644 index 0000000000..cc192d88cd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/general/components/Fingerprints.kt @@ -0,0 +1,177 @@ +package app.revanced.patches.music.general.components + +import app.revanced.patches.music.utils.resourceid.chipCloud +import app.revanced.patches.music.utils.resourceid.historyMenuItem +import app.revanced.patches.music.utils.resourceid.musicTasteBuilderShelf +import app.revanced.patches.music.utils.resourceid.offlineSettingsMenuItem +import app.revanced.patches.music.utils.resourceid.playerOverlayChip +import app.revanced.patches.music.utils.resourceid.toolTipContentView +import app.revanced.patches.music.utils.resourceid.topBarMenuItemImageView +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionReversed +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val chipCloudFingerprint = legacyFingerprint( + name = "chipCloudFingerprint", + returnType = "V", + opcodes = listOf( + Opcode.CONST, + Opcode.CONST_4, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT + ), + literals = listOf(chipCloud), +) + +internal val contentPillFingerprint = legacyFingerprint( + name = "contentPillFingerprint", + returnType = "V", + strings = listOf("Content pill VE is null") +) + +internal val floatingButtonFingerprint = legacyFingerprint( + name = "floatingButtonFingerprint", + returnType = "V", + parameters = listOf("L"), + opcodes = listOf(Opcode.AND_INT_LIT16) +) + +internal val floatingButtonParentFingerprint = legacyFingerprint( + name = "floatingButtonParentFingerprint", + returnType = "V", + accessFlags = AccessFlags.PROTECTED or AccessFlags.FINAL, + parameters = listOf("L"), + opcodes = listOf(Opcode.INVOKE_DIRECT), + literals = listOf(259982244L), +) + +internal val historyMenuItemFingerprint = legacyFingerprint( + name = "historyMenuItemFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Landroid/view/Menu;"), + opcodes = listOf( + Opcode.INVOKE_INTERFACE, + Opcode.RETURN_VOID + ), + literals = listOf(historyMenuItem), + customFingerprint = { _, classDef -> + classDef.methods.count() == 5 + } +) + +internal val historyMenuItemOfflineTabFingerprint = legacyFingerprint( + name = "historyMenuItemOfflineTabFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Landroid/view/Menu;"), + opcodes = listOf( + Opcode.INVOKE_INTERFACE, + Opcode.RETURN_VOID + ), + literals = listOf(historyMenuItem, offlineSettingsMenuItem), +) + +internal val mediaRouteButtonFingerprint = legacyFingerprint( + name = "mediaRouteButtonFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + strings = listOf("MediaRouteButton") +) + +internal val parentToolMenuFingerprint = legacyFingerprint( + name = "parentToolMenuFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + opcodes = listOf( + Opcode.CONST_4, + Opcode.INVOKE_VIRTUAL, + Opcode.IGET, + ), + strings = listOf("pref_key_parent_tools"), + customFingerprint = { method, _ -> + method.name == "onSettingsLoaded" + } +) + +internal val playerOverlayChipFingerprint = legacyFingerprint( + name = "playerOverlayChipFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + literals = listOf(playerOverlayChip), +) + +internal val preferenceScreenFingerprint = legacyFingerprint( + name = "preferenceScreenFingerprint", + returnType = "V", + customFingerprint = { method, _ -> + method.definingClass == "Lcom/google/android/apps/youtube/music/settings/fragment/SettingsHeadersFragment;" && + method.name == "onCreatePreferences" + } +) + +internal val searchBarFingerprint = legacyFingerprint( + name = "searchBarFingerprint", + returnType = "V", + customFingerprint = { method, _ -> + indexOfVisibilityInstruction(method) >= 0 + } +) + +fun indexOfVisibilityInstruction(method: Method) = + method.indexOfFirstInstructionReversed { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "setVisibility" + } + +internal val searchBarParentFingerprint = legacyFingerprint( + name = "searchBarParentFingerprint", + returnType = "Landroid/content/Intent;", + strings = listOf("web_search") +) + +internal val soundSearchFingerprint = legacyFingerprint( + name = "soundSearchFingerprint", + parameters = emptyList(), + literals = listOf(45625491L), +) + +internal val tasteBuilderConstructorFingerprint = legacyFingerprint( + name = "tasteBuilderConstructorFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + literals = listOf(musicTasteBuilderShelf), +) + +internal val tasteBuilderSyntheticFingerprint = legacyFingerprint( + name = "tasteBuilderSyntheticFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL or AccessFlags.SYNTHETIC, + parameters = listOf("L", "Ljava/lang/Object;"), + opcodes = listOf( + Opcode.IF_NEZ, + Opcode.IGET_OBJECT + ) +) + +internal val tooltipContentViewFingerprint = legacyFingerprint( + name = "tooltipContentViewFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + literals = listOf(toolTipContentView), +) + +internal val topBarMenuItemImageViewFingerprint = legacyFingerprint( + name = "topBarMenuItemImageViewFingerprint", + returnType = "Landroid/view/View;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(topBarMenuItemImageView), +) + diff --git a/patches/src/main/kotlin/app/revanced/patches/music/general/components/LayoutComponentsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/general/components/LayoutComponentsPatch.kt new file mode 100644 index 0000000000..9e0a76ad91 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/general/components/LayoutComponentsPatch.kt @@ -0,0 +1,426 @@ +package app.revanced.patches.music.general.components + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.music.utils.extension.Constants.GENERAL_CLASS_DESCRIPTOR +import app.revanced.patches.music.utils.extension.Constants.GENERAL_PATH +import app.revanced.patches.music.utils.patch.PatchList.HIDE_LAYOUT_COMPONENTS +import app.revanced.patches.music.utils.playservice.is_6_42_or_greater +import app.revanced.patches.music.utils.playservice.versionCheckPatch +import app.revanced.patches.music.utils.resourceid.musicTasteBuilderShelf +import app.revanced.patches.music.utils.resourceid.playerOverlayChip +import app.revanced.patches.music.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.music.utils.resourceid.topBarMenuItemImageView +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addPreferenceWithIntent +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.patches.shared.settingmenu.settingsMenuPatch +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.mutableClassOrThrow +import app.revanced.util.fingerprint.resolvable +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +private const val EXTENSION_SETTINGS_MENU_DESCRIPTOR = + "$GENERAL_PATH/SettingsMenuPatch;" +private const val CUSTOM_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/CustomFilter;" +private const val LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/LayoutComponentsFilter;" + +@Suppress("unused") +val layoutComponentsPatch = bytecodePatch( + HIDE_LAYOUT_COMPONENTS.title, + HIDE_LAYOUT_COMPONENTS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + lithoFilterPatch, + sharedResourceIdPatch, + settingsPatch, + settingsMenuPatch, + versionCheckPatch, + ) + + execute { + var notificationButtonIncluded = false + var soundSearchButtonIncluded = false + + // region patch for hide cast button + + // hide cast button + mediaRouteButtonFingerprint.mutableClassOrThrow().let { + val setVisibilityMethod = + it.methods.find { method -> method.name == "setVisibility" } + + setVisibilityMethod?.addInstructions( + 0, """ + invoke-static {p1}, $GENERAL_CLASS_DESCRIPTOR->hideCastButton(I)I + move-result p1 + """ + ) ?: throw PatchException("Failed to find setVisibility method") + } + + // hide floating cast banner + playerOverlayChipFingerprint.methodOrThrow().apply { + val targetIndex = + indexOfFirstLiteralInstructionOrThrow(playerOverlayChip) + 2 + val targetRegister = getInstruction(targetIndex).registerA + + addInstruction( + targetIndex + 1, + "invoke-static {v$targetRegister}, $GENERAL_CLASS_DESCRIPTOR->hideCastButton(Landroid/view/View;)V" + ) + } + + // endregion + + // region patch for hide category bar + + chipCloudFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = it.patternMatch!!.endIndex + val targetRegister = getInstruction(targetIndex).registerA + + addInstruction( + targetIndex + 1, + "invoke-static { v$targetRegister }, $GENERAL_CLASS_DESCRIPTOR->hideCategoryBar(Landroid/view/View;)V" + ) + } + } + + // endregion + + // region patch for hide floating button + + floatingButtonFingerprint.methodOrThrow(floatingButtonParentFingerprint).apply { + addInstructionsWithLabels( + 1, """ + invoke-static {}, $GENERAL_CLASS_DESCRIPTOR->hideFloatingButton()Z + move-result v0 + if-eqz v0, :show + return-void + """, ExternalLabel("show", getInstruction(1)) + ) + } + + // endregion + + // region patch for hide history button + + setOf( + historyMenuItemFingerprint, + historyMenuItemOfflineTabFingerprint + ).forEach { fingerprint -> + fingerprint.matchOrThrow().let { + it.method.apply { + val insertIndex = it.patternMatch!!.startIndex + val insertRegister = + getInstruction(insertIndex).registerD + + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $GENERAL_CLASS_DESCRIPTOR->hideHistoryButton(Z)Z + move-result v$insertRegister + """ + ) + } + } + } + + // endregion + + // region patch for hide notification button + + if (is_6_42_or_greater) { + topBarMenuItemImageViewFingerprint.methodOrThrow().apply { + val constIndex = + indexOfFirstLiteralInstructionOrThrow(topBarMenuItemImageView) + val targetIndex = + indexOfFirstInstructionOrThrow(constIndex, Opcode.MOVE_RESULT_OBJECT) + val targetRegister = getInstruction(targetIndex).registerA + + addInstruction( + targetIndex + 1, + "invoke-static {v$targetRegister}, $GENERAL_CLASS_DESCRIPTOR->hideNotificationButton(Landroid/view/View;)V" + ) + } + notificationButtonIncluded = true + } + + // endregion + + // region patch for hide setting menus + + preferenceScreenFingerprint.methodOrThrow().apply { + addInstructions( + implementation!!.instructions.lastIndex, """ + invoke-virtual/range {p0 .. p0}, Lcom/google/android/apps/youtube/music/settings/fragment/SettingsHeadersFragment;->getPreferenceScreen()Landroidx/preference/PreferenceScreen; + move-result-object v0 + invoke-static {v0}, $EXTENSION_SETTINGS_MENU_DESCRIPTOR->hideSettingsMenu(Landroidx/preference/PreferenceScreen;)V + """ + ) + } + + // The lowest version supported by the patch does not have parent tool settings + if (parentToolMenuFingerprint.resolvable()) { + parentToolMenuFingerprint.matchOrThrow().let { + it.method.apply { + val index = it.patternMatch!!.startIndex + 1 + val register = getInstruction(index).registerD + + addInstructions( + index, """ + invoke-static {v$register}, $EXTENSION_SETTINGS_MENU_DESCRIPTOR->hideParentToolsMenu(Z)Z + move-result v$register + """ + ) + } + } + } + + // endregion + + // region patch for hide sound search button + + if (soundSearchFingerprint.resolvable()) { + soundSearchFingerprint.injectLiteralInstructionBooleanCall( + 45625491L, + "$GENERAL_CLASS_DESCRIPTOR->hideSoundSearchButton(Z)Z" + ) + soundSearchButtonIncluded = true + } + + // endregion + + // region patch for hide tap to update button + + contentPillFingerprint.methodOrThrow().apply { + addInstructionsWithLabels( + 0, + """ + invoke-static {}, $GENERAL_CLASS_DESCRIPTOR->hideTapToUpdateButton()Z + move-result v0 + if-eqz v0, :show + return-void + """, ExternalLabel("show", getInstruction(0)) + ) + } + + // endregion + + // region patch for hide taste builder + + tasteBuilderConstructorFingerprint.methodOrThrow().apply { + val constIndex = + indexOfFirstLiteralInstructionOrThrow(musicTasteBuilderShelf) + val targetIndex = + indexOfFirstInstructionOrThrow(constIndex, Opcode.MOVE_RESULT_OBJECT) + val targetRegister = getInstruction(targetIndex).registerA + + addInstruction( + targetIndex + 1, + "invoke-static {v$targetRegister}, $GENERAL_CLASS_DESCRIPTOR->hideTasteBuilder(Landroid/view/View;)V" + ) + } + + tasteBuilderSyntheticFingerprint.matchOrThrow(tasteBuilderConstructorFingerprint).let { + it.method.apply { + val insertIndex = it.patternMatch!!.startIndex + val insertRegister = getInstruction(insertIndex).registerA + + addInstruction( + insertIndex, + "const/4 v$insertRegister, 0x0" + ) + } + } + + // endregion + + // region patch for hide tooltip content + + tooltipContentViewFingerprint.methodOrThrow().addInstruction( + 0, + "return-void" + ) + + // endregion + + // region patch for hide voice search button + + searchBarFingerprint.methodOrThrow(searchBarParentFingerprint).apply { + val setVisibilityIndex = indexOfVisibilityInstruction(this) + val setVisibilityInstruction = + getInstruction(setVisibilityIndex) + + replaceInstruction( + setVisibilityIndex, + "invoke-static {v${setVisibilityInstruction.registerC}, v${setVisibilityInstruction.registerD}}, " + + "$GENERAL_CLASS_DESCRIPTOR->hideVoiceSearchButton(Landroid/widget/ImageView;I)V" + ) + } + + // endregion + + addLithoFilter(CUSTOM_FILTER_CLASS_DESCRIPTOR) + addLithoFilter(LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR) + + addSwitchPreference( + CategoryType.GENERAL, + "revanced_custom_filter", + "false" + ) + addPreferenceWithIntent( + CategoryType.GENERAL, + "revanced_custom_filter_strings", + "revanced_custom_filter" + ) + addSwitchPreference( + CategoryType.GENERAL, + "revanced_hide_button_shelf", + "false" + ) + addSwitchPreference( + CategoryType.GENERAL, + "revanced_hide_carousel_shelf", + "false" + ) + addSwitchPreference( + CategoryType.GENERAL, + "revanced_hide_playlist_card_shelf", + "false" + ) + addSwitchPreference( + CategoryType.GENERAL, + "revanced_hide_samples_shelf", + "false" + ) + addSwitchPreference( + CategoryType.GENERAL, + "revanced_hide_cast_button", + "true" + ) + addSwitchPreference( + CategoryType.GENERAL, + "revanced_hide_category_bar", + "false" + ) + addSwitchPreference( + CategoryType.GENERAL, + "revanced_hide_floating_button", + "false" + ) + addSwitchPreference( + CategoryType.GENERAL, + "revanced_hide_tap_to_update_button", + "false" + ) + addSwitchPreference( + CategoryType.GENERAL, + "revanced_hide_history_button", + "false" + ) + if (notificationButtonIncluded) { + addSwitchPreference( + CategoryType.GENERAL, + "revanced_hide_notification_button", + "false" + ) + } + if (soundSearchButtonIncluded) { + addSwitchPreference( + CategoryType.GENERAL, + "revanced_hide_sound_search_button", + "false" + ) + } + addSwitchPreference( + CategoryType.GENERAL, + "revanced_hide_voice_search_button", + "false" + ) + + addSwitchPreference( + CategoryType.SETTINGS, + "revanced_hide_settings_menu_parent_tools", + "false", + false + ) + addSwitchPreference( + CategoryType.SETTINGS, + "revanced_hide_settings_menu_general", + "false", + false + ) + addSwitchPreference( + CategoryType.SETTINGS, + "revanced_hide_settings_menu_playback", + "false", + false + ) + addSwitchPreference( + CategoryType.SETTINGS, + "revanced_hide_settings_menu_data_saving", + "false", + false + ) + addSwitchPreference( + CategoryType.SETTINGS, + "revanced_hide_settings_menu_downloads_and_storage", + "false", + false + ) + addSwitchPreference( + CategoryType.SETTINGS, + "revanced_hide_settings_menu_notification", + "false", + false + ) + addSwitchPreference( + CategoryType.SETTINGS, + "revanced_hide_settings_menu_privacy_and_location", + "false", + false + ) + addSwitchPreference( + CategoryType.SETTINGS, + "revanced_hide_settings_menu_recommendations", + "false", + false + ) + addSwitchPreference( + CategoryType.SETTINGS, + "revanced_hide_settings_menu_paid_memberships", + "true", + false + ) + addSwitchPreference( + CategoryType.SETTINGS, + "revanced_hide_settings_menu_about", + "false", + false + ) + + updatePatchStatus(HIDE_LAYOUT_COMPONENTS) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/general/dialog/ViewerDiscretionDialogPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/general/dialog/ViewerDiscretionDialogPatch.kt new file mode 100644 index 0000000000..83b03bfac0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/general/dialog/ViewerDiscretionDialogPatch.kt @@ -0,0 +1,35 @@ +package app.revanced.patches.music.general.dialog + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.GENERAL_CLASS_DESCRIPTOR +import app.revanced.patches.music.utils.patch.PatchList.REMOVE_VIEWER_DISCRETION_DIALOG +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.shared.dialog.baseViewerDiscretionDialogPatch + +@Suppress("unused") +val viewerDiscretionDialogPatch = bytecodePatch( + REMOVE_VIEWER_DISCRETION_DIALOG.title, + REMOVE_VIEWER_DISCRETION_DIALOG.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + baseViewerDiscretionDialogPatch(GENERAL_CLASS_DESCRIPTOR), + settingsPatch, + ) + + execute { + addSwitchPreference( + CategoryType.GENERAL, + "revanced_remove_viewer_discretion_dialog", + "false" + ) + + updatePatchStatus(REMOVE_VIEWER_DISCRETION_DIALOG) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/general/landscapemode/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/general/landscapemode/Fingerprints.kt new file mode 100644 index 0000000000..b57a8fb4e3 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/general/landscapemode/Fingerprints.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.music.general.landscapemode + +import app.revanced.patches.music.utils.resourceid.isTablet +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val tabletIdentifierFingerprint = legacyFingerprint( + name = "tabletIdentifierFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("L"), + opcodes = listOf( + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT + ), + literals = listOf(isTablet) +) + diff --git a/patches/src/main/kotlin/app/revanced/patches/music/general/landscapemode/LandScapeModePatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/general/landscapemode/LandScapeModePatch.kt new file mode 100644 index 0000000000..1d40e3b55b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/general/landscapemode/LandScapeModePatch.kt @@ -0,0 +1,53 @@ +package app.revanced.patches.music.general.landscapemode + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.GENERAL_CLASS_DESCRIPTOR +import app.revanced.patches.music.utils.patch.PatchList.ENABLE_LANDSCAPE_MODE +import app.revanced.patches.music.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.util.fingerprint.matchOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +@Suppress("unused") +val landScapeModePatch = bytecodePatch( + ENABLE_LANDSCAPE_MODE.title, + ENABLE_LANDSCAPE_MODE.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + sharedResourceIdPatch, + settingsPatch, + ) + + execute { + tabletIdentifierFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = it.patternMatch!!.endIndex + val targetRegister = getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, """ + invoke-static {v$targetRegister}, $GENERAL_CLASS_DESCRIPTOR->enableLandScapeMode(Z)Z + move-result v$targetRegister + """ + ) + } + } + + addSwitchPreference( + CategoryType.GENERAL, + "revanced_enable_landscape_mode", + "false" + ) + + updatePatchStatus(ENABLE_LANDSCAPE_MODE) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/general/oldstylelibraryshelf/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/general/oldstylelibraryshelf/Fingerprints.kt new file mode 100644 index 0000000000..fcb45c736a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/general/oldstylelibraryshelf/Fingerprints.kt @@ -0,0 +1,15 @@ +package app.revanced.patches.music.general.oldstylelibraryshelf + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val browseIdFingerprint = legacyFingerprint( + name = "browseIdFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + strings = listOf("FEmusic_offline"), + literals = listOf(45358178L), +) + diff --git a/patches/src/main/kotlin/app/revanced/patches/music/general/oldstylelibraryshelf/OldStyleLibraryShelfPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/general/oldstylelibraryshelf/OldStyleLibraryShelfPatch.kt new file mode 100644 index 0000000000..ad09f98cbe --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/general/oldstylelibraryshelf/OldStyleLibraryShelfPatch.kt @@ -0,0 +1,53 @@ +package app.revanced.patches.music.general.oldstylelibraryshelf + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.GENERAL_CLASS_DESCRIPTOR +import app.revanced.patches.music.utils.patch.PatchList.RESTORE_OLD_STYLE_LIBRARY_SHELF +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import app.revanced.util.indexOfFirstStringInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction + +@Suppress("unused") +val oldStyleLibraryShelfPatch = bytecodePatch( + RESTORE_OLD_STYLE_LIBRARY_SHELF.title, + RESTORE_OLD_STYLE_LIBRARY_SHELF.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + + browseIdFingerprint.methodOrThrow().apply { + val stringIndex = indexOfFirstStringInstructionOrThrow("FEmusic_offline") + val targetIndex = + indexOfFirstInstructionReversedOrThrow(stringIndex, Opcode.IGET_OBJECT) + val targetRegister = getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, """ + invoke-static {v$targetRegister}, $GENERAL_CLASS_DESCRIPTOR->restoreOldStyleLibraryShelf(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$targetRegister + """ + ) + } + + addSwitchPreference( + CategoryType.GENERAL, + "revanced_restore_old_style_library_shelf", + "false" + ) + + updatePatchStatus(RESTORE_OLD_STYLE_LIBRARY_SHELF) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/general/redirection/DislikeRedirectionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/general/redirection/DislikeRedirectionPatch.kt new file mode 100644 index 0000000000..a395826453 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/general/redirection/DislikeRedirectionPatch.kt @@ -0,0 +1,97 @@ +package app.revanced.patches.music.general.redirection + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.GENERAL_CLASS_DESCRIPTOR +import app.revanced.patches.music.utils.patch.PatchList.DISABLE_DISLIKE_REDIRECTION +import app.revanced.patches.music.utils.pendingIntentReceiverFingerprint +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import app.revanced.util.indexOfFirstStringInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.iface.reference.Reference + +@Suppress("unused") +val dislikeRedirectionPatch = bytecodePatch( + DISABLE_DISLIKE_REDIRECTION.title, + DISABLE_DISLIKE_REDIRECTION.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + lateinit var onClickReference: Reference + + pendingIntentReceiverFingerprint.methodOrThrow().apply { + val startIndex = indexOfFirstStringInstructionOrThrow("YTM Dislike") + val onClickRelayIndex = + indexOfFirstInstructionReversedOrThrow(startIndex, Opcode.INVOKE_VIRTUAL) + val onClickRelayMethod = getWalkerMethod(onClickRelayIndex) + + onClickRelayMethod.apply { + val onClickMethodIndex = + indexOfFirstInstructionReversedOrThrow(Opcode.INVOKE_DIRECT) + val onClickMethod = getWalkerMethod(onClickMethodIndex) + + onClickMethod.apply { + val onClickIndex = indexOfFirstInstructionOrThrow { + val reference = + ((this as? ReferenceInstruction)?.reference as? MethodReference) + + opcode == Opcode.INVOKE_INTERFACE && + reference?.returnType == "V" && + reference.parameterTypes.size == 1 + } + onClickReference = + getInstruction(onClickIndex).reference + + disableDislikeRedirection(onClickIndex) + } + } + } + + dislikeButtonOnClickListenerFingerprint.methodOrThrow().apply { + val onClickIndex = indexOfFirstInstructionOrThrow { + getReference()?.toString() == onClickReference.toString() + } + disableDislikeRedirection(onClickIndex) + } + + addSwitchPreference( + CategoryType.GENERAL, + "revanced_disable_dislike_redirection", + "false" + ) + + updatePatchStatus(DISABLE_DISLIKE_REDIRECTION) + + } +} + +private fun MutableMethod.disableDislikeRedirection(onClickIndex: Int) { + val targetIndex = indexOfFirstInstructionReversedOrThrow(onClickIndex, Opcode.IF_EQZ) + val insertRegister = getInstruction(targetIndex).registerA + + addInstructionsWithLabels( + targetIndex + 1, """ + invoke-static {}, $GENERAL_CLASS_DESCRIPTOR->disableDislikeRedirection()Z + move-result v$insertRegister + if-nez v$insertRegister, :disable + """, ExternalLabel("disable", getInstruction(onClickIndex + 1)) + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/general/redirection/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/general/redirection/Fingerprints.kt new file mode 100644 index 0000000000..b7bb4d3a33 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/general/redirection/Fingerprints.kt @@ -0,0 +1,17 @@ +package app.revanced.patches.music.general.redirection + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val dislikeButtonOnClickListenerFingerprint = legacyFingerprint( + name = "dislikeButtonOnClickListenerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Landroid/view/View;"), + literals = listOf(53465L), + customFingerprint = { method, _ -> + method.name == "onClick" + } +) + diff --git a/patches/src/main/kotlin/app/revanced/patches/music/general/spoofappversion/SpoofAppVersionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/general/spoofappversion/SpoofAppVersionPatch.kt new file mode 100644 index 0000000000..268a10c963 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/general/spoofappversion/SpoofAppVersionPatch.kt @@ -0,0 +1,83 @@ +package app.revanced.patches.music.general.spoofappversion + +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.music.general.oldstylelibraryshelf.oldStyleLibraryShelfPatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.GENERAL_CLASS_DESCRIPTOR +import app.revanced.patches.music.utils.extension.Constants.PATCH_STATUS_CLASS_DESCRIPTOR +import app.revanced.patches.music.utils.patch.PatchList.SPOOF_APP_VERSION +import app.revanced.patches.music.utils.playservice.is_7_18_or_greater +import app.revanced.patches.music.utils.playservice.versionCheckPatch +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addPreferenceWithIntent +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.shared.spoof.appversion.baseSpoofAppVersionPatch +import app.revanced.util.appendAppVersion +import app.revanced.util.findMethodOrThrow + +private var defaultValue = "false" + +private val spoofAppVersionBytecodePatch = bytecodePatch { + dependsOn( + baseSpoofAppVersionPatch("$GENERAL_CLASS_DESCRIPTOR->getVersionOverride(Ljava/lang/String;)Ljava/lang/String;"), + versionCheckPatch, + ) + + execute { + if (is_7_18_or_greater) { + findMethodOrThrow(PATCH_STATUS_CLASS_DESCRIPTOR) { + name == "SpoofAppVersionDefaultString" + }.replaceInstruction( + 0, + "const-string v0, \"7.16.53\"" + ) + findMethodOrThrow(PATCH_STATUS_CLASS_DESCRIPTOR) { + name == "SpoofAppVersionDefaultBoolean" + }.replaceInstruction( + 0, + "const/4 v0, 0x1" + ) + + defaultValue = "true" + } + } +} + +@Suppress("unused") +val spoofAppVersionPatch = resourcePatch( + SPOOF_APP_VERSION.title, + SPOOF_APP_VERSION.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + spoofAppVersionBytecodePatch, + oldStyleLibraryShelfPatch, + settingsPatch, + versionCheckPatch, + ) + + execute { + if (is_7_18_or_greater) { + appendAppVersion("7.16.53") + } + + addSwitchPreference( + CategoryType.GENERAL, + "revanced_spoof_app_version", + defaultValue + ) + addPreferenceWithIntent( + CategoryType.GENERAL, + "revanced_spoof_app_version_target", + "revanced_spoof_app_version" + ) + + updatePatchStatus(SPOOF_APP_VERSION) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/general/startpage/ChangeStartPagePatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/general/startpage/ChangeStartPagePatch.kt new file mode 100644 index 0000000000..ac6fb70e0e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/general/startpage/ChangeStartPagePatch.kt @@ -0,0 +1,52 @@ +package app.revanced.patches.music.general.startpage + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.GENERAL_CLASS_DESCRIPTOR +import app.revanced.patches.music.utils.patch.PatchList.CHANGE_START_PAGE +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addPreferenceWithIntent +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.util.fingerprint.matchOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +@Suppress("unused") +val changeStartPagePatch = bytecodePatch( + CHANGE_START_PAGE.title, + CHANGE_START_PAGE.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + + coldStartUpFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = it.patternMatch!!.endIndex + val targetRegister = getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, """ + invoke-static {v$targetRegister}, $GENERAL_CLASS_DESCRIPTOR->changeStartPage(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$targetRegister + return-object v$targetRegister + """ + ) + removeInstruction(targetIndex) + } + } + + addPreferenceWithIntent( + CategoryType.GENERAL, + "revanced_change_start_page" + ) + + updatePatchStatus(CHANGE_START_PAGE) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/general/startpage/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/general/startpage/Fingerprints.kt new file mode 100644 index 0000000000..613adde88b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/general/startpage/Fingerprints.kt @@ -0,0 +1,20 @@ +package app.revanced.patches.music.general.startpage + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val coldStartUpFingerprint = legacyFingerprint( + name = "coldStartUpFingerprint", + returnType = "Ljava/lang/String;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf( + Opcode.GOTO, + Opcode.CONST_STRING, + Opcode.RETURN_OBJECT + ), + strings = listOf("FEmusic_library_sideloaded_tracks", "FEmusic_home") +) + diff --git a/patches/src/main/kotlin/app/revanced/patches/music/layout/branding/icon/CustomBrandingIconPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/layout/branding/icon/CustomBrandingIconPatch.kt new file mode 100644 index 0000000000..19e00fbcb8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/layout/branding/icon/CustomBrandingIconPatch.kt @@ -0,0 +1,288 @@ +package app.revanced.patches.music.layout.branding.icon + +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.booleanOption +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.patch.PatchList.CUSTOM_BRANDING_ICON_FOR_YOUTUBE_MUSIC +import app.revanced.patches.music.utils.playservice.is_7_23_or_greater +import app.revanced.patches.music.utils.playservice.versionCheckPatch +import app.revanced.patches.music.utils.settings.ResourceUtils.setIconType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.util.ResourceGroup +import app.revanced.util.Utils.trimIndentMultiline +import app.revanced.util.copyResources +import app.revanced.util.getResourceGroup +import app.revanced.util.underBarOrThrow +import org.w3c.dom.Element +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +private const val ADAPTIVE_ICON_BACKGROUND_FILE_NAME = + "adaptiveproduct_youtube_music_background_color_108" +private const val ADAPTIVE_ICON_FOREGROUND_FILE_NAME = + "adaptiveproduct_youtube_music_foreground_color_108" +private const val DEFAULT_ICON = "revancify_blue" + +private val availableIcon = mapOf( + "AFN Blue" to "afn_blue", + "AFN Red" to "afn_red", + "MMT" to "mmt", + "Revancify Blue" to DEFAULT_ICON, + "Revancify Red" to "revancify_red", + "YouTube Music" to "youtube_music" +) + +private val sizeArray = arrayOf( + "xxxhdpi", + "xxhdpi", + "xhdpi", + "hdpi", + "mdpi" +) + +private val largeSizeArray = arrayOf( + "xlarge-hdpi", + "xlarge-mdpi", + "large-xhdpi", + "large-hdpi", + "large-mdpi", + "xxhdpi", + "xhdpi", + "hdpi", + "mdpi", +) + +private val largeDrawableDirectories = largeSizeArray.map { "drawable-$it" } + +private val mipmapDirectories = sizeArray.map { "mipmap-$it" } + +private val launcherIconResourceFileNames = arrayOf( + ADAPTIVE_ICON_BACKGROUND_FILE_NAME, + ADAPTIVE_ICON_FOREGROUND_FILE_NAME, + "ic_launcher_release" +).map { "$it.png" }.toTypedArray() + +private val splashIconResourceFileNames = arrayOf( + // This file only exists in [drawable-hdpi] + // Since {@code ResourceUtils#copyResources} checks for null values before copying, + // Just adds it to the array. + "action_bar_logo_release", + "record" +).map { "$it.png" }.toTypedArray() + +private val launcherIconResourceGroups = + mipmapDirectories.getResourceGroup(launcherIconResourceFileNames) + +private val splashIconResourceGroups = + largeDrawableDirectories.getResourceGroup(splashIconResourceFileNames) + +@Suppress("unused") +val customBrandingIconPatch = resourcePatch( + CUSTOM_BRANDING_ICON_FOR_YOUTUBE_MUSIC.title, + CUSTOM_BRANDING_ICON_FOR_YOUTUBE_MUSIC.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + settingsPatch, + versionCheckPatch, + ) + + val appIconOption = stringOption( + key = "appIcon", + default = DEFAULT_ICON, + values = availableIcon, + title = "App icon", + description = """ + The icon to apply to the app. + + If a path to a folder is provided, the folder must contain the following folders: + + ${mipmapDirectories.joinToString("\n") { "- $it" }} + + Each of these folders must contain the following files: + + ${launcherIconResourceFileNames.joinToString("\n") { "- $it" }} + """.trimIndentMultiline(), + required = true, + ) + + val changeSplashIconOption by booleanOption( + key = "changeSplashIcon", + default = true, + title = "Change splash icons", + description = "Apply the custom branding icon to the splash screen.", + required = true + ) + + val restoreOldSplashIconOption by booleanOption( + key = "restoreOldSplashIcon", + default = false, + title = "Restore old splash icon", + description = """ + Restore the old style splash icon. + + If you enable both the old style splash icon and the Cairo splash animation, + + Old style splash icon will appear first and then the Cairo splash animation will start. + """.trimIndentMultiline(), + required = true, + ) + + execute { + // Check patch options first. + val appIcon = appIconOption.underBarOrThrow() + + val appIconResourcePath = "music/branding/$appIcon" + val youtubeMusicIconResourcePath = "music/branding/youtube_music" + + val resourceDirectory = get("res") + + // Check if a custom path is used in the patch options. + if (!availableIcon.containsValue(appIcon)) { + launcherIconResourceGroups.let { resourceGroups -> + try { + val path = File(appIcon) + + resourceGroups.forEach { group -> + val fromDirectory = path.resolve(group.resourceDirectoryName) + val toDirectory = resourceDirectory.resolve(group.resourceDirectoryName) + + group.resources.forEach { iconFileName -> + Files.write( + toDirectory.resolve(iconFileName).toPath(), + fromDirectory.resolve(iconFileName).readBytes() + ) + } + } + } catch (_: Exception) { + // Exception is thrown if an invalid path is used in the patch option. + throw PatchException("Invalid app icon path: $appIcon") + } + } + } else { + + // Change launcher icon. + launcherIconResourceGroups.let { resourceGroups -> + resourceGroups.forEach { + copyResources("$appIconResourcePath/launcher", it) + } + } + + // Change monochrome icon. + arrayOf( + ResourceGroup( + "drawable", + "ic_app_icons_themed_youtube_music.xml" + ) + ).forEach { resourceGroup -> + copyResources("$appIconResourcePath/monochrome", resourceGroup) + } + + // Change splash icon. + if (restoreOldSplashIconOption == true) { + var oldSplashIconNotExists: Boolean + + document("res/drawable/splash_screen.xml").use { document -> + document.apply { + val node = getElementsByTagName("layer-list").item(0) + oldSplashIconNotExists = (node as Element) + .getElementsByTagName("item") + .length == 1 + + if (oldSplashIconNotExists) { + createElement("item").also { itemNode -> + itemNode.appendChild( + createElement("bitmap").also { bitmapNode -> + bitmapNode.setAttribute("android:gravity", "center") + bitmapNode.setAttribute("android:src", "@drawable/record") + } + ) + node.appendChild(itemNode) + } + } + } + } + if (oldSplashIconNotExists) { + splashIconResourceGroups.let { resourceGroups -> + resourceGroups.forEach { + copyResources( + "$youtubeMusicIconResourcePath/splash", + it, + createDirectoryIfNotExist = true + ) + } + } + } + } + + // Change splash icon. + if (changeSplashIconOption == true) { + // Some resources have been removed in the latest YouTube Music. + // For compatibility, use try...catch. + try { + splashIconResourceGroups.let { resourceGroups -> + resourceGroups.forEach { + copyResources("$appIconResourcePath/splash", it) + } + } + } catch (_: Exception) { + } + } + + setIconType(appIcon) + } + + updatePatchStatus(CUSTOM_BRANDING_ICON_FOR_YOUTUBE_MUSIC) + + // region fix app icon + + if (!is_7_23_or_greater) { + return@execute + } + if (appIcon == "youtube_music") { + return@execute + } + + fun getAdaptiveIconResourceFile(tag: String): String { + document("res/mipmap-anydpi/ic_launcher_release.xml").use { document -> + val adaptiveIcon = document + .getElementsByTagName("adaptive-icon") + .item(0) as Element + + val childNodes = adaptiveIcon.childNodes + for (i in 0 until childNodes.length) { + val node = childNodes.item(i) + if (node is Element && node.tagName == tag && node.hasAttribute("android:drawable")) { + return node.getAttribute("android:drawable").split("/")[1] + } + } + throw PatchException("Element not found: $tag") + } + } + + mapOf( + ADAPTIVE_ICON_BACKGROUND_FILE_NAME to getAdaptiveIconResourceFile("background"), + ADAPTIVE_ICON_FOREGROUND_FILE_NAME to getAdaptiveIconResourceFile("foreground") + ).forEach { (oldIconResourceFile, newIconResourceFile) -> + mipmapDirectories.forEach { + val mipmapDirectory = resourceDirectory.resolve(it) + Files.move( + mipmapDirectory + .resolve("$oldIconResourceFile.png") + .toPath(), + mipmapDirectory + .resolve("$newIconResourceFile.png") + .toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + } + + // endregion + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/layout/branding/name/CustomBrandingNamePatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/layout/branding/name/CustomBrandingNamePatch.kt new file mode 100644 index 0000000000..01b6eb8230 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/layout/branding/name/CustomBrandingNamePatch.kt @@ -0,0 +1,81 @@ +package app.revanced.patches.music.layout.branding.name + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.patch.PatchList.CUSTOM_BRANDING_NAME_FOR_YOUTUBE_MUSIC +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.util.removeStringsElements +import app.revanced.util.valueOrThrow + +private const val APP_NAME_NOTIFICATION = "ReVanced Extended Music" +private const val APP_NAME_LAUNCHER = "RVX Music" + +@Suppress("unused") +val customBrandingNamePatch = resourcePatch( + CUSTOM_BRANDING_NAME_FOR_YOUTUBE_MUSIC.title, + CUSTOM_BRANDING_NAME_FOR_YOUTUBE_MUSIC.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + val appNameNotificationOption = stringOption( + key = "appNameNotification", + default = APP_NAME_LAUNCHER, + values = mapOf( + "ReVanced Extended Music" to APP_NAME_NOTIFICATION, + "RVX Music" to APP_NAME_LAUNCHER, + "YouTube Music" to "YouTube Music", + "YT Music" to "YT Music", + ), + title = "App name in notification panel", + description = "The name of the app as it appears in the notification panel.", + required = true + ) + + val appNameLauncherOption = stringOption( + key = "appNameLauncher", + default = APP_NAME_LAUNCHER, + values = mapOf( + "ReVanced Extended Music" to APP_NAME_NOTIFICATION, + "RVX Music" to APP_NAME_LAUNCHER, + "YouTube Music" to "YouTube Music", + "YT Music" to "YT Music", + ), + title = "App name in launcher", + description = "The name of the app as it appears in the launcher.", + required = true + ) + + execute { + // Check patch options first. + val notificationName = appNameNotificationOption + .valueOrThrow() + val launcherName = appNameLauncherOption + .valueOrThrow() + + removeStringsElements( + arrayOf("app_launcher_name", "app_name") + ) + + document("res/values/strings.xml").use { document -> + mapOf( + "app_name" to notificationName, + "app_launcher_name" to launcherName + ).forEach { (k, v) -> + val stringElement = document.createElement("string") + + stringElement.setAttribute("name", k) + stringElement.textContent = v + + document.getElementsByTagName("resources").item(0) + .appendChild(stringElement) + } + } + + updatePatchStatus(CUSTOM_BRANDING_NAME_FOR_YOUTUBE_MUSIC) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/layout/header/ChangeHeaderPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/layout/header/ChangeHeaderPatch.kt new file mode 100644 index 0000000000..3be7d2c935 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/layout/header/ChangeHeaderPatch.kt @@ -0,0 +1,176 @@ +package app.revanced.patches.music.layout.header + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.patch.PatchList.CUSTOM_HEADER_FOR_YOUTUBE_MUSIC +import app.revanced.patches.music.utils.settings.ResourceUtils.getIconType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.util.ResourceGroup +import app.revanced.util.Utils.trimIndentMultiline +import app.revanced.util.copyFile +import app.revanced.util.copyResources +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.resolvable +import app.revanced.util.valueOrThrow + +private const val DEFAULT_HEADER_KEY = "Custom branding icon" +private const val DEFAULT_HEADER_VALUE = "custom_branding_icon" + +private val actionBarLogoResourceDirectoryNames = mapOf( + "xxxhdpi" to "320px x 96px", + "xxhdpi" to "240px x 72px", + "xhdpi" to "160px x 48px", + "hdpi" to "121px x 36px", + "mdpi" to "80px x 24px", +).map { (dpi, dim) -> + "drawable-$dpi" to dim +}.toMap() + +private val logoMusicResourceDirectoryNames = mapOf( + "xxxhdpi" to "576px x 200px", + "xxhdpi" to "432px x 150px", + "xhdpi" to "288px x 100px", + "hdpi" to "217px x 76px", + "mdpi" to "144px x 50px", +).map { (dpi, dim) -> + "drawable-$dpi" to dim +}.toMap() + +private val ytmMusicLogoResourceDirectoryNames = mapOf( + "xxxhdpi" to "412px x 144px", + "xxhdpi" to "309px x 108px", + "xhdpi" to "206px x 72px", + "hdpi" to "155px x 54px", + "mdpi" to "103px x 36px", +).map { (dpi, dim) -> + "drawable-$dpi" to dim +}.toMap() + +private val headerIconResourceFileNames = arrayOf( + "action_bar_logo", + "logo_music", + "ytm_logo" +).map { "$it.png" }.toTypedArray() + +private val headerIconResourceGroups = + actionBarLogoResourceDirectoryNames.keys.map { directory -> + ResourceGroup( + directory, *headerIconResourceFileNames + ) + } + +private val getDescription = { + var descriptionBody = """ + The header to apply to the app. + + Patch option '$DEFAULT_HEADER_KEY' applies only when: + + 1. Patch 'Custom branding icon for YouTube Music' is included. + 2. Patch option for 'Custom branding icon for YouTube Music' is selected from the preset. + + If a path to a folder is provided, the folder must contain one or more of the following folders, depending on the DPI of the device: + + ${actionBarLogoResourceDirectoryNames.keys.joinToString("\n") { "- $it" }} + + Each of the folders must contain all of the following files: + + ${headerIconResourceFileNames.joinToString("\n") { "- $it" }} + """ + + mapOf( + "action_bar_logo.png" to actionBarLogoResourceDirectoryNames, + "logo_music.png" to logoMusicResourceDirectoryNames, + "ytm_logo.png" to ytmMusicLogoResourceDirectoryNames + ).forEach { (images, directoryNames) -> + descriptionBody += """ + The image '$images' dimensions must be as follows: + + ${directoryNames.map { (dpi, dim) -> "- $dpi: $dim" }.joinToString("\n")} + """ + } + + descriptionBody.trimIndentMultiline() +} + +private val changeHeaderBytecodePatch = bytecodePatch( + description = "changeHeaderBytecodePatch" +) { + execute { + /** + * New Header has been added from YouTube Music v7.04.51. + * + * The new header's file names are 'action_bar_logo_ringo2.png' and 'ytm_logo_ringo2.png'. + * The only difference between the existing header and the new header is the dimensions of the image. + * + * The affected patch is [changeHeaderPatch]. + * + * TODO: Add a new header image file to [changeHeaderPatch] later. + */ + if (!headerSwitchConfigFingerprint.resolvable()) { + return@execute + } + headerSwitchConfigFingerprint.injectLiteralInstructionBooleanCall( + 45617851L, + "0x0" + ) + } +} + +@Suppress("unused") +val changeHeaderPatch = resourcePatch( + CUSTOM_HEADER_FOR_YOUTUBE_MUSIC.title, + CUSTOM_HEADER_FOR_YOUTUBE_MUSIC.summary, + use = false, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + changeHeaderBytecodePatch, + settingsPatch, + ) + + val customHeaderOption = stringOption( + key = "customHeader", + default = DEFAULT_HEADER_VALUE, + values = mapOf( + DEFAULT_HEADER_KEY to DEFAULT_HEADER_VALUE + ), + title = "Custom header", + description = getDescription(), + required = true, + ) + + execute { + // Check patch options first. + val customHeader = customHeaderOption + .valueOrThrow() + + val customBrandingIconType = getIconType() + val customBrandingIconIncluded = customBrandingIconType != "default" + + val warnings = "WARNING: Invalid header path: $customHeader. Does not apply patches." + + if (customHeader != DEFAULT_HEADER_VALUE) { + copyFile( + headerIconResourceGroups, + customHeader, + warnings + ) + } else if (customBrandingIconIncluded) { + headerIconResourceGroups.let { resourceGroups -> + resourceGroups.forEach { + copyResources("music/branding/$customBrandingIconType/header", it) + } + } + } else { + println(warnings) + } + + updatePatchStatus(CUSTOM_HEADER_FOR_YOUTUBE_MUSIC) + + } +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/music/layout/header/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/layout/header/Fingerprints.kt new file mode 100644 index 0000000000..866303d2f6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/layout/header/Fingerprints.kt @@ -0,0 +1,12 @@ +package app.revanced.patches.music.layout.header + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val headerSwitchConfigFingerprint = legacyFingerprint( + name = "headerSwitchConfigFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + literals = listOf(45617851L) +) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/layout/overlayfilter/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/layout/overlayfilter/Fingerprints.kt new file mode 100644 index 0000000000..8b180c1b44 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/layout/overlayfilter/Fingerprints.kt @@ -0,0 +1,17 @@ +package app.revanced.patches.music.layout.overlayfilter + +import app.revanced.patches.music.utils.resourceid.designBottomSheetDialog +import app.revanced.util.fingerprint.legacyFingerprint +import com.android.tools.smali.dexlib2.Opcode + +internal val designBottomSheetDialogFingerprint = legacyFingerprint( + name = "designBottomSheetDialogFingerprint", + returnType = "V", + parameters = emptyList(), + opcodes = listOf( + Opcode.IF_NEZ, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT + ), + literals = listOf(designBottomSheetDialog) +) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/layout/overlayfilter/OverlayFilterPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/layout/overlayfilter/OverlayFilterPatch.kt new file mode 100644 index 0000000000..3479d1a094 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/layout/overlayfilter/OverlayFilterPatch.kt @@ -0,0 +1,67 @@ +package app.revanced.patches.music.layout.overlayfilter + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.GENERAL_CLASS_DESCRIPTOR +import app.revanced.patches.music.utils.patch.PatchList.HIDE_OVERLAY_FILTER +import app.revanced.patches.music.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.util.fingerprint.matchOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +private val overlayFilterBytecodePatch = bytecodePatch( + description = "overlayFilterBytecodePatch" +) { + dependsOn(sharedResourceIdPatch) + + execute { + designBottomSheetDialogFingerprint.matchOrThrow().let { + it.method.apply { + val insertIndex = it.patternMatch!!.endIndex - 1 + val freeRegister = getInstruction(insertIndex + 1).registerA + + addInstructions( + insertIndex, """ + invoke-virtual {p0}, $definingClass->getWindow()Landroid/view/Window; + move-result-object v$freeRegister + invoke-static {v$freeRegister}, $GENERAL_CLASS_DESCRIPTOR->disableDimBehind(Landroid/view/Window;)V + """ + ) + } + } + + } +} + +@Suppress("unused") +val overlayFilterPatch = resourcePatch( + HIDE_OVERLAY_FILTER.title, + HIDE_OVERLAY_FILTER.summary, + use = false, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + settingsPatch, + overlayFilterBytecodePatch, + ) + + execute { + val styleFile = get("res/values/styles.xml") + + styleFile.writeText( + styleFile.readText() + .replace( + "ytOverlayBackgroundMedium\">@color/yt_black_pure_opacity60", + "ytOverlayBackgroundMedium\">@android:color/transparent" + ) + ) + + updatePatchStatus(HIDE_OVERLAY_FILTER) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/layout/playeroverlay/PlayerOverlayFilterPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/layout/playeroverlay/PlayerOverlayFilterPatch.kt new file mode 100644 index 0000000000..9981349da4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/layout/playeroverlay/PlayerOverlayFilterPatch.kt @@ -0,0 +1,27 @@ +package app.revanced.patches.music.layout.playeroverlay + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.patch.PatchList.HIDE_PLAYER_OVERLAY_FILTER +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.util.removeOverlayBackground + +@Suppress("unused") +val playerOverlayFilterPatch = resourcePatch( + HIDE_PLAYER_OVERLAY_FILTER.title, + HIDE_PLAYER_OVERLAY_FILTER.summary, + use = false, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + execute { + removeOverlayBackground( + arrayOf("music_controls_overlay.xml"), + arrayOf("player_control_screen") + ) + + updatePatchStatus(HIDE_PLAYER_OVERLAY_FILTER) + + } +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/music/layout/translations/TranslationsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/layout/translations/TranslationsPatch.kt new file mode 100644 index 0000000000..b900ecdca7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/layout/translations/TranslationsPatch.kt @@ -0,0 +1,71 @@ +package app.revanced.patches.music.layout.translations + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.patch.PatchList.TRANSLATIONS_FOR_YOUTUBE_MUSIC +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.shared.translations.APP_LANGUAGES +import app.revanced.patches.shared.translations.baseTranslationsPatch + +// Array of supported translations, each represented by its language code. +private val SUPPORTED_TRANSLATIONS = setOf( + "bg-rBG", "bn", "cs-rCZ", "el-rGR", "es-rES", "fr-rFR", "hu-rHU", "id-rID", "in", "it-rIT", + "ja-rJP", "ko-rKR", "nl-rNL", "pl-rPL", "pt-rBR", "ro-rRO", "ru-rRU", "tr-rTR", "uk-rUA", + "vi-rVN", "zh-rCN", "zh-rTW" +) + +@Suppress("unused") +val translationsPatch = resourcePatch( + TRANSLATIONS_FOR_YOUTUBE_MUSIC.title, + TRANSLATIONS_FOR_YOUTUBE_MUSIC.summary, +) { + val customTranslations by stringOption( + key = "customTranslations", + default = "", + title = "Custom translations", + description = """ + The path to the 'strings.xml' file. + Please note that applying the 'strings.xml' file will overwrite all existing translations. + """.trimIndent(), + required = true, + ) + + val selectedTranslations by stringOption( + key = "selectedTranslations", + default = SUPPORTED_TRANSLATIONS.joinToString(", "), + title = "Translations to add", + description = "A list of translations to be added for the RVX settings, separated by commas.", + required = true, + ) + + val selectedStringResources by stringOption( + key = "selectedStringResources", + default = APP_LANGUAGES.joinToString(", "), + title = "String resources to keep", + description = """ + A list of string resources to be kept, separated by commas. + String resources not in the list will be removed from the app. + + Default string resource, English, is not removed. + """.trimIndent(), + required = true, + ) + + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + settingsPatch, + ) + + execute { + baseTranslationsPatch( + customTranslations, selectedTranslations, selectedStringResources, + SUPPORTED_TRANSLATIONS, "music" + ) + + updatePatchStatus(TRANSLATIONS_FOR_YOUTUBE_MUSIC) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/layout/visual/VisualPreferencesIconsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/layout/visual/VisualPreferencesIconsPatch.kt new file mode 100644 index 0000000000..f11adc92ae --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/layout/visual/VisualPreferencesIconsPatch.kt @@ -0,0 +1,154 @@ +package app.revanced.patches.music.layout.visual + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patches.music.layout.branding.icon.customBrandingIconPatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.patch.PatchList.VISUAL_PREFERENCES_ICONS_FOR_YOUTUBE_MUSIC +import app.revanced.patches.music.utils.settings.ResourceUtils.SETTINGS_HEADER_PATH +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.util.ResourceGroup +import app.revanced.util.copyResources +import app.revanced.util.doRecursively +import app.revanced.util.getStringOptionValue +import app.revanced.util.underBarOrThrow +import org.w3c.dom.Element + +private const val DEFAULT_ICON = "extension" + +@Suppress("unused") +val visualPreferencesIconsPatch = resourcePatch( + VISUAL_PREFERENCES_ICONS_FOR_YOUTUBE_MUSIC.title, + VISUAL_PREFERENCES_ICONS_FOR_YOUTUBE_MUSIC.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + val settingsMenuIconOption = stringOption( + key = "settingsMenuIcon", + default = DEFAULT_ICON, + values = mapOf( + "Custom branding icon" to "custom_branding_icon", + "Extension" to DEFAULT_ICON, + "Gear" to "gear", + "ReVanced" to "revanced", + "ReVanced Colored" to "revanced_colored", + ), + title = "RVX settings menu icon", + description = "The icon for the RVX settings menu.", + required = true, + ) + + execute { + // Check patch options first. + val selectedIconType = settingsMenuIconOption + .underBarOrThrow() + + val appIconOption = customBrandingIconPatch + .getStringOptionValue("appIcon") + + val customBrandingIconType = appIconOption + .underBarOrThrow() + + // region copy shared resources. + + arrayOf( + ResourceGroup( + "drawable", + *preferenceKey.map { it + "_icon.xml" }.toTypedArray() + ), + ).forEach { resourceGroup -> + copyResources("music/visual/shared", resourceGroup) + } + + // endregion. + + // region copy RVX settings menu icon. + + val iconPath = when (selectedIconType) { + "custom_branding_icon" -> "music/branding/$customBrandingIconType/settings" + else -> "music/visual/icons/$selectedIconType" + } + val resourceGroup = ResourceGroup( + "drawable", + "revanced_extended_settings_icon.xml" + ) + + try { + copyResources(iconPath, resourceGroup) + } catch (_: Exception) { + // Ignore if resource copy fails + } + + // endregion. + + updatePatchStatus(VISUAL_PREFERENCES_ICONS_FOR_YOUTUBE_MUSIC) + + } + + finalize { + // region set visual preferences icon. + + document(SETTINGS_HEADER_PATH).use { document -> + document.doRecursively loop@{ node -> + if (node !is Element) return@loop + + node.getAttributeNode("android:key") + ?.textContent + ?.removePrefix("@string/") + ?.let { title -> + val drawableName = when (title) { + in preferenceKey -> title + "_icon" + else -> null + } + + drawableName?.let { + node.setAttribute("android:icon", "@drawable/$it") + } + } + } + } + + // endregion. + } +} + + +// region preference key and icon. + +private val preferenceKey = setOf( + // YouTube settings. + "pref_key_parent_tools", + "settings_header_general", + "settings_header_playback", + "settings_header_data_saving", + "settings_header_downloads_and_storage", + "settings_header_notifications", + "settings_header_privacy_and_location", + "settings_header_recommendations", + "settings_header_paid_memberships", + "settings_header_about_youtube_music", + + // RVX settings. + "revanced_extended_settings", + + "revanced_preference_screen_account", + "revanced_preference_screen_action_bar", + "revanced_preference_screen_ads", + "revanced_preference_screen_flyout", + "revanced_preference_screen_general", + "revanced_preference_screen_navigation", + "revanced_preference_screen_player", + "revanced_preference_screen_settings", + "revanced_preference_screen_video", + "revanced_preference_screen_ryd", + "revanced_preference_screen_return_youtube_username", + "revanced_preference_screen_sb", + "revanced_preference_screen_misc", +) + +// endregion. + + diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/backgroundplayback/BackgroundPlaybackPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/backgroundplayback/BackgroundPlaybackPatch.kt new file mode 100644 index 0000000000..4e0e2b1bd2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/backgroundplayback/BackgroundPlaybackPatch.kt @@ -0,0 +1,107 @@ +package app.revanced.patches.music.misc.backgroundplayback + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.patch.PatchList.REMOVE_BACKGROUND_PLAYBACK_RESTRICTIONS +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.resolvable +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstStringInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +@Suppress("unused") +val backgroundPlaybackPatch = bytecodePatch( + REMOVE_BACKGROUND_PLAYBACK_RESTRICTIONS.title, + REMOVE_BACKGROUND_PLAYBACK_RESTRICTIONS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + // region patch for background play + + backgroundPlaybackManagerFingerprint.methodOrThrow().addInstructions( + 0, """ + const/4 v0, 0x1 + return v0 + """ + ) + + // endregion + + // region patch for exclusive audio playback + + // don't play music video + musicBrowserServiceFingerprint.matchOrThrow().let { + it.method.apply { + val stringIndex = it.stringMatches!!.first().index + val targetIndex = indexOfFirstInstructionOrThrow(stringIndex) { + val reference = getReference() + opcode == Opcode.INVOKE_VIRTUAL && + reference?.returnType == "Z" && + reference.parameterTypes.size == 0 + } + + getWalkerMethod(targetIndex).addInstructions( + 0, """ + const/4 v0, 0x1 + return v0 + """ + ) + } + } + + // don't play podcast videos + // enable by default from YouTube Music 7.05.52+ + + if (podCastConfigFingerprint.resolvable() && + dataSavingSettingsFragmentFingerprint.resolvable() + ) { + podCastConfigFingerprint.methodOrThrow().apply { + val insertIndex = implementation!!.instructions.size - 1 + val targetRegister = getInstruction(insertIndex).registerA + + addInstruction( + insertIndex, + "const/4 v$targetRegister, 0x1" + ) + } + + dataSavingSettingsFragmentFingerprint.methodOrThrow().apply { + val insertIndex = + indexOfFirstStringInstructionOrThrow("pref_key_dont_play_nma_video") + 4 + val targetRegister = getInstruction(insertIndex).registerD + + addInstruction( + insertIndex, + "const/4 v$targetRegister, 0x1" + ) + } + } + + // endregion + + // region patch for minimized playback + + kidsBackgroundPlaybackPolicyControllerFingerprint.methodOrThrow().addInstruction( + 0, "return-void" + ) + + // endregion + + updatePatchStatus(REMOVE_BACKGROUND_PLAYBACK_RESTRICTIONS) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/backgroundplayback/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/backgroundplayback/Fingerprints.kt new file mode 100644 index 0000000000..35524cdb8b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/backgroundplayback/Fingerprints.kt @@ -0,0 +1,65 @@ +package app.revanced.patches.music.misc.backgroundplayback + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val backgroundPlaybackManagerFingerprint = legacyFingerprint( + name = "backgroundPlaybackManagerFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("L"), + literals = listOf(64657230L), +) + +internal val dataSavingSettingsFragmentFingerprint = legacyFingerprint( + name = "dataSavingSettingsFragmentFingerprint", + returnType = "V", + parameters = listOf("Landroid/os/Bundle;", "Ljava/lang/String;"), + strings = listOf("pref_key_dont_play_nma_video"), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/DataSavingSettingsFragment;") && + method.name == "onCreatePreferences" + } +) + +internal val kidsBackgroundPlaybackPolicyControllerFingerprint = legacyFingerprint( + name = "kidsBackgroundPlaybackPolicyControllerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("I", "L", "Z"), + opcodes = listOf( + Opcode.IGET, + Opcode.IF_NE, + Opcode.IGET_OBJECT, + Opcode.IF_NE, + Opcode.IGET_BOOLEAN, + Opcode.IF_EQ, + Opcode.GOTO, + Opcode.RETURN_VOID, + Opcode.SGET_OBJECT, + Opcode.CONST_4, + Opcode.IF_NE, + Opcode.IPUT_BOOLEAN + ) +) + +internal val musicBrowserServiceFingerprint = legacyFingerprint( + name = "musicBrowserServiceFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Ljava/lang/String;", "Landroid/os/Bundle;"), + strings = listOf("android.service.media.extra.RECENT"), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/MusicBrowserService;") + }, +) + +internal val podCastConfigFingerprint = legacyFingerprint( + name = "podCastConfigFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(45388403L), +) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/bitrate/BitrateDefaultValuePatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/bitrate/BitrateDefaultValuePatch.kt new file mode 100644 index 0000000000..c0311d746d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/bitrate/BitrateDefaultValuePatch.kt @@ -0,0 +1,41 @@ +package app.revanced.patches.music.misc.bitrate + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.patch.PatchList.BITRATE_DEFAULT_VALUE +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.settingsPatch + +@Suppress("unused") +val bitrateDefaultValuePatch = resourcePatch( + BITRATE_DEFAULT_VALUE.title, + BITRATE_DEFAULT_VALUE.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + document("res/xml/data_saving_settings.xml").use { document -> + document.getElementsByTagName("com.google.android.apps.youtube.music.ui.preference.PreferenceCategoryCompat") + .item(0).childNodes.apply { + arrayOf("BitrateAudioMobile", "BitrateAudioWiFi").forEach { + for (i in 1 until length) { + val view = item(i) + if ( + view.hasAttributes() && + view.attributes.getNamedItem("android:key").nodeValue.endsWith(it) + ) { + view.attributes.getNamedItem("android:defaultValue").nodeValue = + "Always High" + break + } + } + } + } + } + + updatePatchStatus(BITRATE_DEFAULT_VALUE) + + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/codecs/OpusCodecPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/codecs/OpusCodecPatch.kt new file mode 100644 index 0000000000..ee0a01215a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/codecs/OpusCodecPatch.kt @@ -0,0 +1,37 @@ +package app.revanced.patches.music.misc.codecs + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.MISC_PATH +import app.revanced.patches.music.utils.patch.PatchList.ENABLE_OPUS_CODEC +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.shared.opus.baseOpusCodecsPatch + +@Suppress("unused") +val opusCodecPatch = resourcePatch( + ENABLE_OPUS_CODEC.title, + ENABLE_OPUS_CODEC.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + baseOpusCodecsPatch( + "$MISC_PATH/OpusCodecPatch;->enableOpusCodec()Z" + ), + settingsPatch + ) + + execute { + addSwitchPreference( + CategoryType.MISC, + "revanced_enable_opus_codec", + "false" + ) + + updatePatchStatus(ENABLE_OPUS_CODEC) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/debugging/DebuggingPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/debugging/DebuggingPatch.kt new file mode 100644 index 0000000000..9c51bae09d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/debugging/DebuggingPatch.kt @@ -0,0 +1,36 @@ +package app.revanced.patches.music.misc.debugging + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.patch.PatchList.ENABLE_DEBUG_LOGGING +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch + +@Suppress("unused") +val debuggingPatch = resourcePatch( + ENABLE_DEBUG_LOGGING.title, + ENABLE_DEBUG_LOGGING.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + addSwitchPreference( + CategoryType.MISC, + "revanced_enable_debug_logging", + "false" + ) + addSwitchPreference( + CategoryType.MISC, + "revanced_enable_debug_buffer_logging", + "false", + "revanced_enable_debug_logging" + ) + + updatePatchStatus(ENABLE_DEBUG_LOGGING) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/share/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/share/Fingerprints.kt new file mode 100644 index 0000000000..6924eadba3 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/share/Fingerprints.kt @@ -0,0 +1,14 @@ +package app.revanced.patches.music.misc.share + +import app.revanced.patches.music.utils.resourceid.bottomSheetRecyclerView +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val bottomSheetRecyclerViewFingerprint = legacyFingerprint( + name = "bottomSheetRecyclerViewFingerprint", + returnType = "Lj${'$'}/util/Optional;", + accessFlags = AccessFlags.PROTECTED or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(bottomSheetRecyclerView), +) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/share/ShareSheetPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/share/ShareSheetPatch.kt new file mode 100644 index 0000000000..53de64e8d7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/share/ShareSheetPatch.kt @@ -0,0 +1,66 @@ +package app.revanced.patches.music.misc.share + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.music.utils.extension.Constants.MISC_PATH +import app.revanced.patches.music.utils.patch.PatchList.CHANGE_SHARE_SHEET +import app.revanced.patches.music.utils.resourceid.bottomSheetRecyclerView +import app.revanced.patches.music.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$MISC_PATH/ShareSheetPatch;" + +private const val FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/ShareSheetMenuFilter;" + +@Suppress("unused") +val shareSheetPatch = bytecodePatch( + CHANGE_SHARE_SHEET.title, + CHANGE_SHARE_SHEET.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + lithoFilterPatch, + settingsPatch, + sharedResourceIdPatch + ) + + execute { + bottomSheetRecyclerViewFingerprint.methodOrThrow().apply { + val constIndex = indexOfFirstLiteralInstructionOrThrow(bottomSheetRecyclerView) + val targetIndex = indexOfFirstInstructionOrThrow(constIndex, Opcode.CHECK_CAST) + val targetRegister = getInstruction(targetIndex).registerA + + addInstruction( + targetIndex + 1, + "invoke-static {v$targetRegister}, $EXTENSION_CLASS_DESCRIPTOR->onShareSheetMenuCreate(Landroid/support/v7/widget/RecyclerView;)V" + ) + } + + addLithoFilter(FILTER_CLASS_DESCRIPTOR) + + addSwitchPreference( + CategoryType.MISC, + "revanced_change_share_sheet", + "false" + ) + + updatePatchStatus(CHANGE_SHARE_SHEET) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/splash/CairoSplashAnimationPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/splash/CairoSplashAnimationPatch.kt new file mode 100644 index 0000000000..79779e4d6e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/splash/CairoSplashAnimationPatch.kt @@ -0,0 +1,101 @@ +package app.revanced.patches.music.misc.splash + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.music.utils.compatibility.Constants.YOUTUBE_MUSIC_PACKAGE_NAME +import app.revanced.patches.music.utils.extension.Constants.MISC_PATH +import app.revanced.patches.music.utils.patch.PatchList.DISABLE_CAIRO_SPLASH_ANIMATION +import app.revanced.patches.music.utils.playservice.is_7_06_or_greater +import app.revanced.patches.music.utils.playservice.is_7_20_or_greater +import app.revanced.patches.music.utils.playservice.versionCheckPatch +import app.revanced.patches.music.utils.resourceid.mainActivityLaunchAnimation +import app.revanced.patches.music.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private const val EXTENSION_METHOD_DESCRIPTOR = + "$MISC_PATH/CairoSplashAnimationPatch;->disableCairoSplashAnimation(Z)Z" + +@Suppress("unused") +val cairoSplashAnimationPatch = bytecodePatch( + DISABLE_CAIRO_SPLASH_ANIMATION.title, + DISABLE_CAIRO_SPLASH_ANIMATION.summary, +) { + compatibleWith( + YOUTUBE_MUSIC_PACKAGE_NAME( + "7.06.54", + "7.16.53", + ), + ) + + dependsOn( + settingsPatch, + sharedResourceIdPatch, + versionCheckPatch, + ) + + execute { + if (!is_7_06_or_greater) { + println("WARNING: This patch is not supported in this version. Use YouTube Music 7.06.54 or later.") + return@execute + } else if (!is_7_20_or_greater) { + cairoSplashAnimationConfigFingerprint.injectLiteralInstructionBooleanCall( + 45635386L, + EXTENSION_METHOD_DESCRIPTOR + ) + } else { + cairoSplashAnimationConfigFingerprint.methodOrThrow().apply { + val literalIndex = indexOfFirstLiteralInstructionOrThrow( + mainActivityLaunchAnimation + ) + val insertIndex = indexOfFirstInstructionReversedOrThrow(literalIndex) { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "setContentView" + } + 1 + val viewStubFindViewByIdIndex = indexOfFirstInstructionOrThrow(literalIndex) { + val reference = getReference() + opcode == Opcode.INVOKE_VIRTUAL && + reference?.name == "findViewById" && + reference.definingClass != "Landroid/view/View;" + } + val freeRegister = + getInstruction(viewStubFindViewByIdIndex).registerD + val jumpIndex = indexOfFirstInstructionReversedOrThrow( + viewStubFindViewByIdIndex, + Opcode.IGET_OBJECT + ) + + addInstructionsWithLabels( + insertIndex, """ + const/4 v$freeRegister, 0x1 + invoke-static {v$freeRegister}, $EXTENSION_METHOD_DESCRIPTOR + move-result v$freeRegister + if-eqz v$freeRegister, :skip + """, ExternalLabel("skip", getInstruction(jumpIndex)) + ) + } + } + + addSwitchPreference( + CategoryType.MISC, + "revanced_disable_cairo_splash_animation", + "false" + ) + + updatePatchStatus(DISABLE_CAIRO_SPLASH_ANIMATION) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/splash/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/splash/Fingerprints.kt new file mode 100644 index 0000000000..05fbdf8438 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/splash/Fingerprints.kt @@ -0,0 +1,26 @@ +package app.revanced.patches.music.misc.splash + +import app.revanced.patches.music.utils.playservice.is_7_20_or_greater +import app.revanced.patches.music.utils.resourceid.mainActivityLaunchAnimation +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.indexOfFirstLiteralInstruction + +/** + * This fingerprint is compatible with YouTube Music v7.06.53+ + */ +internal val cairoSplashAnimationConfigFingerprint = legacyFingerprint( + name = "cairoSplashAnimationConfigFingerprint", + returnType = "V", + customFingerprint = handler@{ method, _ -> + if (method.definingClass != "Lcom/google/android/apps/youtube/music/activities/MusicActivity;") + return@handler false + if (method.name != "onCreate") + return@handler false + + if (is_7_20_or_greater) { + method.indexOfFirstLiteralInstruction(mainActivityLaunchAnimation) >= 0 + } else { + method.indexOfFirstLiteralInstruction(45635386) >= 0 + } + } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/thumbnails/BypassImageRegionRestrictionsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/thumbnails/BypassImageRegionRestrictionsPatch.kt new file mode 100644 index 0000000000..7390e1ef3d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/thumbnails/BypassImageRegionRestrictionsPatch.kt @@ -0,0 +1,37 @@ +package app.revanced.patches.music.misc.thumbnails + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.patch.PatchList.BYPASS_IMAGE_REGION_RESTRICTIONS +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.shared.imageurl.addImageUrlHook +import app.revanced.patches.shared.imageurl.cronetImageUrlHookPatch + +@Suppress("unused") +val bypassImageRegionRestrictionsPatch = bytecodePatch( + BYPASS_IMAGE_REGION_RESTRICTIONS.title, + BYPASS_IMAGE_REGION_RESTRICTIONS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + settingsPatch, + cronetImageUrlHookPatch(false) + ) + + execute { + addImageUrlHook() + + addSwitchPreference( + CategoryType.MISC, + "revanced_bypass_image_region_restrictions", + "false" + ) + + updatePatchStatus(BYPASS_IMAGE_REGION_RESTRICTIONS) + + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/tracking/SanitizeUrlQueryPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/tracking/SanitizeUrlQueryPatch.kt new file mode 100644 index 0000000000..1a8421382a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/tracking/SanitizeUrlQueryPatch.kt @@ -0,0 +1,34 @@ +package app.revanced.patches.music.misc.tracking + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.patch.PatchList.SANITIZE_SHARING_LINKS +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.shared.tracking.baseSanitizeUrlQueryPatch + +@Suppress("unused") +val sanitizeUrlQueryPatch = bytecodePatch( + SANITIZE_SHARING_LINKS.title, + SANITIZE_SHARING_LINKS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + baseSanitizeUrlQueryPatch, + settingsPatch, + ) + + execute { + addSwitchPreference( + CategoryType.MISC, + "revanced_sanitize_sharing_links", + "true" + ) + + updatePatchStatus(SANITIZE_SHARING_LINKS) + + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/navigation/components/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/navigation/components/Fingerprints.kt new file mode 100644 index 0000000000..239029a930 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/navigation/components/Fingerprints.kt @@ -0,0 +1,34 @@ +package app.revanced.patches.music.navigation.components + +import app.revanced.patches.music.utils.resourceid.colorGrey +import app.revanced.patches.music.utils.resourceid.text1 +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val tabLayoutFingerprint = legacyFingerprint( + name = "tabLayoutFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + strings = listOf("FEmusic_radio_builder"), + literals = listOf(colorGrey) +) + +internal val tabLayoutTextFingerprint = legacyFingerprint( + name = "tabLayoutTextFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + opcodes = listOf( + Opcode.IGET, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IF_NEZ, + Opcode.SGET_OBJECT, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT + ), + literals = listOf(text1) +) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/navigation/components/NavigationBarComponentsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/navigation/components/NavigationBarComponentsPatch.kt new file mode 100644 index 0000000000..55adfbadf2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/navigation/components/NavigationBarComponentsPatch.kt @@ -0,0 +1,174 @@ +package app.revanced.patches.music.navigation.components + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.NAVIGATION_CLASS_DESCRIPTOR +import app.revanced.patches.music.utils.patch.PatchList.NAVIGATION_BAR_COMPONENTS +import app.revanced.patches.music.utils.resourceid.colorGrey +import app.revanced.patches.music.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.music.utils.resourceid.text1 +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private const val FLAG = "android:layout_weight" +private const val RESOURCE_FILE_PATH = "res/layout/image_with_text_tab.xml" + +private val navigationBarComponentsResourcePatch = resourcePatch( + description = "navigationBarComponentsResourcePatch" +) { + execute { + document(RESOURCE_FILE_PATH).use { document -> + with(document.getElementsByTagName("ImageView").item(0)) { + if (attributes.getNamedItem(FLAG) != null) + return@with + + document.createAttribute(FLAG) + .apply { value = "0.5" } + .let(attributes::setNamedItem) + } + } + } +} + +@Suppress("unused") +val navigationBarComponentsPatch = bytecodePatch( + NAVIGATION_BAR_COMPONENTS.title, + NAVIGATION_BAR_COMPONENTS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + navigationBarComponentsResourcePatch, + sharedResourceIdPatch, + settingsPatch, + ) + + execute { + /** + * Enable black navigation bar + */ + tabLayoutFingerprint.methodOrThrow().apply { + val constIndex = indexOfFirstLiteralInstructionOrThrow(colorGrey) + val insertIndex = indexOfFirstInstructionOrThrow(constIndex) { + opcode == Opcode.INVOKE_VIRTUAL + && getReference()?.name == "setBackgroundColor" + } + val insertRegister = getInstruction(insertIndex).registerD + + addInstructions( + insertIndex, """ + invoke-static {}, $NAVIGATION_CLASS_DESCRIPTOR->enableBlackNavigationBar()I + move-result v$insertRegister + """ + ) + } + + /** + * Hide navigation labels + */ + tabLayoutTextFingerprint.methodOrThrow().apply { + val constIndex = + indexOfFirstLiteralInstructionOrThrow(text1) + val targetIndex = indexOfFirstInstructionOrThrow(constIndex, Opcode.CHECK_CAST) + val targetParameter = getInstruction(targetIndex).reference + val targetRegister = getInstruction(targetIndex).registerA + + if (!targetParameter.toString().endsWith("Landroid/widget/TextView;")) + throw PatchException("Method signature parameter did not match: $targetParameter") + + addInstruction( + targetIndex + 1, + "invoke-static {v$targetRegister}, $NAVIGATION_CLASS_DESCRIPTOR->hideNavigationLabel(Landroid/widget/TextView;)V" + ) + } + + /** + * Hide navigation bar & buttons + */ + tabLayoutTextFingerprint.matchOrThrow().let { + it.method.apply { + val enumIndex = it.patternMatch!!.startIndex + 3 + val enumRegister = getInstruction(enumIndex).registerA + val insertEnumIndex = indexOfFirstInstructionOrThrow(Opcode.AND_INT_LIT8) - 2 + + val pivotTabIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "getVisibility" + } + val pivotTabRegister = + getInstruction(pivotTabIndex).registerC + + addInstruction( + pivotTabIndex, + "invoke-static {v$pivotTabRegister}, $NAVIGATION_CLASS_DESCRIPTOR->hideNavigationButton(Landroid/view/View;)V" + ) + + addInstruction( + insertEnumIndex, + "sput-object v$enumRegister, $NAVIGATION_CLASS_DESCRIPTOR->lastPivotTab:Ljava/lang/Enum;" + ) + } + } + + addSwitchPreference( + CategoryType.NAVIGATION, + "revanced_enable_black_navigation_bar", + "true" + ) + addSwitchPreference( + CategoryType.NAVIGATION, + "revanced_hide_navigation_home_button", + "false" + ) + addSwitchPreference( + CategoryType.NAVIGATION, + "revanced_hide_navigation_samples_button", + "false" + ) + addSwitchPreference( + CategoryType.NAVIGATION, + "revanced_hide_navigation_explore_button", + "false" + ) + addSwitchPreference( + CategoryType.NAVIGATION, + "revanced_hide_navigation_library_button", + "false" + ) + addSwitchPreference( + CategoryType.NAVIGATION, + "revanced_hide_navigation_upgrade_button", + "true" + ) + addSwitchPreference( + CategoryType.NAVIGATION, + "revanced_hide_navigation_bar", + "false" + ) + addSwitchPreference( + CategoryType.NAVIGATION, + "revanced_hide_navigation_label", + "false" + ) + + updatePatchStatus(NAVIGATION_BAR_COMPONENTS) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/player/components/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/player/components/Fingerprints.kt new file mode 100644 index 0000000000..ea0046e1b0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/player/components/Fingerprints.kt @@ -0,0 +1,356 @@ +package app.revanced.patches.music.player.components + +import app.revanced.patches.music.utils.extension.Constants.PLAYER_CLASS_DESCRIPTOR +import app.revanced.patches.music.utils.playservice.is_7_18_or_greater +import app.revanced.patches.music.utils.resourceid.colorGrey +import app.revanced.patches.music.utils.resourceid.darkBackground +import app.revanced.patches.music.utils.resourceid.miniPlayerDefaultText +import app.revanced.patches.music.utils.resourceid.miniPlayerMdxPlaying +import app.revanced.patches.music.utils.resourceid.miniPlayerPlayPauseReplayButton +import app.revanced.patches.music.utils.resourceid.miniPlayerViewPager +import app.revanced.patches.music.utils.resourceid.playerViewPager +import app.revanced.patches.music.utils.resourceid.remixGenericButtonSize +import app.revanced.patches.music.utils.resourceid.tapBloomView +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +const val AUDIO_VIDEO_SWITCH_TOGGLE_VISIBILITY = + "Lcom/google/android/apps/youtube/music/player/AudioVideoSwitcherToggleView;->setVisibility(I)V" + +internal val audioVideoSwitchToggleFingerprint = legacyFingerprint( + name = "audioVideoSwitchToggleFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + customFingerprint = { method, _ -> + method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.toString() == AUDIO_VIDEO_SWITCH_TOGGLE_VISIBILITY + } >= 0 + } +) + +internal val engagementPanelHeightFingerprint = legacyFingerprint( + name = "engagementPanelHeightFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + // In YouTube Music 7.21.50+, there are two methods with similar structure, so this Opcode pattern must be used. + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + ), + parameters = emptyList(), + customFingerprint = { method, _ -> + method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "booleanValue" + } >= 0 + } +) + +internal val engagementPanelHeightParentFingerprint = legacyFingerprint( + name = "engagementPanelHeightParentFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + opcodes = listOf(Opcode.NEW_ARRAY), + parameters = emptyList(), + customFingerprint = custom@{ method, _ -> + if (method.definingClass.startsWith("Lcom/")) { + return@custom false + } + if (method.returnType == "Ljava/lang/Object;") { + return@custom false + } + method.indexOfFirstInstruction { + opcode == Opcode.CHECK_CAST && + (this as? ReferenceInstruction)?.reference?.toString() == "Lcom/google/android/libraries/youtube/engagementpanel/size/EngagementPanelSizeBehavior;" + } >= 0 + } +) + +internal val handleSearchRenderedFingerprint = legacyFingerprint( + name = "handleSearchRenderedFingerprint", + returnType = "V", + parameters = listOf("L"), + customFingerprint = { method, _ -> method.name == "handleSearchRendered" } +) + +internal val handleSignInEventFingerprint = legacyFingerprint( + name = "handleSignInEventFingerprint", + returnType = "V", + parameters = listOf("L"), + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN_VOID + ), + customFingerprint = { method, _ -> method.name == "handleSignInEvent" } +) + +internal val interactionLoggingEnumFingerprint = legacyFingerprint( + name = "interactionLoggingEnumFingerprint", + returnType = "V", + strings = listOf("INTERACTION_LOGGING_GESTURE_TYPE_SWIPE") +) + +internal val minimizedPlayerFingerprint = legacyFingerprint( + name = "minimizedPlayerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "L"), + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_NEZ, + Opcode.IF_EQZ + ), + strings = listOf("w_st") +) + +internal val miniPlayerConstructorFingerprint = legacyFingerprint( + name = "miniPlayerConstructorFingerprint", + returnType = "V", + strings = listOf("sharedToggleMenuItemMutations"), + literals = listOf(colorGrey, miniPlayerPlayPauseReplayButton) +) + +internal val miniPlayerDefaultTextFingerprint = legacyFingerprint( + name = "miniPlayerDefaultTextFingerprint", + returnType = "V", + parameters = listOf("Ljava/lang/Object;"), + opcodes = listOf( + Opcode.SGET_OBJECT, + Opcode.IF_NE + ), + literals = listOf(miniPlayerDefaultText) +) + +internal val miniPlayerDefaultViewVisibilityFingerprint = legacyFingerprint( + name = "miniPlayerDefaultViewVisibilityFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Landroid/view/View;", "F"), + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.SUB_FLOAT_2ADDR, + Opcode.SGET_OBJECT, + Opcode.INVOKE_VIRTUAL + ), + customFingerprint = { method, classDef -> + method.name == "a" && + classDef.methods.count() == 3 + } +) + +internal val miniPlayerParentFingerprint = legacyFingerprint( + name = "miniPlayerParentFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(miniPlayerMdxPlaying) +) + +internal val mppWatchWhileLayoutFingerprint = legacyFingerprint( + name = "mppWatchWhileLayoutFingerprint", + returnType = "V", + opcodes = listOf(Opcode.NEW_ARRAY), + literals = listOf(miniPlayerPlayPauseReplayButton), + customFingerprint = custom@{ method, _ -> + if (!method.definingClass.endsWith("/MppWatchWhileLayout;")) { + return@custom false + } + if (method.name != "onFinishInflate") { + return@custom false + } + if (!is_7_18_or_greater) { + return@custom true + } + + indexOfCallableInstruction(method) >= 0 + } +) + +internal fun indexOfCallableInstruction(method: Method) = + method.indexOfFirstInstruction { + val reference = getReference() + opcode == Opcode.INVOKE_VIRTUAL && + reference?.returnType == "V" && + reference.parameterTypes.size == 1 && + reference.parameterTypes.firstOrNull() == "Ljava/util/concurrent/Callable;" + } + +internal val musicActivityWidgetFingerprint = legacyFingerprint( + name = "musicActivityWidgetFingerprint", + literals = listOf(79500L), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/MusicActivity;") + } +) + +internal val musicPlaybackControlsFingerprint = legacyFingerprint( + name = "musicPlaybackControlsFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Z"), + opcodes = listOf( + Opcode.IPUT_BOOLEAN, + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN_VOID + ), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/MusicPlaybackControls;") + } +) + +internal val nextButtonVisibilityFingerprint = legacyFingerprint( + name = "nextButtonVisibilityFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.CONST_16, + Opcode.IF_EQZ + ) +) + +internal val oldEngagementPanelFingerprint = legacyFingerprint( + name = "oldEngagementPanelFingerprint", + returnType = "Z", + parameters = emptyList(), + literals = listOf(45427672L), +) + +/** + * Deprecated in YouTube Music v6.34.51+ + */ +internal val oldPlayerBackgroundFingerprint = legacyFingerprint( + name = "oldPlayerBackgroundFingerprint", + returnType = "Z", + parameters = emptyList(), + literals = listOf(45415319L), +) + +/** + * Deprecated in YouTube Music v6.31.55+ + */ +internal val oldPlayerLayoutFingerprint = legacyFingerprint( + name = "oldPlayerLayoutFingerprint", + returnType = "Z", + parameters = emptyList(), + literals = listOf(45399578L), +) + +internal val playerPatchConstructorFingerprint = legacyFingerprint( + name = "playerPatchConstructorFingerprint", + returnType = "V", + customFingerprint = { method, _ -> + method.definingClass == PLAYER_CLASS_DESCRIPTOR && + method.name == "" + } +) + +internal val playerViewPagerConstructorFingerprint = legacyFingerprint( + name = "playerViewPagerConstructorFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + literals = listOf(miniPlayerViewPager, playerViewPager), +) + +internal val quickSeekOverlayFingerprint = legacyFingerprint( + name = "quickSeekOverlayFingerprint", + returnType = "V", + parameters = emptyList(), + literals = listOf(darkBackground, tapBloomView), +) + +internal val remixGenericButtonFingerprint = legacyFingerprint( + name = "remixGenericButtonFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + opcodes = listOf( + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.FLOAT_TO_INT + ), + literals = listOf(remixGenericButtonSize), +) + +internal val repeatTrackFingerprint = legacyFingerprint( + name = "repeatTrackFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "L"), + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.IGET_OBJECT, + Opcode.SGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_NEZ + ), + strings = listOf("w_st") +) + +internal val shuffleOnClickFingerprint = legacyFingerprint( + name = "shuffleOnClickFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Landroid/view/View;"), + literals = listOf(45468L), + customFingerprint = { method, _ -> + method.name == "onClick" && + indexOfAccessibilityInstruction(method) >= 0 + } +) + +internal fun indexOfAccessibilityInstruction(method: Method) = + method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "announceForAccessibility" + } + +internal val swipeToCloseFingerprint = legacyFingerprint( + name = "swipeToCloseFingerprint", + returnType = "Z", + parameters = emptyList(), + literals = listOf(45398432L), +) + +internal val switchToggleColorFingerprint = legacyFingerprint( + name = "switchToggleColorFingerprint", + returnType = "V", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + parameters = listOf("L", "J"), + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + Opcode.IGET + ) +) + +internal val zenModeFingerprint = legacyFingerprint( + name = "zenModeFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "J"), + opcodes = listOf( + Opcode.MOVE_RESULT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT, + Opcode.GOTO, + Opcode.NOP, + Opcode.SGET_OBJECT + ) +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/player/components/PlayerComponentsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/player/components/PlayerComponentsPatch.kt new file mode 100644 index 0000000000..ee62ac2669 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/player/components/PlayerComponentsPatch.kt @@ -0,0 +1,1077 @@ +package app.revanced.patches.music.player.components + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.ResourcePatchContext +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableField.Companion.toMutable +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.music.utils.extension.Constants.EXTENSION_PATH +import app.revanced.patches.music.utils.extension.Constants.PLAYER_CLASS_DESCRIPTOR +import app.revanced.patches.music.utils.mainactivity.mainActivityResolvePatch +import app.revanced.patches.music.utils.patch.PatchList.PLAYER_COMPONENTS +import app.revanced.patches.music.utils.pendingIntentReceiverFingerprint +import app.revanced.patches.music.utils.playservice.is_6_27_or_greater +import app.revanced.patches.music.utils.playservice.is_6_42_or_greater +import app.revanced.patches.music.utils.playservice.is_7_18_or_greater +import app.revanced.patches.music.utils.playservice.versionCheckPatch +import app.revanced.patches.music.utils.resourceid.colorGrey +import app.revanced.patches.music.utils.resourceid.darkBackground +import app.revanced.patches.music.utils.resourceid.miniPlayerPlayPauseReplayButton +import app.revanced.patches.music.utils.resourceid.miniPlayerViewPager +import app.revanced.patches.music.utils.resourceid.playerViewPager +import app.revanced.patches.music.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.music.utils.resourceid.tapBloomView +import app.revanced.patches.music.utils.resourceid.topEnd +import app.revanced.patches.music.utils.resourceid.topStart +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.music.utils.videotype.videoTypeHookPatch +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.patches.shared.mainactivity.getMainActivityMethod +import app.revanced.util.REGISTER_TEMPLATE_REPLACEMENT +import app.revanced.util.addStaticFieldToExtension +import app.revanced.util.adoptChild +import app.revanced.util.cloneMutable +import app.revanced.util.doRecursively +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.injectLiteralInstructionViewCall +import app.revanced.util.fingerprint.matchOrNull +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.mutableClassOrThrow +import app.revanced.util.fingerprint.resolvable +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import app.revanced.util.indexOfFirstStringInstructionOrThrow +import app.revanced.util.insertNode +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.immutable.ImmutableField +import org.w3c.dom.Element + +private const val IMAGE_VIEW_TAG_NAME = + "com.google.android.libraries.youtube.common.ui.TouchImageView" +private const val NEXT_BUTTON_VIEW_ID = + "mini_player_next_button" +private const val PREVIOUS_BUTTON_VIEW_ID = + "mini_player_previous_button" + +private val playerComponentsResourcePatch = resourcePatch( + description = "playerComponentsResourcePatch" +) { + dependsOn(versionCheckPatch) + + execute { + val publicFile = get("res/values/public.xml") + + // Since YT Music v6.42.51,the resources for the next button have been removed, we need to add them manually. + if (is_6_42_or_greater) { + publicFile.writeText( + publicFile.readText() + .replace( + "\"TOP_START\"", + "\"$NEXT_BUTTON_VIEW_ID\"" + ) + ) + insertNode(false) + } + publicFile.writeText( + publicFile.readText() + .replace( + "\"TOP_END\"", + "\"$PREVIOUS_BUTTON_VIEW_ID\"" + ) + ) + insertNode(true) + } +} + +private fun ResourcePatchContext.insertNode(isPreviousButton: Boolean) { + var shouldAddPreviousButton = true + + document("res/layout/watch_while_layout.xml").use { document -> + document.doRecursively loop@{ node -> + if (node !is Element) return@loop + + node.getAttributeNode("android:id")?.let { attribute -> + if (isPreviousButton) { + if (attribute.textContent == "@id/mini_player_play_pause_replay_button" && + shouldAddPreviousButton + ) { + node.insertNode(IMAGE_VIEW_TAG_NAME, node) { + setPreviousButtonNodeAttribute() + } + shouldAddPreviousButton = false + } + } else { + if (attribute.textContent == "@id/mini_player") { + node.adoptChild(IMAGE_VIEW_TAG_NAME) { + setNextButtonNodeAttribute() + } + } + } + } + } + } +} + +private fun Element.setNextButtonNodeAttribute() { + mapOf( + "android:id" to "@id/$NEXT_BUTTON_VIEW_ID", + "android:padding" to "@dimen/item_medium_spacing", + "android:layout_width" to "@dimen/remix_generic_button_size", + "android:layout_height" to "@dimen/remix_generic_button_size", + "android:src" to "@drawable/music_player_next", + "android:scaleType" to "fitCenter", + "android:contentDescription" to "@string/accessibility_next", + "style" to "@style/MusicPlayerButton" + ).forEach { (k, v) -> + setAttribute(k, v) + } +} + +private fun Element.setPreviousButtonNodeAttribute() { + mapOf( + "android:id" to "@id/$PREVIOUS_BUTTON_VIEW_ID", + "android:padding" to "@dimen/item_medium_spacing", + "android:layout_width" to "@dimen/remix_generic_button_size", + "android:layout_height" to "@dimen/remix_generic_button_size", + "android:src" to "@drawable/music_player_prev", + "android:scaleType" to "fitCenter", + "android:contentDescription" to "@string/accessibility_previous", + "style" to "@style/MusicPlayerButton" + ).forEach { (k, v) -> + setAttribute(k, v) + } +} + +private const val FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/PlayerComponentsFilter;" + +private const val EXTENSION_VIDEO_UTILS_CLASS_DESCRIPTOR = + "$EXTENSION_PATH/utils/VideoUtils;" + +@Suppress("unused") +val playerComponentsPatch = bytecodePatch( + PLAYER_COMPONENTS.title, + PLAYER_COMPONENTS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + playerComponentsResourcePatch, + sharedResourceIdPatch, + settingsPatch, + lithoFilterPatch, + mainActivityResolvePatch, + videoTypeHookPatch, + ) + + execute { + // region patch for disable gesture in player + + val playerViewPagerConstructorMethod = + playerViewPagerConstructorFingerprint.methodOrThrow() + val mainActivityOnStartMethod = + getMainActivityMethod("onStart") + + mapOf( + miniPlayerViewPager to "disableMiniPlayerGesture", + playerViewPager to "disablePlayerGesture" + ).forEach { (literal, methodName) -> + val viewPagerReference = with(playerViewPagerConstructorMethod) { + val constIndex = indexOfFirstLiteralInstructionOrThrow(literal) + val targetIndex = indexOfFirstInstructionOrThrow(constIndex, Opcode.IPUT_OBJECT) + + getInstruction(targetIndex).reference.toString() + } + mainActivityOnStartMethod.apply { + val insertIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.IGET_OBJECT && + getReference()?.toString() == viewPagerReference + } + val insertRegister = getInstruction(insertIndex).registerA + val jumpIndex = + indexOfFirstInstructionOrThrow(insertIndex, Opcode.INVOKE_VIRTUAL) + 1 + + addInstructionsWithLabels( + insertIndex, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->$methodName()Z + move-result v$insertRegister + if-nez v$insertRegister, :disable + """, ExternalLabel("disable", getInstruction(jumpIndex)) + ) + } + } + + addSwitchPreference( + CategoryType.PLAYER, + "revanced_disable_mini_player_gesture", + "false" + ) + addSwitchPreference( + CategoryType.PLAYER, + "revanced_disable_player_gesture", + "false" + ) + + // endregion + + // region patch for enable color match player and enable black player background + + val ( + colorMathPlayerMethodParameter, + colorMathPlayerInvokeVirtualReference, + colorMathPlayerIGetReference + ) = switchToggleColorFingerprint.matchOrThrow(miniPlayerConstructorFingerprint).let { + with(it.method) { + val relativeIndex = it.patternMatch!!.endIndex + 1 + val invokeVirtualIndex = + indexOfFirstInstructionOrThrow(relativeIndex, Opcode.INVOKE_VIRTUAL) + val iGetIndex = indexOfFirstInstructionOrThrow(relativeIndex, Opcode.IGET) + + // black player background + val invokeDirectIndex = indexOfFirstInstructionOrThrow(Opcode.INVOKE_DIRECT) + val targetMethod = getWalkerMethod(invokeDirectIndex) + val insertIndex = targetMethod.indexOfFirstInstructionOrThrow(Opcode.IF_NE) + + targetMethod.addInstructions( + insertIndex, """ + invoke-static {p1}, $PLAYER_CLASS_DESCRIPTOR->enableBlackPlayerBackground(I)I + move-result p1 + invoke-static {p2}, $PLAYER_CLASS_DESCRIPTOR->enableBlackPlayerBackground(I)I + move-result p2 + """ + ) + Triple( + parameters, + getInstruction(invokeVirtualIndex).reference, + getInstruction(iGetIndex).reference + ) + } + } + + val colorMathPlayerIPutReference = with(miniPlayerConstructorFingerprint.methodOrThrow()) { + val colorGreyIndex = indexOfFirstLiteralInstructionOrThrow(colorGrey) + val iPutIndex = indexOfFirstInstructionOrThrow(colorGreyIndex, Opcode.IPUT) + getInstruction(iPutIndex).reference + } + + miniPlayerConstructorFingerprint.mutableClassOrThrow().methods.filter { + it.accessFlags == AccessFlags.PUBLIC or AccessFlags.FINAL && + it.parameters == colorMathPlayerMethodParameter && + it.returnType == "V" + }.forEach { method -> + method.apply { + val freeRegister = implementation!!.registerCount - parameters.size - 3 + + val invokeDirectIndex = + indexOfFirstInstructionReversedOrThrow(Opcode.INVOKE_DIRECT) + val invokeDirectReference = + getInstruction(invokeDirectIndex).reference + + addInstructionsWithLabels( + invokeDirectIndex + 1, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->enableColorMatchPlayer()Z + move-result v$freeRegister + if-eqz v$freeRegister, :off + invoke-virtual {p1}, $colorMathPlayerInvokeVirtualReference + move-result-object v$freeRegister + check-cast v$freeRegister, ${(colorMathPlayerIGetReference as FieldReference).definingClass} + iget v$freeRegister, v$freeRegister, $colorMathPlayerIGetReference + iput v$freeRegister, p0, $colorMathPlayerIPutReference + :off + invoke-direct {p0}, $invokeDirectReference + """ + ) + removeInstruction(invokeDirectIndex) + } + } + + addSwitchPreference( + CategoryType.PLAYER, + "revanced_enable_black_player_background", + "false" + ) + addSwitchPreference( + CategoryType.PLAYER, + "revanced_enable_color_match_player", + "true" + ) + + // endregion + + // region patch for enable force minimized player + + minimizedPlayerFingerprint.matchOrThrow().let { + it.method.apply { + val insertIndex = it.patternMatch!!.endIndex + val insertRegister = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $PLAYER_CLASS_DESCRIPTOR->enableForceMinimizedPlayer(Z)Z + move-result v$insertRegister + """ + ) + } + } + + addSwitchPreference( + CategoryType.PLAYER, + "revanced_enable_force_minimized_player", + "true" + ) + + // endregion + + // region patch for enable next previous button + + val nextButtonFieldName = "nextButton" + val previousButtonFieldName = "previousButton" + val nextButtonClassFieldName = "nextButtonClass" + val previousButtonClassFieldName = "previousButtonClass" + val nextButtonButtonMethodName = "setNextButton" + val previousButtonMethodName = "setPreviousButton" + val nextButtonOnClickListenerMethodName = "setNextButtonOnClickListener" + val previousButtonOnClickListenerMethodName = "setPreviousButtonOnClickListener" + val nextButtonIntentString = "YTM Next" + val previousButtonIntentString = "YTM Previous" + + fun MutableMethod.setStaticFieldValue( + fieldName: String, + viewId: Long + ) { + val miniPlayerPlayPauseReplayButtonIndex = + indexOfFirstLiteralInstructionOrThrow(miniPlayerPlayPauseReplayButton) + val constRegister = + getInstruction(miniPlayerPlayPauseReplayButtonIndex).registerA + val findViewByIdIndex = + indexOfFirstInstructionOrThrow( + miniPlayerPlayPauseReplayButtonIndex, + Opcode.INVOKE_VIRTUAL + ) + val findViewByIdRegister = + getInstruction(findViewByIdIndex).registerC + + addInstructions( + miniPlayerPlayPauseReplayButtonIndex, """ + const v$constRegister, $viewId + invoke-virtual {v$findViewByIdRegister, v$constRegister}, $definingClass->findViewById(I)Landroid/view/View; + move-result-object v$constRegister + sput-object v$constRegister, $PLAYER_CLASS_DESCRIPTOR->$fieldName:Landroid/view/View; + """ + ) + } + + fun MutableMethod.setViewArray() { + val miniPlayerPlayPauseReplayButtonIndex = + indexOfFirstLiteralInstructionOrThrow(miniPlayerPlayPauseReplayButton) + val invokeStaticIndex = + indexOfFirstInstructionOrThrow( + miniPlayerPlayPauseReplayButtonIndex, + Opcode.INVOKE_STATIC + ) + val viewArrayRegister = + getInstruction(invokeStaticIndex).registerC + + addInstructions( + invokeStaticIndex, """ + invoke-static {v$viewArrayRegister}, $PLAYER_CLASS_DESCRIPTOR->getViewArray([Landroid/view/View;)[Landroid/view/View; + move-result-object v$viewArrayRegister + """ + ) + } + + fun MutableMethod.setOnClickListener( + intentString: String, + methodName: String, + fieldName: String + ) { + val startIndex = indexOfFirstStringInstructionOrThrow(intentString) + val onClickIndex = + indexOfFirstInstructionReversedOrThrow(startIndex, Opcode.INVOKE_VIRTUAL) + val onClickReference = getInstruction(onClickIndex).reference + val onClickReferenceDefiningClass = (onClickReference as MethodReference).definingClass + + findMethodOrThrow(onClickReferenceDefiningClass) + .apply { + addInstruction( + implementation!!.instructions.lastIndex, + "sput-object p0, $PLAYER_CLASS_DESCRIPTOR->$fieldName:$onClickReferenceDefiningClass" + ) + } + + playerPatchConstructorFingerprint.mutableClassOrThrow().let { mutableClass -> + mutableClass.methods.find { method -> method.name == methodName } + ?.apply { + mutableClass.staticFields.add( + ImmutableField( + definingClass, + fieldName, + onClickReferenceDefiningClass, + AccessFlags.PUBLIC or AccessFlags.STATIC, + null, + annotations, + null + ).toMutable() + ) + addInstructionsWithLabels( + 0, """ + sget-object v0, $PLAYER_CLASS_DESCRIPTOR->$fieldName:$onClickReferenceDefiningClass + if-eqz v0, :ignore + invoke-virtual {v0}, $onClickReference + :ignore + return-void + """ + ) + } + } + } + + val miniPlayerConstructorMutableMethod = + miniPlayerConstructorFingerprint.methodOrThrow() + + val mppWatchWhileLayoutMutableMethod = + mppWatchWhileLayoutFingerprint.methodOrThrow() + + val pendingIntentReceiverMutableMethod = + pendingIntentReceiverFingerprint.methodOrThrow() + + if (!is_6_42_or_greater) { + nextButtonVisibilityFingerprint.matchOrThrow(miniPlayerParentFingerprint).let { + it.method.apply { + val targetIndex = it.patternMatch!!.startIndex + 1 + val targetRegister = + getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, """ + invoke-static {v$targetRegister}, $PLAYER_CLASS_DESCRIPTOR->enableMiniPlayerNextButton(Z)Z + move-result v$targetRegister + """ + ) + } + } + } else { + miniPlayerConstructorMutableMethod.setInstanceFieldValue( + nextButtonButtonMethodName, + topStart + ) + mppWatchWhileLayoutMutableMethod.setStaticFieldValue(nextButtonFieldName, topStart) + pendingIntentReceiverMutableMethod.setOnClickListener( + nextButtonIntentString, + nextButtonOnClickListenerMethodName, + nextButtonClassFieldName + ) + } + + miniPlayerConstructorMutableMethod.setInstanceFieldValue( + previousButtonMethodName, + topEnd + ) + mppWatchWhileLayoutMutableMethod.setStaticFieldValue(previousButtonFieldName, topEnd) + pendingIntentReceiverMutableMethod.setOnClickListener( + previousButtonIntentString, + previousButtonOnClickListenerMethodName, + previousButtonClassFieldName + ) + + mppWatchWhileLayoutMutableMethod.setViewArray() + + addSwitchPreference( + CategoryType.PLAYER, + "revanced_enable_mini_player_next_button", + "true" + ) + addSwitchPreference( + CategoryType.PLAYER, + "revanced_enable_mini_player_previous_button", + "true" + ) + + // endregion + + // region patch for enable swipe to dismiss mini player + + if (!is_6_42_or_greater) { + swipeToCloseFingerprint.methodOrThrow().apply { + val insertIndex = implementation!!.instructions.lastIndex + val targetRegister = + getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, """ + invoke-static {v$targetRegister}, $PLAYER_CLASS_DESCRIPTOR->enableSwipeToDismissMiniPlayer(Z)Z + move-result v$targetRegister + """ + ) + } + } else { + + // region dismiss mini player by swiping down + + val swipeToDismissSGetObjectReference = + with(interactionLoggingEnumFingerprint.methodOrThrow()) { + val stringIndex = + indexOfFirstStringInstructionOrThrow("INTERACTION_LOGGING_GESTURE_TYPE_SWIPE") + val sPutObjectIndex = + indexOfFirstInstructionOrThrow(stringIndex, Opcode.SPUT_OBJECT) + + getInstruction(sPutObjectIndex).reference + } + + val musicActivityWidgetMethod = + musicActivityWidgetFingerprint.methodOrThrow() + + val swipeToDismissWidgetIndex = + musicActivityWidgetMethod.indexOfFirstLiteralInstructionOrThrow(79500L) + + fun getSwipeToDismissReference( + opcode: Opcode, + reversed: Boolean + ) = with(musicActivityWidgetMethod) { + val targetIndex = if (reversed) + indexOfFirstInstructionReversedOrThrow(swipeToDismissWidgetIndex, opcode) + else + indexOfFirstInstructionOrThrow(swipeToDismissWidgetIndex, opcode) + + getInstruction(targetIndex).reference + } + + val swipeToDismissIGetObjectReference = + getSwipeToDismissReference(Opcode.IGET_OBJECT, true) + val swipeToDismissInvokeInterfacePrimaryReference = + getSwipeToDismissReference(Opcode.INVOKE_INTERFACE, true) + val swipeToDismissCheckCastReference = + getSwipeToDismissReference(Opcode.CHECK_CAST, true) + val swipeToDismissNewInstanceReference = + getSwipeToDismissReference(Opcode.NEW_INSTANCE, true) + val swipeToDismissInvokeStaticReference = + getSwipeToDismissReference(Opcode.INVOKE_STATIC, false) + val swipeToDismissInvokeDirectReference = + getSwipeToDismissReference(Opcode.INVOKE_DIRECT, false) + val swipeToDismissInvokeInterfaceSecondaryReference = + getSwipeToDismissReference(Opcode.INVOKE_INTERFACE, false) + + handleSignInEventFingerprint.matchOrThrow(handleSearchRenderedFingerprint).let { + val dismissBehaviorMethod = + it.getWalkerMethod(it.patternMatch!!.startIndex) + + dismissBehaviorMethod.apply { + val insertIndex = indexOfFirstInstructionOrThrow { + getReference()?.type == "Ljava/util/concurrent/atomic/AtomicBoolean;" + } + val primaryRegister = + getInstruction(insertIndex).registerB + val secondaryRegister = primaryRegister + 1 + val tertiaryRegister = secondaryRegister + 1 + + val freeRegister = implementation!!.registerCount - parameters.size - 2 + + addInstructionsWithLabels( + insertIndex, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->enableSwipeToDismissMiniPlayer()Z + move-result v$freeRegister + if-nez v$freeRegister, :dismiss + iget-object v$primaryRegister, v$primaryRegister, $swipeToDismissIGetObjectReference + invoke-interface {v$primaryRegister}, $swipeToDismissInvokeInterfacePrimaryReference + move-result-object v$primaryRegister + check-cast v$primaryRegister, $swipeToDismissCheckCastReference + sget-object v$secondaryRegister, $swipeToDismissSGetObjectReference + new-instance v$tertiaryRegister, $swipeToDismissNewInstanceReference + const p0, 0x878b + invoke-static {p0}, $swipeToDismissInvokeStaticReference + move-result-object p0 + invoke-direct {v$tertiaryRegister, p0}, $swipeToDismissInvokeDirectReference + const/4 p0, 0x0 + invoke-interface {v$primaryRegister, v$secondaryRegister, v$tertiaryRegister, p0}, $swipeToDismissInvokeInterfaceSecondaryReference + return-void + """, ExternalLabel("dismiss", getInstruction(insertIndex)) + ) + } + } + + // endregion + + // region hides default text display when the app is cold started + + miniPlayerDefaultTextFingerprint.matchOrThrow().let { + it.method.apply { + val insertIndex = it.patternMatch!!.endIndex + val insertRegister = + getInstruction(insertIndex).registerB + + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $PLAYER_CLASS_DESCRIPTOR->enableSwipeToDismissMiniPlayer(Ljava/lang/Object;)Ljava/lang/Object; + move-result-object v$insertRegister + """ + ) + } + } + + // endregion + + // region hides default text display after dismissing the mini player + + miniPlayerDefaultViewVisibilityFingerprint.mutableClassOrThrow().let { + it.methods.find { method -> + method.parameters == listOf("Landroid/view/View;", "I") + }?.apply { + val bottomSheetBehaviorIndex = indexOfFirstInstructionOrThrow { + val reference = getReference() + opcode == Opcode.INVOKE_VIRTUAL && + reference?.definingClass == "Lcom/google/android/material/bottomsheet/BottomSheetBehavior;" && + reference.parameterTypes.first() == "Z" + } + val freeRegister = + getInstruction(bottomSheetBehaviorIndex).registerD + + addInstructionsWithLabels( + bottomSheetBehaviorIndex - 2, + """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->enableSwipeToDismissMiniPlayer()Z + move-result v$freeRegister + if-nez v$freeRegister, :dismiss + """, + ExternalLabel("dismiss", getInstruction(bottomSheetBehaviorIndex + 1)) + ) + } ?: throw PatchException("Could not find targetMethod") + } + + // endregion + + } + + addSwitchPreference( + CategoryType.PLAYER, + "revanced_enable_swipe_to_dismiss_mini_player", + "true" + ) + + // endregion + + // region patch for enable zen mode + + // this method is used for old player background (deprecated since YT Music v6.34.51) + zenModeFingerprint.matchOrNull(miniPlayerConstructorFingerprint)?.let { + it.method.apply { + val startIndex = it.patternMatch!!.startIndex + val targetRegister = + getInstruction(startIndex).registerA + + val insertIndex = it.patternMatch!!.endIndex + 1 + + addInstructions( + insertIndex, """ + invoke-static {v$targetRegister}, $PLAYER_CLASS_DESCRIPTOR->enableZenMode(I)I + move-result v$targetRegister + """ + ) + } + } // no exception + + switchToggleColorFingerprint.methodOrThrow(miniPlayerConstructorFingerprint).apply { + val invokeDirectIndex = indexOfFirstInstructionOrThrow(Opcode.INVOKE_DIRECT) + val walkerMethod = getWalkerMethod(invokeDirectIndex) + + walkerMethod.addInstructions( + 0, """ + invoke-static {p1}, $PLAYER_CLASS_DESCRIPTOR->enableZenMode(I)I + move-result p1 + invoke-static {p2}, $PLAYER_CLASS_DESCRIPTOR->enableZenMode(I)I + move-result p2 + """ + ) + } + + addSwitchPreference( + CategoryType.PLAYER, + "revanced_enable_zen_mode", + "false" + ) + addSwitchPreference( + CategoryType.PLAYER, + "revanced_enable_zen_mode_podcast", + "false", + "revanced_enable_zen_mode" + ) + + // endregion + + // region patch for hide audio video switch toggle + + audioVideoSwitchToggleFingerprint.methodOrThrow().apply { + implementation!!.instructions + .withIndex() + .filter { (_, instruction) -> + val reference = (instruction as? ReferenceInstruction)?.reference + instruction.opcode == Opcode.INVOKE_VIRTUAL && + reference is MethodReference && + reference.toString() == AUDIO_VIDEO_SWITCH_TOGGLE_VISIBILITY + } + .map { (index, _) -> index } + .reversed() + .forEach { index -> + val instruction = getInstruction(index) + + replaceInstruction( + index, + "invoke-static {v${instruction.registerC}, v${instruction.registerD}}," + + "$PLAYER_CLASS_DESCRIPTOR->hideAudioVideoSwitchToggle(Landroid/view/View;I)V" + ) + } + } + + addSwitchPreference( + CategoryType.PLAYER, + "revanced_hide_audio_video_switch_toggle", + "false" + ) + + // endregion + + // region patch for hide channel guideline, timestamps & emoji picker buttons + + addLithoFilter(FILTER_CLASS_DESCRIPTOR) + + addSwitchPreference( + CategoryType.PLAYER, + "revanced_hide_comment_channel_guidelines", + "true" + ) + addSwitchPreference( + CategoryType.PLAYER, + "revanced_hide_comment_timestamp_and_emoji_buttons", + "false" + ) + + // region patch for hide double-tap overlay filter + + val smaliInstruction = """ + invoke-static {v$REGISTER_TEMPLATE_REPLACEMENT}, $PLAYER_CLASS_DESCRIPTOR->hideDoubleTapOverlayFilter(Landroid/view/View;)V + """ + + arrayOf( + darkBackground, + tapBloomView + ).forEach { literal -> + quickSeekOverlayFingerprint.injectLiteralInstructionViewCall( + literal, + smaliInstruction + ) + } + + addSwitchPreference( + CategoryType.PLAYER, + "revanced_hide_double_tap_overlay_filter", + "false" + ) + + // endregion + + // region patch for hide fullscreen share button + + remixGenericButtonFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = it.patternMatch!!.endIndex + val targetRegister = getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, """ + invoke-static {v$targetRegister}, $PLAYER_CLASS_DESCRIPTOR->hideFullscreenShareButton(I)I + move-result v$targetRegister + """ + ) + } + } + + addSwitchPreference( + CategoryType.PLAYER, + "revanced_hide_fullscreen_share_button", + "false" + ) + + // endregion + + // region patch for remember repeat state + + repeatTrackFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = it.patternMatch!!.endIndex + val targetRegister = getInstruction(targetIndex).registerA + + addInstructions( + targetIndex, """ + invoke-static {v$targetRegister}, $PLAYER_CLASS_DESCRIPTOR->rememberRepeatState(Z)Z + move-result v$targetRegister + """ + ) + } + } + + addSwitchPreference( + CategoryType.PLAYER, + "revanced_remember_repeat_state", + "true" + ) + + // endregion + + // region patch for remember shuffle state + + shuffleOnClickFingerprint.methodOrThrow().apply { + val accessibilityIndex = indexOfAccessibilityInstruction(this) + + // region set shuffle enum + + val enumIndex = indexOfFirstInstructionReversedOrThrow(accessibilityIndex) { + opcode == Opcode.INVOKE_DIRECT && + getReference()?.returnType == "Ljava/lang/String;" + } + val enumRegister = getInstruction(enumIndex).registerD + val enumClass = + (getInstruction(enumIndex).reference as MethodReference).parameterTypes.first() + + addInstruction( + enumIndex, + "invoke-static {v$enumRegister}, $PLAYER_CLASS_DESCRIPTOR->setShuffleState(Ljava/lang/Enum;)V" + ) + + // endregion + + // region set static field + + val shuffleClassIndex = + indexOfFirstInstructionReversedOrThrow(accessibilityIndex, Opcode.CHECK_CAST) + val shuffleClass = + getInstruction(shuffleClassIndex).reference.toString() + val shuffleMutableClass = classBy { classDef -> + classDef.type == shuffleClass + }?.mutableClass + ?: throw PatchException("shuffle class not found") + + val smaliInstructions = + """ + if-eqz v0, :ignore + sget-object v1, $enumClass->b:$enumClass + invoke-virtual {v0, v1}, $shuffleClass->shuffleTracks($enumClass)V + :ignore + return-void + """ + + addStaticFieldToExtension( + EXTENSION_VIDEO_UTILS_CLASS_DESCRIPTOR, + "shuffleTracks", + "shuffleClass", + shuffleClass, + smaliInstructions + ) + + // endregion + + // region make all methods accessible + + val shuffleMethod = shuffleMutableClass.methods.find { method -> + method.parameterTypes.firstOrNull() == enumClass && + method.parameterTypes.size == 1 && + method.returnType == "V" + } ?: throw PatchException("shuffle method not found") + + shuffleMutableClass.methods.add( + shuffleMethod.cloneMutable( + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + name = "shuffleTracks" + ) + ) + + // endregion + + } + + musicPlaybackControlsFingerprint.methodOrThrow().addInstruction( + 0, + "invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->shuffleTracks()V" + ) + + addSwitchPreference( + CategoryType.PLAYER, + "revanced_remember_shuffle_state", + "true" + ) + + // endregion + + // region patch for restore old comments popup panels + + var restoreOldCommentsPopupPanel = false + + if (is_6_27_or_greater && !is_7_18_or_greater) { + oldEngagementPanelFingerprint.injectLiteralInstructionBooleanCall( + 45427672L, + "$PLAYER_CLASS_DESCRIPTOR->restoreOldCommentsPopUpPanels(Z)Z" + ) + restoreOldCommentsPopupPanel = true + } else if (is_7_18_or_greater) { + + // region disable player from being pushed to the top when opening a comment + + mppWatchWhileLayoutFingerprint.methodOrThrow().apply { + val callableIndex = indexOfCallableInstruction(this) + val insertIndex = + indexOfFirstInstructionReversedOrThrow(callableIndex, Opcode.NEW_INSTANCE) + val insertRegister = getInstruction(insertIndex).registerA + + addInstructionsWithLabels( + insertIndex, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->restoreOldCommentsPopUpPanels()Z + move-result v$insertRegister + if-eqz v$insertRegister, :restore + """, ExternalLabel("restore", getInstruction(callableIndex + 1)) + ) + } + + // endregion + + // region region limit the height of the engagement panel + + engagementPanelHeightFingerprint.matchOrThrow(engagementPanelHeightParentFingerprint) + .let { + it.method.apply { + val targetIndex = it.patternMatch!!.endIndex + val targetRegister = + getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, """ + invoke-static {v$targetRegister}, $PLAYER_CLASS_DESCRIPTOR->restoreOldCommentsPopUpPanels(Z)Z + move-result v$targetRegister + """ + ) + } + } + + miniPlayerDefaultViewVisibilityFingerprint.mutableClassOrThrow().let { + it.methods.find { method -> + method.parameters == listOf("Landroid/view/View;", "I") + }?.apply { + val targetIndex = indexOfFirstInstructionOrThrow { + val reference = getReference() + opcode == Opcode.INVOKE_INTERFACE && + reference?.returnType == "Z" && + reference.parameterTypes.size == 0 + } + 1 + val targetRegister = + getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, """ + invoke-static {v$targetRegister}, $PLAYER_CLASS_DESCRIPTOR->restoreOldCommentsPopUpPanels(Z)Z + move-result v$targetRegister + """ + ) + } ?: throw PatchException("Could not find targetMethod") + } + + // endregion + + restoreOldCommentsPopupPanel = true + } + + if (restoreOldCommentsPopupPanel) { + addSwitchPreference( + CategoryType.PLAYER, + "revanced_restore_old_comments_popup_panels", + "false" + ) + } + + // endregion + + // region patch for restore old player background + + if (oldPlayerBackgroundFingerprint.resolvable()) { + oldPlayerBackgroundFingerprint.injectLiteralInstructionBooleanCall( + 45415319L, + "$PLAYER_CLASS_DESCRIPTOR->restoreOldPlayerBackground(Z)Z" + ) + addSwitchPreference( + CategoryType.PLAYER, + "revanced_restore_old_player_background", + "false" + ) + } + + // endregion + + // region patch for restore old player layout + + if (oldPlayerLayoutFingerprint.resolvable()) { + oldPlayerLayoutFingerprint.injectLiteralInstructionBooleanCall( + 45399578L, + "$PLAYER_CLASS_DESCRIPTOR->restoreOldPlayerLayout(Z)Z" + ) + addSwitchPreference( + CategoryType.PLAYER, + "revanced_restore_old_player_layout", + "false" + ) + } + + // endregion + + updatePatchStatus(PLAYER_COMPONENTS) + + } +} + +private fun MutableMethod.setInstanceFieldValue( + methodName: String, + viewId: Long +) { + val miniPlayerPlayPauseReplayButtonIndex = + indexOfFirstLiteralInstructionOrThrow(miniPlayerPlayPauseReplayButton) + val miniPlayerPlayPauseReplayButtonRegister = + getInstruction(miniPlayerPlayPauseReplayButtonIndex).registerA + val findViewByIdIndex = + indexOfFirstInstructionOrThrow( + miniPlayerPlayPauseReplayButtonIndex, + Opcode.INVOKE_VIRTUAL + ) + val parentViewRegister = + getInstruction(findViewByIdIndex).registerC + + addInstructions( + miniPlayerPlayPauseReplayButtonIndex, """ + const v$miniPlayerPlayPauseReplayButtonRegister, $viewId + invoke-virtual {v$parentViewRegister, v$miniPlayerPlayPauseReplayButtonRegister}, Landroid/view/View;->findViewById(I)Landroid/view/View; + move-result-object v$miniPlayerPlayPauseReplayButtonRegister + invoke-static {v$miniPlayerPlayPauseReplayButtonRegister}, $PLAYER_CLASS_DESCRIPTOR->$methodName(Landroid/view/View;)V + """ + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/Fingerprints.kt new file mode 100644 index 0000000000..e7ddbf8717 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/Fingerprints.kt @@ -0,0 +1,35 @@ +package app.revanced.patches.music.utils + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val pendingIntentReceiverFingerprint = legacyFingerprint( + name = "pendingIntentReceiverFingerprint", + returnType = "V", + strings = listOf("YTM Dislike", "YTM Next", "YTM Previous"), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/PendingIntentReceiver;") + } +) + +internal val playbackSpeedFingerprint = legacyFingerprint( + name = "playbackSpeedFingerprint", + returnType = "V", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf( + Opcode.CHECK_CAST, + Opcode.CONST_HIGH16, + Opcode.INVOKE_VIRTUAL + ) +) + +internal val playbackSpeedParentFingerprint = legacyFingerprint( + name = "playbackSpeedParentFingerprint", + returnType = "V", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + parameters = emptyList(), + strings = listOf("BT metadata: %s, %s, %s") +) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/compatibility/Constants.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/compatibility/Constants.kt new file mode 100644 index 0000000000..83606bd71b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/compatibility/Constants.kt @@ -0,0 +1,19 @@ +package app.revanced.patches.music.utils.compatibility + +import app.revanced.patcher.patch.PackageName +import app.revanced.patcher.patch.VersionName + +internal object Constants { + internal const val YOUTUBE_MUSIC_PACKAGE_NAME = "com.google.android.apps.youtube.music" + + val COMPATIBLE_PACKAGE: Pair?> = Pair( + YOUTUBE_MUSIC_PACKAGE_NAME, + setOf( + "6.20.51", // This is the latest version that supports Android 5.0 + "6.29.59", // This is the latest version that supports the 'Restore old player layout' setting. + "6.42.55", // This is the latest version that supports Android 7.0 + "6.51.53", // This is the latest version of YouTube Music 6.xx.xx + "7.16.53", // This is the latest version supported by the RVX patch. + ) + ) +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/extension/Constants.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/extension/Constants.kt new file mode 100644 index 0000000000..554e0e0dc2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/extension/Constants.kt @@ -0,0 +1,29 @@ +package app.revanced.patches.music.utils.extension + +@Suppress("MemberVisibilityCanBePrivate") +internal object Constants { + const val EXTENSION_PATH = "Lapp/revanced/extension/music" + const val SHARED_PATH = "$EXTENSION_PATH/shared" + const val PATCHES_PATH = "$EXTENSION_PATH/patches" + + const val ACCOUNT_PATH = "$PATCHES_PATH/account" + const val ACTIONBAR_PATH = "$PATCHES_PATH/actionbar" + const val ADS_PATH = "$PATCHES_PATH/ads" + const val COMPONENTS_PATH = "$PATCHES_PATH/components" + const val FLYOUT_PATH = "$PATCHES_PATH/flyout" + const val GENERAL_PATH = "$PATCHES_PATH/general" + const val MISC_PATH = "$PATCHES_PATH/misc" + const val NAVIGATION_PATH = "$PATCHES_PATH/navigation" + const val PLAYER_PATH = "$PATCHES_PATH/player" + const val VIDEO_PATH = "$PATCHES_PATH/video" + const val UTILS_PATH = "$PATCHES_PATH/utils" + + const val ACCOUNT_CLASS_DESCRIPTOR = "$ACCOUNT_PATH/AccountPatch;" + const val ACTIONBAR_CLASS_DESCRIPTOR = "$ACTIONBAR_PATH/ActionBarPatch;" + const val FLYOUT_CLASS_DESCRIPTOR = "$FLYOUT_PATH/FlyoutPatch;" + const val GENERAL_CLASS_DESCRIPTOR = "$GENERAL_PATH/GeneralPatch;" + const val NAVIGATION_CLASS_DESCRIPTOR = "$NAVIGATION_PATH/NavigationPatch;" + const val PLAYER_CLASS_DESCRIPTOR = "$PLAYER_PATH/PlayerPatch;" + + const val PATCH_STATUS_CLASS_DESCRIPTOR = "$UTILS_PATH/PatchStatus;" +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/extension/SharedExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/extension/SharedExtensionPatch.kt new file mode 100644 index 0000000000..2151b9af55 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/extension/SharedExtensionPatch.kt @@ -0,0 +1,6 @@ +package app.revanced.patches.music.utils.extension + +import app.revanced.patches.music.utils.extension.hooks.applicationInitHook +import app.revanced.patches.shared.extension.sharedExtensionPatch + +val sharedExtensionPatch = sharedExtensionPatch(applicationInitHook) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/extension/hooks/ApplicationInitHook.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/extension/hooks/ApplicationInitHook.kt new file mode 100644 index 0000000000..c30a7e1b5e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/extension/hooks/ApplicationInitHook.kt @@ -0,0 +1,20 @@ +package app.revanced.patches.music.utils.extension.hooks + +import app.revanced.patches.shared.extension.extensionHook +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val applicationInitHook = extensionHook { + returns("V") + parameters() + strings("activity") + custom { method, _ -> + method.name == "onCreate" && + method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_VIRTUAL + && getReference()?.name == "getRunningAppProcesses" + } >= 0 + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/fix/androidauto/AndroidAutoCertificatePatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/fix/androidauto/AndroidAutoCertificatePatch.kt new file mode 100644 index 0000000000..9f178f0f2c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/fix/androidauto/AndroidAutoCertificatePatch.kt @@ -0,0 +1,29 @@ +package app.revanced.patches.music.utils.fix.androidauto + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.patch.PatchList.CERTIFICATE_SPOOF +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.util.fingerprint.methodOrThrow + +@Suppress("unused") +val androidAutoCertificatePatch = bytecodePatch( + CERTIFICATE_SPOOF.title, + CERTIFICATE_SPOOF.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + execute { + certificateCheckFingerprint.methodOrThrow().addInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + + updatePatchStatus(CERTIFICATE_SPOOF) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/fix/androidauto/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/fix/androidauto/Fingerprints.kt new file mode 100644 index 0000000000..28746adfc5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/fix/androidauto/Fingerprints.kt @@ -0,0 +1,10 @@ +package app.revanced.patches.music.utils.fix.androidauto + +import app.revanced.util.fingerprint.legacyFingerprint + +internal val certificateCheckFingerprint = legacyFingerprint( + name = "certificateCheckFingerprint", + returnType = "Z", + parameters = listOf("L"), + strings = listOf("X509") +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/fix/fileprovider/FileProviderPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/fix/fileprovider/FileProviderPatch.kt new file mode 100644 index 0000000000..1def915a12 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/fix/fileprovider/FileProviderPatch.kt @@ -0,0 +1,45 @@ +package app.revanced.patches.music.utils.fix.fileprovider + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.util.fingerprint.methodOrThrow + +fun fileProviderPatch( + youtubePackageName: String, + musicPackageName: String +) = bytecodePatch( + description = "fileProviderPatch" +) { + execute { + + /** + * For some reason, if the app gets "android.support.FILE_PROVIDER_PATHS", + * the package name of YouTube is used, not the package name of the YT Music. + * + * There is no issue in the stock YT Music, but this is an issue in the GmsCore Build. + * https://github.com/inotia00/ReVanced_Extended/issues/1830 + * + * To solve this issue, replace the package name of YouTube with YT Music's package name. + */ + fileProviderResolverFingerprint.methodOrThrow().apply { + addInstructionsWithLabels( + 0, """ + const-string v0, "com.google.android.youtube.fileprovider" + invoke-static {p1, v0}, Ljava/util/Objects;->equals(Ljava/lang/Object;Ljava/lang/Object;)Z + move-result v0 + if-nez v0, :fix + const-string v0, "$youtubePackageName.fileprovider" + invoke-static {p1, v0}, Ljava/util/Objects;->equals(Ljava/lang/Object;Ljava/lang/Object;)Z + move-result v0 + if-nez v0, :fix + goto :ignore + :fix + const-string p1, "$musicPackageName.fileprovider" + """, ExternalLabel("ignore", getInstruction(0)) + ) + } + + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/fix/fileprovider/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/fix/fileprovider/Fingerprints.kt new file mode 100644 index 0000000000..44e5d72ff5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/fix/fileprovider/Fingerprints.kt @@ -0,0 +1,12 @@ +package app.revanced.patches.music.utils.fix.fileprovider + +import app.revanced.util.fingerprint.legacyFingerprint + +internal val fileProviderResolverFingerprint = legacyFingerprint( + name = "fileProviderResolverFingerprint", + returnType = "L", + strings = listOf( + "android.support.FILE_PROVIDER_PATHS", + "Name must not be empty" + ) +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/flyoutmenu/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/flyoutmenu/Fingerprints.kt new file mode 100644 index 0000000000..17366b1638 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/flyoutmenu/Fingerprints.kt @@ -0,0 +1,14 @@ +package app.revanced.patches.music.utils.flyoutmenu + +import app.revanced.patches.music.utils.resourceid.varispeedUnavailableTitle +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val playbackRateBottomSheetClassFingerprint = legacyFingerprint( + name = "playbackRateBottomSheetClassFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(varispeedUnavailableTitle) +) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/flyoutmenu/FlyoutMenuHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/flyoutmenu/FlyoutMenuHookPatch.kt new file mode 100644 index 0000000000..c2d7b0c8f9 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/flyoutmenu/FlyoutMenuHookPatch.kt @@ -0,0 +1,38 @@ +package app.revanced.patches.music.utils.flyoutmenu + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.utils.extension.Constants.EXTENSION_PATH +import app.revanced.patches.music.utils.resourceid.sharedResourceIdPatch +import app.revanced.util.addStaticFieldToExtension +import app.revanced.util.fingerprint.methodOrThrow + +private const val EXTENSION_VIDEO_UTILS_CLASS_DESCRIPTOR = + "$EXTENSION_PATH/utils/VideoUtils;" + +val flyoutMenuHookPatch = bytecodePatch( + description = "flyoutMenuHookPatch", +) { + dependsOn(sharedResourceIdPatch) + + execute { + + playbackRateBottomSheetClassFingerprint.methodOrThrow().apply { + val smaliInstructions = + """ + if-eqz v0, :ignore + invoke-virtual {v0}, $definingClass->$name()V + :ignore + return-void + """ + + addStaticFieldToExtension( + EXTENSION_VIDEO_UTILS_CLASS_DESCRIPTOR, + "showPlaybackSpeedFlyoutMenu", + "playbackRateBottomSheetClass", + definingClass, + smaliInstructions + ) + } + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/gms/GmsCoreSupportPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/gms/GmsCoreSupportPatch.kt new file mode 100644 index 0000000000..a28b0ebfa5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/gms/GmsCoreSupportPatch.kt @@ -0,0 +1,61 @@ +package app.revanced.patches.music.utils.gms + +import app.revanced.patcher.patch.Option +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.compatibility.Constants.YOUTUBE_MUSIC_PACKAGE_NAME +import app.revanced.patches.music.utils.extension.sharedExtensionPatch +import app.revanced.patches.music.utils.fix.fileprovider.fileProviderPatch +import app.revanced.patches.music.utils.mainactivity.mainActivityFingerprint +import app.revanced.patches.music.utils.patch.PatchList.GMSCORE_SUPPORT +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.addGmsCorePreference +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePackageName +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.shared.gms.gmsCoreSupportPatch +import app.revanced.patches.shared.spoof.useragent.baseSpoofUserAgentPatch +import app.revanced.util.valueOrThrow + +@Suppress("unused") +val gmsCoreSupportPatch = gmsCoreSupportPatch( + fromPackageName = YOUTUBE_MUSIC_PACKAGE_NAME, + mainActivityOnCreateFingerprint = mainActivityFingerprint.second, + extensionPatch = sharedExtensionPatch, + gmsCoreSupportResourcePatchFactory = ::gmsCoreSupportResourcePatch, +) { + compatibleWith(COMPATIBLE_PACKAGE) +} + +private fun gmsCoreSupportResourcePatch( + gmsCoreVendorGroupIdOption: Option, + packageNameYouTubeOption: Option, + packageNameYouTubeMusicOption: Option, +) = app.revanced.patches.shared.gms.gmsCoreSupportResourcePatch( + fromPackageName = YOUTUBE_MUSIC_PACKAGE_NAME, + spoofedPackageSignature = "afb0fed5eeaebdd86f56a97742f4b6b33ef59875", + gmsCoreVendorGroupIdOption = gmsCoreVendorGroupIdOption, + packageNameYouTubeOption = packageNameYouTubeOption, + packageNameYouTubeMusicOption = packageNameYouTubeMusicOption, + executeBlock = { + updatePackageName(packageNameYouTubeMusicOption.valueOrThrow()) + + addGmsCorePreference( + CategoryType.MISC.value, + "gms_core_settings", + gmsCoreVendorGroupIdOption.valueOrThrow() + ".android.gms", + "org.microg.gms.ui.SettingsActivity" + ) + + updatePatchStatus(GMSCORE_SUPPORT) + + }, +) { + dependsOn( + baseSpoofUserAgentPatch(YOUTUBE_MUSIC_PACKAGE_NAME), + settingsPatch, + fileProviderPatch( + packageNameYouTubeOption.valueOrThrow(), + packageNameYouTubeMusicOption.valueOrThrow() + ), + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/mainactivity/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/mainactivity/Fingerprints.kt new file mode 100644 index 0000000000..e80b3f804d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/mainactivity/Fingerprints.kt @@ -0,0 +1,16 @@ +package app.revanced.patches.music.utils.mainactivity + +import app.revanced.util.fingerprint.legacyFingerprint + +internal val mainActivityFingerprint = legacyFingerprint( + name = "mainActivityFingerprint", + returnType = "V", + parameters = listOf("Landroid/os/Bundle;"), + strings = listOf( + "android.intent.action.MAIN", + "FEmusic_home" + ), + customFingerprint = { method, classDef -> + method.name == "onCreate" && classDef.endsWith("Activity;") + } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/mainactivity/MainActivityResolvePatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/mainactivity/MainActivityResolvePatch.kt new file mode 100644 index 0000000000..e1cf54b3ea --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/mainactivity/MainActivityResolvePatch.kt @@ -0,0 +1,5 @@ +package app.revanced.patches.music.utils.mainactivity + +import app.revanced.patches.shared.mainactivity.baseMainActivityResolvePatch + +val mainActivityResolvePatch = baseMainActivityResolvePatch(mainActivityFingerprint) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/patch/PatchList.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/patch/PatchList.kt new file mode 100644 index 0000000000..b9e7ed60af --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/patch/PatchList.kt @@ -0,0 +1,156 @@ +package app.revanced.patches.music.utils.patch + +internal enum class PatchList( + val title: String, + val summary: String, + var included: Boolean? = false +) { + AMOLED( + "Amoled", + "Applies a pure black theme to some components." + ), + BITRATE_DEFAULT_VALUE( + "Bitrate default value", + "Sets the audio quality to 'Always High' when you first install the app." + ), + BYPASS_IMAGE_REGION_RESTRICTIONS( + "Bypass image region restrictions", + "Adds an option to use a different host for static images, so that images blocked in some countries can be received." + ), + CERTIFICATE_SPOOF( + "Certificate spoof", + "Enables YouTube Music to work with Android Auto by spoofing the YouTube Music certificate." + ), + CHANGE_SHARE_SHEET( + "Change share sheet", + "Add option to change from in-app share sheet to system share sheet." + ), + CHANGE_START_PAGE( + "Change start page", + "Adds an option to set which page the app opens in instead of the homepage." + ), + CUSTOM_BRANDING_ICON_FOR_YOUTUBE_MUSIC( + "Custom branding icon for YouTube Music", + "Changes the YouTube Music app icon to the icon specified in patch options." + ), + CUSTOM_BRANDING_NAME_FOR_YOUTUBE_MUSIC( + "Custom branding name for YouTube Music", + "Renames the YouTube Music app to the name specified in patch options." + ), + CUSTOM_HEADER_FOR_YOUTUBE_MUSIC( + "Custom header for YouTube Music", + "Applies a custom header in the top left corner within the app." + ), + DISABLE_CAIRO_SPLASH_ANIMATION( + "Disable Cairo splash animation", + "Adds an option to disable Cairo splash animation." + ), + DISABLE_AUTO_CAPTIONS( + "Disable auto captions", + "Adds an option to disable captions from being automatically enabled." + ), + DISABLE_DISLIKE_REDIRECTION( + "Disable dislike redirection", + "Adds an option to disable redirection to the next track when clicking the Dislike button." + ), + ENABLE_OPUS_CODEC( + "Enable OPUS codec", + "Adds an options to enable the OPUS audio codec if the player response includes." + ), + ENABLE_DEBUG_LOGGING( + "Enable debug logging", + "Adds an option to enable debug logging." + ), + ENABLE_LANDSCAPE_MODE( + "Enable landscape mode", + "Adds an option to enable landscape mode when rotating the screen on phones." + ), + FLYOUT_MENU_COMPONENTS( + "Flyout menu components", + "Adds options to hide or change flyout menu components." + ), + GMSCORE_SUPPORT( + "GmsCore support", + "Allows patched Google apps to run without root and under a different package name by using GmsCore instead of Google Play Services." + ), + HIDE_ACCOUNT_COMPONENTS( + "Hide account components", + "Adds options to hide components related to the account menu." + ), + HIDE_ACTION_BAR_COMPONENTS( + "Hide action bar components", + "Adds options to hide action bar components and replace the offline download button with an external download button." + ), + HIDE_ADS( + "Hide ads", + "Adds options to hide ads." + ), + HIDE_LAYOUT_COMPONENTS( + "Hide layout components", + "Adds options to hide general layout components." + ), + HIDE_OVERLAY_FILTER( + "Hide overlay filter", + "Removes, at compile time, the dark overlay that appears when player flyout menus are open." + ), + HIDE_PLAYER_OVERLAY_FILTER( + "Hide player overlay filter", + "Removes, at compile time, the dark overlay that appears when single-tapping in the player." + ), + NAVIGATION_BAR_COMPONENTS( + "Navigation bar components", + "Adds options to hide or change components related to the navigation bar." + ), + PLAYER_COMPONENTS( + "Player components", + "Adds options to hide or change components related to the player." + ), + REMOVE_BACKGROUND_PLAYBACK_RESTRICTIONS( + "Remove background playback restrictions", + "Removes restrictions on background playback, including for kids videos." + ), + REMOVE_VIEWER_DISCRETION_DIALOG( + "Remove viewer discretion dialog", + "Adds an option to remove the dialog that appears when opening a video that has been age-restricted by accepting it automatically. This does not bypass the age restriction." + ), + RESTORE_OLD_STYLE_LIBRARY_SHELF( + "Restore old style library shelf", + "Adds an option to return the Library tab to the old style." + ), + RETURN_YOUTUBE_DISLIKE( + "Return YouTube Dislike", + "Adds an option to show the dislike count of songs using the Return YouTube Dislike API." + ), + RETURN_YOUTUBE_USERNAME( + "Return YouTube Username", + "Adds an option to replace YouTube handles with usernames in comments using YouTube Data API v3." + ), + SANITIZE_SHARING_LINKS( + "Sanitize sharing links", + "Adds an option to remove tracking query parameters from URLs when sharing links." + ), + SETTINGS_FOR_YOUTUBE_MUSIC( + "Settings for YouTube Music", + "Applies mandatory patches to implement ReVanced Extended settings into the application." + ), + SPONSORBLOCK( + "SponsorBlock", + "Adds options to enable and configure SponsorBlock, which can skip undesired video segments, such as non-music sections." + ), + SPOOF_APP_VERSION( + "Spoof app version", + "Adds options to spoof the YouTube Music client version. This can remove the radio mode restriction in Canadian regions or disable real-time lyrics." + ), + TRANSLATIONS_FOR_YOUTUBE_MUSIC( + "Translations for YouTube Music", + "Add translations or remove string resources." + ), + VIDEO_PLAYBACK( + "Video playback", + "Adds options to customize settings related to video playback, such as default video quality and playback speed." + ), + VISUAL_PREFERENCES_ICONS_FOR_YOUTUBE_MUSIC( + "Visual preferences icons for YouTube Music", + "Adds icons to specific preferences in the settings." + ) +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/playertype/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/playertype/Fingerprints.kt new file mode 100644 index 0000000000..8c8847ebbe --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/playertype/Fingerprints.kt @@ -0,0 +1,22 @@ +package app.revanced.patches.music.utils.playertype + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val playerTypeFingerprint = legacyFingerprint( + name = "playerTypeFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + opcodes = listOf( + Opcode.IGET_BOOLEAN, + Opcode.IF_NEZ, + Opcode.IPUT_OBJECT, + Opcode.RETURN_VOID + ), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/MppWatchWhileLayout;") + } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/playertype/PlayerTypeHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/playertype/PlayerTypeHookPatch.kt new file mode 100644 index 0000000000..f630ee42ef --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/playertype/PlayerTypeHookPatch.kt @@ -0,0 +1,24 @@ +package app.revanced.patches.music.utils.playertype + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.utils.extension.Constants.UTILS_PATH +import app.revanced.util.fingerprint.methodOrThrow + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$UTILS_PATH/PlayerTypeHookPatch;" + +@Suppress("unused") +val playerTypeHookPatch = bytecodePatch( + description = "playerTypeHookPatch" +) { + + execute { + + playerTypeFingerprint.methodOrThrow().addInstruction( + 0, + "invoke-static {p1}, $EXTENSION_CLASS_DESCRIPTOR->setPlayerType(Ljava/lang/Enum;)V" + ) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/playservice/VersionCheckPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/playservice/VersionCheckPatch.kt new file mode 100644 index 0000000000..27115309c2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/playservice/VersionCheckPatch.kt @@ -0,0 +1,45 @@ +@file:Suppress("ktlint:standard:property-naming") + +package app.revanced.patches.music.utils.playservice + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.util.findElementByAttributeValueOrThrow + +var is_6_27_or_greater = false + private set +var is_6_36_or_greater = false + private set +var is_6_42_or_greater = false + private set +var is_7_06_or_greater = false + private set +var is_7_18_or_greater = false + private set +var is_7_20_or_greater = false + private set +var is_7_23_or_greater = false + private set + +val versionCheckPatch = resourcePatch( + description = "versionCheckPatch", +) { + execute { + // The app version is missing from the decompiled manifest, + // so instead use the Google Play services version and compare against specific releases. + val playStoreServicesVersion = document("res/values/integers.xml").use { document -> + document.documentElement.childNodes.findElementByAttributeValueOrThrow( + "name", + "google_play_services_version", + ).textContent.toInt() + } + + // All bug fix releases always seem to use the same play store version as the minor version. + is_6_27_or_greater = 234412000 <= playStoreServicesVersion + is_6_36_or_greater = 240399000 <= playStoreServicesVersion + is_6_42_or_greater = 240999000 <= playStoreServicesVersion + is_7_06_or_greater = 242499000 <= playStoreServicesVersion + is_7_18_or_greater = 243699000 <= playStoreServicesVersion + is_7_20_or_greater = 243899000 <= playStoreServicesVersion + is_7_23_or_greater = 244199000 <= playStoreServicesVersion + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/resourceid/SharedResourceIdPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/resourceid/SharedResourceIdPatch.kt new file mode 100644 index 0000000000..a3358901f5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/resourceid/SharedResourceIdPatch.kt @@ -0,0 +1,269 @@ +package app.revanced.patches.music.utils.resourceid + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.shared.mapping.ResourceType.BOOL +import app.revanced.patches.shared.mapping.ResourceType.COLOR +import app.revanced.patches.shared.mapping.ResourceType.DIMEN +import app.revanced.patches.shared.mapping.ResourceType.ID +import app.revanced.patches.shared.mapping.ResourceType.LAYOUT +import app.revanced.patches.shared.mapping.ResourceType.STRING +import app.revanced.patches.shared.mapping.ResourceType.STYLE +import app.revanced.patches.shared.mapping.get +import app.revanced.patches.shared.mapping.resourceMappingPatch +import app.revanced.patches.shared.mapping.resourceMappings + +var accountSwitcherAccessibility = -1L + private set +var bottomSheetRecyclerView = -1L + private set +var buttonContainer = -1L + private set +var buttonIconPaddingMedium = -1L + private set +var chipCloud = -1L + private set +var colorGrey = -1L + private set +var darkBackground = -1L + private set +var designBottomSheetDialog = -1L + private set +var endButtonsContainer = -1L + private set +var floatingLayout = -1L + private set +var historyMenuItem = -1L + private set +var inlineTimeBarAdBreakMarkerColor = -1L + private set +var interstitialsContainer = -1L + private set +var isTablet = -1L + private set +var likeDislikeContainer = -1L + private set +var mainActivityLaunchAnimation = -1L + private set +var menuEntry = -1L + private set +var miniPlayerDefaultText = -1L + private set +var miniPlayerMdxPlaying = -1L + private set +var miniPlayerPlayPauseReplayButton = -1L + private set +var miniPlayerViewPager = -1L + private set +var musicNotifierShelf = -1L + private set +var musicTasteBuilderShelf = -1L + private set +var namesInactiveAccountThumbnailSize = -1L + private set +var offlineSettingsMenuItem = -1L + private set +var playerOverlayChip = -1L + private set +var playerViewPager = -1L + private set +var privacyTosFooter = -1L + private set +var qualityAuto = -1L + private set +var remixGenericButtonSize = -1L + private set +var slidingDialogAnimation = -1L + private set +var tapBloomView = -1L + private set +var text1 = -1L + private set +var toolTipContentView = -1L + private set +var topEnd = -1L + private set +var topStart = -1L + private set +var topBarMenuItemImageView = -1L + private set +var tosFooter = -1L + private set +var touchOutside = -1L + private set +var trimSilenceSwitch = -1L + private set +var varispeedUnavailableTitle = -1L + private set + +internal val sharedResourceIdPatch = resourcePatch( + description = "sharedResourceIdPatch" +) { + dependsOn(resourceMappingPatch) + + execute { + accountSwitcherAccessibility = resourceMappings[ + STRING, + "account_switcher_accessibility_label", + ] + bottomSheetRecyclerView = resourceMappings[ + LAYOUT, + "bottom_sheet_recycler_view" + ] + buttonContainer = resourceMappings[ + ID, + "button_container" + ] + buttonIconPaddingMedium = resourceMappings[ + DIMEN, + "button_icon_padding_medium" + ] + chipCloud = resourceMappings[ + LAYOUT, + "chip_cloud" + ] + colorGrey = resourceMappings[ + COLOR, + "ytm_color_grey_12" + ] + darkBackground = resourceMappings[ + ID, + "dark_background" + ] + designBottomSheetDialog = resourceMappings[ + LAYOUT, + "design_bottom_sheet_dialog" + ] + endButtonsContainer = resourceMappings[ + ID, + "end_buttons_container" + ] + floatingLayout = resourceMappings[ + ID, + "floating_layout" + ] + historyMenuItem = resourceMappings[ + ID, + "history_menu_item" + ] + inlineTimeBarAdBreakMarkerColor = resourceMappings[ + COLOR, + "inline_time_bar_ad_break_marker_color" + ] + interstitialsContainer = resourceMappings[ + ID, + "interstitials_container" + ] + isTablet = resourceMappings[ + BOOL, + "is_tablet" + ] + likeDislikeContainer = resourceMappings[ + ID, + "like_dislike_container" + ] + mainActivityLaunchAnimation = resourceMappings[ + LAYOUT, + "main_activity_launch_animation" + ] + menuEntry = resourceMappings[ + LAYOUT, + "menu_entry" + ] + miniPlayerDefaultText = resourceMappings[ + STRING, + "mini_player_default_text" + ] + miniPlayerMdxPlaying = resourceMappings[ + STRING, + "mini_player_mdx_playing" + ] + miniPlayerPlayPauseReplayButton = resourceMappings[ + ID, + "mini_player_play_pause_replay_button" + ] + miniPlayerViewPager = resourceMappings[ + ID, + "mini_player_view_pager" + ] + musicNotifierShelf = resourceMappings[ + LAYOUT, + "music_notifier_shelf" + ] + musicTasteBuilderShelf = resourceMappings[ + LAYOUT, + "music_tastebuilder_shelf" + ] + namesInactiveAccountThumbnailSize = resourceMappings[ + DIMEN, + "names_inactive_account_thumbnail_size" + ] + offlineSettingsMenuItem = resourceMappings[ + ID, + "offline_settings_menu_item" + ] + playerOverlayChip = resourceMappings[ + ID, + "player_overlay_chip" + ] + playerViewPager = resourceMappings[ + ID, + "player_view_pager" + ] + privacyTosFooter = resourceMappings[ + ID, + "privacy_tos_footer" + ] + qualityAuto = resourceMappings[ + STRING, + "quality_auto" + ] + remixGenericButtonSize = resourceMappings[ + DIMEN, + "remix_generic_button_size" + ] + slidingDialogAnimation = resourceMappings[ + STYLE, + "SlidingDialogAnimation" + ] + tapBloomView = resourceMappings[ + ID, + "tap_bloom_view" + ] + text1 = resourceMappings[ + ID, + "text1" + ] + toolTipContentView = resourceMappings[ + LAYOUT, + "tooltip_content_view" + ] + topEnd = resourceMappings[ + ID, + "TOP_END" + ] + topStart = resourceMappings[ + ID, + "TOP_START" + ] + topBarMenuItemImageView = resourceMappings[ + ID, + "top_bar_menu_item_image_view" + ] + tosFooter = resourceMappings[ + ID, + "tos_footer" + ] + touchOutside = resourceMappings[ + ID, + "touch_outside" + ] + trimSilenceSwitch = resourceMappings[ + ID, + "trim_silence_switch" + ] + varispeedUnavailableTitle = resourceMappings[ + STRING, + "varispeed_unavailable_title" + ] + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/returnyoutubedislike/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/returnyoutubedislike/Fingerprints.kt new file mode 100644 index 0000000000..d1c803c706 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/returnyoutubedislike/Fingerprints.kt @@ -0,0 +1,12 @@ +package app.revanced.patches.music.utils.returnyoutubedislike + +import app.revanced.patches.music.utils.resourceid.buttonIconPaddingMedium +import app.revanced.util.fingerprint.legacyFingerprint +import com.android.tools.smali.dexlib2.Opcode + +internal val textComponentFingerprint = legacyFingerprint( + name = "textComponentFingerprint", + returnType = "V", + opcodes = listOf(Opcode.CONST_HIGH16), + literals = listOf(buttonIconPaddingMedium), +) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/returnyoutubedislike/ReturnYouTubeDislikePatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/returnyoutubedislike/ReturnYouTubeDislikePatch.kt new file mode 100644 index 0000000000..6daf407aea --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/returnyoutubedislike/ReturnYouTubeDislikePatch.kt @@ -0,0 +1,159 @@ +package app.revanced.patches.music.utils.returnyoutubedislike + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.UTILS_PATH +import app.revanced.patches.music.utils.patch.PatchList.RETURN_YOUTUBE_DISLIKE +import app.revanced.patches.music.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.PREFERENCE_CATEGORY_TAG_NAME +import app.revanced.patches.music.utils.settings.ResourceUtils.SETTINGS_HEADER_PATH +import app.revanced.patches.music.utils.settings.ResourceUtils.addPreferenceCategoryUnderPreferenceScreen +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.music.video.information.videoIdHook +import app.revanced.patches.music.video.information.videoInformationPatch +import app.revanced.patches.shared.dislikeFingerprint +import app.revanced.patches.shared.likeFingerprint +import app.revanced.patches.shared.removeLikeFingerprint +import app.revanced.util.adoptChild +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import org.w3c.dom.Element + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$UTILS_PATH/ReturnYouTubeDislikePatch;" + +private val returnYouTubeDislikeBytecodePatch = bytecodePatch( + description = "returnYouTubeDislikeBytecodePatch" +) { + dependsOn( + settingsPatch, + sharedResourceIdPatch, + videoInformationPatch + ) + + execute { + + mapOf( + likeFingerprint to Vote.LIKE, + dislikeFingerprint to Vote.DISLIKE, + removeLikeFingerprint to Vote.REMOVE_LIKE, + ).forEach { (fingerprint, vote) -> + fingerprint.methodOrThrow().addInstructions( + 0, + """ + const/4 v0, ${vote.value} + invoke-static {v0}, $EXTENSION_CLASS_DESCRIPTOR->sendVote(I)V + """, + ) + } + + + textComponentFingerprint.methodOrThrow().apply { + val insertIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_STATIC + && (this as ReferenceInstruction).reference.toString() + .endsWith("Ljava/lang/CharSequence;") + } + 2 + val insertRegister = + getInstruction(insertIndex - 1).registerA + + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $EXTENSION_CLASS_DESCRIPTOR->onSpannedCreated(Landroid/text/Spanned;)Landroid/text/Spanned; + move-result-object v$insertRegister + """ + ) + } + + videoIdHook("$EXTENSION_CLASS_DESCRIPTOR->newVideoLoaded(Ljava/lang/String;)V") + } +} + +enum class Vote(val value: Int) { + LIKE(1), + DISLIKE(-1), + REMOVE_LIKE(0), +} + +private const val ABOUT_CATEGORY_KEY = "revanced_ryd_about" +private const val RYD_ATTRIBUTION_KEY = "revanced_ryd_attribution" + +@Suppress("unused") +val returnYouTubeDislikePatch = resourcePatch( + RETURN_YOUTUBE_DISLIKE.title, + RETURN_YOUTUBE_DISLIKE.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + returnYouTubeDislikeBytecodePatch, + settingsPatch, + ) + + execute { + addSwitchPreference( + CategoryType.RETURN_YOUTUBE_DISLIKE, + "revanced_ryd_enabled", + "true" + ) + addSwitchPreference( + CategoryType.RETURN_YOUTUBE_DISLIKE, + "revanced_ryd_dislike_percentage", + "false", + "revanced_ryd_enabled" + ) + addSwitchPreference( + CategoryType.RETURN_YOUTUBE_DISLIKE, + "revanced_ryd_compact_layout", + "false", + "revanced_ryd_enabled" + ) + addSwitchPreference( + CategoryType.RETURN_YOUTUBE_DISLIKE, + "revanced_ryd_estimated_like", + "false", + "revanced_ryd_enabled" + ) + addSwitchPreference( + CategoryType.RETURN_YOUTUBE_DISLIKE, + "revanced_ryd_toast_on_connection_error", + "false", + "revanced_ryd_enabled" + ) + + addPreferenceCategoryUnderPreferenceScreen( + CategoryType.RETURN_YOUTUBE_DISLIKE.value, + ABOUT_CATEGORY_KEY + ) + + document(SETTINGS_HEADER_PATH).use { document -> + val tags = document.getElementsByTagName(PREFERENCE_CATEGORY_TAG_NAME) + List(tags.length) { tags.item(it) as Element } + .filter { it.getAttribute("android:key").contains(ABOUT_CATEGORY_KEY) } + .forEach { + it.adoptChild("Preference") { + setAttribute("android:title", "@string/$RYD_ATTRIBUTION_KEY" + "_title") + setAttribute("android:summary", "@string/$RYD_ATTRIBUTION_KEY" + "_summary") + setAttribute("android:key", RYD_ATTRIBUTION_KEY) + this.adoptChild("intent") { + setAttribute("android:action", "android.intent.action.VIEW") + setAttribute("android:data", "https://returnyoutubedislike.com") + } + } + } + } + + updatePatchStatus(RETURN_YOUTUBE_DISLIKE) + + } +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/returnyoutubeusername/ReturnYouTubeUsernamePatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/returnyoutubeusername/ReturnYouTubeUsernamePatch.kt new file mode 100644 index 0000000000..c908814c36 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/returnyoutubeusername/ReturnYouTubeUsernamePatch.kt @@ -0,0 +1,55 @@ +package app.revanced.patches.music.utils.returnyoutubeusername + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.patch.PatchList.RETURN_YOUTUBE_USERNAME +import app.revanced.patches.music.utils.playservice.is_6_42_or_greater +import app.revanced.patches.music.utils.playservice.versionCheckPatch +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addPreferenceWithIntent +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.shared.returnyoutubeusername.baseReturnYouTubeUsernamePatch + +@Suppress("unused") +val returnYouTubeUsernamePatch = resourcePatch( + RETURN_YOUTUBE_USERNAME.title, + RETURN_YOUTUBE_USERNAME.summary, + use = false, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + baseReturnYouTubeUsernamePatch, + settingsPatch, + versionCheckPatch, + ) + + execute { + addSwitchPreference( + CategoryType.RETURN_YOUTUBE_USERNAME, + "revanced_return_youtube_username_enabled", + "false" + ) + addPreferenceWithIntent( + CategoryType.RETURN_YOUTUBE_USERNAME, + "revanced_return_youtube_username_display_format", + "revanced_return_youtube_username_enabled" + ) + addPreferenceWithIntent( + CategoryType.RETURN_YOUTUBE_USERNAME, + "revanced_return_youtube_username_youtube_data_api_v3_developer_key", + "revanced_return_youtube_username_enabled" + ) + if (is_6_42_or_greater) { + addPreferenceWithIntent( + CategoryType.RETURN_YOUTUBE_USERNAME, + "revanced_return_youtube_username_youtube_data_api_v3_about" + ) + } + + updatePatchStatus(RETURN_YOUTUBE_USERNAME) + + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/music/utils/settings/CategoryType.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/settings/CategoryType.kt similarity index 86% rename from src/main/kotlin/app/revanced/patches/music/utils/settings/CategoryType.kt rename to patches/src/main/kotlin/app/revanced/patches/music/utils/settings/CategoryType.kt index 875ccfa44f..70c69ed8d0 100644 --- a/src/main/kotlin/app/revanced/patches/music/utils/settings/CategoryType.kt +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/settings/CategoryType.kt @@ -1,6 +1,6 @@ package app.revanced.patches.music.utils.settings -enum class CategoryType(val value: String, var added: Boolean) { +internal enum class CategoryType(val value: String, var added: Boolean) { ACCOUNT("account", false), ACTION_BAR("action_bar", false), ADS("ads", false), diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/settings/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/settings/Fingerprints.kt new file mode 100644 index 0000000000..543e8ab351 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/settings/Fingerprints.kt @@ -0,0 +1,47 @@ +package app.revanced.patches.music.utils.settings + +import app.revanced.util.fingerprint.legacyFingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val googleApiActivityFingerprint = legacyFingerprint( + name = "googleApiActivityFingerprint", + returnType = "V", + parameters = listOf("Landroid/os/Bundle;"), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/GoogleApiActivity;") && + method.name == "onCreate" + } +) + +internal val preferenceFingerprint = legacyFingerprint( + name = "preferenceFingerprint", + accessFlags = AccessFlags.PROTECTED.value, + returnType = "V", + parameters = listOf("Z"), + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IGET_OBJECT, + Opcode.INVOKE_INTERFACE, + ), + customFingerprint = { method, _ -> + method.definingClass == "Landroidx/preference/Preference;" + } +) + +internal val settingsHeadersFragmentFingerprint = legacyFingerprint( + name = "settingsHeadersFragmentFingerprint", + returnType = "V", + parameters = listOf("L"), + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + ), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/SettingsHeadersFragment;") && + method.name == "onCreate" + } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/settings/ResourceUtils.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/settings/ResourceUtils.kt new file mode 100644 index 0000000000..4889d098f8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/settings/ResourceUtils.kt @@ -0,0 +1,259 @@ +package app.revanced.patches.music.utils.settings + +import app.revanced.patcher.patch.ResourcePatchContext +import app.revanced.patches.music.utils.compatibility.Constants.YOUTUBE_MUSIC_PACKAGE_NAME +import app.revanced.patches.music.utils.patch.PatchList +import app.revanced.util.adoptChild +import app.revanced.util.cloneNodes +import app.revanced.util.doRecursively +import app.revanced.util.insertNode +import org.w3c.dom.Element + +internal object ResourceUtils { + private lateinit var context: ResourcePatchContext + + fun setContext(context: ResourcePatchContext) { + this.context = context + } + + private const val RVX_SETTINGS_KEY = "revanced_extended_settings" + + const val SETTINGS_HEADER_PATH = "res/xml/settings_headers.xml" + + const val PREFERENCE_SCREEN_TAG_NAME = + "PreferenceScreen" + + const val PREFERENCE_CATEGORY_TAG_NAME = + "com.google.android.apps.youtube.music.ui.preference.PreferenceCategoryCompat" + + const val SWITCH_PREFERENCE_TAG_NAME = + "com.google.android.apps.youtube.music.ui.preference.SwitchCompatPreference" + + const val ACTIVITY_HOOK_TARGET_CLASS = + "com.google.android.gms.common.api.GoogleApiActivity" + + var musicPackageName = YOUTUBE_MUSIC_PACKAGE_NAME + + private var iconType = "default" + fun getIconType() = iconType + + fun setIconType(iconName: String) { + iconType = iconName + } + + private fun isIncludedCategory(category: String): Boolean { + CategoryType.entries.forEach { preference -> + if (category == preference.value) + return preference.added + } + return false + } + + private fun replacePackageName() = context.apply { + val xmlFile = get(SETTINGS_HEADER_PATH) + xmlFile.writeText( + xmlFile.readText() + .replace( + "\"com.google.android.apps.youtube.music\"", + "\"" + musicPackageName + "\"" + ) + ) + } + + + private fun setPreferenceCategory(newCategory: String) { + CategoryType.entries.forEach { preference -> + if (newCategory == preference.value) + preference.added = true + } + } + + fun updatePackageName(newPackage: String) { + musicPackageName = newPackage + replacePackageName() + } + + fun updatePatchStatus(patch: PatchList) { + patch.included = true + } + + fun addPreferenceCategory(category: String) { + context.document(SETTINGS_HEADER_PATH).use { document -> + val tags = document.getElementsByTagName(PREFERENCE_SCREEN_TAG_NAME) + List(tags.length) { tags.item(it) as Element } + .filter { it.getAttribute("android:key").contains(RVX_SETTINGS_KEY) } + .forEach { + if (!isIncludedCategory(category)) { + it.adoptChild(PREFERENCE_SCREEN_TAG_NAME) { + setAttribute( + "android:title", + "@string/revanced_preference_screen_$category" + "_title" + ) + setAttribute("android:key", "revanced_preference_screen_$category") + } + setPreferenceCategory(category) + } + } + } + } + + fun addPreferenceCategoryUnderPreferenceScreen( + preferenceScreenKey: String, + category: String + ) { + context.document(SETTINGS_HEADER_PATH).use { document -> + val tags = document.getElementsByTagName(PREFERENCE_SCREEN_TAG_NAME) + List(tags.length) { tags.item(it) as Element } + .filter { it.getAttribute("android:key").contains(preferenceScreenKey) } + .forEach { + it.adoptChild(PREFERENCE_CATEGORY_TAG_NAME) { + setAttribute("android:title", "@string/$category") + setAttribute("android:key", category) + } + } + } + } + + fun sortPreferenceCategory( + category: String + ) { + context.document(SETTINGS_HEADER_PATH).use { document -> + document.doRecursively node@{ + if (it !is Element) return@node + + it.getAttributeNode("android:key")?.let { attribute -> + if (attribute.textContent == "revanced_preference_screen_$category") { + it.cloneNodes(it.parentNode) + } + } + } + } + replacePackageName() + } + + fun addGmsCorePreference( + category: String, + key: String, + packageName: String, + targetClassName: String + ) { + context.document(SETTINGS_HEADER_PATH).use { document -> + val tags = document.getElementsByTagName(PREFERENCE_SCREEN_TAG_NAME) + List(tags.length) { tags.item(it) as Element } + .filter { + it.getAttribute("android:key").contains("revanced_preference_screen_$category") + } + .forEach { + it.adoptChild("Preference") { + setAttribute("android:title", "@string/$key" + "_title") + setAttribute("android:summary", "@string/$key" + "_summary") + this.adoptChild("intent") { + setAttribute("android:targetPackage", packageName) + setAttribute("android:data", key) + setAttribute( + "android:targetClass", + targetClassName + ) + } + } + } + } + } + + fun addSwitchPreference( + category: String, + key: String, + defaultValue: String, + dependencyKey: String, + setSummary: Boolean + ) { + context.document(SETTINGS_HEADER_PATH).use { document -> + val tags = document.getElementsByTagName(PREFERENCE_SCREEN_TAG_NAME) + List(tags.length) { tags.item(it) as Element } + .filter { + it.getAttribute("android:key").contains("revanced_preference_screen_$category") + } + .forEach { + it.adoptChild(SWITCH_PREFERENCE_TAG_NAME) { + setAttribute("android:title", "@string/$key" + "_title") + if (setSummary) { + setAttribute("android:summary", "@string/$key" + "_summary") + } + setAttribute("android:key", key) + setAttribute("android:defaultValue", defaultValue) + if (dependencyKey != "") { + setAttribute("android:dependency", dependencyKey) + } + } + } + } + } + + fun addPreferenceWithIntent( + category: String, + key: String, + dependencyKey: String + ) { + context.document(SETTINGS_HEADER_PATH).use { document -> + val tags = document.getElementsByTagName(PREFERENCE_SCREEN_TAG_NAME) + List(tags.length) { tags.item(it) as Element } + .filter { + it.getAttribute("android:key").contains("revanced_preference_screen_$category") + } + .forEach { + it.adoptChild("Preference") { + setAttribute("android:title", "@string/$key" + "_title") + setAttribute("android:summary", "@string/$key" + "_summary") + setAttribute("android:key", key) + if (dependencyKey != "") { + setAttribute("android:dependency", dependencyKey) + } + this.adoptChild("intent") { + setAttribute("android:targetPackage", musicPackageName) + setAttribute("android:data", key) + setAttribute( + "android:targetClass", + ACTIVITY_HOOK_TARGET_CLASS + ) + } + } + } + } + } + + fun addRVXSettingsPreference() { + context.document(SETTINGS_HEADER_PATH).use { document -> + document.doRecursively node@{ + if (it !is Element) return@node + + it.getAttributeNode("android:key")?.let { attribute -> + if (attribute.textContent == "settings_header_about_youtube_music" && it.getAttributeNode( + "app:allowDividerBelow" + ).textContent == "false" + ) { + it.insertNode(PREFERENCE_SCREEN_TAG_NAME, it) { + setAttribute( + "android:title", + "@string/revanced_extended_settings_title" + ) + setAttribute("android:key", "revanced_extended_settings") + setAttribute("app:allowDividerAbove", "false") + } + it.getAttributeNode("app:allowDividerBelow").textContent = "true" + return@node + } + } + } + + document.doRecursively node@{ + if (it !is Element) return@node + + it.getAttributeNode("app:allowDividerBelow")?.let { attribute -> + if (attribute.textContent == "true") { + attribute.textContent = "false" + } + } + } + } + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/settings/SettingsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/settings/SettingsPatch.kt new file mode 100644 index 0000000000..1966122b57 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/settings/SettingsPatch.kt @@ -0,0 +1,306 @@ +package app.revanced.patches.music.utils.settings + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.EXTENSION_PATH +import app.revanced.patches.music.utils.extension.Constants.UTILS_PATH +import app.revanced.patches.music.utils.extension.sharedExtensionPatch +import app.revanced.patches.music.utils.mainactivity.mainActivityResolvePatch +import app.revanced.patches.music.utils.patch.PatchList.SETTINGS_FOR_YOUTUBE_MUSIC +import app.revanced.patches.music.utils.playservice.versionCheckPatch +import app.revanced.patches.shared.extension.Constants.EXTENSION_UTILS_CLASS_DESCRIPTOR +import app.revanced.patches.shared.mainactivity.injectConstructorMethodCall +import app.revanced.patches.shared.mainactivity.injectOnCreateMethodCall +import app.revanced.patches.shared.sharedSettingFingerprint +import app.revanced.util.copyXmlNode +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.removeStringsElements +import app.revanced.util.valueOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import org.w3c.dom.Element + +private const val EXTENSION_ACTIVITY_CLASS_DESCRIPTOR = + "$EXTENSION_PATH/settings/ActivityHook;" +private const val EXTENSION_FRAGMENT_CLASS_DESCRIPTOR = + "$EXTENSION_PATH/settings/preference/ReVancedPreferenceFragment;" +private const val EXTENSION_INITIALIZATION_CLASS_DESCRIPTOR = + "$UTILS_PATH/InitializationPatch;" + +private val settingsBytecodePatch = bytecodePatch( + description = "settingsBytecodePatch" +) { + dependsOn( + sharedExtensionPatch, + mainActivityResolvePatch, + versionCheckPatch, + ) + + execute { + + // region patch for set SharedPrefCategory + + sharedSettingFingerprint.methodOrThrow().apply { + val stringIndex = indexOfFirstInstructionOrThrow(Opcode.CONST_STRING) + val stringRegister = getInstruction(stringIndex).registerA + + replaceInstruction( + stringIndex, + "const-string v$stringRegister, \"youtube\"" + ) + } + + // endregion + + // region patch for hook activity + + settingsHeadersFragmentFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = it.patternMatch!!.endIndex + val targetRegister = getInstruction(targetIndex).registerA + + addInstruction( + targetIndex + 1, + "invoke-static {v$targetRegister}, $EXTENSION_ACTIVITY_CLASS_DESCRIPTOR->setActivity(Ljava/lang/Object;)V" + ) + } + } + + // endregion + + // region patch for hook preference change listener + + preferenceFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = it.patternMatch!!.endIndex + val keyRegister = getInstruction(targetIndex).registerD + val valueRegister = getInstruction(targetIndex).registerE + + addInstruction( + targetIndex, + "invoke-static {v$keyRegister, v$valueRegister}, $EXTENSION_FRAGMENT_CLASS_DESCRIPTOR->onPreferenceChanged(Ljava/lang/String;Z)V" + ) + } + } + + // endregion + + // region patch for hook dummy Activity for intent + + googleApiActivityFingerprint.methodOrThrow().apply { + addInstructionsWithLabels( + 1, + """ + invoke-static {p0}, $EXTENSION_ACTIVITY_CLASS_DESCRIPTOR->initialize(Landroid/app/Activity;)Z + move-result v0 + if-eqz v0, :show + return-void + """, + ExternalLabel("show", getInstruction(1)), + ) + } + + // endregion + + injectOnCreateMethodCall( + EXTENSION_INITIALIZATION_CLASS_DESCRIPTOR, + "setDeviceInformation" + ) + injectOnCreateMethodCall( + EXTENSION_INITIALIZATION_CLASS_DESCRIPTOR, + "onCreate" + ) + injectConstructorMethodCall( + EXTENSION_UTILS_CLASS_DESCRIPTOR, + "setActivity" + ) + + } +} + +private const val DEFAULT_LABEL = "ReVanced Extended" +private lateinit var customName: String + +val settingsPatch = resourcePatch( + SETTINGS_FOR_YOUTUBE_MUSIC.title, + SETTINGS_FOR_YOUTUBE_MUSIC.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + settingsBytecodePatch, + ) + + val settingsLabel = stringOption( + key = "settingsLabel", + default = DEFAULT_LABEL, + title = "RVX settings label", + description = "The name of the RVX settings menu.", + required = true, + ) + + execute { + /** + * check patch options + */ + customName = settingsLabel + .valueOrThrow() + + /** + * copy arrays, colors and strings + */ + arrayOf( + "arrays.xml", + "colors.xml", + "strings.xml" + ).forEach { xmlFile -> + copyXmlNode("music/settings/host", "values/$xmlFile", "resources") + } + + /** + * hide divider + */ + val styleFile = get("res/values/styles.xml") + + styleFile.writeText( + styleFile.readText() + .replace( + "allowDividerAbove\">true", + "allowDividerAbove\">false" + ).replace( + "allowDividerBelow\">true", + "allowDividerBelow\">false" + ) + ) + + /** + * Change colors + */ + document("res/values/colors.xml").use { document -> + val resourcesNode = document.getElementsByTagName("resources").item(0) as Element + val children = resourcesNode.childNodes + for (i in 0 until children.length) { + val node = children.item(i) as? Element ?: continue + + node.textContent = + when (node.getAttribute("name")) { + "material_deep_teal_500", + -> "@android:color/white" + + else -> continue + } + } + } + + ResourceUtils.setContext(this) + ResourceUtils.addRVXSettingsPreference() + + ResourceUtils.updatePatchStatus(SETTINGS_FOR_YOUTUBE_MUSIC) + } + + finalize { + /** + * change RVX settings menu name + * since it must be invoked after the Translations patch, it must be the last in the order. + */ + if (customName != DEFAULT_LABEL) { + removeStringsElements( + arrayOf("revanced_extended_settings_title") + ) + document("res/values/strings.xml").use { document -> + mapOf( + "revanced_extended_settings_title" to customName + ).forEach { (k, v) -> + val stringElement = document.createElement("string") + + stringElement.setAttribute("name", k) + stringElement.textContent = v + + document.getElementsByTagName("resources").item(0) + .appendChild(stringElement) + } + } + } + + /** + * add open default app settings + */ + addPreferenceWithIntent( + CategoryType.MISC, + "revanced_default_app_settings" + ) + + /** + * add import export settings + */ + addPreferenceWithIntent( + CategoryType.MISC, + "revanced_extended_settings_import_export" + ) + + /** + * sort preference + */ + CategoryType.entries.sorted().forEach { + ResourceUtils.sortPreferenceCategory(it.value) + } + } +} + +internal fun addSwitchPreference( + category: CategoryType, + key: String, + defaultValue: String +) = addSwitchPreference(category, key, defaultValue, "") + +internal fun addSwitchPreference( + category: CategoryType, + key: String, + defaultValue: String, + setSummary: Boolean +) = addSwitchPreference(category, key, defaultValue, "", setSummary) + +internal fun addSwitchPreference( + category: CategoryType, + key: String, + defaultValue: String, + dependencyKey: String +) = addSwitchPreference(category, key, defaultValue, dependencyKey, true) + +internal fun addSwitchPreference( + category: CategoryType, + key: String, + defaultValue: String, + dependencyKey: String, + setSummary: Boolean +) { + val categoryValue = category.value + ResourceUtils.addPreferenceCategory(categoryValue) + ResourceUtils.addSwitchPreference(categoryValue, key, defaultValue, dependencyKey, setSummary) +} + +internal fun addPreferenceWithIntent( + category: CategoryType, + key: String +) = addPreferenceWithIntent(category, key, "") + +internal fun addPreferenceWithIntent( + category: CategoryType, + key: String, + dependencyKey: String +) { + val categoryValue = category.value + ResourceUtils.addPreferenceCategory(categoryValue) + ResourceUtils.addPreferenceWithIntent(categoryValue, key, dependencyKey) +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/sponsorblock/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/sponsorblock/Fingerprints.kt new file mode 100644 index 0000000000..ba2b5bd6f9 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/sponsorblock/Fingerprints.kt @@ -0,0 +1,64 @@ +package app.revanced.patches.music.utils.sponsorblock + +import app.revanced.patches.music.utils.resourceid.inlineTimeBarAdBreakMarkerColor +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionReversed +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val musicPlaybackControlsTimeBarDrawFingerprint = legacyFingerprint( + name = "musicPlaybackControlsTimeBarDrawFingerprint", + returnType = "V", + customFingerprint = { method, _ -> + method.definingClass.endsWith("/MusicPlaybackControlsTimeBar;") && + method.name == "draw" + } +) + +internal val musicPlaybackControlsTimeBarOnMeasureFingerprint = legacyFingerprint( + name = "musicPlaybackControlsTimeBarOnMeasureFingerprint", + returnType = "V", + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN_VOID + ), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/MusicPlaybackControlsTimeBar;") && + method.name == "onMeasure" + } +) + +internal val rectangleFieldInvalidatorFingerprint = legacyFingerprint( + name = "rectangleFieldInvalidatorFingerprint", + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_WIDE, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_WIDE, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_WIDE + ), + customFingerprint = { method, _ -> + indexOfInvalidateInstruction(method) >= 0 + } +) + +internal fun indexOfInvalidateInstruction(method: Method) = + method.indexOfFirstInstructionReversed { + getReference()?.name == "invalidate" + } + +internal val seekBarConstructorFingerprint = legacyFingerprint( + name = "seekBarConstructorFingerprint", + returnType = "V", + literals = listOf(inlineTimeBarAdBreakMarkerColor), +) + +internal val seekbarOnDrawFingerprint = legacyFingerprint( + name = "seekbarOnDrawFingerprint", + customFingerprint = { method, _ -> method.name == "onDraw" } +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/sponsorblock/SponsorBlockPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/sponsorblock/SponsorBlockPatch.kt new file mode 100644 index 0000000000..8eb98a1e6a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/sponsorblock/SponsorBlockPatch.kt @@ -0,0 +1,395 @@ +package app.revanced.patches.music.utils.sponsorblock + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.ResourcePatchContext +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.EXTENSION_PATH +import app.revanced.patches.music.utils.patch.PatchList.SPONSORBLOCK +import app.revanced.patches.music.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.ACTIVITY_HOOK_TARGET_CLASS +import app.revanced.patches.music.utils.settings.ResourceUtils.PREFERENCE_CATEGORY_TAG_NAME +import app.revanced.patches.music.utils.settings.ResourceUtils.PREFERENCE_SCREEN_TAG_NAME +import app.revanced.patches.music.utils.settings.ResourceUtils.SETTINGS_HEADER_PATH +import app.revanced.patches.music.utils.settings.ResourceUtils.SWITCH_PREFERENCE_TAG_NAME +import app.revanced.patches.music.utils.settings.ResourceUtils.addPreferenceCategory +import app.revanced.patches.music.utils.settings.ResourceUtils.musicPackageName +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.music.video.information.videoIdHook +import app.revanced.patches.music.video.information.videoInformationPatch +import app.revanced.patches.music.video.information.videoTimeHook +import app.revanced.util.adoptChild +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import org.w3c.dom.Element + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$EXTENSION_PATH/sponsorblock/SegmentPlaybackController;" + +private val sponsorBlockBytecodePatch = bytecodePatch( + description = "sponsorBlockBytecodePatch" +) { + dependsOn( + sharedResourceIdPatch, + videoInformationPatch + ) + + execute { + + /** + * Hook the video time methods & Initialize the player controller + */ + videoTimeHook(EXTENSION_CLASS_DESCRIPTOR, "setVideoTime") + + /** + * Responsible for seekbar in fullscreen + */ + var rectangleFieldName = + with(rectangleFieldInvalidatorFingerprint.methodOrThrow(seekBarConstructorFingerprint)) { + val invalidateIndex = indexOfInvalidateInstruction(this) + val rectangleIndex = + indexOfFirstInstructionReversedOrThrow(invalidateIndex + 1) { + getReference()?.type == "Landroid/graphics/Rect;" + } + val rectangleReference = + getInstruction(rectangleIndex).reference + + (rectangleReference as FieldReference).name + } + + seekbarOnDrawFingerprint.methodOrThrow(seekBarConstructorFingerprint).apply { + // Initialize seekbar method + addInstructions( + 0, """ + move-object/from16 v0, p0 + const-string v1, "$rectangleFieldName" + invoke-static {v0, v1}, $EXTENSION_CLASS_DESCRIPTOR->setSponsorBarRect(Ljava/lang/Object;Ljava/lang/String;)V + """ + ) + + // Set seekbar thickness + val roundIndex = indexOfFirstInstructionOrThrow { + getReference()?.name == "round" + } + 1 + val roundRegister = getInstruction(roundIndex).registerA + addInstruction( + roundIndex + 1, + "invoke-static {v$roundRegister}, " + + "$EXTENSION_CLASS_DESCRIPTOR->setSponsorBarThickness(I)V" + ) + + // Draw segment + val drawCircleIndex = indexOfFirstInstructionReversedOrThrow { + getReference()?.name == "drawCircle" + } + val drawCircleInstruction = getInstruction(drawCircleIndex) + addInstruction( + drawCircleIndex, + "invoke-static {v${drawCircleInstruction.registerC}, v${drawCircleInstruction.registerE}}, " + + "$EXTENSION_CLASS_DESCRIPTOR->drawSponsorTimeBars(Landroid/graphics/Canvas;F)V" + ) + } + + + /** + * Responsible for seekbar in player + */ + rectangleFieldName = + musicPlaybackControlsTimeBarOnMeasureFingerprint.matchOrThrow().let { + with(it.method) { + val rectangleIndex = it.patternMatch!!.startIndex + val rectangleReference = + getInstruction(rectangleIndex).reference + (rectangleReference as FieldReference).name + } + } + + musicPlaybackControlsTimeBarDrawFingerprint.methodOrThrow().apply { + // Initialize seekbar method + addInstructions( + 1, """ + move-object/from16 v0, p0 + const-string v1, "$rectangleFieldName" + invoke-static {v0, v1}, $EXTENSION_CLASS_DESCRIPTOR->setSponsorBarRect(Ljava/lang/Object;Ljava/lang/String;)V + """ + ) + + // Draw segment + val drawCircleIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "drawCircle" + } + val drawCircleInstruction = getInstruction(drawCircleIndex) + addInstruction( + drawCircleIndex, + "invoke-static {v${drawCircleInstruction.registerC}, v${drawCircleInstruction.registerE}}, " + + "$EXTENSION_CLASS_DESCRIPTOR->drawSponsorTimeBars(Landroid/graphics/Canvas;F)V" + ) + } + + /** + * Set current video id + */ + videoIdHook("$EXTENSION_CLASS_DESCRIPTOR->setVideoId(Ljava/lang/String;)V") + } +} + +private const val SEGMENTS_CATEGORY_KEY = "sb_diff_segments" +private const val ABOUT_CATEGORY_KEY = "sb_about" + +private val SPONSOR_BLOCK_CATEGORY = CategoryType.SPONSOR_BLOCK.value + +@Suppress("unused") +val sponsorBlockPatch = resourcePatch( + SPONSORBLOCK.title, + SPONSORBLOCK.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + sponsorBlockBytecodePatch, + settingsPatch, + ) + + execute { + addPreferenceCategory(SPONSOR_BLOCK_CATEGORY) + + addSwitchPreference( + SPONSOR_BLOCK_CATEGORY, + "sb_enabled", + "true" + ) + addSwitchPreference( + SPONSOR_BLOCK_CATEGORY, + "sb_toast_on_skip", + "true", + "sb_enabled" + ) + addSwitchPreference( + SPONSOR_BLOCK_CATEGORY, + "sb_toast_on_connection_error", + "false", + "sb_enabled" + ) + addPreferenceWithIntent( + SPONSOR_BLOCK_CATEGORY, + "sb_api_url", + "sb_enabled" + ) + + addPreferenceCategoryUnderPreferenceScreen( + SPONSOR_BLOCK_CATEGORY, + SEGMENTS_CATEGORY_KEY + ) + + addSegmentsPreference( + SEGMENTS_CATEGORY_KEY, + "sb_segments_sponsor", + "sb_enabled" + ) + addSegmentsPreference( + SEGMENTS_CATEGORY_KEY, + "sb_segments_selfpromo", + "sb_enabled" + ) + addSegmentsPreference( + SEGMENTS_CATEGORY_KEY, + "sb_segments_interaction", + "sb_enabled" + ) + addSegmentsPreference( + SEGMENTS_CATEGORY_KEY, + "sb_segments_intro", + "sb_enabled" + ) + addSegmentsPreference( + SEGMENTS_CATEGORY_KEY, + "sb_segments_outro", + "sb_enabled" + ) + addSegmentsPreference( + SEGMENTS_CATEGORY_KEY, + "sb_segments_preview", + "sb_enabled" + ) + addSegmentsPreference( + SEGMENTS_CATEGORY_KEY, + "sb_segments_filler", + "sb_enabled" + ) + addSegmentsPreference( + SEGMENTS_CATEGORY_KEY, + "sb_segments_nomusic", + "sb_enabled" + ) + + addPreferenceCategoryUnderPreferenceScreen( + CategoryType.SPONSOR_BLOCK.value, + ABOUT_CATEGORY_KEY + ) + + addAboutPreference( + ABOUT_CATEGORY_KEY, + "sb_about_api", + "https://sponsor.ajay.app" + ) + + get(SETTINGS_HEADER_PATH).apply { + writeText( + readText() + .replace( + "\"sb_segments_nomusic", + "\"sb_segments_music_offtopic" + ) + ) + } + + updatePatchStatus(SPONSORBLOCK) + + } +} + +private fun ResourcePatchContext.addSwitchPreference( + category: String, + key: String, + defaultValue: String +) = addSwitchPreference(category, key, defaultValue, "") + +private fun ResourcePatchContext.addSwitchPreference( + category: String, + key: String, + defaultValue: String, + dependencyKey: String +) { + document(SETTINGS_HEADER_PATH).use { document -> + val tags = document.getElementsByTagName(PREFERENCE_SCREEN_TAG_NAME) + List(tags.length) { tags.item(it) as Element } + .filter { + it.getAttribute("android:key").contains("revanced_preference_screen_$category") + } + .forEach { + it.adoptChild(SWITCH_PREFERENCE_TAG_NAME) { + setAttribute("android:title", "@string/revanced_$key") + setAttribute("android:summary", "@string/revanced_$key" + "_sum") + setAttribute("android:key", key) + setAttribute("android:defaultValue", defaultValue) + if (dependencyKey != "") { + setAttribute("android:dependency", dependencyKey) + } + } + } + } +} + +private fun ResourcePatchContext.addPreferenceWithIntent( + category: String, + key: String, + dependencyKey: String +) { + document(SETTINGS_HEADER_PATH).use { document -> + val tags = document.getElementsByTagName(PREFERENCE_SCREEN_TAG_NAME) + List(tags.length) { tags.item(it) as Element } + .filter { + it.getAttribute("android:key").contains("revanced_preference_screen_$category") + } + .forEach { + it.adoptChild("Preference") { + setAttribute("android:title", "@string/revanced_$key") + setAttribute("android:summary", "@string/revanced_$key" + "_sum") + setAttribute("android:key", key) + setAttribute("android:dependency", dependencyKey) + this.adoptChild("intent") { + setAttribute("android:targetPackage", musicPackageName) + setAttribute("android:data", key) + setAttribute( + "android:targetClass", + ACTIVITY_HOOK_TARGET_CLASS + ) + } + } + } + } +} + +private fun ResourcePatchContext.addPreferenceCategoryUnderPreferenceScreen( + preferenceScreenKey: String, + category: String +) { + document(SETTINGS_HEADER_PATH).use { document -> + val tags = document.getElementsByTagName(PREFERENCE_SCREEN_TAG_NAME) + List(tags.length) { tags.item(it) as Element } + .filter { it.getAttribute("android:key").contains(preferenceScreenKey) } + .forEach { + it.adoptChild(PREFERENCE_CATEGORY_TAG_NAME) { + setAttribute("android:title", "@string/revanced_$category") + setAttribute("android:key", category) + } + } + } +} + +private fun ResourcePatchContext.addSegmentsPreference( + preferenceCategoryKey: String, + key: String, + dependencyKey: String +) { + document(SETTINGS_HEADER_PATH).use { document -> + val tags = document.getElementsByTagName(PREFERENCE_SCREEN_TAG_NAME) + List(tags.length) { tags.item(it) as Element } + .filter { it.getAttribute("android:key").contains(preferenceCategoryKey) } + .forEach { + it.adoptChild("Preference") { + setAttribute("android:title", "@string/revanced_$key") + setAttribute("android:summary", "@string/revanced_$key" + "_sum") + setAttribute("android:key", key) + setAttribute("android:dependency", dependencyKey) + this.adoptChild("intent") { + setAttribute("android:targetPackage", musicPackageName) + setAttribute("android:data", key) + setAttribute( + "android:targetClass", + ACTIVITY_HOOK_TARGET_CLASS + ) + } + } + } + } +} + +private fun ResourcePatchContext.addAboutPreference( + preferenceCategoryKey: String, + key: String, + data: String +) { + document(SETTINGS_HEADER_PATH).use { document -> + val tags = document.getElementsByTagName(PREFERENCE_SCREEN_TAG_NAME) + List(tags.length) { tags.item(it) as Element } + .filter { it.getAttribute("android:key").contains(preferenceCategoryKey) } + .forEach { + it.adoptChild("Preference") { + setAttribute("android:title", "@string/revanced_$key") + setAttribute("android:summary", "@string/revanced_$key" + "_sum") + setAttribute("android:key", key) + this.adoptChild("intent") { + setAttribute("android:action", "android.intent.action.VIEW") + setAttribute("android:data", data) + } + } + } + } +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/videotype/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/videotype/Fingerprints.kt new file mode 100644 index 0000000000..0edb350196 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/videotype/Fingerprints.kt @@ -0,0 +1,30 @@ +package app.revanced.patches.music.utils.videotype + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val videoTypeFingerprint = legacyFingerprint( + name = "videoTypeFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("L"), + opcodes = listOf( + Opcode.IGET, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IF_NEZ, + Opcode.SGET_OBJECT, + Opcode.GOTO, + Opcode.SGET_OBJECT + ) +) + +internal val videoTypeParentFingerprint = legacyFingerprint( + name = "videoTypeParentFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("L", "L"), + strings = listOf("RQ") +) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/videotype/VideoTypeHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/videotype/VideoTypeHookPatch.kt new file mode 100644 index 0000000000..587048a3d9 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/videotype/VideoTypeHookPatch.kt @@ -0,0 +1,38 @@ +package app.revanced.patches.music.utils.videotype + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.utils.extension.Constants.UTILS_PATH +import app.revanced.util.fingerprint.matchOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$UTILS_PATH/VideoTypeHookPatch;" + +@Suppress("unused") +val videoTypeHookPatch = bytecodePatch( + description = "videoTypeHookPatch" +) { + + execute { + + videoTypeFingerprint.matchOrThrow(videoTypeParentFingerprint).let { + it.method.apply { + val insertIndex = it.patternMatch!!.startIndex + 3 + val referenceIndex = insertIndex + 1 + val referenceInstruction = + getInstruction(referenceIndex).reference + + addInstructionsWithLabels( + insertIndex, """ + if-nez p0, :dismiss + sget-object p0, $referenceInstruction + :dismiss + invoke-static {p0}, $EXTENSION_CLASS_DESCRIPTOR->setVideoType(Ljava/lang/Enum;)V + """ + ) + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/video/information/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/video/information/Fingerprints.kt new file mode 100644 index 0000000000..d6a110041f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/video/information/Fingerprints.kt @@ -0,0 +1,62 @@ +package app.revanced.patches.music.video.information + +import app.revanced.patches.music.utils.resourceid.qualityAuto +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val playerControllerSetTimeReferenceFingerprint = legacyFingerprint( + name = "playerControllerSetTimeReferenceFingerprint", + returnType = "V", + opcodes = listOf( + Opcode.INVOKE_DIRECT_RANGE, + Opcode.IGET_OBJECT + ), + strings = listOf("Media progress reported outside media playback: ") +) + +internal val videoEndFingerprint = legacyFingerprint( + name = "videoEndFingerprint", + strings = listOf("Attempting to seek during an ad") +) + +internal val videoIdFingerprint = legacyFingerprint( + name = "videoIdFingerprint", + returnType = "V", + parameters = listOf("L", "Ljava/lang/String;"), + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + opcodes = listOf( + Opcode.INVOKE_INTERFACE_RANGE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_INTERFACE_RANGE, + Opcode.MOVE_RESULT_OBJECT, + ), + strings = listOf("Null initialPlayabilityStatus") +) + +internal val videoQualityListFingerprint = legacyFingerprint( + name = "videoQualityListFingerprint", + returnType = "V", + parameters = listOf("L"), + opcodes = listOf( + Opcode.INVOKE_INTERFACE, + Opcode.RETURN_VOID + ), + literals = listOf(qualityAuto) +) + +internal val videoQualityTextFingerprint = legacyFingerprint( + name = "videoQualityTextFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("[L", "I", "Z"), + opcodes = listOf( + Opcode.IF_EQZ, + Opcode.IF_LTZ, + Opcode.ARRAY_LENGTH, + Opcode.IF_GE, + Opcode.AGET_OBJECT, + Opcode.IGET_OBJECT + ) +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/video/information/VideoInformationPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/video/information/VideoInformationPatch.kt new file mode 100644 index 0000000000..e4901ea360 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/video/information/VideoInformationPatch.kt @@ -0,0 +1,392 @@ +package app.revanced.patches.music.video.information + +import app.revanced.patcher.Fingerprint +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableClass +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable +import app.revanced.patcher.util.smali.toInstructions +import app.revanced.patches.music.utils.extension.Constants.SHARED_PATH +import app.revanced.patches.music.utils.playbackSpeedFingerprint +import app.revanced.patches.music.utils.playbackSpeedParentFingerprint +import app.revanced.patches.music.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.shared.mdxPlayerDirectorSetVideoStageFingerprint +import app.revanced.patches.shared.videoLengthFingerprint +import app.revanced.util.addStaticFieldToExtension +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.mutableClassOrThrow +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.immutable.ImmutableMethod +import com.android.tools.smali.dexlib2.immutable.ImmutableMethodImplementation +import com.android.tools.smali.dexlib2.immutable.ImmutableMethodParameter + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$SHARED_PATH/VideoInformation;" + +private const val REGISTER_PLAYER_RESPONSE_MODEL = 4 + +private const val REGISTER_VIDEO_ID = 0 +private const val REGISTER_VIDEO_LENGTH = 1 + +@Suppress("unused") +private const val REGISTER_VIDEO_LENGTH_DUMMY = 2 + +private lateinit var PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR: String +private lateinit var videoIdMethodCall: String +private lateinit var videoLengthMethodCall: String + +private lateinit var videoInformationMethod: MutableMethod + +/** + * Used in [videoEndFingerprint] and [mdxPlayerDirectorSetVideoStageFingerprint]. + * Since both classes are inherited from the same class, + * [videoEndFingerprint] and [mdxPlayerDirectorSetVideoStageFingerprint] always have the same [seekSourceEnumType] and [seekSourceMethodName]. + */ +private var seekSourceEnumType = "" +private var seekSourceMethodName = "" + +private lateinit var playerConstructorMethod: MutableMethod +private var playerConstructorInsertIndex = -1 + +private lateinit var mdxConstructorMethod: MutableMethod +private var mdxConstructorInsertIndex = -1 + +private lateinit var videoTimeConstructorMethod: MutableMethod +private var videoTimeConstructorInsertIndex = 2 + +val videoInformationPatch = bytecodePatch( + description = "videoInformationPatch", +) { + dependsOn(sharedResourceIdPatch) + + execute { + fun addSeekInterfaceMethods( + targetClass: MutableClass, + targetMethod: MutableMethod, + seekMethodName: String, + methodName: String, + fieldName: String + ) { + targetMethod.apply { + targetClass.methods.add( + ImmutableMethod( + definingClass, + "seekTo", + listOf(ImmutableMethodParameter("J", annotations, "time")), + "Z", + AccessFlags.PUBLIC or AccessFlags.FINAL, + annotations, + null, + ImmutableMethodImplementation( + 4, """ + # first enum (field a) is SEEK_SOURCE_UNKNOWN + sget-object v0, $seekSourceEnumType->a:$seekSourceEnumType + invoke-virtual {p0, p1, p2, v0}, $definingClass->$seekMethodName(J$seekSourceEnumType)Z + move-result p1 + return p1 + """.toInstructions(), + null, + null + ) + ).toMutable() + ) + + val smaliInstructions = + """ + if-eqz v0, :ignore + invoke-virtual {v0, p0, p1}, $definingClass->seekTo(J)Z + move-result v0 + return v0 + :ignore + const/4 v0, 0x0 + return v0 + """ + + addStaticFieldToExtension( + EXTENSION_CLASS_DESCRIPTOR, + methodName, + fieldName, + definingClass, + smaliInstructions + ) + } + } + + fun Pair.getPlayerResponseInstruction(returnType: String): String { + methodOrThrow().apply { + val targetReference = getInstruction( + indexOfFirstInstructionOrThrow { + val reference = getReference() + (opcode == Opcode.INVOKE_INTERFACE_RANGE || opcode == Opcode.INVOKE_INTERFACE) && + reference?.definingClass == PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR && + reference.returnType == returnType + } + ).reference + + return "invoke-interface {v$REGISTER_PLAYER_RESPONSE_MODEL}, $targetReference" + } + } + + videoEndFingerprint.methodOrThrow().apply { + findMethodOrThrow(definingClass).let { + playerConstructorMethod = it + playerConstructorInsertIndex = it.indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_DIRECT && getReference()?.name == "" + } + 1 + } + + // hook the player controller for use through extension + onCreateHook(EXTENSION_CLASS_DESCRIPTOR, "initialize") + + seekSourceEnumType = parameterTypes[1].toString() + seekSourceMethodName = name + + // Create extension interface methods. + addSeekInterfaceMethods( + videoEndFingerprint.mutableClassOrThrow(), + this, + seekSourceMethodName, + "overrideVideoTime", + "videoInformationClass" + ) + } + + mdxPlayerDirectorSetVideoStageFingerprint.methodOrThrow().apply { + findMethodOrThrow(definingClass).let { + mdxConstructorMethod = it + mdxConstructorInsertIndex = it.indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_DIRECT && getReference()?.name == "" + } + 1 + } + + // hook the MDX director for use through extension + onCreateHookMdx(EXTENSION_CLASS_DESCRIPTOR, "initializeMdx") + + // Create extension interface methods. + addSeekInterfaceMethods( + mdxPlayerDirectorSetVideoStageFingerprint.mutableClassOrThrow(), + this, + seekSourceMethodName, + "overrideMDXVideoTime", + "videoInformationMDXClass" + ) + } + + /** + * Set current video information + */ + videoIdFingerprint.matchOrThrow().let { + it.method.apply { + val playerResponseModelIndex = it.patternMatch!!.startIndex + + PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR = + getInstruction(playerResponseModelIndex) + .getReference() + ?.definingClass + ?: throw PatchException("Could not find Player Response Model class") + + videoIdMethodCall = + videoIdFingerprint.getPlayerResponseInstruction("Ljava/lang/String;") + videoLengthMethodCall = + videoLengthFingerprint.getPlayerResponseInstruction("J") + + videoInformationMethod = getVideoInformationMethod() + it.classDef.methods.add(videoInformationMethod) + + addInstruction( + playerResponseModelIndex + 2, + "invoke-direct/range {p0 .. p1}, $definingClass->setVideoInformation($PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR)V" + ) + } + } + + /** + * Set the video time method + */ + playerControllerSetTimeReferenceFingerprint.matchOrThrow().let { + videoTimeConstructorMethod = + it.getWalkerMethod(it.patternMatch!!.startIndex) + } + + /** + * Set current video time + */ + videoTimeHook(EXTENSION_CLASS_DESCRIPTOR, "setVideoTime") + + /** + * Set current video length + */ + videoLengthHook("$EXTENSION_CLASS_DESCRIPTOR->setVideoLength(J)V") + + /** + * Set current video id + */ + videoIdHook("$EXTENSION_CLASS_DESCRIPTOR->setVideoId(Ljava/lang/String;)V") + + /** + * Hook current playback speed + */ + playbackSpeedFingerprint.matchOrThrow(playbackSpeedParentFingerprint).let { + it.getWalkerMethod(it.patternMatch!!.endIndex).apply { + addInstruction( + implementation!!.instructions.lastIndex, + "invoke-static {p1}, $EXTENSION_CLASS_DESCRIPTOR->setPlaybackSpeed(F)V" + ) + } + } + + /** + * Hook current video quality + */ + videoQualityListFingerprint.matchOrThrow().let { + it.method.apply { + val videoQualityMethodName = + findMethodOrThrow(definingClass) { parameterTypes.first() == "I" }.name + // set video quality array + val listIndex = it.patternMatch!!.startIndex + val listRegister = getInstruction(listIndex).registerD + + addInstruction( + listIndex, + "invoke-static {v$listRegister}, $EXTENSION_CLASS_DESCRIPTOR->setVideoQualityList([Ljava/lang/Object;)V" + ) + + val smaliInstructions = + """ + if-eqz v0, :ignore + invoke-virtual {v0, p0}, $definingClass->$videoQualityMethodName(I)V + :ignore + return-void + """ + + addStaticFieldToExtension( + EXTENSION_CLASS_DESCRIPTOR, + "overrideVideoQuality", + "videoQualityClass", + definingClass, + smaliInstructions + ) + + } + } + + // set current video quality + videoQualityTextFingerprint.matchOrThrow().let { + it.method.apply { + val textIndex = it.patternMatch!!.endIndex + val textRegister = getInstruction(textIndex).registerA + + addInstruction( + textIndex + 1, + "invoke-static {v$textRegister}, $EXTENSION_CLASS_DESCRIPTOR->setVideoQuality(Ljava/lang/String;)V" + ) + } + } + } +} + +private fun MutableMethod.getVideoInformationMethod(): MutableMethod = + ImmutableMethod( + definingClass, + "setVideoInformation", + listOf( + ImmutableMethodParameter( + PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR, + annotations, + null + ) + ), + "V", + AccessFlags.PRIVATE or AccessFlags.FINAL, + annotations, + null, + ImmutableMethodImplementation( + REGISTER_PLAYER_RESPONSE_MODEL + 1, """ + $videoIdMethodCall + move-result-object v$REGISTER_VIDEO_ID + $videoLengthMethodCall + move-result-wide v$REGISTER_VIDEO_LENGTH + return-void + """.toInstructions(), + null, + null + ) + ).toMutable() + +private fun MutableMethod.insert(insertIndex: Int, register: String, descriptor: String) = + addInstruction(insertIndex, "invoke-static { $register }, $descriptor") + +private fun MutableMethod.insertTimeHook(insertIndex: Int, descriptor: String) = + insert(insertIndex, "p1, p2", descriptor) + +/** + * Hook the player controller. Called when a video is opened or the current video is changed. + * + * Note: This hook is called very early and is called before the video id, video time, video length, + * and many other data fields are set. + * + * @param targetMethodClass The descriptor for the class to invoke when the player controller is created. + * @param targetMethodName The name of the static method to invoke when the player controller is created. + */ +internal fun onCreateHook(targetMethodClass: String, targetMethodName: String) = + playerConstructorMethod.addInstruction( + playerConstructorInsertIndex++, + "invoke-static { }, $targetMethodClass->$targetMethodName()V" + ) + +/** + * Hook the MDX player director. Called when playing videos while casting to a big screen device. + * + * @param targetMethodClass The descriptor for the class to invoke when the player controller is created. + * @param targetMethodName The name of the static method to invoke when the player controller is created. + */ +internal fun onCreateHookMdx(targetMethodClass: String, targetMethodName: String) = + mdxConstructorMethod.addInstruction( + mdxConstructorInsertIndex++, + "invoke-static { }, $targetMethodClass->$targetMethodName()V" + ) + +internal fun videoIdHook( + descriptor: String +) = videoInformationMethod.apply { + addInstruction( + implementation!!.instructions.lastIndex, + "invoke-static {v$REGISTER_VIDEO_ID}, $descriptor" + ) +} + +internal fun videoLengthHook( + descriptor: String +) = videoInformationMethod.apply { + addInstruction( + implementation!!.instructions.lastIndex, + "invoke-static {v$REGISTER_VIDEO_LENGTH, v$REGISTER_VIDEO_LENGTH_DUMMY}, $descriptor" + ) +} + +/** + * Hook the video time. + * The hook is usually called once per second. + * + * @param targetMethodClass The descriptor for the static method to invoke when the player controller is created. + * @param targetMethodName The name of the static method to invoke when the player controller is created. + */ +internal fun videoTimeHook(targetMethodClass: String, targetMethodName: String) = + videoTimeConstructorMethod.insertTimeHook( + videoTimeConstructorInsertIndex++, + "$targetMethodClass->$targetMethodName(J)V" + ) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/video/playback/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/video/playback/Fingerprints.kt new file mode 100644 index 0000000000..354c6a35b0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/video/playback/Fingerprints.kt @@ -0,0 +1,27 @@ +package app.revanced.patches.music.video.playback + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val playbackSpeedBottomSheetFingerprint = legacyFingerprint( + name = "playbackSpeedBottomSheetFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + strings = listOf("PLAYBACK_RATE_MENU_BOTTOM_SHEET_FRAGMENT") +) + +internal val userQualityChangeFingerprint = legacyFingerprint( + name = "userQualityChangeFingerprint", + returnType = "V", + opcodes = listOf( + Opcode.CONST_STRING, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IF_EQZ, + Opcode.CHECK_CAST + ), + strings = listOf("VIDEO_QUALITIES_MENU_BOTTOM_SHEET_FRAGMENT") +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/video/playback/VideoPlaybackPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/video/playback/VideoPlaybackPatch.kt new file mode 100644 index 0000000000..15abda5fb5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/video/playback/VideoPlaybackPatch.kt @@ -0,0 +1,138 @@ +package app.revanced.patches.music.video.playback + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.extension.Constants.VIDEO_PATH +import app.revanced.patches.music.utils.patch.PatchList.VIDEO_PLAYBACK +import app.revanced.patches.music.utils.playbackSpeedFingerprint +import app.revanced.patches.music.utils.playbackSpeedParentFingerprint +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addPreferenceWithIntent +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.music.video.information.videoIdHook +import app.revanced.patches.music.video.information.videoInformationPatch +import app.revanced.patches.shared.customspeed.customPlaybackSpeedPatch +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.mutableClassOrThrow +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction + +private const val EXTENSION_PLAYBACK_SPEED_CLASS_DESCRIPTOR = + "$VIDEO_PATH/PlaybackSpeedPatch;" +private const val EXTENSION_VIDEO_QUALITY_CLASS_DESCRIPTOR = + "$VIDEO_PATH/VideoQualityPatch;" + +@Suppress("unused") +val videoPlaybackPatch = bytecodePatch( + VIDEO_PLAYBACK.title, + VIDEO_PLAYBACK.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + customPlaybackSpeedPatch( + "$VIDEO_PATH/CustomPlaybackSpeedPatch;", + 5.0f + ), + settingsPatch, + videoInformationPatch, + ) + + execute { + // region patch for default playback speed + + playbackSpeedBottomSheetFingerprint.mutableClassOrThrow().let { + val onItemClickMethod = + it.methods.find { method -> method.name == "onItemClick" } + + onItemClickMethod?.apply { + val targetIndex = indexOfFirstInstructionOrThrow(Opcode.IGET) + val targetRegister = + getInstruction(targetIndex).registerA + + addInstruction( + targetIndex + 1, + "invoke-static {v$targetRegister}, $EXTENSION_PLAYBACK_SPEED_CLASS_DESCRIPTOR->userSelectedPlaybackSpeed(F)V" + ) + } ?: throw PatchException("Failed to find onItemClick method") + } + + playbackSpeedFingerprint.matchOrThrow(playbackSpeedParentFingerprint).let { + it.method.apply { + val startIndex = it.patternMatch!!.startIndex + val speedRegister = + getInstruction(startIndex + 1).registerA + + addInstructions( + startIndex + 2, """ + invoke-static {v$speedRegister}, $EXTENSION_PLAYBACK_SPEED_CLASS_DESCRIPTOR->getPlaybackSpeed(F)F + move-result v$speedRegister + """ + ) + } + } + + // endregion + + // region patch for default video quality + + userQualityChangeFingerprint.matchOrThrow().let { + it.method.apply { + val endIndex = it.patternMatch!!.endIndex + val qualityChangedClass = + getInstruction(endIndex).reference.toString() + + findMethodOrThrow(qualityChangedClass) { + name == "onItemClick" + }.addInstruction( + 0, + "invoke-static {}, $EXTENSION_VIDEO_QUALITY_CLASS_DESCRIPTOR->userSelectedVideoQuality()V" + ) + } + } + + videoIdHook("$EXTENSION_VIDEO_QUALITY_CLASS_DESCRIPTOR->newVideoStarted(Ljava/lang/String;)V") + + // endregion + + addPreferenceWithIntent( + CategoryType.VIDEO, + "revanced_custom_playback_speeds" + ) + addSwitchPreference( + CategoryType.VIDEO, + "revanced_remember_playback_speed_last_selected", + "true" + ) + addSwitchPreference( + CategoryType.VIDEO, + "revanced_remember_playback_speed_last_selected_toast", + "true", + "revanced_remember_playback_speed_last_selected" + ) + addSwitchPreference( + CategoryType.VIDEO, + "revanced_remember_video_quality_last_selected", + "true" + ) + addSwitchPreference( + CategoryType.VIDEO, + "revanced_remember_video_quality_last_selected_toast", + "true", + "revanced_remember_video_quality_last_selected" + ) + + updatePatchStatus(VIDEO_PLAYBACK) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/ad/AdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/ad/AdsPatch.kt new file mode 100644 index 0000000000..9b8d4d5b8e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/ad/AdsPatch.kt @@ -0,0 +1,134 @@ +package app.revanced.patches.reddit.ad + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.reddit.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.reddit.utils.extension.Constants.PATCHES_PATH +import app.revanced.patches.reddit.utils.patch.PatchList.HIDE_ADS +import app.revanced.patches.reddit.utils.settings.settingsPatch +import app.revanced.patches.reddit.utils.settings.updatePatchStatus +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private const val RESOURCE_FILE_PATH = "res/layout/merge_listheader_link_detail.xml" + +private val bannerAdsPatch = resourcePatch( + description = "bannerAdsPatch", +) { + execute { + document(RESOURCE_FILE_PATH).use { document -> + document.getElementsByTagName("merge").item(0).childNodes.apply { + val attributes = arrayOf("height", "width") + + for (i in 1 until length) { + val view = item(i) + if ( + view.hasAttributes() && + view.attributes.getNamedItem("android:id").nodeValue.endsWith("ad_view_stub") + ) { + attributes.forEach { attribute -> + view.attributes.getNamedItem("android:layout_$attribute").nodeValue = + "0.0dip" + } + + break + } + } + } + } + } +} + +private const val EXTENSION_METHOD_DESCRIPTOR = + "$PATCHES_PATH/GeneralAdsPatch;->hideCommentAds()Z" + +private val commentAdsPatch = bytecodePatch( + description = "commentAdsPatch", +) { + execute { + commentAdsFingerprint.matchOrThrow().let { + val walkerMethod = it.getWalkerMethod(it.patternMatch!!.startIndex) + walkerMethod.apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $EXTENSION_METHOD_DESCRIPTOR + move-result v0 + if-eqz v0, :show + new-instance v0, Ljava/lang/Object; + invoke-direct {v0}, Ljava/lang/Object;->()V + return-object v0 + """, ExternalLabel("show", getInstruction(0)) + ) + } + } + } +} + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$PATCHES_PATH/GeneralAdsPatch;" + +@Suppress("unused") +val adsPatch = bytecodePatch( + HIDE_ADS.title, + HIDE_ADS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + bannerAdsPatch, + commentAdsPatch, + settingsPatch + ) + + execute { + // region Filter promoted ads (does not work in popular or latest feed) + adPostFingerprint.methodOrThrow().apply { + val targetIndex = indexOfFirstInstructionOrThrow { + getReference()?.name == "children" + } + val targetRegister = getInstruction(targetIndex).registerA + + addInstructions( + targetIndex, """ + invoke-static {v$targetRegister}, $EXTENSION_CLASS_DESCRIPTOR->hideOldPostAds(Ljava/util/List;)Ljava/util/List; + move-result-object v$targetRegister + """ + ) + } + + // The new feeds work by inserting posts into lists. + // AdElementConverter is conveniently responsible for inserting all feed ads. + // By removing the appending instruction no ad posts gets appended to the feed. + newAdPostFingerprint.methodOrThrow().apply { + val targetIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.toString() == "Ljava/util/ArrayList;->add(Ljava/lang/Object;)Z" + } + val targetInstruction = getInstruction(targetIndex) + + replaceInstruction( + targetIndex, + "invoke-static {v${targetInstruction.registerC}, v${targetInstruction.registerD}}, " + + "$EXTENSION_CLASS_DESCRIPTOR->hideNewPostAds(Ljava/util/ArrayList;Ljava/lang/Object;)V" + ) + } + + updatePatchStatus( + "enableGeneralAds", + HIDE_ADS + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/ad/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/ad/Fingerprints.kt new file mode 100644 index 0000000000..c4218a431a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/ad/Fingerprints.kt @@ -0,0 +1,54 @@ +package app.revanced.patches.reddit.ad + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val commentAdsFingerprint = legacyFingerprint( + name = "commentAdsFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + opcodes = listOf( + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.RETURN_OBJECT + ), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/PostDetailPresenter\$loadAd\$1;") && + method.name == "invokeSuspend" + }, +) + +internal val adPostFingerprint = legacyFingerprint( + name = "adPostFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + opcodes = listOf( + Opcode.INVOKE_DIRECT, + Opcode.IPUT_OBJECT + ), + // "children" are present throughout multiple versions + strings = listOf( + "children", + "uxExperiences" + ), + customFingerprint = { method, classDef -> + method.definingClass.endsWith("/Listing;") && + method.name == "" && + classDef.sourceFile == "Listing.kt" + }, +) + +internal val newAdPostFingerprint = legacyFingerprint( + name = "newAdPostFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + opcodes = listOf(Opcode.INVOKE_VIRTUAL), + strings = listOf( + "chain", + "feedElement" + ), + customFingerprint = { _, classDef -> classDef.sourceFile == "AdElementConverter.kt" }, +) diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/branding/name/CustomBrandingNamePatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/branding/name/CustomBrandingNamePatch.kt new file mode 100644 index 0000000000..f32ed7dc2a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/branding/name/CustomBrandingNamePatch.kt @@ -0,0 +1,73 @@ +package app.revanced.patches.reddit.layout.branding.name + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patches.reddit.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.reddit.utils.patch.PatchList.CUSTOM_BRANDING_NAME_FOR_REDDIT +import app.revanced.patches.reddit.utils.settings.updatePatchStatus +import app.revanced.util.valueOrThrow +import java.io.FileWriter +import java.nio.file.Files + +private const val ORIGINAL_APP_NAME = "Reddit" +private const val APP_NAME = "RVX Reddit" + +@Suppress("unused") +val customBrandingNamePatch = resourcePatch( + CUSTOM_BRANDING_NAME_FOR_REDDIT.title, + CUSTOM_BRANDING_NAME_FOR_REDDIT.summary, + false, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + val appNameOption = stringOption( + key = "appName", + default = ORIGINAL_APP_NAME, + values = mapOf( + "Default" to APP_NAME, + "Original" to ORIGINAL_APP_NAME, + ), + title = "App name", + description = "The name of the app.", + required = true + ) + + execute { + val appName = appNameOption + .valueOrThrow() + + if (appName == ORIGINAL_APP_NAME) { + println("INFO: App name will remain unchanged as it matches the original.") + return@execute + } + + val resDirectory = get("res") + + val valuesV24Directory = resDirectory.resolve("values-v24") + if (!valuesV24Directory.isDirectory) + Files.createDirectories(valuesV24Directory.toPath()) + + val stringsXml = valuesV24Directory.resolve("strings.xml") + + if (!stringsXml.exists()) { + FileWriter(stringsXml).use { + it.write("") + } + } + + document("res/values-v24/strings.xml").use { document -> + mapOf( + "app_name" to appName + ).forEach { (k, v) -> + val stringElement = document.createElement("string") + + stringElement.setAttribute("name", k) + stringElement.textContent = v + + document.getElementsByTagName("resources").item(0).appendChild(stringElement) + } + } + + updatePatchStatus(CUSTOM_BRANDING_NAME_FOR_REDDIT) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/branding/packagename/ChangePackageNamePatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/branding/packagename/ChangePackageNamePatch.kt new file mode 100644 index 0000000000..9201291d92 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/branding/packagename/ChangePackageNamePatch.kt @@ -0,0 +1,110 @@ +package app.revanced.patches.reddit.layout.branding.packagename + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patches.reddit.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.reddit.utils.patch.PatchList.CHANGE_PACKAGE_NAME +import app.revanced.patches.reddit.utils.settings.updatePatchStatus +import app.revanced.util.valueOrThrow +import org.w3c.dom.Element + +private const val PACKAGE_NAME_REDDIT = "com.reddit.frontpage" +private const val CLONE_PACKAGE_NAME_REDDIT = "$PACKAGE_NAME_REDDIT.revanced" +private const val DEFAULT_PACKAGE_NAME_REDDIT = "$PACKAGE_NAME_REDDIT.rvx" + +private var redditPackageName = PACKAGE_NAME_REDDIT + +@Suppress("unused") +val changePackageNamePatch = resourcePatch( + CHANGE_PACKAGE_NAME.title, + CHANGE_PACKAGE_NAME.summary, + false, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + val packageNameRedditOption = stringOption( + key = "packageNameReddit", + default = PACKAGE_NAME_REDDIT, + values = mapOf( + "Clone" to CLONE_PACKAGE_NAME_REDDIT, + "Default" to DEFAULT_PACKAGE_NAME_REDDIT, + "Original" to PACKAGE_NAME_REDDIT, + ), + title = "Package name of Reddit", + description = "The name of the package to rename the app to.", + required = true + ) + + execute { + fun replacePackageName() { + // replace strings + document("res/values/strings.xml").use { document -> + val resourcesNode = document.getElementsByTagName("resources").item(0) as Element + + val children = resourcesNode.childNodes + for (i in 0 until children.length) { + val node = children.item(i) as? Element ?: continue + + node.textContent = when (node.getAttribute("name")) { + "provider_authority_appdata", "provider_authority_file", + "provider_authority_userdata", "provider_workmanager_init" + -> node.textContent.replace(PACKAGE_NAME_REDDIT, redditPackageName) + + else -> continue + } + } + } + + // replace manifest permission and provider + get("AndroidManifest.xml").apply { + writeText( + readText() + .replace( + "android:authorities=\"$PACKAGE_NAME_REDDIT", + "android:authorities=\"$redditPackageName" + ) + ) + } + } + + redditPackageName = packageNameRedditOption + .valueOrThrow() + + if (redditPackageName == PACKAGE_NAME_REDDIT) { + println("INFO: Package name will remain unchanged as it matches the original.") + return@execute + } + + // Ensure device runs Android. + try { + // RVX Manager + // ==== + // For some reason, in Android AAPT2, a compilation error occurs when changing the [strings.xml] of the Reddit + // This only affects RVX Manager, and has not yet found a valid workaround + Class.forName("android.os.Environment") + } catch (_: ClassNotFoundException) { + // CLI + replacePackageName() + } + + updatePatchStatus(CHANGE_PACKAGE_NAME) + } + + finalize { + if (redditPackageName != PACKAGE_NAME_REDDIT) { + get("AndroidManifest.xml").apply { + writeText( + readText() + .replace( + "package=\"$PACKAGE_NAME_REDDIT", + "package=\"$redditPackageName" + ) + .replace( + "$PACKAGE_NAME_REDDIT.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION", + "$redditPackageName.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" + ) + ) + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/communities/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/communities/Fingerprints.kt new file mode 100644 index 0000000000..eeb7f8051b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/communities/Fingerprints.kt @@ -0,0 +1,14 @@ +package app.revanced.patches.reddit.layout.communities + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val communityRecommendationSectionFingerprint = legacyFingerprint( + name = "communityRecommendationSectionFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + customFingerprint = { method, _ -> + method.definingClass.endsWith("/CommunityRecommendationSection;") + } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/communities/RecommendedCommunitiesPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/communities/RecommendedCommunitiesPatch.kt new file mode 100644 index 0000000000..e0cc431d71 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/communities/RecommendedCommunitiesPatch.kt @@ -0,0 +1,44 @@ +package app.revanced.patches.reddit.layout.communities + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.reddit.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.reddit.utils.extension.Constants.PATCHES_PATH +import app.revanced.patches.reddit.utils.patch.PatchList.HIDE_RECOMMENDED_COMMUNITIES_SHELF +import app.revanced.patches.reddit.utils.settings.settingsPatch +import app.revanced.patches.reddit.utils.settings.updatePatchStatus +import app.revanced.util.fingerprint.methodOrThrow + +private const val EXTENSION_METHOD_DESCRIPTOR = + "$PATCHES_PATH/RecommendedCommunitiesPatch;->hideRecommendedCommunitiesShelf()Z" + +@Suppress("unused") +val recommendedCommunitiesPatch = bytecodePatch( + HIDE_RECOMMENDED_COMMUNITIES_SHELF.title, + HIDE_RECOMMENDED_COMMUNITIES_SHELF.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + communityRecommendationSectionFingerprint.methodOrThrow().apply { + addInstructionsWithLabels( + 0, + """ + invoke-static {}, $EXTENSION_METHOD_DESCRIPTOR + move-result v0 + if-eqz v0, :off + return-void + """, ExternalLabel("off", getInstruction(0)) + ) + } + + updatePatchStatus( + "enableRecommendedCommunitiesShelf", + HIDE_RECOMMENDED_COMMUNITIES_SHELF + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/navigation/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/navigation/Fingerprints.kt new file mode 100644 index 0000000000..7316791b7b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/navigation/Fingerprints.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.reddit.layout.navigation + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val bottomNavScreenFingerprint = legacyFingerprint( + name = "bottomNavScreenFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN_VOID + ), + customFingerprint = { method, classDef -> + method.name == "onGlobalLayout" && + classDef.type.startsWith("Lcom/reddit/launch/bottomnav/BottomNavScreen\$") + } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/navigation/NavigationButtonsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/navigation/NavigationButtonsPatch.kt new file mode 100644 index 0000000000..e517df7ec2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/navigation/NavigationButtonsPatch.kt @@ -0,0 +1,45 @@ +package app.revanced.patches.reddit.layout.navigation + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.reddit.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.reddit.utils.extension.Constants.PATCHES_PATH +import app.revanced.patches.reddit.utils.patch.PatchList.HIDE_NAVIGATION_BUTTONS +import app.revanced.patches.reddit.utils.settings.settingsPatch +import app.revanced.patches.reddit.utils.settings.updatePatchStatus +import app.revanced.util.fingerprint.matchOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction + +private const val EXTENSION_METHOD_DESCRIPTOR = + "$PATCHES_PATH/NavigationButtonsPatch;->hideNavigationButtons(Landroid/view/ViewGroup;)V" + +@Suppress("unused") +val navigationButtonsPatch = bytecodePatch( + HIDE_NAVIGATION_BUTTONS.title, + HIDE_NAVIGATION_BUTTONS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + bottomNavScreenFingerprint.matchOrThrow().let { + it.method.apply { + val startIndex = it.patternMatch!!.startIndex + val targetRegister = + getInstruction(startIndex).registerC + + addInstruction( + startIndex + 1, + "invoke-static {v$targetRegister}, $EXTENSION_METHOD_DESCRIPTOR" + ) + } + } + + updatePatchStatus( + "enableNavigationButtons", + HIDE_NAVIGATION_BUTTONS + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/premiumicon/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/premiumicon/Fingerprints.kt new file mode 100644 index 0000000000..899ceb2fee --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/premiumicon/Fingerprints.kt @@ -0,0 +1,13 @@ +package app.revanced.patches.reddit.layout.premiumicon + +import app.revanced.util.fingerprint.legacyFingerprint + +internal val premiumIconFingerprint = legacyFingerprint( + name = "premiumIconFingerprint", + returnType = "Z", + customFingerprint = { method, classDef -> + method.definingClass.endsWith("/MyAccount;") && + method.name == "isPremiumSubscriber" && + classDef.sourceFile == "MyAccount.kt" + } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/premiumicon/PremiumIconPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/premiumicon/PremiumIconPatch.kt new file mode 100644 index 0000000000..d7d2eee1ac --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/premiumicon/PremiumIconPatch.kt @@ -0,0 +1,27 @@ +package app.revanced.patches.reddit.layout.premiumicon + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.reddit.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.reddit.utils.patch.PatchList.PREMIUM_ICON +import app.revanced.patches.reddit.utils.settings.updatePatchStatus +import app.revanced.util.fingerprint.methodOrThrow + +@Suppress("unused") +val premiumIconPatch = bytecodePatch( + PREMIUM_ICON.title, + PREMIUM_ICON.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + execute { + premiumIconFingerprint.methodOrThrow().addInstructions( + 0, """ + const/4 v0, 0x1 + return v0 + """ + ) + + updatePatchStatus(PREMIUM_ICON) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/recentlyvisited/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/recentlyvisited/Fingerprints.kt new file mode 100644 index 0000000000..f057a104ef --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/recentlyvisited/Fingerprints.kt @@ -0,0 +1,17 @@ +package app.revanced.patches.reddit.layout.recentlyvisited + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val communityDrawerPresenterFingerprint = legacyFingerprint( + name = "communityDrawerPresenterFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf(Opcode.AGET), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/CommunityDrawerPresenter;") + } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/recentlyvisited/RecentlyVisitedShelfPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/recentlyvisited/RecentlyVisitedShelfPatch.kt new file mode 100644 index 0000000000..1981cb8940 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/recentlyvisited/RecentlyVisitedShelfPatch.kt @@ -0,0 +1,80 @@ +package app.revanced.patches.reddit.layout.recentlyvisited + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.reddit.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.reddit.utils.extension.Constants.PATCHES_PATH +import app.revanced.patches.reddit.utils.patch.PatchList.HIDE_RECENTLY_VISITED_SHELF +import app.revanced.patches.reddit.utils.settings.settingsPatch +import app.revanced.patches.reddit.utils.settings.updatePatchStatus +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +private const val EXTENSION_METHOD_DESCRIPTOR = + "$PATCHES_PATH/RecentlyVisitedShelfPatch;" + + "->" + + "hideRecentlyVisitedShelf(Ljava/util/List;)Ljava/util/List;" + +@Suppress("unused") +val recentlyVisitedShelfPatch = bytecodePatch( + HIDE_RECENTLY_VISITED_SHELF.title, + HIDE_RECENTLY_VISITED_SHELF.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + val communityDrawerPresenterMethod = communityDrawerPresenterFingerprint.methodOrThrow() + val constructorMethod = findMethodOrThrow(communityDrawerPresenterMethod.definingClass) + val recentlyVisitedReference = with(constructorMethod) { + val recentlyVisitedFieldIndex = indexOfFirstInstructionOrThrow { + getReference()?.name == "RECENTLY_VISITED" + } + val recentlyVisitedObjectIndex = + indexOfFirstInstructionOrThrow( + recentlyVisitedFieldIndex, + Opcode.IPUT_OBJECT + ) + getInstruction(recentlyVisitedObjectIndex).reference + } + communityDrawerPresenterMethod.apply { + val recentlyVisitedObjectIndex = indexOfFirstInstructionOrThrow { + getReference()?.toString() == recentlyVisitedReference.toString() + } + arrayOf( + indexOfFirstInstructionOrThrow( + recentlyVisitedObjectIndex, + Opcode.INVOKE_STATIC + ), + indexOfFirstInstructionReversedOrThrow( + recentlyVisitedObjectIndex, + Opcode.INVOKE_STATIC + ) + ).forEach { staticIndex -> + val insertRegister = + getInstruction(staticIndex + 1).registerA + + addInstructions( + staticIndex + 2, """ + invoke-static {v$insertRegister}, $EXTENSION_METHOD_DESCRIPTOR + move-result-object v$insertRegister + """ + ) + } + } + + updatePatchStatus( + "enableRecentlyVisitedShelf", + HIDE_RECENTLY_VISITED_SHELF + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/screenshotpopup/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/screenshotpopup/Fingerprints.kt new file mode 100644 index 0000000000..e5bc11a19b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/screenshotpopup/Fingerprints.kt @@ -0,0 +1,16 @@ +package app.revanced.patches.reddit.layout.screenshotpopup + +import app.revanced.patches.reddit.utils.resourceid.screenShotShareBanner +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val screenshotTakenBannerFingerprint = legacyFingerprint( + name = "screenshotTakenBannerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + literals = listOf(screenShotShareBanner), + customFingerprint = { _, classDef -> + classDef.sourceFile == "ScreenshotTakenBanner.kt" + } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/screenshotpopup/ScreenshotPopupPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/screenshotpopup/ScreenshotPopupPatch.kt new file mode 100644 index 0000000000..cf8f026649 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/screenshotpopup/ScreenshotPopupPatch.kt @@ -0,0 +1,47 @@ +package app.revanced.patches.reddit.layout.screenshotpopup + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.reddit.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.reddit.utils.extension.Constants.PATCHES_PATH +import app.revanced.patches.reddit.utils.patch.PatchList.DISABLE_SCREENSHOT_POPUP +import app.revanced.patches.reddit.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.reddit.utils.settings.settingsPatch +import app.revanced.patches.reddit.utils.settings.updatePatchStatus +import app.revanced.util.fingerprint.methodOrThrow + +private const val EXTENSION_METHOD_DESCRIPTOR = + "$PATCHES_PATH/ScreenshotPopupPatch;->disableScreenshotPopup()Z" + +@Suppress("unused") +val screenshotPopupPatch = bytecodePatch( + DISABLE_SCREENSHOT_POPUP.title, + DISABLE_SCREENSHOT_POPUP.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + sharedResourceIdPatch, + settingsPatch + ) + + execute { + screenshotTakenBannerFingerprint.methodOrThrow().apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $EXTENSION_METHOD_DESCRIPTOR + move-result v0 + if-eqz v0, :dismiss + return-void + """, ExternalLabel("dismiss", getInstruction(0)) + ) + } + + updatePatchStatus( + "enableScreenshotPopup", + DISABLE_SCREENSHOT_POPUP + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/subredditdialog/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/subredditdialog/Fingerprints.kt new file mode 100644 index 0000000000..e4810f9b8d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/subredditdialog/Fingerprints.kt @@ -0,0 +1,27 @@ +package app.revanced.patches.reddit.layout.subredditdialog + +import app.revanced.patches.reddit.utils.resourceid.cancelButton +import app.revanced.patches.reddit.utils.resourceid.textAppearanceRedditBaseOldButtonColored +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val frequentUpdatesSheetScreenFingerprint = legacyFingerprint( + name = "frequentUpdatesSheetScreenFingerprint", + returnType = "Landroid/view/View;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + literals = listOf(cancelButton), + customFingerprint = { _, classDef -> + classDef.sourceFile == "FrequentUpdatesSheetScreen.kt" + } +) + +internal val redditAlertDialogsFingerprint = legacyFingerprint( + name = "redditAlertDialogsFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + literals = listOf(textAppearanceRedditBaseOldButtonColored), + customFingerprint = { _, classDef -> + classDef.sourceFile == "RedditAlertDialogs.kt" + } +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/subredditdialog/SubRedditDialogPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/subredditdialog/SubRedditDialogPatch.kt new file mode 100644 index 0000000000..2684e5fbc3 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/subredditdialog/SubRedditDialogPatch.kt @@ -0,0 +1,65 @@ +package app.revanced.patches.reddit.layout.subredditdialog + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.reddit.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.reddit.utils.extension.Constants.PATCHES_PATH +import app.revanced.patches.reddit.utils.patch.PatchList.REMOVE_SUBREDDIT_DIALOG +import app.revanced.patches.reddit.utils.resourceid.cancelButton +import app.revanced.patches.reddit.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.reddit.utils.resourceid.textAppearanceRedditBaseOldButtonColored +import app.revanced.patches.reddit.utils.settings.settingsPatch +import app.revanced.patches.reddit.utils.settings.updatePatchStatus +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$PATCHES_PATH/RemoveSubRedditDialogPatch;" + +@Suppress("unused") +val subRedditDialogPatch = bytecodePatch( + REMOVE_SUBREDDIT_DIALOG.title, + REMOVE_SUBREDDIT_DIALOG.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + sharedResourceIdPatch, + settingsPatch + ) + + execute { + frequentUpdatesSheetScreenFingerprint.methodOrThrow().apply { + val cancelButtonViewIndex = + indexOfFirstLiteralInstructionOrThrow(cancelButton) + 2 + val cancelButtonViewRegister = + getInstruction(cancelButtonViewIndex).registerA + + addInstruction( + cancelButtonViewIndex + 1, + "invoke-static {v$cancelButtonViewRegister}, $EXTENSION_CLASS_DESCRIPTOR->dismissDialog(Landroid/view/View;)V" + ) + } + + redditAlertDialogsFingerprint.methodOrThrow().apply { + val insertIndex = + indexOfFirstLiteralInstructionOrThrow( + textAppearanceRedditBaseOldButtonColored + ) + 1 + val insertRegister = getInstruction(insertIndex).registerC + + addInstruction( + insertIndex, + "invoke-static {v$insertRegister}, $EXTENSION_CLASS_DESCRIPTOR->confirmDialog(Landroid/widget/TextView;)V" + ) + } + + updatePatchStatus( + "enableSubRedditDialog", + REMOVE_SUBREDDIT_DIALOG + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/toolbar/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/toolbar/Fingerprints.kt new file mode 100644 index 0000000000..79b69ffb40 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/toolbar/Fingerprints.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.reddit.layout.toolbar + +import app.revanced.patches.reddit.utils.resourceid.toolBarNavSearchCtaContainer +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val homePagerScreenFingerprint = legacyFingerprint( + name = "homePagerScreenFingerprint", + returnType = "Landroid/view/View;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Landroid/view/LayoutInflater;", "Landroid/view/ViewGroup;"), + literals = listOf(toolBarNavSearchCtaContainer), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/HomePagerScreen;") + } +) + diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/toolbar/ToolBarButtonPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/toolbar/ToolBarButtonPatch.kt new file mode 100644 index 0000000000..29c0b517ab --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/toolbar/ToolBarButtonPatch.kt @@ -0,0 +1,47 @@ +package app.revanced.patches.reddit.layout.toolbar + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.reddit.utils.extension.Constants.PATCHES_PATH +import app.revanced.patches.reddit.utils.patch.PatchList.HIDE_TOOLBAR_BUTTON +import app.revanced.patches.reddit.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.reddit.utils.resourceid.toolBarNavSearchCtaContainer +import app.revanced.patches.reddit.utils.settings.settingsPatch +import app.revanced.patches.reddit.utils.settings.updatePatchStatus +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +private const val EXTENSION_METHOD_DESCRIPTOR = + "$PATCHES_PATH/ToolBarButtonPatch;->hideToolBarButton(Landroid/view/View;)V" + +@Suppress("unused") +@Deprecated("This patch is deprecated until Reddit adds a button like r/place or Reddit recap button to the toolbar.") +val toolBarButtonPatch = bytecodePatch { + // compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + sharedResourceIdPatch, + settingsPatch + ) + + execute { + homePagerScreenFingerprint.methodOrThrow().apply { + val targetIndex = + indexOfFirstLiteralInstructionOrThrow(toolBarNavSearchCtaContainer) + 3 + val targetRegister = + getInstruction(targetIndex - 1).registerA + + addInstruction( + targetIndex, + "invoke-static {v$targetRegister}, $EXTENSION_METHOD_DESCRIPTOR" + ) + } + + updatePatchStatus( + "enableToolBarButton", + HIDE_TOOLBAR_BUTTON + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/misc/openlink/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/misc/openlink/Fingerprints.kt new file mode 100644 index 0000000000..108e0d742a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/misc/openlink/Fingerprints.kt @@ -0,0 +1,20 @@ +package app.revanced.patches.reddit.misc.openlink + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val screenNavigatorFingerprint = legacyFingerprint( + name = "screenNavigatorFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + opcodes = listOf( + Opcode.CONST_STRING, + Opcode.INVOKE_STATIC, + Opcode.CONST_STRING, + Opcode.INVOKE_STATIC + ), + strings = listOf("activity", "uri"), + customFingerprint = { _, classDef -> classDef.sourceFile == "RedditScreenNavigator.kt" } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/misc/openlink/OpenLinksDirectlyPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/misc/openlink/OpenLinksDirectlyPatch.kt new file mode 100644 index 0000000000..d9b0e67286 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/misc/openlink/OpenLinksDirectlyPatch.kt @@ -0,0 +1,39 @@ +package app.revanced.patches.reddit.misc.openlink + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.reddit.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.reddit.utils.extension.Constants.PATCHES_PATH +import app.revanced.patches.reddit.utils.patch.PatchList.OPEN_LINKS_DIRECTLY +import app.revanced.patches.reddit.utils.settings.settingsPatch +import app.revanced.patches.reddit.utils.settings.updatePatchStatus +import app.revanced.util.fingerprint.methodOrThrow + +private const val EXTENSION_METHOD_DESCRIPTOR = + "$PATCHES_PATH/OpenLinksDirectlyPatch;" + + "->" + + "parseRedirectUri(Landroid/net/Uri;)Landroid/net/Uri;" + +@Suppress("unused") +val openLinksDirectlyPatch = bytecodePatch( + OPEN_LINKS_DIRECTLY.title, + OPEN_LINKS_DIRECTLY.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + screenNavigatorFingerprint.methodOrThrow().addInstructions( + 0, """ + invoke-static {p2}, $EXTENSION_METHOD_DESCRIPTOR + move-result-object p2 + """ + ) + + updatePatchStatus( + "enableOpenLinksDirectly", + OPEN_LINKS_DIRECTLY + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/misc/openlink/OpenLinksExternallyPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/misc/openlink/OpenLinksExternallyPatch.kt new file mode 100644 index 0000000000..781c0e48c2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/misc/openlink/OpenLinksExternallyPatch.kt @@ -0,0 +1,48 @@ +package app.revanced.patches.reddit.misc.openlink + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.reddit.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.reddit.utils.extension.Constants.PATCHES_PATH +import app.revanced.patches.reddit.utils.patch.PatchList.OPEN_LINKS_EXTERNALLY +import app.revanced.patches.reddit.utils.settings.settingsPatch +import app.revanced.patches.reddit.utils.settings.updatePatchStatus +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.indexOfFirstStringInstructionOrThrow + +private const val EXTENSION_METHOD_DESCRIPTOR = + "$PATCHES_PATH/OpenLinksExternallyPatch;" + + "->" + + "openLinksExternally(Landroid/app/Activity;Landroid/net/Uri;)Z" + +@Suppress("unused") +val openLinksExternallyPatch = bytecodePatch( + OPEN_LINKS_EXTERNALLY.title, + OPEN_LINKS_EXTERNALLY.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + screenNavigatorFingerprint.methodOrThrow().apply { + val insertIndex = indexOfFirstStringInstructionOrThrow("uri") + 2 + + addInstructionsWithLabels( + insertIndex, """ + invoke-static {p1, p2}, $EXTENSION_METHOD_DESCRIPTOR + move-result v0 + if-eqz v0, :dismiss + return-void + """, ExternalLabel("dismiss", getInstruction(insertIndex)) + ) + } + + updatePatchStatus( + "enableOpenLinksExternally", + OPEN_LINKS_EXTERNALLY + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/misc/tracking/url/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/misc/tracking/url/Fingerprints.kt new file mode 100644 index 0000000000..739c84cf35 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/misc/tracking/url/Fingerprints.kt @@ -0,0 +1,13 @@ +package app.revanced.patches.reddit.misc.tracking.url + +import app.revanced.util.fingerprint.legacyFingerprint + +internal val shareLinkFormatterFingerprint = legacyFingerprint( + name = "shareLinkFormatterFingerprint", + returnType = "Ljava/lang/String;", + parameters = listOf("Ljava/lang/String;", "Ljava/util/Map;"), + customFingerprint = { method, classDef -> + method.definingClass.startsWith("Lcom/reddit/sharing/") && + classDef.sourceFile == "UrlUtil.kt" + } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/misc/tracking/url/SanitizeUrlQueryPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/misc/tracking/url/SanitizeUrlQueryPatch.kt new file mode 100644 index 0000000000..22a14728fc --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/misc/tracking/url/SanitizeUrlQueryPatch.kt @@ -0,0 +1,44 @@ +package app.revanced.patches.reddit.misc.tracking.url + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.reddit.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.reddit.utils.extension.Constants.PATCHES_PATH +import app.revanced.patches.reddit.utils.patch.PatchList.SANITIZE_SHARING_LINKS +import app.revanced.patches.reddit.utils.settings.settingsPatch +import app.revanced.patches.reddit.utils.settings.updatePatchStatus +import app.revanced.util.fingerprint.methodOrThrow + +private const val SANITIZE_METHOD_DESCRIPTOR = + "$PATCHES_PATH/SanitizeUrlQueryPatch;->stripQueryParameters()Z" + +@Suppress("unused") +val sanitizeUrlQueryPatch = bytecodePatch( + SANITIZE_SHARING_LINKS.title, + SANITIZE_SHARING_LINKS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + shareLinkFormatterFingerprint.methodOrThrow().apply { + addInstructionsWithLabels( + 0, + """ + invoke-static {}, $SANITIZE_METHOD_DESCRIPTOR + move-result v0 + if-eqz v0, :off + return-object p0 + """, ExternalLabel("off", getInstruction(0)) + ) + } + + updatePatchStatus( + "enableSanitizeUrlQuery", + SANITIZE_SHARING_LINKS + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/utils/compatibility/Constants.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/utils/compatibility/Constants.kt new file mode 100644 index 0000000000..450b2521f8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/utils/compatibility/Constants.kt @@ -0,0 +1,14 @@ +package app.revanced.patches.reddit.utils.compatibility + +import app.revanced.patcher.patch.PackageName +import app.revanced.patcher.patch.VersionName + +internal object Constants { + val COMPATIBLE_PACKAGE: Pair?> = Pair( + "com.reddit.frontpage", + setOf( + "2023.12.0", + "2024.17.0" + ) + ) +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/utils/extension/Constants.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/utils/extension/Constants.kt new file mode 100644 index 0000000000..0ae266b524 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/utils/extension/Constants.kt @@ -0,0 +1,7 @@ +package app.revanced.patches.reddit.utils.extension + +@Suppress("MemberVisibilityCanBePrivate") +internal object Constants { + const val EXTENSION_PATH = "Lapp/revanced/extension/reddit" + const val PATCHES_PATH = "$EXTENSION_PATH/patches" +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/utils/extension/SharedExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/utils/extension/SharedExtensionPatch.kt new file mode 100644 index 0000000000..92d2851d93 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/utils/extension/SharedExtensionPatch.kt @@ -0,0 +1,6 @@ +package app.revanced.patches.reddit.utils.extension + +import app.revanced.patches.reddit.utils.extension.hooks.applicationInitHook +import app.revanced.patches.shared.extension.sharedExtensionPatch + +val sharedExtensionPatch = sharedExtensionPatch(applicationInitHook) diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/utils/extension/hooks/ApplicationInitHook.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/utils/extension/hooks/ApplicationInitHook.kt new file mode 100644 index 0000000000..dd8f644317 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/utils/extension/hooks/ApplicationInitHook.kt @@ -0,0 +1,10 @@ +package app.revanced.patches.reddit.utils.extension.hooks + +import app.revanced.patches.shared.extension.extensionHook + +internal val applicationInitHook = extensionHook { + custom { method, _ -> + method.definingClass.endsWith("/FrontpageApplication;") && + method.name == "onCreate" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/utils/patch/PatchList.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/utils/patch/PatchList.kt new file mode 100644 index 0000000000..434e5321fa --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/utils/patch/PatchList.kt @@ -0,0 +1,64 @@ +package app.revanced.patches.reddit.utils.patch + +internal enum class PatchList( + val title: String, + val summary: String, + var included: Boolean? = false +) { + CHANGE_PACKAGE_NAME( + "Change package name", + "Changes the package name for Reddit to the name specified in patch options." + ), + CUSTOM_BRANDING_NAME_FOR_REDDIT( + "Custom branding name for Reddit", + "Renames the Reddit app to the name specified in patch options." + ), + DISABLE_SCREENSHOT_POPUP( + "Disable screenshot popup", + "Adds an option to disable the popup that appears when taking a screenshot." + ), + HIDE_RECENTLY_VISITED_SHELF( + "Hide Recently Visited shelf", + "Adds an option to hide the Recently Visited shelf in the sidebar." + ), + HIDE_ADS( + "Hide ads", + "Adds options to hide ads." + ), + HIDE_NAVIGATION_BUTTONS( + "Hide navigation buttons", + "Adds options to hide buttons in the navigation bar." + ), + HIDE_RECOMMENDED_COMMUNITIES_SHELF( + "Hide recommended communities shelf", + "Adds an option to hide the recommended communities shelves in subreddits." + ), + HIDE_TOOLBAR_BUTTON( + "Hide toolbar button", + "Adds an option to hide the r/place or Reddit recap button in the toolbar." + ), + OPEN_LINKS_DIRECTLY( + "Open links directly", + "Adds an option to skip over redirection URLs in external links." + ), + OPEN_LINKS_EXTERNALLY( + "Open links externally", + "Adds an option to always open links in your browser instead of in the in-app-browser." + ), + PREMIUM_ICON( + "Premium icon", + "Unlocks premium app icons." + ), + REMOVE_SUBREDDIT_DIALOG( + "Remove subreddit dialog", + "Adds options to remove the NSFW community warning and notifications suggestion dialogs by dismissing them automatically." + ), + SANITIZE_SHARING_LINKS( + "Sanitize sharing links", + "Adds an option to remove tracking query parameters from URLs when sharing links." + ), + SETTINGS_FOR_REDDIT( + "Settings for Reddit", + "Applies mandatory patches to implement ReVanced Extended settings into the application." + ) +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/utils/resourceid/SharedResourceIdPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/utils/resourceid/SharedResourceIdPatch.kt new file mode 100644 index 0000000000..45fc71eba5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/utils/resourceid/SharedResourceIdPatch.kt @@ -0,0 +1,49 @@ +package app.revanced.patches.reddit.utils.resourceid + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.shared.mapping.ResourceType.ID +import app.revanced.patches.shared.mapping.ResourceType.STRING +import app.revanced.patches.shared.mapping.ResourceType.STYLE +import app.revanced.patches.shared.mapping.get +import app.revanced.patches.shared.mapping.resourceMappingPatch +import app.revanced.patches.shared.mapping.resourceMappings + +var cancelButton = -1L + private set +var labelAcknowledgements = -1L + private set +var screenShotShareBanner = -1L + private set +var textAppearanceRedditBaseOldButtonColored = -1L + private set +var toolBarNavSearchCtaContainer = -1L + private set + +internal val sharedResourceIdPatch = resourcePatch( + description = "sharedResourceIdPatch" +) { + dependsOn(resourceMappingPatch) + + execute { + cancelButton = resourceMappings[ + ID, + "cancel_button", + ] + labelAcknowledgements = resourceMappings[ + STRING, + "label_acknowledgements" + ] + screenShotShareBanner = resourceMappings[ + STRING, + "screenshot_share_banner_title" + ] + textAppearanceRedditBaseOldButtonColored = resourceMappings[ + STYLE, + "TextAppearance.RedditBase.OldButton.Colored" + ] + toolBarNavSearchCtaContainer = resourceMappings[ + ID, + "toolbar_nav_search_cta_container" + ] + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/utils/settings/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/utils/settings/Fingerprints.kt new file mode 100644 index 0000000000..f5d7a9772a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/utils/settings/Fingerprints.kt @@ -0,0 +1,41 @@ +package app.revanced.patches.reddit.utils.settings + +import app.revanced.patches.reddit.utils.extension.Constants.EXTENSION_PATH +import app.revanced.patches.reddit.utils.resourceid.labelAcknowledgements +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val acknowledgementsLabelBuilderFingerprint = legacyFingerprint( + name = "acknowledgementsLabelBuilderFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Landroidx/preference/Preference;"), + literals = listOf(labelAcknowledgements), + customFingerprint = { method, _ -> + method.definingClass.startsWith("Lcom/reddit/screen/settings/preferences/") + } +) + +internal val ossLicensesMenuActivityOnCreateFingerprint = legacyFingerprint( + name = "ossLicensesMenuActivityOnCreateFingerprint", + returnType = "V", + opcodes = listOf( + Opcode.IGET_BOOLEAN, + Opcode.IF_EQZ, + Opcode.INVOKE_STATIC + ), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/OssLicensesMenuActivity;") && + method.name == "onCreate" + } +) + +internal val settingsStatusLoadFingerprint = legacyFingerprint( + name = "settingsStatusLoadFingerprint", + customFingerprint = { method, _ -> + method.definingClass.endsWith("$EXTENSION_PATH/settings/SettingsStatus;") && + method.name == "load" + } +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/utils/settings/SettingsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/utils/settings/SettingsPatch.kt new file mode 100644 index 0000000000..e872e637ca --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/utils/settings/SettingsPatch.kt @@ -0,0 +1,158 @@ +package app.revanced.patches.reddit.utils.settings + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patches.reddit.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.reddit.utils.extension.Constants.EXTENSION_PATH +import app.revanced.patches.reddit.utils.extension.sharedExtensionPatch +import app.revanced.patches.reddit.utils.patch.PatchList +import app.revanced.patches.reddit.utils.patch.PatchList.SETTINGS_FOR_REDDIT +import app.revanced.patches.reddit.utils.resourceid.labelAcknowledgements +import app.revanced.patches.shared.sharedSettingFingerprint +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import app.revanced.util.valueOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import kotlin.io.path.exists + +private const val EXTENSION_METHOD_DESCRIPTOR = + "$EXTENSION_PATH/settings/ActivityHook;->initialize(Landroid/app/Activity;)V" + +private lateinit var acknowledgementsLabelBuilderMethod: MutableMethod +private lateinit var settingsStatusLoadMethod: MutableMethod + +private val settingsBytecodePatch = bytecodePatch( + description = "settingsBytecodePatch" +) { + + execute { + /** + * Set SharedPrefCategory + */ + sharedSettingFingerprint.methodOrThrow().apply { + val stringIndex = indexOfFirstInstructionOrThrow(Opcode.CONST_STRING) + val stringRegister = getInstruction(stringIndex).registerA + + replaceInstruction( + stringIndex, + "const-string v$stringRegister, \"reddit_revanced\"" + ) + } + + /** + * Replace settings label + */ + acknowledgementsLabelBuilderMethod = acknowledgementsLabelBuilderFingerprint + .methodOrThrow() + + /** + * Initialize settings activity + */ + ossLicensesMenuActivityOnCreateFingerprint.matchOrThrow().let { + it.method.apply { + val insertIndex = it.patternMatch!!.startIndex + 1 + + addInstructions( + insertIndex, """ + invoke-static {p0}, $EXTENSION_METHOD_DESCRIPTOR + return-void + """ + ) + } + } + + settingsStatusLoadMethod = settingsStatusLoadFingerprint.methodOrThrow() + } +} + +internal fun updateSettingsLabel(label: String) = + acknowledgementsLabelBuilderMethod.apply { + val insertIndex = + indexOfFirstLiteralInstructionOrThrow(labelAcknowledgements) + 3 + val insertRegister = + getInstruction(insertIndex - 1).registerA + + addInstruction( + insertIndex, + "const-string v$insertRegister, \"$label\"" + ) + } + +internal fun updatePatchStatus(description: String) = + settingsStatusLoadMethod.addInstruction( + 0, + "invoke-static {}, $EXTENSION_PATH/settings/SettingsStatus;->$description()V" + ) + +internal fun updatePatchStatus(patch: PatchList) { + patch.included = true +} + +internal fun updatePatchStatus( + description: String, + patch: PatchList +) { + updatePatchStatus(description) + updatePatchStatus(patch) +} + +private const val DEFAULT_LABEL = "ReVanced Extended" + +val settingsPatch = resourcePatch( + SETTINGS_FOR_REDDIT.title, + SETTINGS_FOR_REDDIT.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + sharedExtensionPatch, + settingsBytecodePatch + ) + + val settingsLabelOption = stringOption( + key = "settingsLabel", + default = DEFAULT_LABEL, + title = "RVX settings menu name", + description = "The name of the RVX settings menu.", + required = true + ) + + execute { + /** + * Replace settings icon and label + */ + val settingsLabel = settingsLabelOption + .valueOrThrow() + + arrayOf("preferences.xml", "preferences_logged_in.xml").forEach { targetXML -> + val resDirectory = get("res") + val targetXml = resDirectory.resolve("xml").resolve(targetXML).toPath() + + if (!targetXml.exists()) + throw PatchException("The preferences can not be found.") + + val preference = get("res/xml/$targetXML") + + preference.writeText( + preference.readText() + .replace( + "\"@drawable/icon_text_post\" android:title=\"@string/label_acknowledgements\"", + "\"@drawable/icon_beta_planet\" android:title=\"$settingsLabel\"" + ) + ) + } + + updateSettingsLabel(settingsLabel) + updatePatchStatus(SETTINGS_FOR_REDDIT) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/Fingerprints.kt new file mode 100644 index 0000000000..d3735393cc --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/Fingerprints.kt @@ -0,0 +1,113 @@ +package app.revanced.patches.shared + +import app.revanced.patches.shared.extension.Constants.EXTENSION_SETTING_CLASS_DESCRIPTOR +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val createPlayerRequestBodyWithModelFingerprint = legacyFingerprint( + name = "createPlayerRequestBodyWithModelFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf(Opcode.OR_INT_LIT16), + customFingerprint = { method, _ -> + indexOfModelInstruction(method) >= 0 && + indexOfReleaseInstruction(method) >= 0 + } +) + +fun indexOfModelInstruction(method: Method) = + method.indexOfFieldReference("Landroid/os/Build;->MODEL:Ljava/lang/String;") + +fun indexOfReleaseInstruction(method: Method) = + method.indexOfFieldReference("Landroid/os/Build${'$'}VERSION;->RELEASE:Ljava/lang/String;") + +private fun Method.indexOfFieldReference(string: String) = indexOfFirstInstruction { + val reference = getReference() ?: return@indexOfFirstInstruction false + + reference.toString() == string +} + +internal val mdxPlayerDirectorSetVideoStageFingerprint = legacyFingerprint( + name = "mdxPlayerDirectorSetVideoStageFingerprint", + strings = listOf("MdxDirector setVideoStage ad should be null when videoStage is not an Ad state ") +) + +internal val sharedSettingFingerprint = legacyFingerprint( + name = "sharedSettingFingerprint", + returnType = "V", + customFingerprint = { method, _ -> + method.definingClass == EXTENSION_SETTING_CLASS_DESCRIPTOR && + method.name == "" + } +) + +internal val spannableStringBuilderFingerprint = legacyFingerprint( + name = "spannableStringBuilderFingerprint", + returnType = "Ljava/lang/CharSequence;", + strings = listOf("Failed to set PB Style Run Extension in TextComponentSpec. Extension id: %s"), + customFingerprint = { method, _ -> + indexOfSpannableStringInstruction(method) >= 0 + } +) + +const val SPANNABLE_STRING_REFERENCE = + "Landroid/text/SpannableString;->valueOf(Ljava/lang/CharSequence;)Landroid/text/SpannableString;" + +fun indexOfSpannableStringInstruction(method: Method) = method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_STATIC && + getReference()?.toString() == SPANNABLE_STRING_REFERENCE +} + +internal val startVideoInformerFingerprint = legacyFingerprint( + name = "startVideoInformerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + opcodes = listOf( + Opcode.INVOKE_INTERFACE, + Opcode.RETURN_VOID + ), + strings = listOf("pc"), + customFingerprint = { method, _ -> + method.implementation + ?.instructions + ?.withIndex() + ?.filter { (_, instruction) -> + instruction.opcode == Opcode.CONST_STRING + } + ?.map { (index, _) -> index } + ?.size == 1 + } +) + +internal val videoLengthFingerprint = legacyFingerprint( + name = "videoLengthFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + strings = listOf("Gaplessly transitioning away from an Ad before it ends.") +) + +internal val dislikeFingerprint = legacyFingerprint( + name = "dislikeFingerprint", + returnType = "V", + strings = listOf("like/dislike") +) + +internal val likeFingerprint = legacyFingerprint( + name = "likeFingerprint", + returnType = "V", + strings = listOf("like/like") +) + +internal val removeLikeFingerprint = legacyFingerprint( + name = "removeLikeFingerprint", + returnType = "V", + strings = listOf("like/removelike") +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/ads/BaseAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/ads/BaseAdsPatch.kt new file mode 100644 index 0000000000..5d181481c9 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/ads/BaseAdsPatch.kt @@ -0,0 +1,137 @@ +package app.revanced.patches.shared.ads + +import app.revanced.patcher.Match +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.shared.extension.Constants.PATCHES_PATH +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$PATCHES_PATH/FullscreenAdsPatch;" + +fun baseAdsPatch( + classDescriptor: String, + methodDescriptor: String, +) = bytecodePatch( + description = "baseAdsPatch" +) { + execute { + setOf( + sslGuardFingerprint, + videoAdsFingerprint, + ).forEach { fingerprint -> + fingerprint.methodOrThrow().apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $classDescriptor->$methodDescriptor()Z + move-result v0 + if-nez v0, :show_ads + return-void + """, ExternalLabel("show_ads", getInstruction(0)) + ) + } + } + + musicAdsFingerprint.methodOrThrow().apply { + val targetIndex = indexOfFirstInstructionOrThrow { + val reference = getReference() + opcode == Opcode.INVOKE_VIRTUAL && + reference?.returnType == "V" && + reference.parameterTypes.size == 1 && + reference.parameterTypes.first() == "Z" + } + + getWalkerMethod(targetIndex) + .addInstructions( + 0, """ + invoke-static {p1}, $classDescriptor->$methodDescriptor(Z)Z + move-result p1 + """ + ) + } + + advertisingIdFingerprint.matchOrThrow().let { + it.method.apply { + val insertIndex = it.stringMatches!!.first().index + val insertRegister = getInstruction(insertIndex).registerA + + addInstructionsWithLabels( + insertIndex, """ + invoke-static {}, $classDescriptor->$methodDescriptor()Z + move-result v$insertRegister + if-nez v$insertRegister, :enable_id + return-void + """, ExternalLabel("enable_id", getInstruction(insertIndex)) + ) + } + } + + } +} + +internal fun MutableMethod.hookNonLithoFullscreenAds(literal: Long) { + val targetIndex = indexOfFirstLiteralInstructionOrThrow(literal) + 2 + val targetRegister = getInstruction(targetIndex).registerA + + addInstruction( + targetIndex + 1, + "invoke-static {v$targetRegister}, $EXTENSION_CLASS_DESCRIPTOR->hideFullscreenAds(Landroid/view/View;)V" + ) +} + +internal fun Match.hookLithoFullscreenAds() { + method.apply { + val dialogCodeIndex = patternMatch!!.endIndex + val dialogCodeField = + getInstruction(dialogCodeIndex).reference as FieldReference + if (dialogCodeField.type != "I") + throw PatchException("Invalid dialogCodeField: $dialogCodeField") + + var prependInstructions = """ + move-object/from16 v0, p1 + move-object/from16 v1, p2 + """ + + if (parameterTypes.firstOrNull() != "[B") { + val toByteArrayReference = getInstruction( + indexOfFirstInstructionOrThrow { + getReference()?.name == "toByteArray" + } + ).reference + + prependInstructions += """ + invoke-virtual {v0}, $toByteArrayReference + move-result-object v0 + """ + } + + // Disable fullscreen ads + addInstructionsWithLabels( + 0, prependInstructions + """ + check-cast v1, ${dialogCodeField.definingClass} + iget v1, v1, $dialogCodeField + invoke-static {v0, v1}, $EXTENSION_CLASS_DESCRIPTOR->disableFullscreenAds([BI)Z + move-result v1 + if-eqz v1, :show + return-void + """, ExternalLabel("show", getInstruction(0)) + ) + } +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/ads/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/ads/Fingerprints.kt new file mode 100644 index 0000000000..6853546b45 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/ads/Fingerprints.kt @@ -0,0 +1,48 @@ +package app.revanced.patches.shared.ads + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.util.MethodUtil + +internal val advertisingIdFingerprint = legacyFingerprint( + name = "advertisingIdFingerprint", + returnType = "V", + strings = listOf("a."), + customFingerprint = { method, classDef -> + MethodUtil.isConstructor(method) && + classDef.fields.find { it.type == "Ljava/util/Random;" } != null + } +) + +internal val sslGuardFingerprint = legacyFingerprint( + name = "sslGuardFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + strings = listOf("Cannot initialize SslGuardSocketFactory will null"), +) + +internal val musicAdsFingerprint = legacyFingerprint( + name = "musicAdsFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf( + Opcode.CONST_WIDE_16, + Opcode.IPUT_WIDE, + Opcode.CONST_WIDE_16, + Opcode.IPUT_WIDE, + Opcode.IPUT_WIDE, + Opcode.IPUT_WIDE, + Opcode.IPUT_WIDE, + Opcode.CONST_4, + ), + literals = listOf(4L) +) + +internal val videoAdsFingerprint = legacyFingerprint( + name = "videoAdsFingerprint", + returnType = "V", + strings = listOf("markFillRequested", "requestEnterSlot") +) diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/captions/BaseAutoCaptionsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/captions/BaseAutoCaptionsPatch.kt new file mode 100644 index 0000000000..26c19b4cf3 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/captions/BaseAutoCaptionsPatch.kt @@ -0,0 +1,44 @@ +package app.revanced.patches.shared.captions + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.shared.extension.Constants.PATCHES_PATH +import app.revanced.patches.shared.startVideoInformerFingerprint +import app.revanced.util.fingerprint.methodOrThrow + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$PATCHES_PATH/AutoCaptionsPatch;" + +val baseAutoCaptionsPatch = bytecodePatch( + description = "baseAutoCaptionsPatch" +) { + execute { + subtitleTrackFingerprint.methodOrThrow().apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $EXTENSION_CLASS_DESCRIPTOR->disableAutoCaptions()Z + move-result v0 + if-eqz v0, :disabled + const/4 v0, 0x1 + return v0 + """, ExternalLabel("disabled", getInstruction(0)) + ) + } + + mapOf( + startVideoInformerFingerprint to 0, + storyboardRendererDecoderRecommendedLevelFingerprint to 1 + ).forEach { (fingerprint, enabled) -> + fingerprint.methodOrThrow().addInstructions( + 0, """ + const/4 v0, 0x$enabled + invoke-static {v0}, $EXTENSION_CLASS_DESCRIPTOR->setCaptionsButtonStatus(Z)V + """ + ) + } + } +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/captions/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/captions/Fingerprints.kt new file mode 100644 index 0000000000..0f2bb490ee --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/captions/Fingerprints.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.shared.captions + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val storyboardRendererDecoderRecommendedLevelFingerprint = legacyFingerprint( + name = "storyboardRendererDecoderRecommendedLevelFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + strings = listOf("#-1#") +) + +internal val subtitleTrackFingerprint = legacyFingerprint( + name = "subtitleTrackFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + strings = listOf("DISABLE_CAPTIONS_OPTION") +) diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/customspeed/CustomPlaybackSpeedPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/customspeed/CustomPlaybackSpeedPatch.kt new file mode 100644 index 0000000000..c233163581 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/customspeed/CustomPlaybackSpeedPatch.kt @@ -0,0 +1,90 @@ +package app.revanced.patches.shared.customspeed + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +fun customPlaybackSpeedPatch( + descriptor: String, + maxSpeed: Float +) = bytecodePatch( + description = "customPlaybackSpeedPatch" +) { + execute { + arrayGeneratorFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = it.patternMatch!!.startIndex + val targetRegister = getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, """ + invoke-static {v$targetRegister}, $descriptor->getLength(I)I + move-result v$targetRegister + """ + ) + + val sizeIndex = indexOfFirstInstructionOrThrow { + getReference()?.name == "size" + } + 1 + val sizeRegister = getInstruction(sizeIndex).registerA + + addInstructions( + sizeIndex + 1, """ + invoke-static {v$sizeRegister}, $descriptor->getSize(I)I + move-result v$sizeRegister + """ + ) + + val arrayIndex = indexOfFirstInstructionOrThrow { + getReference()?.type == "[F" + } + val arrayRegister = getInstruction(arrayIndex).registerA + + addInstructions( + arrayIndex + 1, """ + invoke-static {v$arrayRegister}, $descriptor->getArray([F)[F + move-result-object v$arrayRegister + """ + ) + } + } + + setOf( + limiterFallBackFingerprint.methodOrThrow(), + limiterFingerprint.methodOrThrow(limiterFallBackFingerprint) + ).forEach { method -> + method.apply { + val limitMinIndex = + indexOfFirstLiteralInstructionOrThrow(0.25f.toRawBits().toLong()) + val limitMaxIndex = + indexOfFirstInstructionOrThrow(limitMinIndex + 1, Opcode.CONST_HIGH16) + + val limitMinRegister = + getInstruction(limitMinIndex).registerA + val limitMaxRegister = + getInstruction(limitMaxIndex).registerA + + replaceInstruction( + limitMinIndex, + "const/high16 v$limitMinRegister, 0x0" + ) + replaceInstruction( + limitMaxIndex, + "const/high16 v$limitMaxRegister, ${maxSpeed.toRawBits()}" + ) + } + } + + } +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/customspeed/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/customspeed/Fingerprints.kt new file mode 100644 index 0000000000..0632c4218d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/customspeed/Fingerprints.kt @@ -0,0 +1,41 @@ +package app.revanced.patches.shared.customspeed + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val arrayGeneratorFingerprint = legacyFingerprint( + name = "arrayGeneratorFingerprint", + returnType = "[L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + opcodes = listOf( + Opcode.CONST_4, + Opcode.NEW_ARRAY + ), + strings = listOf("0.0#") +) + +internal val limiterFallBackFingerprint = legacyFingerprint( + name = "limiterFallBackFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + opcodes = listOf( + Opcode.CONST_HIGH16, + Opcode.CONST_HIGH16, + Opcode.INVOKE_STATIC + ), + strings = listOf("Playback rate: %f") +) + +internal val limiterFingerprint = legacyFingerprint( + name = "limiterFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("F"), + opcodes = listOf( + Opcode.CONST_HIGH16, + Opcode.CONST_HIGH16, + Opcode.INVOKE_STATIC, + ) +) diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/dialog/BaseViewerDiscretionDialogPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/dialog/BaseViewerDiscretionDialogPatch.kt new file mode 100644 index 0000000000..c68befc60a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/dialog/BaseViewerDiscretionDialogPatch.kt @@ -0,0 +1,46 @@ +package app.revanced.patches.shared.dialog + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +fun baseViewerDiscretionDialogPatch( + classDescriptor: String, + isAgeVerified: Boolean = false +) = bytecodePatch( + description = "baseViewerDiscretionDialogPatch" +) { + execute { + createDialogFingerprint + .methodOrThrow() + .invoke(classDescriptor, "confirmDialog") + + if (isAgeVerified) { + ageVerifiedFingerprint.matchOrThrow().let { + it.getWalkerMethod(it.patternMatch!!.endIndex - 1) + .invoke(classDescriptor, "confirmDialogAgeVerified") + } + } + } +} + +private fun MutableMethod.invoke(classDescriptor: String, methodName: String) { + val showDialogIndex = indexOfFirstInstructionOrThrow { + getReference()?.name == "show" + } + val dialogRegister = getInstruction(showDialogIndex).registerC + + addInstruction( + showDialogIndex + 1, + "invoke-static { v$dialogRegister }, $classDescriptor->$methodName(Landroid/app/AlertDialog;)V" + ) +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/dialog/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/dialog/Fingerprints.kt new file mode 100644 index 0000000000..d20d5bf2f3 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/dialog/Fingerprints.kt @@ -0,0 +1,46 @@ +package app.revanced.patches.shared.dialog + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val ageVerifiedFingerprint = legacyFingerprint( + name = "ageVerifiedFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "Ljava/util/Map;"), + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN_VOID + ), + strings = listOf( + "com.google.android.libraries.youtube.rendering.elements.sender_view", + "com.google.android.libraries.youtube.innertube.endpoint.tag", + "com.google.android.libraries.youtube.innertube.bundle", + "com.google.android.libraries.youtube.logging.interaction_logger" + ) +) + +internal val createDialogFingerprint = legacyFingerprint( + name = "createDialogFingerprint", + returnType = "V", + accessFlags = AccessFlags.PROTECTED.value, + parameters = listOf("L", "L", "Ljava/lang/String;"), + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IPUT_OBJECT, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL // dialog.show() + ) +) + diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/drawable/DrawableColorHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/drawable/DrawableColorHookPatch.kt new file mode 100644 index 0000000000..89d8c816a4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/drawable/DrawableColorHookPatch.kt @@ -0,0 +1,43 @@ +package app.revanced.patches.shared.drawable + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private lateinit var insertMethod: MutableMethod +private var insertIndex: Int = 0 +private var insertRegister: Int = 0 +private var offset = 0 + +val drawableColorHookPatch = bytecodePatch( + description = "drawableColorHookPatch" +) { + execute { + drawableColorFingerprint.methodOrThrow().apply { + insertMethod = this + insertIndex = indexOfFirstInstructionReversedOrThrow { + getReference()?.name == "setColor" + } + insertRegister = getInstruction(insertIndex).registerD + } + } +} + +internal fun addDrawableColorHook( + methodDescriptor: String +) { + insertMethod.addInstructions( + insertIndex + offset, """ + invoke-static {v$insertRegister}, $methodDescriptor + move-result v$insertRegister + """ + ) + offset += 2 +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/drawable/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/drawable/Fingerprints.kt new file mode 100644 index 0000000000..536622b134 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/drawable/Fingerprints.kt @@ -0,0 +1,24 @@ +package app.revanced.patches.shared.drawable + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val drawableColorFingerprint = legacyFingerprint( + name = "drawableColorFingerprint", + returnType = "V", + accessFlags = AccessFlags.PROTECTED or AccessFlags.FINAL, + parameters = listOf("L"), + opcodes = listOf( + Opcode.IF_NEZ, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, // Paint.setColor: inject point + Opcode.RETURN_VOID + ), + customFingerprint = { method, classDef -> + method.name == "onBoundsChange" && + classDef.superclass == "Landroid/graphics/drawable/Drawable;" + } +) + diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/extension/Constants.kt b/patches/src/main/kotlin/app/revanced/patches/shared/extension/Constants.kt new file mode 100644 index 0000000000..6229b593fb --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/extension/Constants.kt @@ -0,0 +1,13 @@ +package app.revanced.patches.shared.extension + +@Suppress("MemberVisibilityCanBePrivate") +internal object Constants { + const val EXTENSION_PATH = "Lapp/revanced/extension/shared" + const val PATCHES_PATH = "$EXTENSION_PATH/patches" + const val COMPONENTS_PATH = "$PATCHES_PATH/components" + const val SPANS_PATH = "$PATCHES_PATH/spans" + + const val EXTENSION_UTILS_PATH = "$EXTENSION_PATH/utils" + const val EXTENSION_SETTING_CLASS_DESCRIPTOR = "$EXTENSION_PATH/settings/Setting;" + const val EXTENSION_UTILS_CLASS_DESCRIPTOR = "$EXTENSION_UTILS_PATH/Utils;" +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/extension/SharedExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/extension/SharedExtensionPatch.kt new file mode 100644 index 0000000000..d389463ab2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/extension/SharedExtensionPatch.kt @@ -0,0 +1,59 @@ +package app.revanced.patches.shared.extension + +import app.revanced.patcher.Fingerprint +import app.revanced.patcher.FingerprintBuilder +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.fingerprint +import app.revanced.patcher.patch.BytecodePatchContext +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.extension.Constants.EXTENSION_UTILS_CLASS_DESCRIPTOR +import com.android.tools.smali.dexlib2.iface.Method + +fun sharedExtensionPatch( + vararg hooks: ExtensionHook, +) = bytecodePatch( + description = "sharedExtensionPatch" +) { + extendWith("extensions/shared.rve") + + execute { + if (classes.none { EXTENSION_UTILS_CLASS_DESCRIPTOR == it.type }) { + throw PatchException( + "Shared extension has not been merged yet. This patch can not succeed without merging it.", + ) + } + hooks.forEach { hook -> hook(EXTENSION_UTILS_CLASS_DESCRIPTOR) } + } +} + +@Suppress("CONTEXT_RECEIVERS_DEPRECATED") +class ExtensionHook internal constructor( + val fingerprint: Fingerprint, + private val insertIndexResolver: ((Method) -> Int), + private val contextRegisterResolver: (Method) -> String, +) { + context(BytecodePatchContext) + operator fun invoke(extensionClassDescriptor: String) { + if (System.getenv("GITHUB_REPOSITORY") == null) { + val insertIndex = insertIndexResolver(fingerprint.method) + val contextRegister = contextRegisterResolver(fingerprint.method) + + fingerprint.method.addInstruction( + insertIndex, + "invoke-static/range { $contextRegister .. $contextRegister }, " + + "$extensionClassDescriptor->setContext(Landroid/content/Context;)V", + ) + } + } +} + +fun extensionHook( + insertIndexResolver: ((Method) -> Int) = { 0 }, + contextRegisterResolver: (Method) -> String = { "p0" }, + fingerprintBuilderBlock: FingerprintBuilder.() -> Unit, +) = ExtensionHook( + fingerprint(block = fingerprintBuilderBlock), + insertIndexResolver, + contextRegisterResolver +) diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/gms/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/gms/Fingerprints.kt new file mode 100644 index 0000000000..29cd4178b1 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/gms/Fingerprints.kt @@ -0,0 +1,109 @@ +package app.revanced.patches.shared.gms + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.iface.reference.StringReference +import com.android.tools.smali.dexlib2.util.MethodUtil + +const val GET_GMS_CORE_VENDOR_GROUP_ID_METHOD_NAME = "getGmsCoreVendorGroupId" + +internal val gmsCoreSupportFingerprint = legacyFingerprint( + name = "gmsCoreSupportFingerprint", + customFingerprint = { _, classDef -> + classDef.endsWith("GmsCoreSupport;") + } +) + +internal val castContextFetchFingerprint = legacyFingerprint( + name = "castContextFetchFingerprint", + strings = listOf("Error fetching CastContext.") +) + +internal val castDynamiteModuleFingerprint = legacyFingerprint( + name = "castDynamiteModuleFingerprint", + strings = listOf("com.google.android.gms.cast.framework.internal.CastDynamiteModuleImpl") +) + +internal val castDynamiteModuleV2Fingerprint = legacyFingerprint( + name = "castDynamiteModuleV2Fingerprint", + strings = listOf("Failed to load module via V2: ") +) + +internal val googlePlayUtilityFingerprint = legacyFingerprint( + name = "castContextFetchFingerprint", + returnType = "I", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("L", "I"), + strings = listOf( + "This should never happen.", + "MetadataValueReader" + ) +) + +internal val serviceCheckFingerprint = legacyFingerprint( + name = "serviceCheckFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("L", "I"), + strings = listOf("Google Play Services not available") +) + +internal val primesApiFingerprint = legacyFingerprint( + name = "primesApiFingerprint", + returnType = "V", + strings = listOf("PrimesApiImpl.java"), + customFingerprint = { method, _ -> + MethodUtil.isConstructor(method) + } +) + +internal val primesBackgroundInitializationFingerprint = legacyFingerprint( + name = "primesBackgroundInitializationFingerprint", + opcodes = listOf(Opcode.NEW_INSTANCE), + customFingerprint = { method, _ -> + method.indexOfFirstInstruction { + opcode == Opcode.CONST_STRING && + getReference() + ?.string.toString() + .startsWith("Primes init triggered from background in package:") + } >= 0 + } +) + +internal val primesLifecycleEventFingerprint = legacyFingerprint( + name = "primesLifecycleEventFingerprint", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + returnType = "V", + parameters = emptyList(), + opcodes = listOf(Opcode.NEW_INSTANCE), + customFingerprint = { method, _ -> + method.indexOfFirstInstruction { + opcode == Opcode.CONST_STRING && + getReference() + ?.string.toString() + .startsWith("Primes did not observe lifecycle events in the expected order.") + } >= 0 + } +) + +internal val certificateFingerprint = legacyFingerprint( + name = "certificateFingerprint", + returnType = "Ljava/lang/String;", + accessFlags = AccessFlags.PROTECTED or AccessFlags.FINAL, + parameters = emptyList(), + strings = listOf("X.509", "user", "S"), + customFingerprint = { method, _ -> + indexOfGetPackageNameInstruction(method) >= 0 + } +) + +fun indexOfGetPackageNameInstruction(method: Method) = + method.indexOfFirstInstruction { + getReference()?.toString() == "Landroid/content/Context;->getPackageName()Ljava/lang/String;" + } \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/gms/GmsCoreSupportPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/gms/GmsCoreSupportPatch.kt new file mode 100644 index 0000000000..83085f4989 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/gms/GmsCoreSupportPatch.kt @@ -0,0 +1,626 @@ +package app.revanced.patches.shared.gms + +import app.revanced.patcher.Fingerprint +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.BytecodePatchBuilder +import app.revanced.patcher.patch.BytecodePatchContext +import app.revanced.patcher.patch.Option +import app.revanced.patcher.patch.Patch +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.ResourcePatchBuilder +import app.revanced.patcher.patch.ResourcePatchContext +import app.revanced.patcher.patch.booleanOption +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patches.shared.extension.Constants.PATCHES_PATH +import app.revanced.patches.shared.gms.Constants.ACTIONS +import app.revanced.patches.shared.gms.Constants.AUTHORITIES +import app.revanced.patches.shared.gms.Constants.PERMISSIONS +import app.revanced.util.Utils.trimIndentMultiline +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.mutableClassOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import app.revanced.util.returnEarly +import app.revanced.util.valueOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.builder.instruction.BuilderInstruction21c +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction21c +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.iface.reference.StringReference +import com.android.tools.smali.dexlib2.immutable.reference.ImmutableStringReference +import com.android.tools.smali.dexlib2.util.MethodUtil +import org.w3c.dom.Element +import org.w3c.dom.Node + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$PATCHES_PATH/GmsCoreSupport;" + +private const val PACKAGE_NAME_REGEX_PATTERN = "^[a-z]\\w*(\\.[a-z]\\w*)+\$" + +private const val CLONE_PACKAGE_NAME_YOUTUBE = "com.rvx.android.youtube" +private const val DEFAULT_PACKAGE_NAME_YOUTUBE = "app.rvx.android.youtube" +internal const val ORIGINAL_PACKAGE_NAME_YOUTUBE = "com.google.android.youtube" + +private const val CLONE_PACKAGE_NAME_YOUTUBE_MUSIC = "com.rvx.android.apps.youtube.music" +private const val DEFAULT_PACKAGE_NAME_YOUTUBE_MUSIC = "app.rvx.android.apps.youtube.music" +internal const val ORIGINAL_PACKAGE_NAME_YOUTUBE_MUSIC = + "com.google.android.apps.youtube.music" + +/** + * A patch that allows patched Google apps to run without root and under a different package name + * by using GmsCore instead of Google Play Services. + * + * @param fromPackageName The package name of the original app. + * @param mainActivityOnCreateFingerprint The fingerprint of the main activity onCreate method. + * @param extensionPatch The patch responsible for the extension. + * @param gmsCoreSupportResourcePatchFactory The factory for the corresponding resource patch + * that is used to patch the resources. + * @param executeBlock The additional execution block of the patch. + * @param block The additional block to build the patch. + */ +fun gmsCoreSupportPatch( + fromPackageName: String, + mainActivityOnCreateFingerprint: Fingerprint, + extensionPatch: Patch<*>, + gmsCoreSupportResourcePatchFactory: (gmsCoreVendorGroupIdOption: Option, packageNameYouTubeOption: Option, packageNameYouTubeMusicOption: Option) -> Patch<*>, + executeBlock: BytecodePatchContext.() -> Unit = {}, + block: BytecodePatchBuilder.() -> Unit = {}, +) = bytecodePatch( + name = "GmsCore support", + description = "Allows patched Google apps to run without root and under a different package name " + + "by using GmsCore instead of Google Play Services.", +) { + val gmsCoreVendorGroupIdOption = stringOption( + key = "gmsCoreVendorGroupId", + default = "app.revanced", + values = + mapOf( + "ReVanced" to "app.revanced", + ), + title = "GmsCore vendor group ID", + description = "The vendor's group ID for GmsCore.", + required = true, + ) { it!!.matches(Regex(PACKAGE_NAME_REGEX_PATTERN)) } + + val checkGmsCore by booleanOption( + key = "checkGmsCore", + default = true, + title = "Check GmsCore", + description = """ + Check if GmsCore is installed on the device and has battery optimizations disabled when the app starts. + + If GmsCore is not installed the app will not work, so disabling this is not recommended. + """.trimIndentMultiline(), + required = true, + ) + + val packageNameYouTubeOption = stringOption( + key = "packageNameYouTube", + default = DEFAULT_PACKAGE_NAME_YOUTUBE, + values = mapOf( + "Clone" to CLONE_PACKAGE_NAME_YOUTUBE, + "Default" to DEFAULT_PACKAGE_NAME_YOUTUBE + ), + title = "Package name of YouTube", + description = "The name of the package to use in GmsCore support.", + required = true + ) { it!!.matches(Regex(PACKAGE_NAME_REGEX_PATTERN)) && it != ORIGINAL_PACKAGE_NAME_YOUTUBE } + + val packageNameYouTubeMusicOption = stringOption( + key = "packageNameYouTubeMusic", + default = DEFAULT_PACKAGE_NAME_YOUTUBE_MUSIC, + values = mapOf( + "Clone" to CLONE_PACKAGE_NAME_YOUTUBE_MUSIC, + "Default" to DEFAULT_PACKAGE_NAME_YOUTUBE_MUSIC + ), + title = "Package name of YouTube Music", + description = "The name of the package to use in GmsCore support.", + required = true + ) { it!!.matches(Regex(PACKAGE_NAME_REGEX_PATTERN)) && it != ORIGINAL_PACKAGE_NAME_YOUTUBE_MUSIC } + + dependsOn( + gmsCoreSupportResourcePatchFactory( + gmsCoreVendorGroupIdOption, + packageNameYouTubeOption, + packageNameYouTubeMusicOption + ), + extensionPatch, + ) + + val gmsCoreVendorGroupId by gmsCoreVendorGroupIdOption + + execute { + fun transformStringReferences(transform: (str: String) -> String?) = classes.forEach { + val mutableClass by lazy { + proxy(it).mutableClass + } + + it.methods.forEach classLoop@{ method -> + val implementation = method.implementation ?: return@classLoop + + val mutableMethod by lazy { + mutableClass.methods.first { target -> + MethodUtil.methodSignaturesMatch( + target, + method + ) + } + } + + implementation.instructions.forEachIndexed insnLoop@{ index, instruction -> + val string = + ((instruction as? Instruction21c)?.reference as? StringReference)?.string + ?: return@insnLoop + + // Apply transformation. + val transformedString = transform(string) ?: return@insnLoop + + mutableMethod.replaceInstruction( + index, + BuilderInstruction21c( + Opcode.CONST_STRING, + instruction.registerA, + ImmutableStringReference(transformedString), + ), + ) + } + } + } + + // region Collection of transformations that are applied to all strings. + + fun commonTransform(referencedString: String): String? = + when (referencedString) { + "com.google", + "com.google.android.gms", + in PERMISSIONS, + in ACTIONS, + in AUTHORITIES, + -> referencedString.replace("com.google", gmsCoreVendorGroupId!!) + + // No vendor prefix for whatever reason... + "subscribedfeeds" -> "$gmsCoreVendorGroupId.subscribedfeeds" + else -> null + } + + fun contentUrisTransform(str: String): String? { + // only when content:// uri + if (str.startsWith("content://")) { + // check if matches any authority + for (authority in AUTHORITIES) { + val uriPrefix = "content://$authority" + if (str.startsWith(uriPrefix)) { + return str.replace( + uriPrefix, + "content://${authority.replace("com.google", gmsCoreVendorGroupId!!)}", + ) + } + } + + // gms also has a 'subscribedfeeds' authority, check for that one too + val subFeedsUriPrefix = "content://subscribedfeeds" + if (str.startsWith(subFeedsUriPrefix)) { + return str.replace( + subFeedsUriPrefix, + "content://$gmsCoreVendorGroupId.subscribedfeeds" + ) + } + } + + return null + } + + fun packageNameTransform( + fromPackageName: String, + toPackageName: String + ): (String) -> String? = { string -> + when (string) { + "$fromPackageName.SuggestionsProvider", + "$fromPackageName.fileprovider", + -> string.replace(fromPackageName, toPackageName) + + else -> null + } + } + + fun transformPrimeMethod() { + setOf( + primesBackgroundInitializationFingerprint, + primesLifecycleEventFingerprint + ).forEach { fingerprint -> + fingerprint.methodOrThrow().apply { + val exceptionIndex = indexOfFirstInstructionReversedOrThrow { + opcode == Opcode.NEW_INSTANCE && + (this as? ReferenceInstruction)?.reference?.toString() == "Ljava/lang/IllegalStateException;" + } + val index = + indexOfFirstInstructionReversedOrThrow(exceptionIndex, Opcode.IF_EQZ) + val register = getInstruction(index).registerA + addInstruction( + index, + "const/4 v$register, 0x1" + ) + } + } + primesApiFingerprint.mutableClassOrThrow().methods.filter { method -> + method.name != "" && + method.returnType == "V" + }.forEach { method -> + method.apply { + val index = if (MethodUtil.isConstructor(method)) + indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_DIRECT && + getReference()?.name == "" + } + 1 + else 0 + addInstruction( + index, + "return-void" + ) + } + } + } + + // endregion + + val packageName = + getPackageName(fromPackageName, packageNameYouTubeOption, packageNameYouTubeMusicOption) + + // Transform all strings using all provided transforms, first match wins. + val transformations = arrayOf( + ::commonTransform, + ::contentUrisTransform, + packageNameTransform(fromPackageName, packageName), + ) + transformStringReferences transform@{ string -> + transformations.forEach { transform -> + transform(string)?.let { transformedString -> return@transform transformedString } + } + + return@transform null + } + + // Return these methods early to prevent the app from crashing. + setOf( + castContextFetchFingerprint, + castDynamiteModuleFingerprint, + castDynamiteModuleV2Fingerprint, + googlePlayUtilityFingerprint, + serviceCheckFingerprint, + ).forEach { it.methodOrThrow().returnEarly() } + + // Specific method that needs to be patched. + transformPrimeMethod() + + // Verify GmsCore is installed and whitelisted for power optimizations and background usage. + mainActivityOnCreateFingerprint.method.apply { + // Temporary fix for patches with an extension patch that hook the onCreate method as well. + val setContextIndex = indexOfFirstInstruction { + val reference = + getReference() ?: return@indexOfFirstInstruction false + + reference.toString() == "Lapp/revanced/extension/shared/Utils;->setContext(Landroid/content/Context;)V" + } + + // Add after setContext call, because this patch needs the context. + if (checkGmsCore == true) { + addInstructions( + if (setContextIndex < 0) 0 else setContextIndex + 1, + "invoke-static/range { p0 .. p0 }, $EXTENSION_CLASS_DESCRIPTOR->" + + "checkGmsCore(Landroid/app/Activity;)V", + ) + } + } + + // Change the vendor of GmsCore in the extension. + gmsCoreSupportFingerprint.mutableClassOrThrow().methods + .single { it.name == GET_GMS_CORE_VENDOR_GROUP_ID_METHOD_NAME } + .replaceInstruction(0, "const-string v0, \"$gmsCoreVendorGroupId\"") + + certificateFingerprint.second.classDefOrNull?.methods?.forEach { mutableMethod -> + mutableMethod.apply { + val getPackageNameIndex = indexOfGetPackageNameInstruction(this) + + if (getPackageNameIndex > -1) { + val targetRegister = + (getInstruction(getPackageNameIndex) as FiveRegisterInstruction).registerC + + replaceInstruction( + getPackageNameIndex, + "invoke-static {v$targetRegister}, $EXTENSION_CLASS_DESCRIPTOR->spoofPackageName(Landroid/content/Context;)Ljava/lang/String;", + ) + } + } + } // Since it has only been confirmed to work on YouTube and YouTube Music, does not raise an exception even if the fingerprint cannot be solved. + + executeBlock() + } + + block() +} + +/** + * A collection of permissions, intents and content provider authorities + * that are present in GmsCore which need to be transformed. + */ +private object Constants { + /** + * All permissions. + */ + val PERMISSIONS = setOf( + // C2DM / GCM + "com.google.android.c2dm.permission.RECEIVE", + "com.google.android.c2dm.permission.SEND", + "com.google.android.gtalkservice.permission.GTALK_SERVICE", + "com.google.android.providers.gsf.permission.READ_GSERVICES", + + // GAuth + "com.google.android.googleapps.permission.GOOGLE_AUTH", + "com.google.android.googleapps.permission.GOOGLE_AUTH.cp", + "com.google.android.googleapps.permission.GOOGLE_AUTH.local", + "com.google.android.googleapps.permission.GOOGLE_AUTH.mail", + "com.google.android.googleapps.permission.GOOGLE_AUTH.writely", + + // Ad + "com.google.android.gms.permission.AD_ID_NOTIFICATION", + "com.google.android.gms.permission.AD_ID", + ) + + /** + * All intent actions. + */ + val ACTIONS = setOf( + // location + "com.google.android.gms.location.places.ui.PICK_PLACE", + "com.google.android.gms.location.places.GeoDataApi", + "com.google.android.gms.location.places.PlacesApi", + "com.google.android.gms.location.places.PlaceDetectionApi", + "com.google.android.gms.wearable.MESSAGE_RECEIVED", + "com.google.android.gms.checkin.BIND_TO_SERVICE", + + // C2DM / GCM + "com.google.android.c2dm.intent.REGISTER", + "com.google.android.c2dm.intent.REGISTRATION", + "com.google.android.c2dm.intent.UNREGISTER", + "com.google.android.c2dm.intent.RECEIVE", + "com.google.iid.TOKEN_REQUEST", + "com.google.android.gcm.intent.SEND", + + // car + "com.google.android.gms.car.service.START", + + // people + "com.google.android.gms.people.service.START", + + // wearable + "com.google.android.gms.wearable.BIND", + + // auth + "com.google.android.gsf.login", + "com.google.android.gsf.action.GET_GLS", + "com.google.android.gms.common.account.CHOOSE_ACCOUNT", + "com.google.android.gms.auth.login.LOGIN", + "com.google.android.gms.auth.api.credentials.PICKER", + "com.google.android.gms.auth.api.credentials.service.START", + "com.google.android.gms.auth.service.START", + "com.google.firebase.auth.api.gms.service.START", + "com.google.android.gms.auth.be.appcert.AppCertService", + "com.google.android.gms.credential.manager.service.firstparty.START", + "com.google.android.gms.auth.GOOGLE_SIGN_IN", + "com.google.android.gms.signin.service.START", + "com.google.android.gms.auth.api.signin.service.START", + "com.google.android.gms.auth.api.identity.service.signin.START", + "com.google.android.gms.accountsettings.action.VIEW_SETTINGS", + + // fido + "com.google.android.gms.fido.fido2.privileged.START", + + // gass + "com.google.android.gms.gass.START", + + // games + "com.google.android.gms.games.service.START", + "com.google.android.gms.games.PLAY_GAMES_UPGRADE", + "com.google.android.gms.games.internal.connect.service.START", + + // help + "com.google.android.gms.googlehelp.service.GoogleHelpService.START", + "com.google.android.gms.googlehelp.HELP", + "com.google.android.gms.feedback.internal.IFeedbackService", + + // cast + "com.google.android.gms.cast.firstparty.START", + "com.google.android.gms.cast.service.BIND_CAST_DEVICE_CONTROLLER_SERVICE", + + // fonts + "com.google.android.gms.fonts", + + // phenotype + "com.google.android.gms.phenotype.service.START", + + // location + "com.google.android.gms.location.reporting.service.START", + + // misc + "com.google.android.gms.gmscompliance.service.START", + "com.google.android.gms.oss.licenses.service.START", + "com.google.android.gms.tapandpay.service.BIND", + "com.google.android.gms.measurement.START", + "com.google.android.gms.languageprofile.service.START", + "com.google.android.gms.clearcut.service.START", + "com.google.android.gms.icing.LIGHTWEIGHT_INDEX_SERVICE", + "com.google.android.gms.icing.INDEX_SERVICE", + "com.google.android.gms.mdm.services.START", + + // potoken + "com.google.android.gms.potokens.service.START", + + // droidguard, safetynet + "com.google.android.gms.droidguard.service.START", + "com.google.android.gms.safetynet.service.START", + ) + + /** + * All content provider authorities. + */ + val AUTHORITIES = setOf( + // gsf + "com.google.android.gsf.gservices", + "com.google.settings", + + // auth + "com.google.android.gms.auth.accounts", + + // chimera + "com.google.android.gms.chimera", + + // fonts + "com.google.android.gms.fonts", + + // phenotype + "com.google.android.gms.phenotype", + ) +} + +private fun getPackageName( + originalPackageName: String, + packageNameYouTubeOption: Option, + packageNameYouTubeMusicOption: Option +): String { + if (originalPackageName == ORIGINAL_PACKAGE_NAME_YOUTUBE) { + return packageNameYouTubeOption.valueOrThrow() + } else if (originalPackageName == ORIGINAL_PACKAGE_NAME_YOUTUBE_MUSIC) { + return packageNameYouTubeMusicOption.valueOrThrow() + } + throw PatchException("Unknown package name: $originalPackageName") +} + +/** + * Abstract resource patch that allows Google apps to run without root and under a different package name + * by using GmsCore instead of Google Play Services. + * + * @param fromPackageName The package name of the original app. + * @param spoofedPackageSignature The signature of the package to spoof to. + * @param gmsCoreVendorGroupIdOption The option to get the vendor group ID of GmsCore. + * @param executeBlock The additional execution block of the patch. + * @param block The additional block to build the patch. + */ +fun gmsCoreSupportResourcePatch( + fromPackageName: String, + spoofedPackageSignature: String, + gmsCoreVendorGroupIdOption: Option, + packageNameYouTubeOption: Option, + packageNameYouTubeMusicOption: Option, + executeBlock: ResourcePatchContext.() -> Unit = {}, + block: ResourcePatchBuilder.() -> Unit = {}, +) = resourcePatch { + val gmsCoreVendorGroupId by gmsCoreVendorGroupIdOption + + execute { + /** + * Add metadata to manifest to support spoofing the package name and signature of GmsCore. + */ + fun addSpoofingMetadata() { + fun Node.adoptChild( + tagName: String, + block: Element.() -> Unit, + ) { + val child = ownerDocument.createElement(tagName) + child.block() + appendChild(child) + } + + document("AndroidManifest.xml").use { document -> + val applicationNode = + document + .getElementsByTagName("application") + .item(0) + + // Spoof package name and signature. + applicationNode.adoptChild("meta-data") { + setAttribute( + "android:name", + "$gmsCoreVendorGroupId.android.gms.SPOOFED_PACKAGE_NAME" + ) + setAttribute("android:value", fromPackageName) + } + + applicationNode.adoptChild("meta-data") { + setAttribute( + "android:name", + "$gmsCoreVendorGroupId.android.gms.SPOOFED_PACKAGE_SIGNATURE" + ) + setAttribute("android:value", spoofedPackageSignature) + } + + // GmsCore presence detection in extension. + applicationNode.adoptChild("meta-data") { + // TODO: The name of this metadata should be dynamic. + setAttribute("android:name", "app.revanced.MICROG_PACKAGE_NAME") + setAttribute("android:value", "$gmsCoreVendorGroupId.android.gms") + } + } + } + + /** + * Patch the manifest to support GmsCore. + */ + fun patchManifest() { + val packageName = getPackageName( + fromPackageName, + packageNameYouTubeOption, + packageNameYouTubeMusicOption + ) + + val transformations = mapOf( + "package=\"$fromPackageName" to "package=\"$packageName", + "android:authorities=\"$fromPackageName" to "android:authorities=\"$packageName", + "$fromPackageName.permission.C2D_MESSAGE" to "$packageName.permission.C2D_MESSAGE", + "$fromPackageName.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" to "$packageName.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION", + "com.google.android.c2dm" to "$gmsCoreVendorGroupId.android.c2dm", + "com.google.android.libraries.photos.api.mars" to "$gmsCoreVendorGroupId.android.apps.photos.api.mars", + ) + + // 'QUERY_ALL_PACKAGES' permission is required, + // To check whether apps such as GmsCore, YouTube or YouTube Music are installed on the device. + document("AndroidManifest.xml").use { document -> + document.getElementsByTagName("manifest").item(0).also { + it.appendChild( + it.ownerDocument.createElement("uses-permission").also { element -> + element.setAttribute( + "android:name", + "android.permission.QUERY_ALL_PACKAGES" + ) + }) + } + } + + val manifest = get("AndroidManifest.xml") + manifest.writeText( + transformations.entries.fold(manifest.readText()) { acc, (from, to) -> + acc.replace( + from, + to, + ) + }, + ) + } + + patchManifest() + addSpoofingMetadata() + + executeBlock() + } + + block() +} diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/imageurl/CronetImageUrlHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/imageurl/CronetImageUrlHookPatch.kt new file mode 100644 index 0000000000..ea8c411531 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/imageurl/CronetImageUrlHookPatch.kt @@ -0,0 +1,124 @@ +package app.revanced.patches.shared.imageurl + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.instructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable +import app.revanced.patches.shared.extension.Constants.PATCHES_PATH +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.mutableClassOrThrow +import app.revanced.util.getReference +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.builder.MutableMethodImplementation +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.immutable.ImmutableMethod + +private const val EXTENSION_SHARED_CLASS_DESCRIPTOR = + "$PATCHES_PATH/BypassImageRegionRestrictionsPatch;" + +private lateinit var loadImageUrlMethod: MutableMethod +private var loadImageUrlIndex = 0 + +private lateinit var loadImageSuccessCallbackMethod: MutableMethod +private var loadImageSuccessCallbackIndex = 0 + +private lateinit var loadImageErrorCallbackMethod: MutableMethod +private var loadImageErrorCallbackIndex = 0 + +fun cronetImageUrlHookPatch( + resolveCronetRequest: Boolean, +) = bytecodePatch( + description = "cronetImageUrlHookPatch", +) { + execute { + loadImageUrlMethod = messageDigestImageUrlFingerprint + .matchOrThrow(messageDigestImageUrlParentFingerprint).method + + if (!resolveCronetRequest) return@execute + + loadImageSuccessCallbackMethod = onSucceededFingerprint + .matchOrThrow(onResponseStartedFingerprint).method + + loadImageErrorCallbackMethod = onFailureFingerprint + .matchOrThrow(onResponseStartedFingerprint).method + + // The URL is required for the failure callback hook, but the URL field is obfuscated. + // Add a helper get method that returns the URL field. + requestFingerprint.methodOrThrow().apply { + // The url is the only string field that is set inside the constructor. + val urlFieldInstruction = instructions.first { + val reference = it.getReference() + it.opcode == Opcode.IPUT_OBJECT && reference?.type == "Ljava/lang/String;" + } as ReferenceInstruction + + val urlFieldName = (urlFieldInstruction.reference as FieldReference).name + val definingClass = CRONET_URL_REQUEST_CLASS_DESCRIPTOR + val addedMethodName = "getHookedUrl" + requestFingerprint.mutableClassOrThrow().methods.add( + ImmutableMethod( + definingClass, + addedMethodName, + emptyList(), + "Ljava/lang/String;", + AccessFlags.PUBLIC.value, + null, + null, + MutableMethodImplementation(2), + ).toMutable().apply { + addInstructions( + """ + iget-object v0, p0, $definingClass->$urlFieldName:Ljava/lang/String; + return-object v0 + """, + ) + } + ) + } + } +} + +/** + * @param highPriority If the hook should be called before all other hooks. + */ +internal fun addImageUrlHook( + targetMethodClass: String = EXTENSION_SHARED_CLASS_DESCRIPTOR, + highPriority: Boolean = true +) { + loadImageUrlMethod.addInstructions( + if (highPriority) 0 else loadImageUrlIndex, + """ + invoke-static { p1 }, $targetMethodClass->overrideImageURL(Ljava/lang/String;)Ljava/lang/String; + move-result-object p1 + """, + ) + loadImageUrlIndex += 2 +} + +/** + * If a connection completed, which includes normal 200 responses but also includes + * status 404 and other error like http responses. + */ +internal fun addImageUrlSuccessCallbackHook(targetMethodClass: String) { + loadImageSuccessCallbackMethod.addInstruction( + loadImageSuccessCallbackIndex++, + "invoke-static { p1, p2 }, $targetMethodClass->handleCronetSuccess(" + + "Lorg/chromium/net/UrlRequest;Lorg/chromium/net/UrlResponseInfo;)V", + ) +} + +/** + * If a connection outright failed to complete any connection. + */ +internal fun addImageUrlErrorCallbackHook(targetMethodClass: String) { + loadImageErrorCallbackMethod.addInstruction( + loadImageErrorCallbackIndex++, + "invoke-static { p1, p2, p3 }, $targetMethodClass->handleCronetFailure(" + + "Lorg/chromium/net/UrlRequest;Lorg/chromium/net/UrlResponseInfo;Ljava/io/IOException;)V", + ) +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/imageurl/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/imageurl/Fingerprints.kt new file mode 100644 index 0000000000..083f50d41e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/imageurl/Fingerprints.kt @@ -0,0 +1,71 @@ +package app.revanced.patches.shared.imageurl + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val onFailureFingerprint = legacyFingerprint( + name = "onFailureFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + returnType = "V", + parameters = listOf( + "Lorg/chromium/net/UrlRequest;", + "Lorg/chromium/net/UrlResponseInfo;", + "Lorg/chromium/net/CronetException;" + ), + customFingerprint = { method, _ -> + method.name == "onFailed" + } +) + +// Acts as a parent fingerprint. +internal val onResponseStartedFingerprint = legacyFingerprint( + name = "onResponseStartedFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Lorg/chromium/net/UrlRequest;", "Lorg/chromium/net/UrlResponseInfo;"), + strings = listOf( + "Content-Length", + "Content-Type", + "identity", + "application/x-protobuf" + ), + customFingerprint = { method, _ -> + method.name == "onResponseStarted" + } +) + +internal val onSucceededFingerprint = legacyFingerprint( + name = "onSucceededFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Lorg/chromium/net/UrlRequest;", "Lorg/chromium/net/UrlResponseInfo;"), + customFingerprint = { method, _ -> + method.name == "onSucceeded" + } +) + +internal const val CRONET_URL_REQUEST_CLASS_DESCRIPTOR = "Lorg/chromium/net/impl/CronetUrlRequest;" + +internal val requestFingerprint = legacyFingerprint( + name = "requestFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + customFingerprint = { _, classDef -> + classDef.type == CRONET_URL_REQUEST_CLASS_DESCRIPTOR + } +) + +internal val messageDigestImageUrlFingerprint = legacyFingerprint( + name = "messageDigestImageUrlFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + parameters = listOf("Ljava/lang/String;", "L") +) + +internal val messageDigestImageUrlParentFingerprint = legacyFingerprint( + name = "messageDigestImageUrlParentFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + returnType = "Ljava/lang/String;", + parameters = emptyList(), + strings = listOf("@#&=*+-_.,:!?()/~'%;\$"), +) diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/litho/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/litho/Fingerprints.kt new file mode 100644 index 0000000000..1adf981c20 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/litho/Fingerprints.kt @@ -0,0 +1,74 @@ +package app.revanced.patches.shared.litho + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val bufferUpbFeatureFlagFingerprint = legacyFingerprint( + name = "bufferUpbFeatureFlagFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("L"), + literals = listOf(45419603L), +) + +internal val byteBufferFingerprint = legacyFingerprint( + name = "byteBufferFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("I", "Ljava/nio/ByteBuffer;"), + opcodes = listOf( + null, + Opcode.IF_EQZ, + Opcode.IPUT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.SUB_INT_2ADDR, + Opcode.IPUT, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IPUT, + Opcode.RETURN_VOID, + Opcode.CONST_4, + Opcode.IPUT, + Opcode.IPUT, + Opcode.GOTO + ), + // Check method count and field count to support both YouTube and YouTube Music + customFingerprint = { _, classDef -> + classDef.methods.count() > 6 + && classDef.fields.count() > 4 + }, +) + +internal val emptyComponentsFingerprint = legacyFingerprint( + name = "emptyComponentsFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + opcodes = listOf( + Opcode.INVOKE_INTERFACE, + Opcode.INVOKE_STATIC_RANGE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IGET_OBJECT + ), + strings = listOf("Error while converting %s"), +) + +/** + * Since YouTube v19.18.41 and YT Music 7.01.53, pathBuilder is being handled by a different Method. + */ +internal val pathBuilderFingerprint = legacyFingerprint( + name = "pathBuilderFingerprint", + returnType = "L", + strings = listOf("Number of bits must be positive"), +) + +internal val pathUpbFeatureFlagFingerprint = legacyFingerprint( + name = "pathUpbFeatureFlagFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(45631264L), +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/litho/LithoFilterPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/litho/LithoFilterPatch.kt new file mode 100644 index 0000000000..af61831fd8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/litho/LithoFilterPatch.kt @@ -0,0 +1,235 @@ +package app.revanced.patches.shared.litho + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.shared.extension.Constants.COMPONENTS_PATH +import app.revanced.util.findMethodsOrThrow +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import app.revanced.util.indexOfFirstStringInstructionOrThrow +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.builder.MutableMethodImplementation +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.immutable.ImmutableMethod +import com.android.tools.smali.dexlib2.util.MethodUtil + +private const val EXTENSION_LITHO_FILER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/LithoFilterPatch;" + +private const val EXTENSION_FILER_ARRAY_DESCRIPTOR = + "[$COMPONENTS_PATH/Filter;" + +private lateinit var filterArrayMethod: MutableMethod +private var filterCount = 0 + +internal lateinit var addLithoFilter: (String) -> Unit + private set + +val lithoFilterPatch = bytecodePatch( + description = "lithoFilterPatch", +) { + execute { + + // region Pass the buffer into extension. + + byteBufferFingerprint.methodOrThrow().addInstruction( + 0, + "invoke-static { p2 }, $EXTENSION_LITHO_FILER_CLASS_DESCRIPTOR->setProtoBuffer(Ljava/nio/ByteBuffer;)V" + ) + + // endregion + + var (emptyComponentMethod, emptyComponentLabel) = + emptyComponentsFingerprint.matchOrThrow().let { + with(it.method) { + val emptyComponentMethodIndex = it.patternMatch!!.startIndex + 1 + val emptyComponentMethodReference = + getInstruction(emptyComponentMethodIndex).reference + val emptyComponentFieldReference = + getInstruction(emptyComponentMethodIndex + 2).reference + + val label = """ + move-object/from16 v0, p1 + invoke-static {v0}, $emptyComponentMethodReference + move-result-object v0 + iget-object v0, v0, $emptyComponentFieldReference + return-object v0 + """ + + Pair(this, label) + } + } + + fun checkMethodSignatureMatch(pathBuilder: MutableMethod) = emptyComponentMethod.apply { + if (!MethodUtil.methodSignaturesMatch(pathBuilder, this)) { + implementation!!.instructions + .withIndex() + .filter { (_, instruction) -> + val reference = (instruction as? ReferenceInstruction)?.reference + reference is MethodReference && + MethodUtil.methodSignaturesMatch(pathBuilder, reference) + } + .map { (index, _) -> index } + .reversed() + .forEach { index -> + val insertInstruction = getInstruction(index + 1) + if (insertInstruction is OneRegisterInstruction) { + val insertRegister = + insertInstruction.registerA + val insertIndex = index + 2 + + addInstructionsWithLabels( + insertIndex, """ + if-nez v$insertRegister, :ignore + """ + emptyComponentLabel, + ExternalLabel("ignore", getInstruction(insertIndex)) + ) + } + } + + emptyComponentLabel = """ + const/4 v0, 0x0 + return-object v0 + """ + } + } + + pathBuilderFingerprint.methodOrThrow().apply { + checkMethodSignatureMatch(this) + + val stringBuilderIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.IPUT_OBJECT && + getReference()?.type == "Ljava/lang/StringBuilder;" + } + val stringBuilderRegister = + getInstruction(stringBuilderIndex).registerA + + val emptyStringIndex = indexOfFirstStringInstructionOrThrow("") + val identifierRegister = getInstruction( + indexOfFirstInstructionReversedOrThrow(emptyStringIndex) { + opcode == Opcode.IPUT_OBJECT + && getReference()?.type == "Ljava/lang/String;" + } + ).registerA + val objectRegister = getInstruction( + indexOfFirstInstructionOrThrow(emptyStringIndex) { + opcode == Opcode.INVOKE_VIRTUAL + } + ).registerC + + val insertIndex = stringBuilderIndex + 1 + + addInstructionsWithLabels( + insertIndex, """ + invoke-static {v$stringBuilderRegister, v$identifierRegister, v$objectRegister}, $EXTENSION_LITHO_FILER_CLASS_DESCRIPTOR->filter(Ljava/lang/StringBuilder;Ljava/lang/String;Ljava/lang/Object;)Z + move-result v$stringBuilderRegister + if-eqz v$stringBuilderRegister, :filter + """ + emptyComponentLabel, + ExternalLabel("filter", getInstruction(insertIndex)) + ) + } + + // region A/B test of new Litho native code. + + // Turn off native code that handles litho component names. If this feature is on then nearly + // all litho components have a null name and identifier/path filtering is completely broken. + + if (bufferUpbFeatureFlagFingerprint.second.methodOrNull != null && + pathUpbFeatureFlagFingerprint.second.methodOrNull != null + ) { + mapOf( + bufferUpbFeatureFlagFingerprint to 45419603L, + pathUpbFeatureFlagFingerprint to 45631264L, + ).forEach { (fingerprint, literalValue) -> + fingerprint.injectLiteralInstructionBooleanCall( + literalValue, + "0x0" + ) + } + } + + // endregion + + // Create a new method to get the filter array to avoid register conflicts. + // This fixes an issue with extension compiled with Android Gradle Plugin 8.3.0+. + // https://github.com/ReVanced/revanced-patches/issues/2818 + val lithoFilterMethods = findMethodsOrThrow(EXTENSION_LITHO_FILER_CLASS_DESCRIPTOR) + + lithoFilterMethods + .first { it.name == "" } + .apply { + val setArrayIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.SPUT_OBJECT && + getReference()?.type == EXTENSION_FILER_ARRAY_DESCRIPTOR + } + val setArrayRegister = + getInstruction(setArrayIndex).registerA + val addedMethodName = "getFilterArray" + + addInstructions( + setArrayIndex, """ + invoke-static {}, $EXTENSION_LITHO_FILER_CLASS_DESCRIPTOR->$addedMethodName()$EXTENSION_FILER_ARRAY_DESCRIPTOR + move-result-object v$setArrayRegister + """ + ) + + filterArrayMethod = ImmutableMethod( + definingClass, + addedMethodName, + emptyList(), + EXTENSION_FILER_ARRAY_DESCRIPTOR, + AccessFlags.PRIVATE or AccessFlags.STATIC, + null, + null, + MutableMethodImplementation(3), + ).toMutable().apply { + addInstruction( + 0, + "return-object v2" + ) + } + + lithoFilterMethods.add(filterArrayMethod) + } + + addLithoFilter = { classDescriptor -> + filterArrayMethod.addInstructions( + 0, + """ + new-instance v0, $classDescriptor + invoke-direct {v0}, $classDescriptor->()V + const/16 v1, ${filterCount++} + aput-object v0, v2, v1 + """ + ) + } + } + + finalize { + filterArrayMethod.addInstructions( + 0, """ + const/16 v0, $filterCount + new-array v2, v0, $EXTENSION_FILER_ARRAY_DESCRIPTOR + """ + ) + } +} + + diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/mainactivity/BaseMainActivityResolvePatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/mainactivity/BaseMainActivityResolvePatch.kt new file mode 100644 index 0000000000..8122949daa --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/mainactivity/BaseMainActivityResolvePatch.kt @@ -0,0 +1,84 @@ +package app.revanced.patches.shared.mainactivity + +import app.revanced.patcher.Fingerprint +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableClass +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.mutableClassOrThrow +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import kotlin.properties.Delegates + +lateinit var mainActivityMutableClass: MutableClass + private set +lateinit var onConfigurationChangedMethod: MutableMethod + private set +lateinit var onCreateMethod: MutableMethod + private set + +private lateinit var constructorMethod: MutableMethod +private lateinit var onBackPressedMethod: MutableMethod + +private var constructorMethodIndex by Delegates.notNull() +private var onBackPressedMethodIndex by Delegates.notNull() + +fun baseMainActivityResolvePatch( + mainActivityOnCreateFingerprint: Pair, +) = bytecodePatch( + description = "baseMainActivityResolvePatch" +) { + execute { + onCreateMethod = mainActivityOnCreateFingerprint.methodOrThrow() + mainActivityMutableClass = mainActivityOnCreateFingerprint.mutableClassOrThrow() + + // set constructor method + constructorMethod = getMainActivityMethod("") + constructorMethodIndex = constructorMethod.implementation!!.instructions.lastIndex + + // set onBackPressed method + onBackPressedMethod = getMainActivityMethod("onBackPressed") + onBackPressedMethodIndex = + onBackPressedMethod.indexOfFirstInstructionOrThrow(Opcode.RETURN_VOID) + + // set onConfigurationChanged method + onConfigurationChangedMethod = getMainActivityMethod("onConfigurationChanged") + } +} + +internal fun injectConstructorMethodCall(classDescriptor: String, methodDescriptor: String) = + constructorMethod.injectMethodCall( + classDescriptor, + methodDescriptor, + constructorMethodIndex + ) + +internal fun injectOnBackPressedMethodCall(classDescriptor: String, methodDescriptor: String) = + onBackPressedMethod.injectMethodCall( + classDescriptor, + methodDescriptor, + onBackPressedMethodIndex + ) + +internal fun injectOnCreateMethodCall(classDescriptor: String, methodDescriptor: String) = + onCreateMethod.injectMethodCall(classDescriptor, methodDescriptor) + +internal fun getMainActivityMethod(methodDescriptor: String) = + mainActivityMutableClass.methods.find { method -> method.name == methodDescriptor } + ?: throw PatchException("Could not find $methodDescriptor") + +private fun MutableMethod.injectMethodCall( + classDescriptor: String, + methodDescriptor: String +) = injectMethodCall(classDescriptor, methodDescriptor, 0) + +private fun MutableMethod.injectMethodCall( + classDescriptor: String, + methodDescriptor: String, + insertIndex: Int +) = addInstruction( + insertIndex, + "invoke-static/range {p0 .. p0}, $classDescriptor->$methodDescriptor(Landroid/app/Activity;)V" +) diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/mapping/ResourceMappingPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/mapping/ResourceMappingPatch.kt new file mode 100644 index 0000000000..a19a195997 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/mapping/ResourceMappingPatch.kt @@ -0,0 +1,81 @@ +package app.revanced.patches.shared.mapping + +import app.revanced.patcher.patch.resourcePatch +import org.w3c.dom.Element +import java.util.Collections +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +// TODO: Probably renaming the patch/this is a good idea. +lateinit var resourceMappings: List + private set + +val resourceMappingPatch = resourcePatch( + description = "resourceMappingPatch" +) { + val threadCount = Runtime.getRuntime().availableProcessors() + val threadPoolExecutor = Executors.newFixedThreadPool(threadCount) + + val resourceMappings = Collections.synchronizedList(mutableListOf()) + + execute { + // Save the file in memory to concurrently read from it. + val resourceXmlFile = get("res/values/public.xml").readBytes() + + for (threadIndex in 0 until threadCount) { + threadPoolExecutor.execute thread@{ + document(resourceXmlFile.inputStream()).use { document -> + + val resources = document.documentElement.childNodes + val resourcesLength = resources.length + val jobSize = resourcesLength / threadCount + + val batchStart = jobSize * threadIndex + val batchEnd = jobSize * (threadIndex + 1) + element@ for (i in batchStart until batchEnd) { + // Prevent out of bounds. + if (i >= resourcesLength) return@thread + + val node = resources.item(i) + if (node !is Element) continue + + val nameAttribute = node.getAttribute("name") + val typeAttribute = node.getAttribute("type") + + if (node.nodeName != "public" || nameAttribute.startsWith("APKTOOL")) continue + + val id = node.getAttribute("id").substring(2).toLong(16) + + resourceMappings.add(ResourceElement(typeAttribute, nameAttribute, id)) + } + } + } + } + + threadPoolExecutor.also { it.shutdown() }.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS) + + app.revanced.patches.shared.mapping.resourceMappings = resourceMappings + } +} + +operator fun List.get(type: String, name: String) = resourceMappings.firstOrNull { + it.type == type && it.name == name +}?.id ?: -1L + +operator fun List.get(resourceType: ResourceType, name: String) = + get(resourceType.value, name) + +data class ResourceElement(val type: String, val name: String, val id: Long) + +enum class ResourceType(val value: String) { + ATTR("attr"), + BOOL("bool"), + COLOR("color"), + DIMEN("dimen"), + DRAWABLE("drawable"), + ID("id"), + INTEGER("integer"), + LAYOUT("layout"), + STRING("string"), + STYLE("style") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/opus/BaseOpusCodecsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/opus/BaseOpusCodecsPatch.kt new file mode 100644 index 0000000000..3918ebfbd4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/opus/BaseOpusCodecsPatch.kt @@ -0,0 +1,49 @@ +package app.revanced.patches.shared.opus + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +fun baseOpusCodecsPatch( + descriptor: String, +) = bytecodePatch( + description = "baseOpusCodecsPatch" +) { + execute { + val opusCodecReference = with(codecReferenceFingerprint.methodOrThrow()) { + val codecIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_STATIC && + getReference()?.returnType == "Ljava/util/Set;" + } + getInstruction(codecIndex).reference + } + + codecSelectorFingerprint.matchOrThrow().let { + it.method.apply { + val freeRegister = implementation!!.registerCount - parameters.size - 2 + val targetIndex = it.patternMatch!!.endIndex + val targetRegister = getInstruction(targetIndex).registerA + + addInstructionsWithLabels( + targetIndex + 1, """ + invoke-static {}, $descriptor + move-result v$freeRegister + if-eqz v$freeRegister, :mp4a + invoke-static {}, $opusCodecReference + move-result-object v$targetRegister + """, ExternalLabel("mp4a", getInstruction(targetIndex + 1)) + ) + } + } + } +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/opus/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/opus/Fingerprints.kt new file mode 100644 index 0000000000..45d6be36c6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/opus/Fingerprints.kt @@ -0,0 +1,29 @@ +package app.revanced.patches.shared.opus + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val codecReferenceFingerprint = legacyFingerprint( + name = "codecReferenceFingerprint", + returnType = "J", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + opcodes = listOf(Opcode.INVOKE_SUPER), + strings = listOf("itag") +) + +internal val codecSelectorFingerprint = legacyFingerprint( + name = "codecSelectorFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + opcodes = listOf( + Opcode.NEW_INSTANCE, + Opcode.NEW_INSTANCE, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT + ), + strings = listOf("Audio track id %s not in audio streams") +) + diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/returnyoutubeusername/BaseReturnYouTubeUsernamePatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/returnyoutubeusername/BaseReturnYouTubeUsernamePatch.kt new file mode 100644 index 0000000000..83e6b03346 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/returnyoutubeusername/BaseReturnYouTubeUsernamePatch.kt @@ -0,0 +1,22 @@ +package app.revanced.patches.shared.returnyoutubeusername + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.extension.Constants.PATCHES_PATH +import app.revanced.patches.shared.textcomponent.hookSpannableString +import app.revanced.patches.shared.textcomponent.hookTextComponent +import app.revanced.patches.shared.textcomponent.textComponentPatch + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$PATCHES_PATH/ReturnYouTubeUsernamePatch;" + +val baseReturnYouTubeUsernamePatch = bytecodePatch( + description = "baseReturnYouTubeUsernamePatch" +) { + dependsOn(textComponentPatch) + + execute { + hookSpannableString(EXTENSION_CLASS_DESCRIPTOR, "preFetchLithoText") + hookTextComponent(EXTENSION_CLASS_DESCRIPTOR) + } +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/settingmenu/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/settingmenu/Fingerprints.kt new file mode 100644 index 0000000000..6e1e0212b9 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/settingmenu/Fingerprints.kt @@ -0,0 +1,31 @@ +package app.revanced.patches.shared.settingmenu + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val findPreferenceFingerprint = legacyFingerprint( + name = "findPreferenceFingerprint", + returnType = "Landroidx/preference/Preference;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Ljava/lang/CharSequence;"), + strings = listOf("Key cannot be null"), + customFingerprint = { method, _ -> + method.definingClass == "Landroidx/preference/PreferenceGroup;" + } +) + +internal val removePreferenceFingerprint = legacyFingerprint( + name = "removePreferenceFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Landroidx/preference/Preference;"), + opcodes = listOf(Opcode.INVOKE_VIRTUAL), + customFingerprint = custom@{ method, _ -> + if (method.definingClass != "Landroidx/preference/PreferenceGroup;") { + return@custom false + } + val instructions = method.implementation?.instructions ?: return@custom false + instructions.elementAt(0).opcode == Opcode.INVOKE_DIRECT + } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/settingmenu/SettingsMenuPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/settingmenu/SettingsMenuPatch.kt new file mode 100644 index 0000000000..ba54b950e5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/settingmenu/SettingsMenuPatch.kt @@ -0,0 +1,33 @@ +package app.revanced.patches.shared.settingmenu + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.extension.Constants.PATCHES_PATH +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.methodCall + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$PATCHES_PATH/BaseSettingsMenuPatch;" + +val settingsMenuPatch = bytecodePatch( + description = "settingsMenuPatch", +) { + execute { + val findPreferenceMethodCall = findPreferenceFingerprint.methodCall() + val removePreferenceMethodCall = findPreferenceFingerprint.methodCall() + + findMethodOrThrow(EXTENSION_CLASS_DESCRIPTOR) { + name == "removePreference" + }.addInstructionsWithLabels( + 0, """ + invoke-virtual {p0, p1}, $findPreferenceMethodCall + move-result-object v0 + if-eqz v0, :ignore + invoke-virtual {p0, v0}, $removePreferenceMethodCall + :ignore + return-void + """ + ) + } +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/spans/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/spans/Fingerprints.kt new file mode 100644 index 0000000000..a54bda5832 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/spans/Fingerprints.kt @@ -0,0 +1,10 @@ +package app.revanced.patches.shared.spans + +import app.revanced.util.fingerprint.legacyFingerprint + +internal val customCharacterStyleFingerprint = legacyFingerprint( + name = "customCharacterStyleFingerprint", + returnType = "Landroid/graphics/Path;", + parameters = listOf("Landroid/text/Layout;"), +) + diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/spans/InclusiveSpanPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/spans/InclusiveSpanPatch.kt new file mode 100644 index 0000000000..e60ae1f053 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/spans/InclusiveSpanPatch.kt @@ -0,0 +1,168 @@ +package app.revanced.patches.shared.spans + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable +import app.revanced.patches.shared.extension.Constants.SPANS_PATH +import app.revanced.patches.shared.indexOfSpannableStringInstruction +import app.revanced.patches.shared.spannableStringBuilderFingerprint +import app.revanced.patches.shared.textcomponent.hookSpannableString +import app.revanced.patches.shared.textcomponent.textComponentPatch +import app.revanced.util.findMethodOrThrow +import app.revanced.util.findMethodsOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.mutableClassOrThrow +import app.revanced.util.getFiveRegisters +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.builder.MutableMethodImplementation +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.immutable.ImmutableMethod + +private const val EXTENSION_SPANS_CLASS_DESCRIPTOR = + "$SPANS_PATH/InclusiveSpanPatch;" + +private const val EXTENSION_FILER_ARRAY_DESCRIPTOR = + "[$SPANS_PATH/Filter;" + +private lateinit var filterArrayMethod: MutableMethod +private var filterCount = 0 + +internal lateinit var addSpanFilter: (String) -> Unit + private set + +val inclusiveSpanPatch = bytecodePatch( + description = "inclusiveSpanPatch" +) { + dependsOn(textComponentPatch) + + execute { + hookSpannableString( + EXTENSION_SPANS_CLASS_DESCRIPTOR, + "setConversionContext" + ) + + spannableStringBuilderFingerprint.methodOrThrow().apply { + val spannedIndex = indexOfSpannableStringInstruction(this) + val setInclusiveSpanIndex = indexOfFirstInstructionOrThrow(spannedIndex) { + val reference = getReference() + opcode == Opcode.INVOKE_STATIC && + reference?.returnType == "V" && + reference.parameterTypes.size > 3 && + reference.parameterTypes.firstOrNull() == "Landroid/text/SpannableString;" + } + val setInclusiveSpanMethod = getWalkerMethod(setInclusiveSpanIndex) + + setInclusiveSpanMethod.apply { + val insertIndex = indexOfFirstInstructionReversedOrThrow { + opcode == Opcode.INVOKE_VIRTUAL && + getReference().toString() == "Landroid/text/SpannableString;->setSpan(Ljava/lang/Object;III)V" + } + replaceInstruction( + insertIndex, + "invoke-static { ${getFiveRegisters(insertIndex)} }, " + + EXTENSION_SPANS_CLASS_DESCRIPTOR + + "->" + + "setSpan(Landroid/text/SpannableString;Ljava/lang/Object;III)V" + ) + } + + val customCharacterStyle = + customCharacterStyleFingerprint.mutableClassOrThrow().type + + findMethodOrThrow(EXTENSION_SPANS_CLASS_DESCRIPTOR) { + name == "getSpanType" && + returnType != "Ljava/lang/String;" + }.apply { + val index = indexOfFirstInstructionOrThrow { + opcode == Opcode.INSTANCE_OF && + (this as? ReferenceInstruction)?.reference?.toString() == "Landroid/text/style/CharacterStyle;" + } + val instruction = getInstruction(index) + replaceInstruction( + index, + "instance-of v${instruction.registerA}, v${instruction.registerB}, $customCharacterStyle" + ) + } + + + // Create a new method to get the filter array to avoid register conflicts. + // This fixes an issue with extension compiled with Android Gradle Plugin 8.3.0+. + // https://github.com/ReVanced/revanced-patches/issues/2818 + val spansFilterMethods = findMethodsOrThrow(EXTENSION_SPANS_CLASS_DESCRIPTOR) + + spansFilterMethods + .first { it.name == "" } + .apply { + val setArrayIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.SPUT_OBJECT && + getReference()?.type == EXTENSION_FILER_ARRAY_DESCRIPTOR + } + val setArrayRegister = + getInstruction(setArrayIndex).registerA + val addedMethodName = "getFilterArray" + + addInstructions( + setArrayIndex, """ + invoke-static {}, $EXTENSION_SPANS_CLASS_DESCRIPTOR->$addedMethodName()$EXTENSION_FILER_ARRAY_DESCRIPTOR + move-result-object v$setArrayRegister + """ + ) + + filterArrayMethod = ImmutableMethod( + definingClass, + addedMethodName, + emptyList(), + EXTENSION_FILER_ARRAY_DESCRIPTOR, + AccessFlags.PRIVATE or AccessFlags.STATIC, + null, + null, + MutableMethodImplementation(3), + ).toMutable().apply { + addInstruction( + 0, + "return-object v2" + ) + } + + spansFilterMethods.add(filterArrayMethod) + } + + addSpanFilter = { classDescriptor -> + filterArrayMethod.addInstructions( + 0, """ + new-instance v0, $classDescriptor + invoke-direct {v0}, $classDescriptor->()V + const/16 v1, ${filterCount++} + aput-object v0, v2, v1 + """ + ) + } + } + + } + + finalize { + filterArrayMethod.addInstructions( + 0, """ + const/16 v0, $filterCount + new-array v2, v0, $EXTENSION_FILER_ARRAY_DESCRIPTOR + """ + ) + } +} + + diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/spoof/appversion/BaseSpoofAppVersionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/spoof/appversion/BaseSpoofAppVersionPatch.kt new file mode 100644 index 0000000000..8d4025c41a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/spoof/appversion/BaseSpoofAppVersionPatch.kt @@ -0,0 +1,33 @@ +package app.revanced.patches.shared.spoof.appversion + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.createPlayerRequestBodyWithModelFingerprint +import app.revanced.patches.shared.indexOfReleaseInstruction +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction + +fun baseSpoofAppVersionPatch( + descriptor: String, +) = bytecodePatch( + description = "baseSpoofAppVersionPatch" +) { + execute { + createPlayerRequestBodyWithModelFingerprint.methodOrThrow().apply { + val versionIndex = indexOfReleaseInstruction(this) + 1 + val insertIndex = + indexOfFirstInstructionReversedOrThrow(versionIndex, Opcode.IPUT_OBJECT) + val insertRegister = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $descriptor + move-result-object v$insertRegister + """ + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/spoof/useragent/BaseSpoofUserAgentPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/spoof/useragent/BaseSpoofUserAgentPatch.kt new file mode 100644 index 0000000000..105472d90c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/spoof/useragent/BaseSpoofUserAgentPatch.kt @@ -0,0 +1,84 @@ +package app.revanced.patches.shared.spoof.useragent + +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patches.shared.transformation.IMethodCall +import app.revanced.patches.shared.transformation.filterMapInstruction35c +import app.revanced.patches.shared.transformation.transformInstructionsPatch +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.iface.reference.StringReference + +private const val USER_AGENT_STRING_BUILDER_APPEND_METHOD_REFERENCE = + "Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;" + +fun baseSpoofUserAgentPatch( + packageName: String, +) = transformInstructionsPatch( + filterMap = { classDef, _, instruction, instructionIndex -> + filterMapInstruction35c( + "Lapp/revanced/extension", + classDef, + instruction, + instructionIndex, + ) + }, + transform = transform@{ mutableMethod, entry -> + val (_, _, instructionIndex) = entry + + // Replace the result of context.getPackageName(), if it is used in a user agent string. + mutableMethod.apply { + // After context.getPackageName() the result is moved to a register. + val targetRegister = ( + getInstruction(instructionIndex + 1) + as? OneRegisterInstruction ?: return@transform + ).registerA + + // IndexOutOfBoundsException is technically possible here, + // but no such occurrences are present in the app. + val referee = + getInstruction(instructionIndex + 2).getReference()?.toString() + + // Only replace string builder usage. + if (referee != USER_AGENT_STRING_BUILDER_APPEND_METHOD_REFERENCE) { + return@transform + } + + // Do not change the package name in methods that use resources, or for methods that use GmsCore. + // Changing these package names will result in playback limitations, + // particularly Android VR background audio only playback. + val resourceOrGmsStringInstructionIndex = indexOfFirstInstruction { + val reference = getReference() + opcode == Opcode.CONST_STRING && + (reference?.string == "android.resource://" || reference?.string == "gcore_") + } + if (resourceOrGmsStringInstructionIndex >= 0) { + return@transform + } + + // Overwrite the result of context.getPackageName() with the original package name. + replaceInstruction( + instructionIndex + 1, + "const-string v$targetRegister, \"$packageName\"", + ) + } + }, +) + +@Suppress("unused") +private enum class MethodCall( + override val definedClassName: String, + override val methodName: String, + override val methodParams: Array, + override val returnType: String, +) : IMethodCall { + GetPackageName( + "Landroid/content/Context;", + "getPackageName", + emptyArray(), + "Ljava/lang/String;", + ), +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/textcomponent/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/textcomponent/Fingerprints.kt new file mode 100644 index 0000000000..cd5f034663 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/textcomponent/Fingerprints.kt @@ -0,0 +1,26 @@ +package app.revanced.patches.shared.textcomponent + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val textComponentConstructorFingerprint = legacyFingerprint( + name = "textComponentConstructorFingerprint", + returnType = "V", + accessFlags = AccessFlags.PRIVATE or AccessFlags.CONSTRUCTOR, + strings = listOf("TextComponent") +) + +internal val textComponentContextFingerprint = legacyFingerprint( + name = "textComponentContextFingerprint", + returnType = "L", + accessFlags = AccessFlags.PROTECTED or AccessFlags.FINAL, + parameters = listOf("L"), + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.IGET_OBJECT, + Opcode.IGET_OBJECT, + Opcode.IGET_BOOLEAN + ) +) diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/textcomponent/TextComponentPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/textcomponent/TextComponentPatch.kt new file mode 100644 index 0000000000..6b04a1689b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/textcomponent/TextComponentPatch.kt @@ -0,0 +1,125 @@ +package app.revanced.patches.shared.textcomponent + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patches.shared.SPANNABLE_STRING_REFERENCE +import app.revanced.patches.shared.indexOfSpannableStringInstruction +import app.revanced.patches.shared.spannableStringBuilderFingerprint +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private lateinit var spannedMethod: MutableMethod +private var spannedIndex = 0 +private var spannedRegister = 0 +private var spannedContextRegister = 0 + +private lateinit var textComponentMethod: MutableMethod +private var textComponentIndex = 0 +private var textComponentRegister = 0 +private var textComponentContextRegister = 0 + +val textComponentPatch = bytecodePatch( + description = "textComponentPatch" +) { + execute { + spannableStringBuilderFingerprint.methodOrThrow().apply { + spannedMethod = this + spannedIndex = indexOfSpannableStringInstruction(this) + spannedRegister = getInstruction(spannedIndex).registerC + spannedContextRegister = + getInstruction(0).registerA + + replaceInstruction( + spannedIndex, + "nop" + ) + addInstruction( + ++spannedIndex, + "invoke-static {v$spannedRegister}, $SPANNABLE_STRING_REFERENCE" + ) + } + + textComponentContextFingerprint.methodOrThrow(textComponentConstructorFingerprint).apply { + textComponentMethod = this + val conversionContextFieldIndex = indexOfFirstInstructionOrThrow { + getReference()?.type == "Ljava/util/Map;" + } - 1 + val conversionContextFieldReference = + getInstruction(conversionContextFieldIndex).reference + + // ~ YouTube 19.32.xx + val legacyCharSequenceIndex = indexOfFirstInstruction { + getReference()?.type == "Ljava/util/BitSet;" + } - 1 + val charSequenceIndex = indexOfFirstInstruction { + val reference = getReference() + opcode == Opcode.INVOKE_VIRTUAL && + reference?.returnType == "V" && + reference.parameterTypes.firstOrNull() == "Ljava/lang/CharSequence;" + } + + val insertIndex: Int + + if (legacyCharSequenceIndex > -2) { + textComponentRegister = + getInstruction(legacyCharSequenceIndex).registerA + insertIndex = legacyCharSequenceIndex - 1 + } else if (charSequenceIndex > -1) { + textComponentRegister = + getInstruction(charSequenceIndex).registerD + insertIndex = charSequenceIndex + } else { + throw PatchException("Could not find insert index") + } + + textComponentContextRegister = getInstruction( + indexOfFirstInstructionOrThrow(insertIndex, Opcode.IGET_OBJECT) + ).registerA + + addInstructions( + insertIndex, """ + move-object/from16 v$textComponentContextRegister, p0 + iget-object v$textComponentContextRegister, v$textComponentContextRegister, $conversionContextFieldReference + """ + ) + textComponentIndex = insertIndex + 2 + } + } +} + +internal fun hookSpannableString( + classDescriptor: String, + methodName: String +) = spannedMethod.addInstructions( + spannedIndex, """ + invoke-static {v$spannedContextRegister, v$spannedRegister}, $classDescriptor->$methodName(Ljava/lang/Object;Ljava/lang/CharSequence;)Ljava/lang/CharSequence; + move-result-object v$spannedRegister + """ +) + +internal fun hookTextComponent( + classDescriptor: String, + methodName: String = "onLithoTextLoaded" +) = textComponentMethod.apply { + addInstructions( + textComponentIndex, """ + invoke-static {v$textComponentContextRegister, v$textComponentRegister}, $classDescriptor->$methodName(Ljava/lang/Object;Ljava/lang/CharSequence;)Ljava/lang/CharSequence; + move-result-object v$textComponentRegister + """ + ) + textComponentIndex += 2 +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/tracking/BaseSanitizeUrlQueryPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/tracking/BaseSanitizeUrlQueryPatch.kt new file mode 100644 index 0000000000..cb3cd55b10 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/tracking/BaseSanitizeUrlQueryPatch.kt @@ -0,0 +1,63 @@ +package app.revanced.patches.shared.tracking + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.extension.Constants.PATCHES_PATH +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$PATCHES_PATH/SanitizeUrlQueryPatch;" + +val baseSanitizeUrlQueryPatch = bytecodePatch( + description = "baseSanitizeUrlQueryPatch" +) { + execute { + copyTextEndpointFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = it.patternMatch!!.startIndex + val targetRegister = getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 2, """ + invoke-static {v$targetRegister}, $EXTENSION_CLASS_DESCRIPTOR->stripQueryParameters(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$targetRegister + """ + ) + } + } + + setOf( + shareLinkFormatterFingerprint, + systemShareLinkFormatterFingerprint + ).forEach { fingerprint -> + fingerprint.methodOrThrow().apply { + for ((index, instruction) in implementation!!.instructions.withIndex()) { + if (instruction.opcode != Opcode.INVOKE_VIRTUAL) + continue + + if ((instruction as ReferenceInstruction).reference.toString() != "Landroid/content/Intent;->putExtra(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;") + continue + + if (getInstruction(index + 1).opcode != Opcode.GOTO) + continue + + val invokeInstruction = instruction as FiveRegisterInstruction + + replaceInstruction( + index, + "invoke-static {v${invokeInstruction.registerC}, v${invokeInstruction.registerD}, v${invokeInstruction.registerE}}, " + + "$EXTENSION_CLASS_DESCRIPTOR->stripQueryParameters(Landroid/content/Intent;Ljava/lang/String;Ljava/lang/String;)V" + ) + } + } + } + } +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/tracking/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/tracking/Fingerprints.kt new file mode 100644 index 0000000000..471464fbc5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/tracking/Fingerprints.kt @@ -0,0 +1,64 @@ +package app.revanced.patches.shared.tracking + +import app.revanced.util.fingerprint.legacyFingerprint +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +/** + * Copy URL from sharing panel + */ +internal val copyTextEndpointFingerprint = legacyFingerprint( + name = "copyTextEndpointFingerprint", + returnType = "V", + parameters = listOf("L", "Ljava/util/Map;"), + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.CONST_STRING, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.IGET_OBJECT, + Opcode.IGET_OBJECT, + Opcode.INVOKE_INTERFACE, + Opcode.RETURN_VOID + ), + strings = listOf("text/plain") +) + +/** + * Sharing panel + */ +internal val shareLinkFormatterFingerprint = legacyFingerprint( + name = "shareLinkFormatterFingerprint", + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.CHECK_CAST, + Opcode.GOTO, + null, + Opcode.INVOKE_VIRTUAL + ), + customFingerprint = custom@{ method, _ -> + method.implementation + ?.instructions + ?.withIndex() + ?.filter { (_, instruction) -> + val reference = (instruction as? ReferenceInstruction)?.reference + instruction.opcode == Opcode.SGET_OBJECT && + reference is FieldReference && + reference.name == "androidAppEndpoint" + } + ?.map { (index, _) -> index } + ?.size == 2 + } +) + +/** + * Sharing panel of System + */ +internal val systemShareLinkFormatterFingerprint = legacyFingerprint( + name = "systemShareLinkFormatterFingerprint", + returnType = "V", + parameters = listOf("L", "Ljava/util/Map;"), + strings = listOf("YTShare_Logging_Share_Intent_Endpoint_Byte_Array") +) diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/transformation/MethodCall.kt b/patches/src/main/kotlin/app/revanced/patches/shared/transformation/MethodCall.kt new file mode 100644 index 0000000000..37213570e1 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/transformation/MethodCall.kt @@ -0,0 +1,95 @@ +package app.revanced.patches.shared.transformation + +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.ClassDef +import com.android.tools.smali.dexlib2.iface.instruction.Instruction +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +typealias Instruction35cInfo = Triple + +interface IMethodCall { + val definedClassName: String + val methodName: String + val methodParams: Array + val returnType: String + + /** + * Replaces an invoke-virtual instruction with an invoke-static instruction, + * which calls a static replacement method in the respective extension class. + * The method definition in the extension class is expected to be the same, + * except that the method should be static and take as a first parameter + * an instance of the class, in which the original method was defined in. + * + * Example: + * + * original method: Window#setFlags(int, int) + * + * replacement method: Extension#setFlags(Window, int, int) + */ + fun replaceInvokeVirtualWithExtension( + definingClassDescriptor: String, + method: MutableMethod, + instruction: Instruction35c, + instructionIndex: Int, + ) { + val registers = arrayOf( + instruction.registerC, + instruction.registerD, + instruction.registerE, + instruction.registerF, + instruction.registerG, + ) + val argsNum = methodParams.size + 1 // + 1 for instance of definedClassName + if (argsNum > registers.size) { + // should never happen, but just to be sure (also for the future) a safety check + throw RuntimeException( + "Not enough registers for $definedClassName#$methodName: " + + "Required $argsNum registers, but only got ${registers.size}.", + ) + } + + val args = registers.take(argsNum).joinToString(separator = ", ") { reg -> "v$reg" } + val replacementMethod = + "$methodName(${definedClassName}${methodParams.joinToString(separator = "")})$returnType" + + method.replaceInstruction( + instructionIndex, + "invoke-static { $args }, $definingClassDescriptor->$replacementMethod", + ) + } +} + +inline fun fromMethodReference( + methodReference: MethodReference, +) + where E : Enum, E : IMethodCall = enumValues().firstOrNull { search -> + search.definedClassName == methodReference.definingClass && + search.methodName == methodReference.name && + methodReference.parameterTypes.toTypedArray().contentEquals(search.methodParams) && + search.returnType == methodReference.returnType +} + +inline fun filterMapInstruction35c( + extensionClassDescriptorPrefix: String, + classDef: ClassDef, + instruction: Instruction, + instructionIndex: Int, +): Instruction35cInfo? where E : Enum, E : IMethodCall { + if (classDef.startsWith(extensionClassDescriptorPrefix)) { + // avoid infinite recursion + return null + } + + if (instruction.opcode != Opcode.INVOKE_VIRTUAL) { + return null + } + + val invokeInstruction = instruction as Instruction35c + val methodRef = invokeInstruction.reference as MethodReference + val methodCall = fromMethodReference(methodRef) ?: return null + + return Instruction35cInfo(methodCall, invokeInstruction, instructionIndex) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/transformation/TransformInstructionsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/transformation/TransformInstructionsPatch.kt new file mode 100644 index 0000000000..46a78a7142 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/transformation/TransformInstructionsPatch.kt @@ -0,0 +1,53 @@ +package app.revanced.patches.shared.transformation + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.util.findMutableMethodOf +import com.android.tools.smali.dexlib2.iface.ClassDef +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.instruction.Instruction + +fun transformInstructionsPatch( + filterMap: (ClassDef, Method, Instruction, Int) -> T?, + transform: (MutableMethod, T) -> Unit, +) = bytecodePatch( + description = "transformInstructionsPatch" +) { + // Returns the patch indices as a Sequence, which will execute lazily. + fun findPatchIndices(classDef: ClassDef, method: Method): Sequence? = + method.implementation?.instructions?.asSequence()?.withIndex() + ?.mapNotNull { (index, instruction) -> + filterMap(classDef, method, instruction, index) + } + + execute { + // Find all methods to patch + buildMap { + classes.forEach { classDef -> + val methods = buildList { + classDef.methods.forEach { method -> + // Since the Sequence executes lazily, + // using any() results in only calling + // filterMap until the first index has been found. + if (findPatchIndices(classDef, method)?.any() == true) add(method) + } + } + + if (methods.isNotEmpty()) { + put(classDef, methods) + } + } + }.forEach { (classDef, methods) -> + // And finally transform the methods... + val mutableClass = proxy(classDef).mutableClass + + methods.map(mutableClass::findMutableMethodOf).forEach methods@{ mutableMethod -> + val patchIndices = + findPatchIndices(mutableClass, mutableMethod)?.toCollection(ArrayDeque()) + ?: return@methods + + while (!patchIndices.isEmpty()) transform(mutableMethod, patchIndices.removeLast()) + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/translations/BaseTranslationsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/translations/BaseTranslationsPatch.kt new file mode 100644 index 0000000000..8554a4d62d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/translations/BaseTranslationsPatch.kt @@ -0,0 +1,171 @@ +package app.revanced.patches.shared.translations + +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.ResourcePatchContext +import app.revanced.util.inputStreamFromBundledResource +import org.w3c.dom.Node +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.OutputKeys +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult + +// Array of all possible app languages. +val APP_LANGUAGES = arrayOf( + "af", "am", "ar", "ar-rXB", "as", "az", + "b+es+419", "b+sr+Latn", "be", "bg", "bn", "bs", + "ca", "cs", + "da", "de", + "el", "en-rAU", "en-rCA", "en-rGB", "en-rIN", "en-rXA", "en-rXC", "es", "es-rUS", "et", "eu", + "fa", "fi", "fr", "fr-rCA", + "gl", "gu", + "hi", "hr", "hu", "hy", + "id", "in", "is", "it", "iw", + "ja", + "ka", "kk", "km", "kn", "ko", "ky", + "lo", "lt", "lv", + "mk", "ml", "mn", "mr", "ms", "my", + "nb", "ne", "nl", "no", + "or", + "pa", "pl", "pt", "pt-rBR", "pt-rPT", + "ro", "ru", + "si", "sk", "sl", "sq", "sr", "sv", "sw", + "ta", "te", "th", "tl", "tr", + "uk", "ur", "uz", + "vi", + "zh", "zh-rCN", "zh-rHK", "zh-rTW", "zu", +) + +fun ResourcePatchContext.baseTranslationsPatch( + customTranslations: String?, + selectedTranslations: String?, + selectedStringResources: String?, + translationsArray: Set, + sourceDirectory: String, +) { + val resourceDirectory = get("res") + + // Check if the custom translation path is valid. + customTranslations?.takeIf { it.isNotEmpty() }?.let { customLang -> + try { + val customLangFile = File(customLang) + if (!customLangFile.exists() || !customLangFile.isFile || customLangFile.name != "strings.xml") { + throw PatchException("Invalid custom language file: $customLang") + } + val valuesDirectory = resourceDirectory.resolve("values") + val destinationFile = valuesDirectory.resolve("strings.xml") + + updateStringsXml(customLangFile, destinationFile) + } catch (e: Exception) { + // Exception is thrown if an invalid path is used in the patch option. + throw PatchException("Invalid custom translations path: $customLang") + } + } ?: run { + // Process selected translations if no custom translation is set. + val selectedTranslationsArray = + selectedTranslations?.split(",")?.map { it.trim() }?.toTypedArray() + ?: throw PatchException("Invalid selected languages.") + val filteredLanguages = + translationsArray.filter { it in selectedTranslationsArray }.toTypedArray() + copyStringsXml(sourceDirectory, filteredLanguages) + } + + // Process selected string resources. + val selectedStringResourcesArray = + selectedStringResources?.split(",")?.map { it.trim() }?.toTypedArray() + ?: throw PatchException("Invalid selected string resources.") + val filteredStringResources = + APP_LANGUAGES.filter { it in selectedStringResourcesArray }.toTypedArray() + + // Remove unselected app languages. + APP_LANGUAGES.filter { it !in filteredStringResources }.forEach { language -> + resourceDirectory.resolve("values-$language").takeIf { it.exists() && it.isDirectory } + ?.deleteRecursively() + } +} + +/** + * Extension function to ResourceContext to copy XML translation files. + * + * @param sourceDirectory The source directory containing the translation files. + * @param languageArray The array of language codes to process. + */ +private fun ResourcePatchContext.copyStringsXml( + sourceDirectory: String, + languageArray: Array +) { + val resourceDirectory = get("res") + languageArray.forEach { language -> + inputStreamFromBundledResource( + "$sourceDirectory/translations", + "$language/strings.xml" + )?.let { inputStream -> + val directory = "values-$language-v21" + val valuesV21Directory = resourceDirectory.resolve(directory) + if (!valuesV21Directory.isDirectory) Files.createDirectories(valuesV21Directory.toPath()) + + Files.copy( + inputStream, + resourceDirectory.resolve("$directory/strings.xml").toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + } +} + +/** + * Updates the contents of the destination strings.xml file by merging it with the source strings.xml file. + * + * This function reads both source and destination XML files, compares each element by their + * unique "name" attribute, and if a match is found, it replaces the content in the destination file with + * the content from the source file. + * + * @param sourceFile The source strings.xml file containing new string values. + * @param destinationFile The destination strings.xml file to be updated with values from the source file. + */ +private fun updateStringsXml(sourceFile: File, destinationFile: File) { + val documentBuilderFactory = DocumentBuilderFactory.newInstance() + val documentBuilder = documentBuilderFactory.newDocumentBuilder() + + // Parse the source and destination XML files into Document objects + val sourceDoc = documentBuilder.parse(sourceFile) + val destinationDoc = documentBuilder.parse(destinationFile) + + val sourceStrings = sourceDoc.getElementsByTagName("string") + val destinationStrings = destinationDoc.getElementsByTagName("string") + + // Create a map to store the elements from the source document by their "name" attribute + val sourceMap = mutableMapOf() + + // Populate the map with nodes from the source document + for (i in 0 until sourceStrings.length) { + val node = sourceStrings.item(i) + val name = node.attributes.getNamedItem("name").nodeValue + sourceMap[name] = node + } + + // Update the destination document with values from the source document + for (i in 0 until destinationStrings.length) { + val node = destinationStrings.item(i) + val name = node.attributes.getNamedItem("name").nodeValue + if (sourceMap.containsKey(name)) { + node.textContent = sourceMap[name]?.textContent + } + } + + /** + * Prepare the transformer for writing the updated document back to the file. + * The transformer is configured to indent the output XML for better readability. + */ + val transformerFactory = TransformerFactory.newInstance() + val transformer = transformerFactory.newTransformer() + transformer.setOutputProperty(OutputKeys.INDENT, "yes") + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2") + + val domSource = DOMSource(destinationDoc) + val streamResult = StreamResult(destinationFile) + transformer.transform(domSource, streamResult) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/viewgroup/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/viewgroup/Fingerprints.kt new file mode 100644 index 0000000000..b9b0e7fdbe --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/viewgroup/Fingerprints.kt @@ -0,0 +1,20 @@ +package app.revanced.patches.shared.viewgroup + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val viewGroupMarginFingerprint = legacyFingerprint( + name = "viewGroupMarginFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("Landroid/view/View;", "I", "I"), +) + +internal val viewGroupMarginParentFingerprint = legacyFingerprint( + name = "viewGroupMarginParentFingerprint", + returnType = "Landroid/view/ViewGroup${'$'}LayoutParams;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("Ljava/lang/Class;", "Landroid/view/ViewGroup${'$'}LayoutParams;"), + strings = listOf("SafeLayoutParams"), +) diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/viewgroup/ViewGroupMarginLayoutParamsHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/viewgroup/ViewGroupMarginLayoutParamsHookPatch.kt new file mode 100644 index 0000000000..370cf14608 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/viewgroup/ViewGroupMarginLayoutParamsHookPatch.kt @@ -0,0 +1,29 @@ +package app.revanced.patches.shared.viewgroup + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.extension.Constants.EXTENSION_UTILS_CLASS_DESCRIPTOR +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.methodOrThrow + +val viewGroupMarginLayoutParamsHookPatch = bytecodePatch( + description = "viewGroupMarginLayoutParamsHookPatch" +) { + execute { + val setViewGroupMarginCall = with( + viewGroupMarginFingerprint.methodOrThrow(viewGroupMarginParentFingerprint) + ) { + "$definingClass->$name(Landroid/view/View;II)V" + } + + findMethodOrThrow(EXTENSION_UTILS_CLASS_DESCRIPTOR) { + name == "hideViewGroupByMarginLayoutParams" + }.addInstructions( + 0, """ + const/4 v0, 0x0 + invoke-static {p0, v0, v0}, $setViewGroupMarginCall + """ + ) + } +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/ads/general/AdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/ads/general/AdsPatch.kt new file mode 100644 index 0000000000..7fd1383c15 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/ads/general/AdsPatch.kt @@ -0,0 +1,144 @@ +package app.revanced.patches.youtube.ads.general + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.shared.ads.baseAdsPatch +import app.revanced.patches.shared.ads.hookLithoFullscreenAds +import app.revanced.patches.shared.ads.hookNonLithoFullscreenAds +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.ADS_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.youtube.utils.fix.doublebacktoclose.doubleBackToClosePatch +import app.revanced.patches.youtube.utils.fix.swiperefresh.swipeRefreshPatch +import app.revanced.patches.youtube.utils.patch.PatchList.HIDE_ADS +import app.revanced.patches.youtube.utils.resourceid.adAttribution +import app.revanced.patches.youtube.utils.resourceid.interstitialsContainer +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.findMutableMethodOf +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.injectHideViewCall +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction31i +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c + +private const val ADS_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/AdsFilter;" + +@Suppress("unused") +val adsPatch = bytecodePatch( + HIDE_ADS.title, + HIDE_ADS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + baseAdsPatch(ADS_CLASS_DESCRIPTOR, "hideVideoAds"), + doubleBackToClosePatch, + lithoFilterPatch, + sharedResourceIdPatch, + settingsPatch, + swipeRefreshPatch, + ) + + execute { + addLithoFilter(ADS_FILTER_CLASS_DESCRIPTOR) + + // region patch for hide fullscreen ads + + // non-litho view, used in some old clients. + interstitialsContainerFingerprint + .methodOrThrow() + .hookNonLithoFullscreenAds(interstitialsContainer) + + // litho view, used in 'ShowDialogCommandOuterClass' in innertube + showDialogCommandFingerprint + .matchOrThrow() + .hookLithoFullscreenAds() + + // endregion + + // region patch for hide general ads + + classes.forEach { classDef -> + classDef.methods.forEach { method -> + method.implementation.apply { + this?.instructions?.forEachIndexed { index, instruction -> + if (instruction.opcode != Opcode.CONST) + return@forEachIndexed + // Instruction to store the id adAttribution into a register + if ((instruction as Instruction31i).wideLiteral != adAttribution) + return@forEachIndexed + + val insertIndex = index + 1 + + // Call to get the view with the id adAttribution + (instructions.elementAt(insertIndex)).apply { + if (opcode != Opcode.INVOKE_VIRTUAL) + return@forEachIndexed + + // Hide the view + val viewRegister = (this as Instruction35c).registerC + proxy(classDef) + .mutableClass + .findMutableMethodOf(method) + .injectHideViewCall( + insertIndex, + viewRegister, + ADS_CLASS_DESCRIPTOR, + "hideAdAttributionView" + ) + } + } + } + } + } + + // endregion + + // region patch for hide get premium + + compactYpcOfferModuleViewFingerprint.matchOrThrow().let { + it.method.apply { + val startIndex = it.patternMatch!!.startIndex + val measuredWidthRegister = + getInstruction(startIndex).registerA + val measuredHeightInstruction = + getInstruction(startIndex + 1) + val measuredHeightRegister = measuredHeightInstruction.registerA + val tempRegister = measuredHeightInstruction.registerB + + addInstructionsWithLabels( + startIndex + 2, """ + invoke-static {}, $ADS_CLASS_DESCRIPTOR->hideGetPremium()Z + move-result v$tempRegister + if-eqz v$tempRegister, :show + const/4 v$measuredWidthRegister, 0x0 + const/4 v$measuredHeightRegister, 0x0 + """, ExternalLabel("show", getInstruction(startIndex + 2)) + ) + } + } + + // endregion + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: ADS" + ), + HIDE_ADS + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/ads/general/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/ads/general/Fingerprints.kt new file mode 100644 index 0000000000..c51509b41a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/ads/general/Fingerprints.kt @@ -0,0 +1,53 @@ +package app.revanced.patches.youtube.ads.general + +import app.revanced.patches.youtube.utils.resourceid.interstitialsContainer +import app.revanced.patches.youtube.utils.resourceid.slidingDialogAnimation +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val compactYpcOfferModuleViewFingerprint = legacyFingerprint( + name = "compactYpcOfferModuleViewFingerprint", + returnType = "V", + accessFlags = AccessFlags.PROTECTED or AccessFlags.FINAL, + parameters = listOf("I", "I"), + opcodes = listOf( + Opcode.ADD_INT_2ADDR, + Opcode.ADD_INT_2ADDR, + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN_VOID + ), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/CompactYpcOfferModuleView;") && + method.name == "onMeasure" + } +) + +internal val interstitialsContainerFingerprint = legacyFingerprint( + name = "interstitialsContainerFingerprint", + returnType = "V", + strings = listOf("overlay_controller_param"), + literals = listOf(interstitialsContainer) +) + +internal val showDialogCommandFingerprint = legacyFingerprint( + name = "showDialogCommandFingerprint", + returnType = "V", + opcodes = listOf( + Opcode.IF_EQ, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.IGET, // get dialog code + ), + literals = listOf(slidingDialogAnimation), + // 18.43 and earlier has a different first parameter. + // Since this fingerprint is somewhat weak, work around by checking for both method parameter signatures. + customFingerprint = { method, _ -> + // 18.43 and earlier parameters are: "L", "L" + // 18.44+ parameters are "[B", "L" + val parameterTypes = method.parameterTypes + + parameterTypes.size == 2 && parameterTypes[1].startsWith("L") + }, +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/alternative/thumbnails/AlternativeThumbnailsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/alternative/thumbnails/AlternativeThumbnailsPatch.kt new file mode 100644 index 0000000000..15d5f8d235 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/alternative/thumbnails/AlternativeThumbnailsPatch.kt @@ -0,0 +1,48 @@ +package app.revanced.patches.youtube.alternative.thumbnails + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.imageurl.addImageUrlErrorCallbackHook +import app.revanced.patches.shared.imageurl.addImageUrlHook +import app.revanced.patches.shared.imageurl.addImageUrlSuccessCallbackHook +import app.revanced.patches.shared.imageurl.cronetImageUrlHookPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.ALTERNATIVE_THUMBNAILS_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.navigation.navigationBarHookPatch +import app.revanced.patches.youtube.utils.patch.PatchList.ALTERNATIVE_THUMBNAILS +import app.revanced.patches.youtube.utils.playertype.playerTypeHookPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch + +@Suppress("unused") +val alternativeThumbnailsPatch = bytecodePatch( + ALTERNATIVE_THUMBNAILS.title, + ALTERNATIVE_THUMBNAILS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + cronetImageUrlHookPatch(true), + navigationBarHookPatch, + playerTypeHookPatch, + settingsPatch, + ) + execute { + + addImageUrlHook(ALTERNATIVE_THUMBNAILS_CLASS_DESCRIPTOR) + addImageUrlSuccessCallbackHook(ALTERNATIVE_THUMBNAILS_CLASS_DESCRIPTOR) + addImageUrlErrorCallbackHook(ALTERNATIVE_THUMBNAILS_CLASS_DESCRIPTOR) + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: ALTERNATIVE_THUMBNAILS", + "SETTINGS: ALTERNATIVE_THUMBNAILS" + ), + ALTERNATIVE_THUMBNAILS + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/alternative/thumbnails/BypassImageRegionRestrictionsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/alternative/thumbnails/BypassImageRegionRestrictionsPatch.kt new file mode 100644 index 0000000000..ac627b1707 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/alternative/thumbnails/BypassImageRegionRestrictionsPatch.kt @@ -0,0 +1,39 @@ +package app.revanced.patches.youtube.alternative.thumbnails + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.imageurl.addImageUrlHook +import app.revanced.patches.shared.imageurl.cronetImageUrlHookPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.patch.PatchList.BYPASS_IMAGE_REGION_RESTRICTIONS +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch + +@Suppress("unused") +val bypassImageRegionRestrictionsPatch = bytecodePatch( + BYPASS_IMAGE_REGION_RESTRICTIONS.title, + BYPASS_IMAGE_REGION_RESTRICTIONS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + cronetImageUrlHookPatch(true), + settingsPatch, + ) + execute { + + addImageUrlHook() + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: ALTERNATIVE_THUMBNAILS", + "SETTINGS: BYPASS_IMAGE_REGION_RESTRICTIONS" + ), + BYPASS_IMAGE_REGION_RESTRICTIONS + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/feed/components/FeedComponentsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/feed/components/FeedComponentsPatch.kt new file mode 100644 index 0000000000..4c19d9fc45 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/feed/components/FeedComponentsPatch.kt @@ -0,0 +1,398 @@ +package app.revanced.patches.youtube.feed.components + +import app.revanced.patcher.Fingerprint +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.mainactivity.onCreateMethod +import app.revanced.patches.youtube.utils.bottomsheet.bottomSheetHookPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.engagementPanelBuilderFingerprint +import app.revanced.patches.youtube.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.youtube.utils.extension.Constants.FEED_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.extension.Constants.FEED_PATH +import app.revanced.patches.youtube.utils.mainactivity.mainActivityResolvePatch +import app.revanced.patches.youtube.utils.navigation.navigationBarHookPatch +import app.revanced.patches.youtube.utils.patch.PatchList.HIDE_FEED_COMPONENTS +import app.revanced.patches.youtube.utils.playertype.playerTypeHookPatch +import app.revanced.patches.youtube.utils.resourceid.bar +import app.revanced.patches.youtube.utils.resourceid.captionToggleContainer +import app.revanced.patches.youtube.utils.resourceid.channelListSubMenu +import app.revanced.patches.youtube.utils.resourceid.contentPill +import app.revanced.patches.youtube.utils.resourceid.horizontalCardList +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.scrollTopParentFingerprint +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.REGISTER_TEMPLATE_REPLACEMENT +import app.revanced.util.fingerprint.injectLiteralInstructionViewCall +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.mutableClassOrThrow +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.iface.reference.StringReference +import com.android.tools.smali.dexlib2.util.MethodUtil + +private const val CAROUSEL_SHELF_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/CarouselShelfFilter;" +private const val FEED_COMPONENTS_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/FeedComponentsFilter;" +private const val FEED_VIDEO_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/FeedVideoFilter;" +private const val FEED_VIDEO_VIEWS_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/FeedVideoViewsFilter;" +private const val KEYWORD_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/KeywordContentFilter;" +private const val RELATED_VIDEO_CLASS_DESCRIPTOR = + "$FEED_PATH/RelatedVideoPatch;" + +@Suppress("unused") +val feedComponentsPatch = bytecodePatch( + HIDE_FEED_COMPONENTS.title, + HIDE_FEED_COMPONENTS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + mainActivityResolvePatch, + navigationBarHookPatch, + playerTypeHookPatch, + sharedResourceIdPatch, + settingsPatch, + bottomSheetHookPatch, + ) + execute { + + // region patch for hide carousel shelf, subscriptions channel section, latest videos button + + listOf( + // carousel shelf, only used to tablet layout. + Triple( + breakingNewsFingerprint, + "hideBreakingNewsShelf", + horizontalCardList + ), + // subscriptions channel section. + Triple( + channelListSubMenuFingerprint, + "hideSubscriptionsChannelSection", + channelListSubMenu + ), + // latest videos button + Triple( + contentPillFingerprint, + "hideLatestVideosButton", + contentPill + ), + Triple( + latestVideosButtonFingerprint, + "hideLatestVideosButton", + bar + ), + ).forEach { (fingerprint, methodName, literal) -> + val smaliInstruction = """ + invoke-static {v$REGISTER_TEMPLATE_REPLACEMENT}, $FEED_CLASS_DESCRIPTOR->$methodName(Landroid/view/View;)V + """ + fingerprint.injectLiteralInstructionViewCall(literal, smaliInstruction) + } + + // endregion + + // region patch for hide caption button + + captionsButtonFingerprint.methodOrThrow().apply { + val constIndex = indexOfFirstLiteralInstructionOrThrow(captionToggleContainer) + val insertIndex = indexOfFirstInstructionReversedOrThrow(constIndex, Opcode.IF_EQZ) + val insertRegister = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $FEED_CLASS_DESCRIPTOR->hideCaptionsButton(Landroid/view/View;)Landroid/view/View; + move-result-object v$insertRegister + """ + ) + } + + captionsButtonSyntheticFingerprint.methodOrThrow().apply { + val constIndex = indexOfFirstLiteralInstructionOrThrow(captionToggleContainer) + val targetIndex = indexOfFirstInstructionOrThrow(constIndex, Opcode.MOVE_RESULT_OBJECT) + val targetRegister = getInstruction(targetIndex).registerA + + addInstruction( + targetIndex + 1, + "invoke-static {v$targetRegister}, $FEED_CLASS_DESCRIPTOR->hideCaptionsButtonContainer(Landroid/view/View;)V" + ) + } + + // endregion + + // region patch for hide floating button + + onCreateMethod.apply { + val fabIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.CONST_STRING && + getReference()?.string == "fab" + } + val fabRegister = getInstruction(fabIndex).registerA + val jumpIndex = indexOfFirstInstructionOrThrow(fabIndex + 1, Opcode.CONST_STRING) + + addInstructionsWithLabels( + fabIndex, """ + invoke-static {}, $FEED_CLASS_DESCRIPTOR->hideFloatingButton()Z + move-result v$fabRegister + if-nez v$fabRegister, :hide + """, ExternalLabel("hide", getInstruction(jumpIndex)) + ) + } + + // endregion + + // region patch for hide relative video + + fun Method.indexOfEngagementPanelBuilderInstruction(targetMethod: MutableMethod) = + indexOfFirstInstruction { + opcode == Opcode.INVOKE_DIRECT && + MethodUtil.methodSignaturesMatch( + targetMethod, + getReference()!! + ) + } + + engagementPanelBuilderFingerprint.matchOrThrow().let { + it.classDef.methods.filter { method -> + method.indexOfEngagementPanelBuilderInstruction(it.method) >= 0 + }.forEach { method -> + method.apply { + val index = indexOfEngagementPanelBuilderInstruction(it.method) + val register = getInstruction(index + 1).registerA + + addInstruction( + index + 2, + "invoke-static {v$register}, " + + "$RELATED_VIDEO_CLASS_DESCRIPTOR->showEngagementPanel(Ljava/lang/Object;)V" + ) + } + } + } + + engagementPanelUpdateFingerprint.methodOrThrow(engagementPanelBuilderFingerprint) + .addInstruction( + 0, + "invoke-static {}, $RELATED_VIDEO_CLASS_DESCRIPTOR->hideEngagementPanel()V" + ) + + linearLayoutManagerItemCountsFingerprint.matchOrThrow().let { + val methodWalker = + it.getWalkerMethod(it.patternMatch!!.endIndex) + methodWalker.apply { + val index = indexOfFirstInstructionOrThrow(Opcode.MOVE_RESULT) + val register = getInstruction(index).registerA + + addInstructions( + index + 1, """ + invoke-static {v$register}, $RELATED_VIDEO_CLASS_DESCRIPTOR->overrideItemCounts(I)I + move-result v$register + """ + ) + } + } + + // endregion + + // region patch for hide subscriptions channel section for tablet + + arrayOf( + channelListSubMenuTabletFingerprint, + channelListSubMenuTabletSyntheticFingerprint + ).forEach { fingerprint -> + fingerprint.methodOrThrow().apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $FEED_CLASS_DESCRIPTOR->hideSubscriptionsChannelSection()Z + move-result v0 + if-eqz v0, :show + return-void + """, ExternalLabel("show", getInstruction(0)) + ) + } + } + + // endregion + + // region patch for hide category bar + + fun Pair.patch( + insertIndexOffset: Int = 0, + hookRegisterOffset: Int = 0, + instructions: (Int) -> String + ) = + matchOrThrow().let { + it.method.apply { + val endIndex = it.patternMatch!!.endIndex + + val insertIndex = endIndex + insertIndexOffset + val register = + getInstruction(endIndex + hookRegisterOffset).registerA + + addInstructions(insertIndex, instructions(register)) + } + } + + filterBarHeightFingerprint.patch { register -> + """ + invoke-static { v$register }, $FEED_CLASS_DESCRIPTOR->hideCategoryBarInFeed(I)I + move-result v$register + """ + } + + relatedChipCloudFingerprint.patch(1) { register -> + "invoke-static { v$register }, " + + "$FEED_CLASS_DESCRIPTOR->hideCategoryBarInRelatedVideos(Landroid/view/View;)V" + } + + searchResultsChipBarFingerprint.patch(-1, -2) { register -> + """ + invoke-static { v$register }, $FEED_CLASS_DESCRIPTOR->hideCategoryBarInSearch(I)I + move-result v$register + """ + } + + // endregion + + // region patch for hide mix playlists + + elementParserFingerprint.matchOrThrow(elementParserParentFingerprint).let { + it.method.apply { + val freeRegister = implementation!!.registerCount - parameters.size - 2 + val insertIndex = indexOfFirstInstructionOrThrow { + val reference = ((this as? ReferenceInstruction)?.reference as? MethodReference) + + reference?.parameterTypes?.size == 1 && + reference.parameterTypes.first() == "[B" && + reference.returnType.startsWith("L") + } + + val objectIndex = indexOfFirstInstructionOrThrow(Opcode.MOVE_OBJECT) + val objectRegister = getInstruction(objectIndex).registerA + + val jumpIndex = it.patternMatch!!.startIndex + + addInstructionsWithLabels( + insertIndex, """ + invoke-static {v$objectRegister, v$freeRegister}, $FEED_COMPONENTS_FILTER_CLASS_DESCRIPTOR->filterMixPlaylists(Ljava/lang/Object;[B)Z + move-result v$freeRegister + if-nez v$freeRegister, :filter + """, ExternalLabel("filter", getInstruction(jumpIndex)) + ) + + addInstruction( + 0, + "move-object/from16 v$freeRegister, p3" + ) + } + } + + // endregion + + // region patch for hide show more button + + showMoreButtonFingerprint.mutableClassOrThrow().let { + val getViewMethod = + it.methods.find { method -> + method.parameters.isEmpty() && + method.returnType == "Landroid/view/View;" + } + + getViewMethod?.apply { + val targetIndex = implementation!!.instructions.size - 1 + val targetRegister = getInstruction(targetIndex).registerA + + addInstruction( + targetIndex, + "invoke-static {v$targetRegister}, $FEED_CLASS_DESCRIPTOR->hideShowMoreButton(Landroid/view/View;)V" + ) + } ?: throw PatchException("Failed to find getView method") + } + + // endregion + + // region patch for hide channel tab + + val channelTabBuilderMethod = + channelTabBuilderFingerprint.methodOrThrow(scrollTopParentFingerprint) + + channelTabRendererFingerprint.matchOrThrow().let { + it.method.apply { + val iteratorIndex = indexOfFirstInstructionOrThrow { + getReference()?.name == "hasNext" + } + val iteratorRegister = + getInstruction(iteratorIndex).registerC + + val targetIndex = indexOfFirstInstructionOrThrow { + val reference = ((this as? ReferenceInstruction)?.reference as? MethodReference) + + opcode == Opcode.INVOKE_INTERFACE && + reference?.returnType == channelTabBuilderMethod.returnType && + reference.parameterTypes == channelTabBuilderMethod.parameterTypes + } + + val objectIndex = + indexOfFirstInstructionReversedOrThrow(targetIndex, Opcode.IGET_OBJECT) + val objectInstruction = getInstruction(objectIndex) + val objectReference = getInstruction(objectIndex).reference + + addInstructionsWithLabels( + objectIndex + 1, """ + invoke-static {v${objectInstruction.registerA}}, $FEED_CLASS_DESCRIPTOR->hideChannelTab(Ljava/lang/String;)Z + move-result v${objectInstruction.registerA} + if-eqz v${objectInstruction.registerA}, :ignore + invoke-interface {v$iteratorRegister}, Ljava/util/Iterator;->remove()V + goto :next_iterator + :ignore + iget-object v${objectInstruction.registerA}, v${objectInstruction.registerB}, $objectReference + """, ExternalLabel("next_iterator", getInstruction(iteratorIndex)) + ) + } + } + + // endregion + + addLithoFilter(CAROUSEL_SHELF_FILTER_CLASS_DESCRIPTOR) + addLithoFilter(FEED_COMPONENTS_FILTER_CLASS_DESCRIPTOR) + addLithoFilter(FEED_VIDEO_FILTER_CLASS_DESCRIPTOR) + addLithoFilter(FEED_VIDEO_VIEWS_FILTER_CLASS_DESCRIPTOR) + addLithoFilter(KEYWORD_FILTER_CLASS_DESCRIPTOR) + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: FEED", + "SETTINGS: HIDE_FEED_COMPONENTS" + ), + HIDE_FEED_COMPONENTS + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/feed/components/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/feed/components/Fingerprints.kt new file mode 100644 index 0000000000..f1e655d25e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/feed/components/Fingerprints.kt @@ -0,0 +1,192 @@ +package app.revanced.patches.youtube.feed.components + +import app.revanced.patches.youtube.utils.resourceid.bar +import app.revanced.patches.youtube.utils.resourceid.barContainerHeight +import app.revanced.patches.youtube.utils.resourceid.captionToggleContainer +import app.revanced.patches.youtube.utils.resourceid.channelListSubMenu +import app.revanced.patches.youtube.utils.resourceid.contentPill +import app.revanced.patches.youtube.utils.resourceid.drawerResults +import app.revanced.patches.youtube.utils.resourceid.expandButtonDown +import app.revanced.patches.youtube.utils.resourceid.filterBarHeight +import app.revanced.patches.youtube.utils.resourceid.horizontalCardList +import app.revanced.patches.youtube.utils.resourceid.relatedChipCloudMargin +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val breakingNewsFingerprint = legacyFingerprint( + name = "breakingNewsFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + literals = listOf(horizontalCardList), +) + +internal val captionsButtonFingerprint = legacyFingerprint( + name = "captionsButtonFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(captionToggleContainer), +) + +internal val captionsButtonSyntheticFingerprint = legacyFingerprint( + name = "captionsButtonSyntheticFingerprint", + returnType = "Landroid/view/View;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL or AccessFlags.BRIDGE or AccessFlags.SYNTHETIC, + parameters = listOf("Landroid/content/Context;"), + literals = listOf(captionToggleContainer), +) + +internal val channelListSubMenuFingerprint = legacyFingerprint( + name = "channelListSubMenuFingerprint", + literals = listOf(channelListSubMenu), +) + +internal val channelListSubMenuTabletFingerprint = legacyFingerprint( + name = "channelListSubMenuTabletFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(drawerResults), +) + +internal val channelListSubMenuTabletSyntheticFingerprint = legacyFingerprint( + name = "channelListSubMenuTabletSyntheticFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL or AccessFlags.SYNTHETIC, + strings = listOf("is_horizontal_drawer_context") +) + +internal val channelTabBuilderFingerprint = legacyFingerprint( + name = "channelTabBuilderFingerprint", + returnType = "Landroid/view/View;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Ljava/lang/CharSequence;", "Ljava/lang/CharSequence;", "Z", "L") +) + +internal val channelTabRendererFingerprint = legacyFingerprint( + name = "channelTabRendererFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "Ljava/util/List;", "I"), + strings = listOf("TabRenderer.content contains SectionListRenderer but the tab does not have a section list controller.") +) + +internal val contentPillFingerprint = legacyFingerprint( + name = "contentPillFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "Z"), + literals = listOf(contentPill), +) + +internal val elementParserFingerprint = legacyFingerprint( + name = "elementParserFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "L", "[B", "L", "L"), + opcodes = listOf( + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IGET_OBJECT, + Opcode.RETURN_OBJECT + ) +) + +internal val elementParserParentFingerprint = legacyFingerprint( + name = "elementParserParentFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + strings = listOf("Element tree missing id in debug mode.") +) + +internal val engagementPanelUpdateFingerprint = legacyFingerprint( + name = "engagementPanelUpdateFingerprint", + returnType = "V", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + parameters = listOf("L", "Z"), + customFingerprint = { method, _ -> + method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_VIRTUAL && + getReference().toString() == "Ljava/util/ArrayDeque;->pop()Ljava/lang/Object;" + } >= 0 + } +) + +internal val filterBarHeightFingerprint = legacyFingerprint( + name = "filterBarHeightFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + opcodes = listOf( + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IPUT + ), + literals = listOf(filterBarHeight), +) + +internal val latestVideosButtonFingerprint = legacyFingerprint( + name = "latestVideosButtonFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "Z"), + literals = listOf(bar), +) + +internal val linearLayoutManagerItemCountsFingerprint = legacyFingerprint( + name = "linearLayoutManagerItemCountsFingerprint", + returnType = "I", + accessFlags = AccessFlags.FINAL.value, + parameters = listOf("L", "L", "L", "Z"), + opcodes = listOf( + Opcode.IF_NEZ, + Opcode.IF_LEZ, + Opcode.INVOKE_VIRTUAL, + ), + customFingerprint = { method, _ -> + method.definingClass == "Landroid/support/v7/widget/LinearLayoutManager;" + } +) + +internal val relatedChipCloudFingerprint = legacyFingerprint( + name = "relatedChipCloudFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + opcodes = listOf( + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT + ), + literals = listOf(relatedChipCloudMargin), +) + +internal val searchResultsChipBarFingerprint = legacyFingerprint( + name = "searchResultsChipBarFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + opcodes = listOf( + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT + ), + literals = listOf(barContainerHeight), +) + +internal val showMoreButtonFingerprint = legacyFingerprint( + name = "showMoreButtonFingerprint", + opcodes = listOf( + Opcode.CONST, + Opcode.CONST_4, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT + ), + literals = listOf(expandButtonDown), +) + + diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/feed/flyoutmenu/FeedFlyoutMenuPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/feed/flyoutmenu/FeedFlyoutMenuPatch.kt new file mode 100644 index 0000000000..0223783bcb --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/feed/flyoutmenu/FeedFlyoutMenuPatch.kt @@ -0,0 +1,96 @@ +package app.revanced.patches.youtube.feed.flyoutmenu + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.FEED_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.patch.PatchList.HIDE_FEED_FLYOUT_MENU +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.fingerprint.matchOrNull +import app.revanced.util.fingerprint.matchOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +@Suppress("unused") +val feedFlyoutMenuPatch = bytecodePatch( + HIDE_FEED_FLYOUT_MENU.title, + HIDE_FEED_FLYOUT_MENU.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + sharedResourceIdPatch, + settingsPatch, + ) + execute { + + // region patch for phone + + val bottomSheetMenuItemBuilderMatch = + bottomSheetMenuItemBuilderLegacyFingerprint.matchOrNull() + ?: bottomSheetMenuItemBuilderFingerprint.matchOrThrow() + + bottomSheetMenuItemBuilderMatch.let { + it.method.apply { + val targetIndex = it.patternMatch!!.endIndex + val targetRegister = getInstruction(targetIndex).registerA + + val targetParameter = + getInstruction(targetIndex - 1).reference + if (!targetParameter.toString().endsWith("Ljava/lang/CharSequence;")) + throw PatchException("Method signature parameter did not match: $targetParameter") + + addInstructions( + targetIndex + 1, """ + invoke-static {v$targetRegister}, $FEED_CLASS_DESCRIPTOR->hideFlyoutMenu(Ljava/lang/CharSequence;)Ljava/lang/CharSequence; + move-result-object v$targetRegister + """ + ) + } + } + + // endregion + + // region patch for tablet + + contextualMenuItemBuilderFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = it.patternMatch!!.startIndex + 1 + val targetInstruction = getInstruction(targetIndex) + + val targetReferenceName = + (targetInstruction.reference as MethodReference).name + if (targetReferenceName != "setText") + throw PatchException("Method name did not match: $targetReferenceName") + + addInstruction( + targetIndex + 1, + "invoke-static {v${targetInstruction.registerC}, v${targetInstruction.registerD}}, " + + "$FEED_CLASS_DESCRIPTOR->hideFlyoutMenu(Landroid/widget/TextView;Ljava/lang/CharSequence;)V" + ) + } + } + + // endregion + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: FEED", + "SETTINGS: HIDE_FEED_FLYOUT_MENU" + ), + HIDE_FEED_FLYOUT_MENU + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/feed/flyoutmenu/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/feed/flyoutmenu/Fingerprints.kt new file mode 100644 index 0000000000..2c905c376b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/feed/flyoutmenu/Fingerprints.kt @@ -0,0 +1,56 @@ +package app.revanced.patches.youtube.feed.flyoutmenu + +import app.revanced.patches.youtube.utils.resourceid.posterArtWidthDefault +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +/** + * Compatible with YouTube v19.11.43~ + */ +internal val bottomSheetMenuItemBuilderFingerprint = legacyFingerprint( + name = "bottomSheetMenuItemBuilderFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + returnType = "L", + parameters = listOf("L"), + opcodes = listOf( + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT + ), + strings = listOf("Text missing for BottomSheetMenuItem with iconType: ") +) + +/** + * Compatible with ~YouTube v19.10.39 + */ +internal val bottomSheetMenuItemBuilderLegacyFingerprint = legacyFingerprint( + name = "bottomSheetMenuItemBuilderLegacyFingerprint", + returnType = "L", + parameters = listOf("L"), + opcodes = listOf( + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT + ), + strings = listOf("ElementTransformer, ElementPresenter and InteractionLogger cannot be null") +) + +internal val contextualMenuItemBuilderFingerprint = legacyFingerprint( + name = "contextualMenuItemBuilderFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL or AccessFlags.SYNTHETIC, + parameters = listOf("L", "L"), + opcodes = listOf( + Opcode.CHECK_CAST, + Opcode.INVOKE_VIRTUAL, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT, + Opcode.ADD_INT_2ADDR + ), + literals = listOf(posterArtWidthDefault), +) + diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/audiotracks/AudioTracksPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/audiotracks/AudioTracksPatch.kt new file mode 100644 index 0000000000..e4fb18962d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/audiotracks/AudioTracksPatch.kt @@ -0,0 +1,76 @@ +package app.revanced.patches.youtube.general.audiotracks + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.patch.PatchList.DISABLE_AUTO_AUDIO_TRACKS +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +@Suppress("unused") +val audioTracksPatch = bytecodePatch( + DISABLE_AUTO_AUDIO_TRACKS.title, + DISABLE_AUTO_AUDIO_TRACKS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + execute { + + + streamingModelBuilderFingerprint.methodOrThrow().apply { + val formatStreamModelIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.CHECK_CAST + && (this as ReferenceInstruction).reference.toString() == "Lcom/google/android/libraries/youtube/innertube/model/media/FormatStreamModel;" + } + val arrayListIndex = indexOfFirstInstructionOrThrow(formatStreamModelIndex) { + opcode == Opcode.INVOKE_INTERFACE && + getReference()?.toString() == "Ljava/util/List;->add(Ljava/lang/Object;)Z" + } + val insertIndex = indexOfFirstInstructionOrThrow(arrayListIndex) { + opcode == Opcode.INVOKE_INTERFACE && + getReference()?.toString() == "Ljava/util/List;->isEmpty()Z" + } + 2 + + val formatStreamModelRegister = + getInstruction(formatStreamModelIndex).registerA + val arrayListRegister = + getInstruction(arrayListIndex).registerC + + addInstructions( + insertIndex, """ + invoke-static {v$arrayListRegister}, $GENERAL_CLASS_DESCRIPTOR->getFormatStreamModelArray(Ljava/util/ArrayList;)Ljava/util/ArrayList; + move-result-object v$arrayListRegister + """ + ) + + addInstructions( + formatStreamModelIndex + 1, + "invoke-static {v$formatStreamModelRegister}, $GENERAL_CLASS_DESCRIPTOR->setFormatStreamModelArray(Ljava/lang/Object;)V" + ) + } + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: GENERAL", + "SETTINGS: DISABLE_AUTO_AUDIO_TRACKS" + ), + DISABLE_AUTO_AUDIO_TRACKS + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/audiotracks/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/audiotracks/Fingerprints.kt new file mode 100644 index 0000000000..1c9c134035 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/audiotracks/Fingerprints.kt @@ -0,0 +1,13 @@ +package app.revanced.patches.youtube.general.audiotracks + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val streamingModelBuilderFingerprint = legacyFingerprint( + name = "streamingModelBuilderFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + strings = listOf("vprng") +) + diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/autocaptions/AutoCaptionsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/autocaptions/AutoCaptionsPatch.kt new file mode 100644 index 0000000000..2beaf7ae2f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/autocaptions/AutoCaptionsPatch.kt @@ -0,0 +1,37 @@ +package app.revanced.patches.youtube.general.autocaptions + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.captions.baseAutoCaptionsPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.patch.PatchList.DISABLE_AUTO_CAPTIONS +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch + +@Suppress("unused") +val autoCaptionsPatch = bytecodePatch( + DISABLE_AUTO_CAPTIONS.title, + DISABLE_AUTO_CAPTIONS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + baseAutoCaptionsPatch, + settingsPatch, + ) + + execute { + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: GENERAL", + "SETTINGS: DISABLE_AUTO_CAPTIONS" + ), + DISABLE_AUTO_CAPTIONS + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/components/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/components/Fingerprints.kt new file mode 100644 index 0000000000..e37d96d1dd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/components/Fingerprints.kt @@ -0,0 +1,136 @@ +package app.revanced.patches.youtube.general.components + +import app.revanced.patches.youtube.utils.resourceid.accountSwitcherAccessibility +import app.revanced.patches.youtube.utils.resourceid.compactLink +import app.revanced.patches.youtube.utils.resourceid.compactListItem +import app.revanced.patches.youtube.utils.resourceid.editSettingsAction +import app.revanced.patches.youtube.utils.resourceid.fab +import app.revanced.patches.youtube.utils.resourceid.toolTipContentView +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionReversed +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val accountListFingerprint = legacyFingerprint( + name = "accountListFingerprint", + returnType = "V", + accessFlags = AccessFlags.PROTECTED or AccessFlags.FINAL or AccessFlags.SYNTHETIC, + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.IGET + ) +) + +internal val accountListParentFingerprint = legacyFingerprint( + name = "accountListParentFingerprint", + literals = listOf(compactListItem), +) + +internal val accountMenuFingerprint = legacyFingerprint( + name = "accountMenuFingerprint", + returnType = "V", + opcodes = listOf( + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.IGET, + Opcode.AND_INT_LIT16 + ) +) + +internal val accountMenuParentFingerprint = legacyFingerprint( + name = "accountMenuParentFingerprint", + opcodes = listOf( + Opcode.CONST, + Opcode.CONST_4, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT + ), + literals = listOf(compactLink), +) + +internal val accountSwitcherAccessibilityLabelFingerprint = legacyFingerprint( + name = "accountSwitcherAccessibilityLabelFingerprint", + returnType = "V", + parameters = listOf("L", "Ljava/lang/Object;"), + literals = listOf(accountSwitcherAccessibility), +) + +internal val appBlockingCheckResultToStringFingerprint = legacyFingerprint( + name = "appBlockingCheckResultToStringFingerprint", + returnType = "Ljava/lang/String;", + strings = listOf("AppBlockingCheckResult{intent=") +) + +internal val bottomUiContainerFingerprint = legacyFingerprint( + name = "bottomUiContainerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "L"), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/BottomUiContainer;") + } +) + +internal val floatingMicrophoneFingerprint = legacyFingerprint( + name = "floatingMicrophoneFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf( + Opcode.IGET_BOOLEAN, + Opcode.IF_EQZ, + Opcode.RETURN_VOID + ), + literals = listOf(fab), +) + +internal val pipNotificationFingerprint = legacyFingerprint( + name = "pipNotificationFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + literals = listOf(editSettingsAction), +) + +internal val preferenceScreenFingerprint = legacyFingerprint( + name = "preferenceScreenFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + strings = listOf(":android:show_fragment_args"), + customFingerprint = { method, classDef -> + AccessFlags.SYNTHETIC.isSet(classDef.accessFlags) && + indexOfPreferenceScreenInstruction(method) >= 0 + } +) + +internal fun indexOfPreferenceScreenInstruction(method: Method) = + method.indexOfFirstInstructionReversed { + val reference = getReference() + opcode == Opcode.INVOKE_VIRTUAL && + reference?.returnType == "Landroidx/preference/PreferenceScreen;" && + reference.parameterTypes.isEmpty() + } + +internal val tooltipContentFullscreenFingerprint = legacyFingerprint( + name = "tooltipContentFullscreenFingerprint", + returnType = "V", + literals = listOf(45384061L), +) + +internal val tooltipContentViewFingerprint = legacyFingerprint( + name = "tooltipContentViewFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + literals = listOf(toolTipContentView), +) + diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/components/LayoutComponentsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/components/LayoutComponentsPatch.kt new file mode 100644 index 0000000000..20b30093c0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/components/LayoutComponentsPatch.kt @@ -0,0 +1,245 @@ +package app.revanced.patches.youtube.general.components + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.patches.shared.viewgroup.viewGroupMarginLayoutParamsHookPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_PATH +import app.revanced.patches.youtube.utils.patch.PatchList.HIDE_LAYOUT_COMPONENTS +import app.revanced.patches.youtube.utils.resourceid.accountSwitcherAccessibility +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.mutableClassOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.util.MethodUtil + +private const val EXTENSION_SETTINGS_MENU_DESCRIPTOR = + "$GENERAL_PATH/SettingsMenuPatch;" +private const val CUSTOM_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/CustomFilter;" +private const val LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/LayoutComponentsFilter;" + +@Suppress("unused") +val layoutComponentsPatch = bytecodePatch( + HIDE_LAYOUT_COMPONENTS.title, + HIDE_LAYOUT_COMPONENTS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + lithoFilterPatch, + sharedResourceIdPatch, + settingsPatch, + viewGroupMarginLayoutParamsHookPatch, + ) + + execute { + + // region patch for disable pip notification + + pipNotificationFingerprint.matchOrThrow().let { + it.method.apply { + val checkCastCalls = implementation!!.instructions.withIndex() + .filter { instruction -> + (instruction.value as? ReferenceInstruction)?.reference.toString() == "Lcom/google/apps/tiktok/account/AccountId;" + } + + val checkCastCallSize = checkCastCalls.size + if (checkCastCallSize != 3) + throw PatchException("Couldn't find target index, size: $checkCastCallSize") + + arrayOf( + checkCastCalls.elementAt(1).index, + checkCastCalls.elementAt(0).index + ).forEach { index -> + addInstruction( + index + 1, + "return-void" + ) + } + } + } + + // endregion + + // region patch for disable update screen + + appBlockingCheckResultToStringFingerprint.mutableClassOrThrow().methods.first { method -> + MethodUtil.isConstructor(method) && + method.parameters == listOf("Landroid/content/Intent;", "Z") + }.addInstructions( + 1, + "const/4 p1, 0x0" + ) + + // endregion + + // region patch for hide account menu + + // for you tab + accountListFingerprint.matchOrThrow(accountListParentFingerprint).let { + it.method.apply { + val targetIndex = it.patternMatch!!.startIndex + 3 + val targetInstruction = getInstruction(targetIndex) + + addInstruction( + targetIndex, + "invoke-static {v${targetInstruction.registerC}, v${targetInstruction.registerD}}, " + + "$GENERAL_CLASS_DESCRIPTOR->hideAccountList(Landroid/view/View;Ljava/lang/CharSequence;)V" + ) + } + } + + // for tablet and old clients + accountMenuFingerprint.matchOrThrow(accountMenuParentFingerprint).let { + it.method.apply { + val targetIndex = it.patternMatch!!.startIndex + 2 + val targetInstruction = getInstruction(targetIndex) + + addInstruction( + targetIndex, + "invoke-static {v${targetInstruction.registerC}, v${targetInstruction.registerD}}, " + + "$GENERAL_CLASS_DESCRIPTOR->hideAccountMenu(Landroid/view/View;Ljava/lang/CharSequence;)V" + ) + } + } + + // endregion + + // region patch for hide floating microphone + + floatingMicrophoneFingerprint.matchOrThrow().let { + it.method.apply { + val insertIndex = it.patternMatch!!.startIndex + val register = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex + 1, """ + invoke-static {v$register}, $GENERAL_CLASS_DESCRIPTOR->hideFloatingMicrophone(Z)Z + move-result v$register + """ + ) + } + } + + // endregion + + // region patch for hide handle + + accountSwitcherAccessibilityLabelFingerprint.methodOrThrow().apply { + val constIndex = + indexOfFirstLiteralInstructionOrThrow(accountSwitcherAccessibility) + val insertIndex = indexOfFirstInstructionOrThrow(constIndex, Opcode.IF_EQZ) + val setVisibilityIndex = indexOfFirstInstructionOrThrow(insertIndex) { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "setVisibility" + } + val visibilityRegister = + getInstruction(setVisibilityIndex).registerD + + addInstructions( + insertIndex, """ + invoke-static {v$visibilityRegister}, $GENERAL_CLASS_DESCRIPTOR->hideHandle(I)I + move-result v$visibilityRegister + """ + ) + } + + // endregion + + // region patch for hide setting menus + + preferenceScreenFingerprint.methodOrThrow().apply { + val targetIndex = indexOfPreferenceScreenInstruction(this) + val targetRegister = getInstruction(targetIndex).registerC + val targetReference = getInstruction(targetIndex).reference + + val insertIndex = implementation!!.instructions.lastIndex + + addInstructions( + insertIndex + 1, """ + invoke-virtual {v$targetRegister}, $targetReference + move-result-object v$targetRegister + invoke-static {v$targetRegister}, $EXTENSION_SETTINGS_MENU_DESCRIPTOR->hideSettingsMenu(Landroidx/preference/PreferenceScreen;)V + return-void + """ + ) + removeInstruction(insertIndex) + } + + // endregion + + // region patch for hide snack bar + + bottomUiContainerFingerprint.methodOrThrow().apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $GENERAL_CLASS_DESCRIPTOR->hideSnackBar()Z + move-result v0 + if-eqz v0, :show + return-void + """, ExternalLabel("show", getInstruction(0)) + ) + } + + // endregion + + // region patch for hide tooltip content + + tooltipContentFullscreenFingerprint.methodOrThrow().apply { + val literalIndex = indexOfFirstLiteralInstructionOrThrow(45384061L) + val targetIndex = indexOfFirstInstructionOrThrow(literalIndex, Opcode.MOVE_RESULT) + val targetRegister = getInstruction(targetIndex).registerA + + addInstruction( + targetIndex + 1, + "const/4 v$targetRegister, 0x0" + ) + } + + tooltipContentViewFingerprint.methodOrThrow().addInstruction( + 0, + "return-void" + ) + + // endregion + + addLithoFilter(CUSTOM_FILTER_CLASS_DESCRIPTOR) + addLithoFilter(LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR) + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: GENERAL", + "SETTINGS: HIDE_LAYOUT_COMPONENTS" + ), + HIDE_LAYOUT_COMPONENTS + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/dialog/ViewerDiscretionDialogPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/dialog/ViewerDiscretionDialogPatch.kt new file mode 100644 index 0000000000..bbeebc0b3a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/dialog/ViewerDiscretionDialogPatch.kt @@ -0,0 +1,41 @@ +package app.revanced.patches.youtube.general.dialog + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.dialog.baseViewerDiscretionDialogPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.patch.PatchList.REMOVE_VIEWER_DISCRETION_DIALOG +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch + +@Suppress("unused") +val viewerDiscretionDialogPatch = bytecodePatch( + REMOVE_VIEWER_DISCRETION_DIALOG.title, + REMOVE_VIEWER_DISCRETION_DIALOG.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + baseViewerDiscretionDialogPatch( + GENERAL_CLASS_DESCRIPTOR, + true + ), + settingsPatch, + ) + + execute { + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: GENERAL", + "SETTINGS: REMOVE_VIEWER_DISCRETION_DIALOG" + ), + REMOVE_VIEWER_DISCRETION_DIALOG + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/downloads/DownloadActionsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/downloads/DownloadActionsPatch.kt new file mode 100644 index 0000000000..d6f4d54b47 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/downloads/DownloadActionsPatch.kt @@ -0,0 +1,178 @@ +package app.revanced.patches.youtube.general.downloads + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.instructions +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_PATH +import app.revanced.patches.youtube.utils.patch.PatchList.HOOK_DOWNLOAD_ACTIONS +import app.revanced.patches.youtube.utils.pip.pipStateHookPatch +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$GENERAL_PATH/DownloadActionsPatch;" + +private const val OFFLINE_PLAYLIST_ENDPOINT_OUTER_CLASS_DESCRIPTOR = + "Lcom/google/protos/youtube/api/innertube/OfflinePlaylistEndpointOuterClass${'$'}OfflinePlaylistEndpoint;" + +@Suppress("unused") +val downloadActionsPatch = bytecodePatch( + HOOK_DOWNLOAD_ACTIONS.title, + HOOK_DOWNLOAD_ACTIONS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + pipStateHookPatch, + sharedResourceIdPatch, + settingsPatch, + ) + + execute { + + // region patch for hook download actions (video action bar and flyout panel) + + offlineVideoEndpointFingerprint.methodOrThrow().apply { + addInstructionsWithLabels( + 0, """ + invoke-static/range {p3 .. p3}, $EXTENSION_CLASS_DESCRIPTOR->inAppVideoDownloadButtonOnClick(Ljava/lang/String;)Z + move-result v0 + if-eqz v0, :show_native_downloader + return-void + """, ExternalLabel("show_native_downloader", getInstruction(0)) + ) + } + + // endregion + + // region patch for hook download actions (playlist) + + val onClickListenerClass = + downloadPlaylistButtonOnClickFingerprint.methodOrThrow().let { + val playlistDownloadActionInvokeIndex = + indexOfPlaylistDownloadActionInvokeInstruction(it) + + it.instructions.subList( + playlistDownloadActionInvokeIndex - 10, + playlistDownloadActionInvokeIndex, + ).find { instruction -> + instruction.opcode == Opcode.INVOKE_VIRTUAL_RANGE + && instruction.getReference()?.parameterTypes?.first() == "Ljava/lang/String;" + }?.getReference()?.returnType + ?: throw PatchException("Could not find onClickListenerClass") + } + + findMethodOrThrow(onClickListenerClass) { + name == "onClick" + }.apply { + val insertIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_STATIC && + getReference()?.name == "isEmpty" + } + val insertRegister = getInstruction(insertIndex).registerC + + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $EXTENSION_CLASS_DESCRIPTOR->inAppPlaylistDownloadButtonOnClick(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$insertRegister + """ + ) + } + + offlinePlaylistEndpointFingerprint.methodOrThrow().apply { + val playlistIdParameter = parameterTypes.indexOf("Ljava/lang/String;") + 1 + if (playlistIdParameter > 0) { + addInstructionsWithLabels( + 0, """ + invoke-static {p$playlistIdParameter}, $EXTENSION_CLASS_DESCRIPTOR->inAppPlaylistDownloadMenuOnClick(Ljava/lang/String;)Z + move-result v0 + if-eqz v0, :show_native_downloader + return-void + """, ExternalLabel("show_native_downloader", getInstruction(0)) + ) + } else { + val freeRegister = implementation!!.registerCount - parameters.size - 2 + + val playlistIdIndex = indexOfFirstInstructionOrThrow { + val reference = getReference() + opcode == Opcode.IGET_OBJECT && + reference?.definingClass == OFFLINE_PLAYLIST_ENDPOINT_OUTER_CLASS_DESCRIPTOR && + reference.type == "Ljava/lang/String;" + } + val playlistIdReference = + getInstruction(playlistIdIndex).reference + + val targetIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.CHECK_CAST && + (this as? ReferenceInstruction)?.reference?.toString() == OFFLINE_PLAYLIST_ENDPOINT_OUTER_CLASS_DESCRIPTOR + } + val targetRegister = getInstruction(targetIndex).registerA + + addInstructionsWithLabels( + targetIndex + 1, + """ + iget-object v$freeRegister, v$targetRegister, $playlistIdReference + invoke-static {v$freeRegister}, $EXTENSION_CLASS_DESCRIPTOR->inAppPlaylistDownloadMenuOnClick(Ljava/lang/String;)Z + move-result v$freeRegister + if-eqz v$freeRegister, :show_native_downloader + return-void + """, + ExternalLabel("show_native_downloader", getInstruction(targetIndex + 1)) + ) + } + } + + // endregion + + // region patch for show the playlist download button + + setPlaylistDownloadButtonVisibilityFingerprint + .matchOrThrow(accessibilityOfflineButtonSyncFingerprint).let { + it.method.apply { + val insertIndex = it.patternMatch!!.startIndex + 2 + val insertRegister = + getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, """ + invoke-static {}, $EXTENSION_CLASS_DESCRIPTOR->overridePlaylistDownloadButtonVisibility()Z + move-result v$insertRegister + """ + ) + } + } + + // endregion + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: GENERAL", + "SETTINGS: HOOK_BUTTONS", + "SETTINGS: HOOK_DOWNLOAD_ACTIONS" + ), + HOOK_DOWNLOAD_ACTIONS + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/downloads/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/downloads/Fingerprints.kt new file mode 100644 index 0000000000..3c39b5432f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/downloads/Fingerprints.kt @@ -0,0 +1,90 @@ +package app.revanced.patches.youtube.general.downloads + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import app.revanced.util.parametersEqual +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.util.MethodUtil + +private val ENDS_WITH_PARAMETER_LIST = listOf( + "Lcom/google/android/apps/youtube/app/offline/ui/OfflineArrowView;", + "I", + "Landroid/view/View${'$'}OnClickListener;" +) + +internal val accessibilityOfflineButtonSyncFingerprint = legacyFingerprint( + name = "accessibilityOfflineButtonSyncFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + returnType = "V", + customFingerprint = custom@{ method, _ -> + if (!MethodUtil.isConstructor(method)) { + return@custom false + } + val parameterTypes = method.parameterTypes + val parameterSize = parameterTypes.size + if (parameterSize < 6) { + return@custom false + } + + val endsWithMethodParameterList = parameterTypes.slice(parameterSize - 3.. + indexOfPlaylistDownloadActionInvokeInstruction(method) >= 0 + } +) + +internal fun indexOfPlaylistDownloadActionInvokeInstruction(method: Method) = + method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.parameterTypes == + listOf( + "Ljava/lang/String;", + "Lcom/google/android/apps/youtube/app/offline/ui/OfflineArrowView;", + "I", + "Landroid/view/View${'$'}OnClickListener;" + ) + } + +internal val offlinePlaylistEndpointFingerprint = legacyFingerprint( + name = "offlinePlaylistEndpointFingerprint", + returnType = "V", + strings = listOf("Object is not an offlineable playlist: ") +) + +internal val offlineVideoEndpointFingerprint = legacyFingerprint( + name = "offlineVideoEndpointFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + returnType = "V", + parameters = listOf( + "Ljava/util/Map;", + "L", + "Ljava/lang/String", // VideoId + "L" + ), + strings = listOf("Object is not an offlineable video: ") +) + +internal val setPlaylistDownloadButtonVisibilityFingerprint = legacyFingerprint( + name = "setPlaylistDownloadButtonVisibilityFingerprint", + returnType = "V", + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_NEZ, + Opcode.IGET, + Opcode.CONST_4 + ) +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/layoutswitch/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/layoutswitch/Fingerprints.kt new file mode 100644 index 0000000000..f5d8e5b4bc --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/layoutswitch/Fingerprints.kt @@ -0,0 +1,46 @@ +package app.revanced.patches.youtube.general.layoutswitch + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val formFactorEnumConstructorFingerprint = legacyFingerprint( + name = "formFactorEnumConstructorFingerprint", + returnType = "V", + strings = listOf( + "UNKNOWN_FORM_FACTOR", + "SMALL_FORM_FACTOR", + "LARGE_FORM_FACTOR" + ) +) + +internal val layoutSwitchFingerprint = legacyFingerprint( + name = "layoutSwitchFingerprint", + returnType = "I", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("L"), + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT, + Opcode.IF_NEZ, + Opcode.CONST_4, + Opcode.RETURN, + Opcode.CONST_16, + Opcode.IF_GE, + Opcode.CONST_4, + Opcode.RETURN, + Opcode.CONST_16, + Opcode.IF_GE, + Opcode.CONST_4, + Opcode.RETURN, + Opcode.CONST_16, + Opcode.IF_GE, + Opcode.CONST_4, + Opcode.RETURN, + Opcode.CONST_4, + Opcode.RETURN + ) +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/layoutswitch/LayoutSwitchPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/layoutswitch/LayoutSwitchPatch.kt new file mode 100644 index 0000000000..ec4891efa9 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/layoutswitch/LayoutSwitchPatch.kt @@ -0,0 +1,82 @@ +package app.revanced.patches.youtube.general.layoutswitch + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.createPlayerRequestBodyWithModelFingerprint +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_PATH +import app.revanced.patches.youtube.utils.patch.PatchList.LAYOUT_SWITCH +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.fingerprint.definingClassOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$GENERAL_PATH/LayoutSwitchPatch;" + +@Suppress("unused") +val layoutSwitchPatch = bytecodePatch( + LAYOUT_SWITCH.title, + LAYOUT_SWITCH.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + + val formFactorEnumClass = formFactorEnumConstructorFingerprint + .definingClassOrThrow() + + createPlayerRequestBodyWithModelFingerprint.methodOrThrow().apply { + val index = indexOfFirstInstructionOrThrow { + val reference = getReference() + opcode == Opcode.IGET && + reference?.definingClass == formFactorEnumClass && + reference.type == "I" + } + val register = getInstruction(index).registerA + + addInstructions( + index + 1, """ + invoke-static {v$register}, $EXTENSION_CLASS_DESCRIPTOR->getFormFactor(I)I + move-result v$register + """ + ) + } + + layoutSwitchFingerprint.methodOrThrow().apply { + val index = indexOfFirstInstructionReversedOrThrow(Opcode.IF_NEZ) + val register = getInstruction(index).registerA + + addInstructions( + index, """ + invoke-static {v$register}, $EXTENSION_CLASS_DESCRIPTOR->getWidthDp(I)I + move-result v$register + """ + ) + } + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: GENERAL", + "PREFERENCE_CATEGORY: GENERAL_EXPERIMENTAL_FLAGS", + "SETTINGS: LAYOUT_SWITCH" + ), + LAYOUT_SWITCH + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/loadingscreen/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/loadingscreen/Fingerprints.kt new file mode 100644 index 0000000000..79c5c029bd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/loadingscreen/Fingerprints.kt @@ -0,0 +1,13 @@ +package app.revanced.patches.youtube.general.loadingscreen + +import app.revanced.util.fingerprint.legacyFingerprint + +internal val gradientLoadingScreenPrimaryFingerprint = legacyFingerprint( + name = "gradientLoadingScreenPrimaryFingerprint", + literals = listOf(45412406L), +) + +internal val gradientLoadingScreenSecondaryFingerprint = legacyFingerprint( + name = "gradientLoadingScreenSecondaryFingerprint", + literals = listOf(45418917L), +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/loadingscreen/GradientLoadingScreenPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/loadingscreen/GradientLoadingScreenPatch.kt new file mode 100644 index 0000000000..1cd08b8b25 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/loadingscreen/GradientLoadingScreenPatch.kt @@ -0,0 +1,45 @@ +package app.revanced.patches.youtube.general.loadingscreen + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.patch.PatchList.ENABLE_GRADIENT_LOADING_SCREEN +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall + +@Suppress("unused") +val gradientLoadingScreenPatch = bytecodePatch( + ENABLE_GRADIENT_LOADING_SCREEN.title, + ENABLE_GRADIENT_LOADING_SCREEN.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + + mapOf( + gradientLoadingScreenPrimaryFingerprint to 45412406L, + gradientLoadingScreenSecondaryFingerprint to 45418917L + ).forEach { (fingerprint, literal) -> + fingerprint.injectLiteralInstructionBooleanCall( + literal, + "$GENERAL_CLASS_DESCRIPTOR->enableGradientLoadingScreen()Z" + ) + } + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: GENERAL", + "SETTINGS: ENABLE_GRADIENT_LOADING_SCREEN" + ), + ENABLE_GRADIENT_LOADING_SCREEN + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/miniplayer/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/miniplayer/Fingerprints.kt new file mode 100644 index 0000000000..6a5f0b04e0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/miniplayer/Fingerprints.kt @@ -0,0 +1,163 @@ +@file:Suppress("SpellCheckingInspection") + +package app.revanced.patches.youtube.general.miniplayer + +import app.revanced.patches.youtube.utils.playservice.is_19_25_or_greater +import app.revanced.patches.youtube.utils.resourceid.floatyBarTopMargin +import app.revanced.patches.youtube.utils.resourceid.modernMiniPlayerClose +import app.revanced.patches.youtube.utils.resourceid.modernMiniPlayerExpand +import app.revanced.patches.youtube.utils.resourceid.modernMiniPlayerForwardButton +import app.revanced.patches.youtube.utils.resourceid.modernMiniPlayerRewindButton +import app.revanced.patches.youtube.utils.resourceid.scrimOverlay +import app.revanced.patches.youtube.utils.resourceid.ytOutlinePictureInPictureWhite +import app.revanced.util.containsLiteralInstruction +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.util.MethodUtil + +internal val miniplayerDimensionsCalculatorParentFingerprint = legacyFingerprint( + name = "miniplayerDimensionsCalculatorParentFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + literals = listOf(floatyBarTopMargin), +) + +internal val miniplayerModernAddViewListenerFingerprint = legacyFingerprint( + name = "miniplayerModernAddViewListenerFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + returnType = "V", + parameters = listOf("Landroid/view/View;") +) + +internal val miniplayerModernCloseButtonFingerprint = legacyFingerprint( + name = "miniplayerModernCloseButtonFingerprint", + returnType = "Landroid/widget/ImageView;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(modernMiniPlayerClose), +) + +private var constructorMethodCount = 0 + +internal fun isMultiConstructorMethod() = constructorMethodCount > 1 + +internal val miniplayerModernConstructorFingerprint = legacyFingerprint( + name = "miniplayerModernConstructorFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + parameters = listOf("L"), + literals = listOf(45623000L), + customFingerprint = custom@{ method, classDef -> + classDef.methods.forEach { + if (MethodUtil.isConstructor(it)) constructorMethodCount += 1 + } + + if (!is_19_25_or_greater) + return@custom true + + // Double tap action (Used in YouTube 19.25.39+). + method.containsLiteralInstruction(45628823L) + && method.containsLiteralInstruction(45630429L) + } +) + +internal val miniplayerModernDragAndDropFingerprint = legacyFingerprint( + name = "miniplayerModernDragAndDropFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + parameters = listOf("L"), + literals = listOf(45628752L), +) + +internal val miniplayerModernEnabledFingerprint = legacyFingerprint( + name = "miniplayerModernEnabledFingerprint", + literals = listOf(45622882L), +) + +internal val miniplayerModernExpandButtonFingerprint = legacyFingerprint( + name = "miniplayerModernExpandButtonFingerprint", + returnType = "Landroid/widget/ImageView;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(modernMiniPlayerExpand), +) + +internal val miniplayerModernExpandCloseDrawablesFingerprint = legacyFingerprint( + name = "miniplayerModernExpandCloseDrawablesFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + literals = listOf(ytOutlinePictureInPictureWhite), +) + +internal val miniplayerModernForwardButtonFingerprint = legacyFingerprint( + name = "miniplayerModernForwardButtonFingerprint", + returnType = "Landroid/widget/ImageView;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(modernMiniPlayerForwardButton), +) + +internal val miniplayerModernOverlayViewFingerprint = legacyFingerprint( + name = "miniplayerModernOverlayViewFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(scrimOverlay), +) + +internal val miniplayerModernRewindButtonFingerprint = legacyFingerprint( + name = "miniplayerModernRewindButtonFingerprint", + returnType = "Landroid/widget/ImageView;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(modernMiniPlayerRewindButton), +) + +internal val miniplayerModernViewParentFingerprint = legacyFingerprint( + name = "miniplayerModernViewParentFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + returnType = "Ljava/lang/String;", + parameters = listOf(), + strings = listOf("player_overlay_modern_mini_player_controls") +) + +internal val miniplayerOverrideFingerprint = legacyFingerprint( + name = "miniplayerOverrideFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + strings = listOf("appName") +) + +internal val miniplayerOverrideNoContextFingerprint = legacyFingerprint( + name = "miniplayerOverrideNoContextFingerprint", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + returnType = "Z", + opcodes = listOf(Opcode.IGET_BOOLEAN), // anchor to insert the instruction +) + +internal val miniplayerResponseModelSizeCheckFingerprint = legacyFingerprint( + name = "miniplayerResponseModelSizeCheckFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + returnType = "L", + parameters = listOf("Ljava/lang/Object;", "Ljava/lang/Object;"), + opcodes = listOf( + Opcode.CHECK_CAST, + Opcode.CHECK_CAST, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT, + Opcode.IF_NEZ, + ) +) + +internal val youTubePlayerOverlaysLayoutFingerprint = legacyFingerprint( + name = "youTubePlayerOverlaysLayoutFingerprint", + customFingerprint = { _, classDef -> + classDef.type == YOUTUBE_PLAYER_OVERLAYS_LAYOUT_CLASS_NAME + } +) + +internal const val YOUTUBE_PLAYER_OVERLAYS_LAYOUT_CLASS_NAME = + "Lcom/google/android/apps/youtube/app/common/player/overlay/YouTubePlayerOverlaysLayout;" diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/miniplayer/MiniplayerPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/miniplayer/MiniplayerPatch.kt new file mode 100644 index 0000000000..6fa89f9ba2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/miniplayer/MiniplayerPatch.kt @@ -0,0 +1,376 @@ +package app.revanced.patches.youtube.general.miniplayer + +import app.revanced.patcher.Fingerprint +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_PATH +import app.revanced.patches.youtube.utils.patch.PatchList.MINIPLAYER +import app.revanced.patches.youtube.utils.playservice.is_19_15_or_greater +import app.revanced.patches.youtube.utils.playservice.is_19_23_or_greater +import app.revanced.patches.youtube.utils.playservice.is_19_25_or_greater +import app.revanced.patches.youtube.utils.playservice.versionCheckPatch +import app.revanced.patches.youtube.utils.resourceid.modernMiniPlayerClose +import app.revanced.patches.youtube.utils.resourceid.modernMiniPlayerExpand +import app.revanced.patches.youtube.utils.resourceid.modernMiniPlayerForwardButton +import app.revanced.patches.youtube.utils.resourceid.modernMiniPlayerRewindButton +import app.revanced.patches.youtube.utils.resourceid.scrimOverlay +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.resourceid.ytOutlinePictureInPictureWhite +import app.revanced.patches.youtube.utils.resourceid.ytOutlineXWhite +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.findInstructionIndicesReversedOrThrow +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.mutableClassOrThrow +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.builder.MutableMethodImplementation +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.TypeReference +import com.android.tools.smali.dexlib2.immutable.ImmutableMethod +import com.android.tools.smali.dexlib2.immutable.ImmutableMethodParameter +import com.android.tools.smali.dexlib2.util.MethodUtil + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$GENERAL_PATH/MiniplayerPatch;" + +// YT uses "Miniplayer" without a space between 'mini' and 'player: https://support.google.com/youtube/answer/9162927. +@Suppress("unused", "SpellCheckingInspection") +val miniplayerPatch = bytecodePatch( + MINIPLAYER.title, + MINIPLAYER.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + sharedResourceIdPatch, + settingsPatch, + versionCheckPatch, + ) + + execute { + + var settingArray = arrayOf( + "PREFERENCE_SCREEN: GENERAL" + ) + + fun Method.findReturnIndicesReversed() = + findInstructionIndicesReversedOrThrow(Opcode.RETURN) + + fun MutableMethod.insertBooleanOverride(index: Int, methodName: String) { + val register = getInstruction(index).registerA + addInstructions( + index, + """ + invoke-static {v$register}, $EXTENSION_CLASS_DESCRIPTOR->$methodName(Z)Z + move-result v$register + """ + ) + } + + /** + * Adds an override to force legacy tablet miniplayer to be used or not used. + */ + fun MutableMethod.insertLegacyTabletMiniplayerOverride(index: Int) { + insertBooleanOverride(index, "getLegacyTabletMiniplayerOverride") + } + + /** + * Adds an override to force modern miniplayer to be used or not used. + */ + fun MutableMethod.insertModernMiniplayerOverride(index: Int) { + insertBooleanOverride(index, "getModernMiniplayerOverride") + } + + /** + * Adds an override to specify which modern miniplayer is used. + */ + fun MutableMethod.insertModernMiniplayerTypeOverride(iPutIndex: Int) { + val targetInstruction = getInstruction(iPutIndex) + val targetReference = (targetInstruction as ReferenceInstruction).reference + + addInstructions( + iPutIndex + 1, """ + invoke-static { v${targetInstruction.registerA} }, $EXTENSION_CLASS_DESCRIPTOR->getModernMiniplayerOverrideType(I)I + move-result v${targetInstruction.registerA} + # Original instruction + iput v${targetInstruction.registerA}, v${targetInstruction.registerB}, $targetReference + """ + ) + removeInstruction(iPutIndex) + } + + fun Pair.hookInflatedView( + literalValue: Long, + hookedClassType: String, + extensionMethodName: String, + ) { + methodOrThrow(miniplayerModernViewParentFingerprint).apply { + val imageViewIndex = indexOfFirstInstructionOrThrow( + indexOfFirstLiteralInstructionOrThrow(literalValue) + ) { + opcode == Opcode.CHECK_CAST && + getReference()?.type == hookedClassType + } + + val register = getInstruction(imageViewIndex).registerA + addInstruction( + imageViewIndex + 1, + "invoke-static { v$register }, $extensionMethodName" + ) + } + } + + // Modern mini player is only present and functional in 19.15+. + // Resource is not present in older versions. Using it to determine, if patching an old version. + val isPatchingOldVersion = !is_19_15_or_greater + + // From 19.15 to 19.16 using mixed up drawables for tablet modern. + val shouldFixMixedUpDrawables = ytOutlineXWhite > 0 && ytOutlinePictureInPictureWhite > 0 + + // region Enable tablet miniplayer. + + miniplayerOverrideNoContextFingerprint.methodOrThrow( + miniplayerDimensionsCalculatorParentFingerprint + ).apply { + findReturnIndicesReversed().forEach { index -> + insertLegacyTabletMiniplayerOverride( + index + ) + } + } + + // endregion + + // region Legacy tablet Miniplayer hooks. + + miniplayerOverrideFingerprint.matchOrThrow().let { + val appNameStringIndex = it.stringMatches!!.first().index + 2 + + it.method.apply { + val walkerMethod = getWalkerMethod(appNameStringIndex) + + walkerMethod.apply { + findReturnIndicesReversed().forEach { index -> + insertLegacyTabletMiniplayerOverride( + index + ) + } + } + } + } + + miniplayerResponseModelSizeCheckFingerprint.matchOrThrow().let { + it.method.insertLegacyTabletMiniplayerOverride(it.patternMatch!!.endIndex) + } + + if (isPatchingOldVersion) { + settingArray += "SETTINGS: MINIPLAYER_TYPE_LEGACY" + addPreference(settingArray, MINIPLAYER) + + // Return here, as patch below is only intended for new versions of the app. + return@execute + } + + // endregion + + // region Enable modern miniplayer. + + miniplayerModernConstructorFingerprint.mutableClassOrThrow().methods.forEach { + it.apply { + if (MethodUtil.isConstructor(it)) { + val iPutIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.IPUT && + getReference()?.type == "I" + } + + insertModernMiniplayerTypeOverride(iPutIndex) + } else if (isMultiConstructorMethod()) { + findReturnIndicesReversed().forEach { index -> + insertModernMiniplayerOverride( + index + ) + } + } + } + } + + if (is_19_25_or_greater) { + miniplayerModernEnabledFingerprint.injectLiteralInstructionBooleanCall( + 45622882L, + "$EXTENSION_CLASS_DESCRIPTOR->getModernMiniplayerOverride(Z)Z" + ) + } + + // endregion + + // region Enable double tap action. + + if (is_19_25_or_greater) { + miniplayerModernConstructorFingerprint.injectLiteralInstructionBooleanCall( + 45628823L, + "$EXTENSION_CLASS_DESCRIPTOR->enableMiniplayerDoubleTapAction()Z" + ) + miniplayerModernConstructorFingerprint.injectLiteralInstructionBooleanCall( + 45630429L, + "$EXTENSION_CLASS_DESCRIPTOR->getModernMiniplayerOverride(Z)Z" + ) + settingArray += "SETTINGS: MINIPLAYER_DOUBLE_TAP_ACTION" + } + + // endregion + + // region Fix 19.16 using mixed up drawables for tablet modern. + // YT fixed this mistake in 19.17. + // Fix this, by swapping the drawable resource values with each other. + if (shouldFixMixedUpDrawables) { + miniplayerModernExpandCloseDrawablesFingerprint.methodOrThrow( + miniplayerModernViewParentFingerprint + ).apply { + listOf( + ytOutlinePictureInPictureWhite to ytOutlineXWhite, + ytOutlineXWhite to ytOutlinePictureInPictureWhite, + ).forEach { (originalResource, replacementResource) -> + val imageResourceIndex = + indexOfFirstLiteralInstructionOrThrow(originalResource) + val register = + getInstruction(imageResourceIndex).registerA + + replaceInstruction(imageResourceIndex, "const v$register, $replacementResource") + } + } + } + + // endregion + + // region Add hooks to hide tablet modern miniplayer buttons. + + listOf( + Triple( + miniplayerModernExpandButtonFingerprint, + modernMiniPlayerExpand, + "hideMiniplayerExpandClose" + ), + Triple( + miniplayerModernCloseButtonFingerprint, + modernMiniPlayerClose, + "hideMiniplayerExpandClose" + ), + Triple( + miniplayerModernRewindButtonFingerprint, + modernMiniPlayerRewindButton, + "hideMiniplayerRewindForward" + ), + Triple( + miniplayerModernForwardButtonFingerprint, + modernMiniPlayerForwardButton, + "hideMiniplayerRewindForward" + ), + Triple( + miniplayerModernOverlayViewFingerprint, + scrimOverlay, + "adjustMiniplayerOpacity" + ) + ).forEach { (fingerprint, literalValue, methodName) -> + fingerprint.hookInflatedView( + literalValue, + "Landroid/widget/ImageView;", + "$EXTENSION_CLASS_DESCRIPTOR->$methodName(Landroid/widget/ImageView;)V" + ) + } + + miniplayerModernAddViewListenerFingerprint.methodOrThrow( + miniplayerModernViewParentFingerprint + ).apply { + addInstructionsWithLabels( + 0, + """ + invoke-static { p1 }, $EXTENSION_CLASS_DESCRIPTOR->hideMiniplayerSubTexts(Landroid/view/View;)Z + move-result v0 + if-nez v0, :hidden + """, + ExternalLabel("hidden", getInstruction(implementation!!.instructions.lastIndex)) + ) + } + + + // Modern 2 has a broken overlay subtitle view that is always present. + // Modern 2 uses the same overlay controls as the regular video player, + // and the overlay views are added at runtime. + // Add a hook to the overlay class, and pass the added views to extension. + youTubePlayerOverlaysLayoutFingerprint.matchOrThrow().let { + it.method.apply { + it.classDef.methods.add( + ImmutableMethod( + YOUTUBE_PLAYER_OVERLAYS_LAYOUT_CLASS_NAME, + "addView", + listOf( + ImmutableMethodParameter("Landroid/view/View;", annotations, null), + ImmutableMethodParameter("I", annotations, null), + ImmutableMethodParameter( + "Landroid/view/ViewGroup\$LayoutParams;", + annotations, + null + ), + ), + "V", + AccessFlags.PUBLIC.value, + annotations, + null, + MutableMethodImplementation(4), + ).toMutable().apply { + addInstructions( + """ + invoke-super { p0, p1, p2, p3 }, Landroid/view/ViewGroup;->addView(Landroid/view/View;ILandroid/view/ViewGroup${'$'}LayoutParams;)V + invoke-static { p1 }, $EXTENSION_CLASS_DESCRIPTOR->playerOverlayGroupCreated(Landroid/view/View;)V + return-void + """, + ) + } + ) + } + } + + // endregion + + // region Enable drag and drop. + + if (is_19_23_or_greater) { + miniplayerModernDragAndDropFingerprint.injectLiteralInstructionBooleanCall( + 45628752L, + "$EXTENSION_CLASS_DESCRIPTOR->enableMiniplayerDragAndDrop()Z" + ) + settingArray += "SETTINGS: MINIPLAYER_DRAG_AND_DROP" + } + + // endregion + + settingArray += "SETTINGS: MINIPLAYER_TYPE_MODERN" + + // region add settings + + addPreference(settingArray, MINIPLAYER) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/music/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/music/Fingerprints.kt new file mode 100644 index 0000000000..08009eefff --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/music/Fingerprints.kt @@ -0,0 +1,28 @@ +package app.revanced.patches.youtube.general.music + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +internal val appDeepLinkFingerprint = legacyFingerprint( + name = "appDeepLinkFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "Ljava/util/Map;"), + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.CONST_STRING, + ), + strings = listOf("android.intent.action.VIEW"), + customFingerprint = { method, _ -> + method.indexOfFirstInstruction { + getReference()?.name == "appDeepLinkEndpoint" + } >= 0 + } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/music/YouTubeMusicActionsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/music/YouTubeMusicActionsPatch.kt new file mode 100644 index 0000000000..25666dd63d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/music/YouTubeMusicActionsPatch.kt @@ -0,0 +1,110 @@ +package app.revanced.patches.youtube.general.music + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_PATH +import app.revanced.patches.youtube.utils.extension.Constants.PATCH_STATUS_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.patch.PatchList.GMSCORE_SUPPORT +import app.revanced.patches.youtube.utils.patch.PatchList.HOOK_YOUTUBE_MUSIC_ACTIONS +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.ResourceUtils.getContext +import app.revanced.patches.youtube.utils.settings.ResourceUtils.youtubeMusicPackageName +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.addEntryValues +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$GENERAL_PATH/YouTubeMusicActionsPatch;" + +@Suppress("unused") +val youtubeMusicActionsPatch = bytecodePatch( + HOOK_YOUTUBE_MUSIC_ACTIONS.title, + HOOK_YOUTUBE_MUSIC_ACTIONS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + + appDeepLinkFingerprint.matchOrThrow().let { + it.method.apply { + val packageNameIndex = it.patternMatch!!.startIndex + val packageNameField = + getInstruction(packageNameIndex).reference.toString() + + implementation!!.instructions + .withIndex() + .filter { (_, instruction) -> + instruction.opcode == Opcode.IGET_OBJECT && + instruction.getReference() + ?.toString() == packageNameField + } + .map { (index, _) -> index } + .reversed() + .forEach { index -> + val register = getInstruction(index).registerA + + addInstructions( + index + 1, """ + invoke-static {v$register}, $EXTENSION_CLASS_DESCRIPTOR->overridePackageName(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$register + """ + ) + } + } + } + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: GENERAL", + "SETTINGS: HOOK_BUTTONS", + "SETTINGS: HOOK_YOUTUBE_MUSIC_ACTIONS" + ), + HOOK_YOUTUBE_MUSIC_ACTIONS + ) + + // endregion + + } + + finalize { + if (GMSCORE_SUPPORT.included == true) { + getContext().apply { + addEntryValues( + "revanced_third_party_youtube_music_label", + "RVX Music" + ) + addEntryValues( + "revanced_third_party_youtube_music_package_name", + youtubeMusicPackageName + ) + } + findMethodOrThrow(PATCH_STATUS_CLASS_DESCRIPTOR) { + name == "RVXMusicPackageName" + }.apply { + val replaceIndex = indexOfFirstInstructionOrThrow(Opcode.CONST_STRING) + val replaceRegister = + getInstruction(replaceIndex).registerA + + replaceInstruction( + replaceIndex, + "const-string v$replaceRegister, \"$youtubeMusicPackageName\"" + ) + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/navigation/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/navigation/Fingerprints.kt new file mode 100644 index 0000000000..c1fbb878fe --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/navigation/Fingerprints.kt @@ -0,0 +1,65 @@ +package app.revanced.patches.youtube.general.navigation + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val autoMotiveFingerprint = legacyFingerprint( + name = "autoMotiveFingerprint", + opcodes = listOf( + Opcode.GOTO, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT, + Opcode.IF_EQZ + ), + strings = listOf("Android Automotive") +) + +internal val pivotBarChangedFingerprint = legacyFingerprint( + name = "pivotBarChangedFingerprint", + returnType = "V", + opcodes = listOf( + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT + ), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/PivotBar;") + && method.name == "onConfigurationChanged" + } +) + +internal val pivotBarSetTextFingerprint = legacyFingerprint( + name = "pivotBarSetTextFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + parameters = listOf( + "Lcom/google/android/libraries/youtube/rendering/ui/pivotbar/PivotBar;", + "Landroid/widget/TextView;", + "Ljava/lang/CharSequence;" + ), + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN_VOID + ), + customFingerprint = { method, _ -> method.name == "" } +) + +internal val pivotBarStyleFingerprint = legacyFingerprint( + name = "pivotBarStyleFingerprint", + returnType = "V", + parameters = listOf("L"), + opcodes = listOf( + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT, + Opcode.XOR_INT_2ADDR + ), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/PivotBar;") + } +) + +internal val translucentNavigationBarFingerprint = legacyFingerprint( + name = "translucentNavigationBarFingerprint", + literals = listOf(45630927L), +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/navigation/NavigationBarComponentsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/navigation/NavigationBarComponentsPatch.kt new file mode 100644 index 0000000000..8a41f9bd8c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/navigation/NavigationBarComponentsPatch.kt @@ -0,0 +1,135 @@ +package app.revanced.patches.youtube.general.navigation + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.navigation.addBottomBarContainerHook +import app.revanced.patches.youtube.utils.navigation.hookNavigationButtonCreated +import app.revanced.patches.youtube.utils.navigation.navigationBarHookPatch +import app.revanced.patches.youtube.utils.patch.PatchList.NAVIGATION_BAR_COMPONENTS +import app.revanced.patches.youtube.utils.playservice.is_19_23_or_greater +import app.revanced.patches.youtube.utils.playservice.versionCheckPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstStringInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +@Suppress("unused") +val navigationBarComponentsPatch = bytecodePatch( + NAVIGATION_BAR_COMPONENTS.title, + NAVIGATION_BAR_COMPONENTS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + settingsPatch, + navigationBarHookPatch, + versionCheckPatch, + ) + + execute { + + var settingArray = arrayOf( + "PREFERENCE_SCREEN: GENERAL", + "SETTINGS: HIDE_NAVIGATION_COMPONENTS" + ) + + // region patch for enable translucent navigation bar + + if (is_19_23_or_greater) { + translucentNavigationBarFingerprint.injectLiteralInstructionBooleanCall( + 45630927L, + "$GENERAL_CLASS_DESCRIPTOR->enableTranslucentNavigationBar()Z" + ) + + settingArray += "SETTINGS: TRANSLUCENT_NAVIGATION_BAR" + } + + // endregion + + // region patch for enable narrow navigation buttons + + arrayOf( + pivotBarChangedFingerprint, + pivotBarStyleFingerprint + ).forEach { fingerprint -> + fingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = it.patternMatch!!.startIndex + 1 + val register = getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, """ + invoke-static {v$register}, $GENERAL_CLASS_DESCRIPTOR->enableNarrowNavigationButton(Z)Z + move-result v$register + """ + ) + } + } + } + + // endregion + + // region patch for hide navigation bar + + addBottomBarContainerHook("$GENERAL_CLASS_DESCRIPTOR->hideNavigationBar(Landroid/view/View;)V") + + // endregion + + // region patch for hide navigation buttons + + autoMotiveFingerprint.methodOrThrow().apply { + val insertIndex = indexOfFirstStringInstructionOrThrow("Android Automotive") - 1 + val insertRegister = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $GENERAL_CLASS_DESCRIPTOR->switchCreateWithNotificationButton(Z)Z + move-result v$insertRegister + """ + ) + } + + // endregion + + // region patch for hide navigation label + + pivotBarSetTextFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "setText" + } + val targetRegister = getInstruction(targetIndex).registerC + + addInstruction( + targetIndex, + "invoke-static {v$targetRegister}, $GENERAL_CLASS_DESCRIPTOR->hideNavigationLabel(Landroid/widget/TextView;)V" + ) + } + } + + // endregion + + // Hook navigation button created, in order to hide them. + hookNavigationButtonCreated(GENERAL_CLASS_DESCRIPTOR) + + // region add settings + + addPreference(settingArray, NAVIGATION_BAR_COMPONENTS) + + // endregion + } +} + diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/splashanimation/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/splashanimation/Fingerprints.kt new file mode 100644 index 0000000000..6f602ef1c4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/splashanimation/Fingerprints.kt @@ -0,0 +1,32 @@ +package app.revanced.patches.youtube.general.splashanimation + +import app.revanced.patches.youtube.utils.resourceid.darkSplashAnimation +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val splashAnimationFingerprint = legacyFingerprint( + name = "splashAnimationFingerprint", + returnType = "V", + parameters = listOf("Landroid/os/Bundle;"), + literals = listOf(darkSplashAnimation), + customFingerprint = { method, _ -> + method.name == "onCreate" + } +) + +internal val startUpResourceIdFingerprint = legacyFingerprint( + name = "startUpResourceIdFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("I"), + literals = listOf(3L, 4L) +) + +internal val startUpResourceIdParentFingerprint = legacyFingerprint( + name = "startUpResourceIdParentFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL or AccessFlags.DECLARED_SYNCHRONIZED, + parameters = listOf("I", "I"), + strings = listOf("early type", "final type") +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/splashanimation/SplashAnimationPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/splashanimation/SplashAnimationPatch.kt new file mode 100644 index 0000000000..838c4e15ef --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/splashanimation/SplashAnimationPatch.kt @@ -0,0 +1,72 @@ +package app.revanced.patches.youtube.general.splashanimation + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.patch.PatchList.DISABLE_SPLASH_ANIMATION +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction + +@Suppress("unused") +val splashAnimationPatch = bytecodePatch( + DISABLE_SPLASH_ANIMATION.title, + DISABLE_SPLASH_ANIMATION.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + sharedResourceIdPatch, + settingsPatch, + ) + + execute { + + val startUpResourceIdMethod = + startUpResourceIdFingerprint.methodOrThrow(startUpResourceIdParentFingerprint) + val startUpResourceIdMethodCall = + startUpResourceIdMethod.definingClass + "->" + startUpResourceIdMethod.name + "(I)Z" + + splashAnimationFingerprint.matchOrThrow().let { + it.method.apply { + for (index in implementation!!.instructions.size - 1 downTo 0) { + val instruction = getInstruction(index) + if (instruction.opcode != Opcode.INVOKE_STATIC) + continue + + if ((instruction as ReferenceInstruction).reference.toString() != startUpResourceIdMethodCall) + continue + + val register = getInstruction(index + 1).registerA + + addInstructions( + index + 2, """ + invoke-static {v$register}, $GENERAL_CLASS_DESCRIPTOR->disableSplashAnimation(Z)Z + move-result v$register + """ + ) + } + } + } + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: GENERAL", + "SETTINGS: DISABLE_SPLASH_ANIMATION" + ), + DISABLE_SPLASH_ANIMATION + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/spoofappversion/SpoofAppVersionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/spoofappversion/SpoofAppVersionPatch.kt new file mode 100644 index 0000000000..269bb1f39f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/spoofappversion/SpoofAppVersionPatch.kt @@ -0,0 +1,55 @@ +package app.revanced.patches.youtube.general.spoofappversion + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.shared.spoof.appversion.baseSpoofAppVersionPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.patch.PatchList.SPOOF_APP_VERSION +import app.revanced.patches.youtube.utils.playservice.is_18_34_or_greater +import app.revanced.patches.youtube.utils.playservice.is_18_39_or_greater +import app.revanced.patches.youtube.utils.playservice.is_18_49_or_greater +import app.revanced.patches.youtube.utils.playservice.versionCheckPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.appendAppVersion + +@Suppress("unused") +val spoofAppVersionPatch = resourcePatch( + SPOOF_APP_VERSION.title, + SPOOF_APP_VERSION.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + baseSpoofAppVersionPatch("$GENERAL_CLASS_DESCRIPTOR->getVersionOverride(Ljava/lang/String;)Ljava/lang/String;"), + settingsPatch, + versionCheckPatch, + ) + + execute { + + if (is_18_34_or_greater) { + appendAppVersion("18.33.40") + if (is_18_39_or_greater) { + appendAppVersion("18.38.45") + if (is_18_49_or_greater) { + appendAppVersion("18.48.39") + } + } + } + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: GENERAL", + "PREFERENCE_CATEGORY: GENERAL_EXPERIMENTAL_FLAGS", + "SETTINGS: SPOOF_APP_VERSION" + ), + SPOOF_APP_VERSION + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/startpage/ChangeStartPagePatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/startpage/ChangeStartPagePatch.kt new file mode 100644 index 0000000000..d83f96fa22 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/startpage/ChangeStartPagePatch.kt @@ -0,0 +1,69 @@ +package app.revanced.patches.youtube.general.startpage + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_PATH +import app.revanced.patches.youtube.utils.patch.PatchList.CHANGE_START_PAGE +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.StringReference + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$GENERAL_PATH/ChangeStartPagePatch;" + +@Suppress("unused") +val changeStartPagePatch = bytecodePatch( + CHANGE_START_PAGE.title, + CHANGE_START_PAGE.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + + // Hook browseId. + browseIdFingerprint.methodOrThrow().apply { + val browseIdIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.CONST_STRING && + getReference()?.string == "FEwhat_to_watch" + } + val browseIdRegister = getInstruction(browseIdIndex).registerA + + addInstructions( + browseIdIndex + 1, """ + invoke-static { v$browseIdRegister }, $EXTENSION_CLASS_DESCRIPTOR->overrideBrowseId(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$browseIdRegister + """ + ) + } + + // There is no browseId assigned to Shorts and Search. + // Just hook the Intent action. + intentActionFingerprint.methodOrThrow().addInstruction( + 0, + "invoke-static { p1 }, $EXTENSION_CLASS_DESCRIPTOR->overrideIntentAction(Landroid/content/Intent;)V" + ) + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: GENERAL", + "SETTINGS: CHANGE_START_PAGE" + ), + CHANGE_START_PAGE + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/startpage/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/startpage/Fingerprints.kt new file mode 100644 index 0000000000..e67f99af3f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/startpage/Fingerprints.kt @@ -0,0 +1,22 @@ +package app.revanced.patches.youtube.general.startpage + +import app.revanced.util.fingerprint.legacyFingerprint +import com.android.tools.smali.dexlib2.Opcode + +internal val browseIdFingerprint = legacyFingerprint( + name = "browseIdFingerprint", + returnType = "Lcom/google/android/apps/youtube/app/common/ui/navigation/PaneDescriptor;", + parameters = emptyList(), + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.RETURN_OBJECT, + ), + strings = listOf("FEwhat_to_watch"), +) + +internal val intentActionFingerprint = legacyFingerprint( + name = "intentActionFingerprint", + parameters = listOf("Landroid/content/Intent;"), + strings = listOf("has_handled_intent"), +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/toolbar/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/toolbar/Fingerprints.kt new file mode 100644 index 0000000000..00d8842c28 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/toolbar/Fingerprints.kt @@ -0,0 +1,216 @@ +package app.revanced.patches.youtube.general.toolbar + +import app.revanced.patches.youtube.utils.resourceid.actionBarRingo +import app.revanced.patches.youtube.utils.resourceid.actionBarRingoBackground +import app.revanced.patches.youtube.utils.resourceid.drawerContentView +import app.revanced.patches.youtube.utils.resourceid.voiceSearch +import app.revanced.patches.youtube.utils.resourceid.youTubeLogo +import app.revanced.patches.youtube.utils.resourceid.ytOutlineVideoCamera +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.indexOfFirstInstructionReversed +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.util.MethodUtil + +internal val actionBarRingoBackgroundFingerprint = legacyFingerprint( + name = "actionBarRingoBackgroundFingerprint", + returnType = "Landroid/view/View;", + literals = listOf(actionBarRingoBackground), + customFingerprint = { method, _ -> + indexOfStaticInstruction(method) >= 0 + } +) + +internal fun indexOfStaticInstruction(method: Method) = + method.indexOfFirstInstruction { + val reference = getReference() + opcode == Opcode.INVOKE_STATIC && + reference?.parameterTypes?.size == 1 && + reference.parameterTypes.firstOrNull() == "Landroid/content/Context;" && + reference.returnType == "Z" + } + +internal val actionBarRingoConstructorFingerprint = legacyFingerprint( + name = "actionBarRingoConstructorFingerprint", + returnType = "V", + strings = listOf("default"), + customFingerprint = custom@{ method, _ -> + if (!MethodUtil.isConstructor(method)) { + return@custom false + } + + val parameterTypes = method.parameterTypes + parameterTypes.size >= 5 && parameterTypes[0] == "Landroid/content/Context;" + } +) + +internal val actionBarRingoTextFingerprint = legacyFingerprint( + name = "actionBarRingoTextFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + customFingerprint = { method, _ -> + indexOfStartDelayInstruction(method) >= 0 && + indexOfStaticInstructions(method) >= 0 + } +) + +internal fun indexOfStartDelayInstruction(method: Method) = + method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "setStartDelay" + } + +internal fun indexOfStaticInstructions(method: Method) = + method.indexOfFirstInstructionReversed(indexOfStartDelayInstruction(method)) { + val reference = getReference() + opcode == Opcode.INVOKE_STATIC && + reference?.parameterTypes?.size == 1 && + reference.parameterTypes.firstOrNull() == "Landroid/content/Context;" && + reference.returnType == "Z" + } + +internal val attributeResolverFingerprint = legacyFingerprint( + name = "attributeResolverFingerprint", + returnType = "Landroid/graphics/drawable/Drawable;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("Landroid/content/Context;", "I"), + strings = listOf("Type of attribute is not a reference to a drawable (attr = %d, value = %s)") +) + +internal val createButtonDrawableFingerprint = legacyFingerprint( + name = "createButtonDrawableFingerprint", + literals = listOf(ytOutlineVideoCamera), +) + +internal val createSearchSuggestionsFingerprint = legacyFingerprint( + name = "createSearchSuggestionsFingerprint", + returnType = "Landroid/view/View;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("I", "Landroid/view/View;", "Landroid/view/ViewGroup;"), + strings = listOf("ss_rds") +) + +internal val drawerContentViewConstructorFingerprint = legacyFingerprint( + name = "drawerContentViewConstructorFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + literals = listOf(drawerContentView), +) + +internal val drawerContentViewFingerprint = legacyFingerprint( + name = "drawerContentViewFingerprint", + returnType = "V", + parameters = listOf("L"), + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.NEW_INSTANCE, + Opcode.INVOKE_DIRECT, + ), + customFingerprint = { method, _ -> + indexOfAddViewInstruction(method) >= 0 + } +) + +internal fun indexOfAddViewInstruction(method: Method) = + method.indexOfFirstInstructionReversed { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "addView" + } + +/** + * This fingerprint is compatible with YouTube v19.07.40+ + */ +internal val imageSearchButtonConfigFingerprint = legacyFingerprint( + name = "imageSearchButtonConfigFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + literals = listOf(45617544L), +) + +internal val searchBarFingerprint = legacyFingerprint( + name = "searchBarFingerprint", + returnType = "V", + parameters = listOf("Ljava/lang/String;"), + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.IF_EQZ, + Opcode.IGET_BOOLEAN, + Opcode.IF_EQZ + ), + customFingerprint = { method, _ -> + method.indexOfFirstInstructionReversed { + getReference()?.name == "isEmpty" + } >= 0 + } +) + +internal val searchBarParentFingerprint = legacyFingerprint( + name = "searchBarParentFingerprint", + returnType = "Landroid/view/View;", + strings = listOf("voz-target-id"), + literals = listOf(voiceSearch), +) + +internal val searchResultFingerprint = legacyFingerprint( + name = "searchResultFingerprint", + returnType = "Landroid/view/View;", + strings = listOf("search_filter_chip_applied", "search_original_chip_query"), + literals = listOf(voiceSearch), +) + +internal val setActionBarRingoFingerprint = legacyFingerprint( + name = "setActionBarRingoFingerprint", + returnType = "L", + parameters = listOf("L", "L"), + opcodes = listOf( + Opcode.IF_NEZ, + Opcode.SGET_OBJECT, + Opcode.IGET_OBJECT, + Opcode.INVOKE_STATIC + ), + literals = listOf(actionBarRingo), +) + +internal val setWordMarkHeaderFingerprint = legacyFingerprint( + name = "setWordMarkHeaderFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + returnType = "V", + parameters = listOf("Landroid/widget/ImageView;"), + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT, + Opcode.IF_NEZ, + Opcode.IGET_BOOLEAN, + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, + Opcode.CONST, + Opcode.INVOKE_STATIC, + ) +) + +@Suppress("SpellCheckingInspection") +internal val yoodlesImageViewFingerprint = legacyFingerprint( + name = "yoodlesImageViewFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "L"), + returnType = "Landroid/view/View;", + literals = listOf(youTubeLogo) +) + +internal val youActionBarFingerprint = legacyFingerprint( + name = "youActionBarFingerprint", + returnType = "V", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + parameters = listOf("L"), + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_EQZ, + ) +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/toolbar/ToolBarComponentsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/toolbar/ToolBarComponentsPatch.kt new file mode 100644 index 0000000000..1a72c93b1e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/toolbar/ToolBarComponentsPatch.kt @@ -0,0 +1,413 @@ +package app.revanced.patches.youtube.general.toolbar + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.youtube.utils.castbutton.castButtonPatch +import app.revanced.patches.youtube.utils.castbutton.hookToolBarCastButton +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.extension.Constants.PATCH_STATUS_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.patch.PatchList.TOOLBAR_COMPONENTS +import app.revanced.patches.youtube.utils.playservice.is_19_28_or_greater +import app.revanced.patches.youtube.utils.playservice.versionCheckPatch +import app.revanced.patches.youtube.utils.resourceid.actionBarRingoBackground +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.resourceid.voiceSearch +import app.revanced.patches.youtube.utils.resourceid.ytOutlineVideoCamera +import app.revanced.patches.youtube.utils.resourceid.ytPremiumWordMarkHeader +import app.revanced.patches.youtube.utils.resourceid.ytWordMarkHeader +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.ResourceUtils.getContext +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.patches.youtube.utils.toolbar.hookToolBar +import app.revanced.patches.youtube.utils.toolbar.toolBarHookPatch +import app.revanced.util.REGISTER_TEMPLATE_REPLACEMENT +import app.revanced.util.doRecursively +import app.revanced.util.findInstructionIndicesReversedOrThrow +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.mutableClassOrThrow +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import app.revanced.util.replaceLiteralInstructionCall +import app.revanced.util.updatePatchStatus +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.util.MethodUtil +import org.w3c.dom.Element + +@Suppress("unused") +val toolBarComponentsPatch = bytecodePatch( + TOOLBAR_COMPONENTS.title, + TOOLBAR_COMPONENTS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + castButtonPatch, + sharedResourceIdPatch, + settingsPatch, + toolBarHookPatch, + versionCheckPatch, + ) + + execute { + fun MutableMethod.injectSearchBarHook( + insertIndex: Int, + insertRegister: Int, + descriptor: String + ) = + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $GENERAL_CLASS_DESCRIPTOR->$descriptor(Z)Z + move-result v$insertRegister + """ + ) + + fun MutableMethod.injectSearchBarHook( + insertIndex: Int, + descriptor: String + ) = + injectSearchBarHook( + insertIndex, + getInstruction(insertIndex).registerA, + descriptor + ) + + var settingArray = arrayOf( + "PREFERENCE_SCREEN: GENERAL", + "SETTINGS: TOOLBAR_COMPONENTS" + ) + + // region patch for change YouTube header + + // Invoke YouTube's header attribute into extension. + val smaliInstruction = """ + invoke-static {}, $GENERAL_CLASS_DESCRIPTOR->getHeaderAttributeId()I + move-result v$REGISTER_TEMPLATE_REPLACEMENT + """ + + arrayOf( + ytPremiumWordMarkHeader, + ytWordMarkHeader + ).forEach { literal -> + replaceLiteralInstructionCall(literal, smaliInstruction) + } + + // YouTube's headers have the form of AttributeSet, which is decoded from YouTube's built-in classes. + val attributeResolverMethod = attributeResolverFingerprint.methodOrThrow() + val attributeResolverMethodCall = + attributeResolverMethod.definingClass + "->" + attributeResolverMethod.name + "(Landroid/content/Context;I)Landroid/graphics/drawable/Drawable;" + + findMethodOrThrow(GENERAL_CLASS_DESCRIPTOR) { + name == "getHeaderDrawable" + }.addInstructions( + 0, """ + invoke-static {p0, p1}, $attributeResolverMethodCall + move-result-object p0 + return-object p0 + """ + ) + + // The sidebar's header is lithoView. Add a listener to change it. + drawerContentViewFingerprint.methodOrThrow(drawerContentViewConstructorFingerprint).apply { + val insertIndex = indexOfAddViewInstruction(this) + val insertRegister = getInstruction(insertIndex).registerD + + addInstruction( + insertIndex, + "invoke-static {v$insertRegister}, $GENERAL_CLASS_DESCRIPTOR->setDrawerNavigationHeader(Landroid/view/View;)V" + ) + } + + // Override the header in the search bar. + setActionBarRingoFingerprint.mutableClassOrThrow().methods.first { method -> + MethodUtil.isConstructor(method) + }.apply { + val insertIndex = indexOfFirstInstructionOrThrow(Opcode.IPUT_BOOLEAN) + val insertRegister = getInstruction(insertIndex).registerA + + addInstruction( + insertIndex + 1, + "const/4 v$insertRegister, 0x0" + ) + addInstructions( + insertIndex, """ + invoke-static {}, $GENERAL_CLASS_DESCRIPTOR->overridePremiumHeader()Z + move-result v$insertRegister + """ + ) + } + + // endregion + + // region patch for enable wide search bar + + // Limitation: Premium header will not be applied for YouTube Premium users if the user uses the 'Wide search bar with header' option. + // This is because it forces the deprecated search bar to be loaded. + // As a solution to this limitation, 'Change YouTube header' patch is required. + actionBarRingoBackgroundFingerprint.methodOrThrow().apply { + val viewIndex = + indexOfFirstLiteralInstructionOrThrow(actionBarRingoBackground) + 2 + val viewRegister = getInstruction(viewIndex).registerA + + addInstructions( + viewIndex + 1, + "invoke-static {v$viewRegister}, $GENERAL_CLASS_DESCRIPTOR->setWideSearchBarLayout(Landroid/view/View;)V" + ) + + val targetIndex = indexOfStaticInstruction(this) + 1 + val targetRegister = getInstruction(targetIndex).registerA + + injectSearchBarHook( + targetIndex + 1, + targetRegister, + "enableWideSearchBarWithHeaderInverse" + ) + } + + actionBarRingoTextFingerprint.methodOrThrow(actionBarRingoBackgroundFingerprint).apply { + val targetIndex = indexOfStaticInstruction(this) + 1 + val targetRegister = getInstruction(targetIndex).registerA + + injectSearchBarHook( + targetIndex + 1, + targetRegister, + "enableWideSearchBarWithHeader" + ) + } + + actionBarRingoConstructorFingerprint.methodOrThrow().apply { + val staticCalls = implementation!!.instructions + .withIndex() + .filter { (_, instruction) -> + val methodReference = (instruction as? ReferenceInstruction)?.reference + instruction.opcode == Opcode.INVOKE_STATIC && + methodReference is MethodReference && + methodReference.parameterTypes.size == 1 && + methodReference.returnType == "Z" + } + + if (staticCalls.size != 2) + throw PatchException("Size of staticCalls does not match: ${staticCalls.size}") + + mapOf( + staticCalls.elementAt(0).index to "enableWideSearchBar", + staticCalls.elementAt(1).index to "enableWideSearchBarWithHeader" + ).forEach { (index, descriptor) -> + val walkerMethod = getWalkerMethod(index) + + walkerMethod.apply { + injectSearchBarHook( + implementation!!.instructions.lastIndex, + descriptor + ) + } + } + } + + youActionBarFingerprint.matchOrThrow(setActionBarRingoFingerprint).let { + it.method.apply { + injectSearchBarHook( + it.patternMatch!!.endIndex, + "enableWideSearchBarInYouTab" + ) + } + } + + // This attribution cannot be changed in extension, so change it in the xml file. + + getContext().document("res/layout/action_bar_ringo_background.xml").use { document -> + document.doRecursively { node -> + arrayOf("layout_marginStart").forEach replacement@{ replacement -> + if (node !is Element) return@replacement + + node.getAttributeNode("android:$replacement")?.let { attribute -> + attribute.textContent = "0.0dip" + } + } + } + } + + // endregion + + // region patch for hide cast button + + hookToolBarCastButton() + + // endregion + + // region patch for hide create button + + hookToolBar("$GENERAL_CLASS_DESCRIPTOR->hideCreateButton") + + // endregion + + // region patch for hide notification button + + hookToolBar("$GENERAL_CLASS_DESCRIPTOR->hideNotificationButton") + + // endregion + + // region patch for hide search term thumbnail + + createSearchSuggestionsFingerprint.methodOrThrow().apply { + val relativeIndex = indexOfFirstLiteralInstructionOrThrow(40L) + val replaceIndex = indexOfFirstInstructionReversedOrThrow(relativeIndex) { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.toString() == "Landroid/widget/ImageView;->setVisibility(I)V" + } - 1 + + val jumpIndex = indexOfFirstInstructionOrThrow(relativeIndex) { + opcode == Opcode.INVOKE_STATIC && + getReference()?.toString() == "Landroid/net/Uri;->parse(Ljava/lang/String;)Landroid/net/Uri;" + } + 4 + + val replaceIndexInstruction = getInstruction(replaceIndex) + val replaceIndexReference = + getInstruction(replaceIndex).reference + + addInstructionsWithLabels( + replaceIndex + 1, """ + invoke-static { }, $GENERAL_CLASS_DESCRIPTOR->hideSearchTermThumbnail()Z + move-result v${replaceIndexInstruction.registerA} + if-nez v${replaceIndexInstruction.registerA}, :hidden + iget-object v${replaceIndexInstruction.registerA}, v${replaceIndexInstruction.registerB}, $replaceIndexReference + """, ExternalLabel("hidden", getInstruction(jumpIndex)) + ) + removeInstruction(replaceIndex) + } + + // endregion + + // region patch for hide voice search button + + if (is_19_28_or_greater) { + imageSearchButtonConfigFingerprint.injectLiteralInstructionBooleanCall( + 45617544L, + "$GENERAL_CLASS_DESCRIPTOR->hideImageSearchButton(Z)Z" + ) + + updatePatchStatus(PATCH_STATUS_CLASS_DESCRIPTOR, "ImageSearchButton") + + settingArray += "SETTINGS: HIDE_IMAGE_SEARCH_BUTTON" + } + + // endregion + + // region patch for hide voice search button + + searchBarFingerprint.matchOrThrow(searchBarParentFingerprint).let { + it.method.apply { + val startIndex = it.patternMatch!!.startIndex + val setVisibilityIndex = indexOfFirstInstructionOrThrow(startIndex) { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "setVisibility" + } + val setVisibilityInstruction = + getInstruction(setVisibilityIndex) + + replaceInstruction( + setVisibilityIndex, + "invoke-static {v${setVisibilityInstruction.registerC}, v${setVisibilityInstruction.registerD}}, " + + "$GENERAL_CLASS_DESCRIPTOR->hideVoiceSearchButton(Landroid/view/View;I)V" + ) + } + } + + searchResultFingerprint.matchOrThrow().let { + it.method.apply { + val startIndex = indexOfFirstLiteralInstructionOrThrow(voiceSearch) + val setOnClickListenerIndex = indexOfFirstInstructionOrThrow(startIndex) { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "setOnClickListener" + } + val viewRegister = + getInstruction(setOnClickListenerIndex).registerC + + addInstruction( + setOnClickListenerIndex + 1, + "invoke-static {v$viewRegister}, $GENERAL_CLASS_DESCRIPTOR->hideVoiceSearchButton(Landroid/view/View;)V" + ) + } + } + + // endregion + + // region patch for hide YouTube Doodles + + yoodlesImageViewFingerprint.methodOrThrow().apply { + findInstructionIndicesReversedOrThrow { + opcode == Opcode.INVOKE_VIRTUAL + && getReference()?.name == "setImageDrawable" + }.forEach { insertIndex -> + val (viewRegister, drawableRegister) = getInstruction( + insertIndex + ).let { + Pair(it.registerC, it.registerD) + } + replaceInstruction( + insertIndex, + "invoke-static {v$viewRegister, v$drawableRegister}, " + + "$GENERAL_CLASS_DESCRIPTOR->hideYouTubeDoodles(Landroid/widget/ImageView;Landroid/graphics/drawable/Drawable;)V" + ) + } + } + + // endregion + + // region patch for replace create button + + createButtonDrawableFingerprint.methodOrThrow().apply { + val index = indexOfFirstLiteralInstructionOrThrow(ytOutlineVideoCamera) + val register = getInstruction(index).registerA + + addInstructions( + index + 1, """ + invoke-static {v$register}, $GENERAL_CLASS_DESCRIPTOR->getCreateButtonDrawableId(I)I + move-result v$register + """ + ) + } + + hookToolBar("$GENERAL_CLASS_DESCRIPTOR->replaceCreateButton") + + findMethodOrThrow( + "Lcom/google/android/apps/youtube/app/application/Shell_SettingsActivity;" + ) { + name == "onCreate" + }.addInstruction( + 0, + "invoke-static {p0}, $GENERAL_CLASS_DESCRIPTOR->setShellActivityTheme(Landroid/app/Activity;)V" + ) + + // endregion + + // region add settings + + addPreference( + settingArray, + TOOLBAR_COMPONENTS + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/actionbuttons/ShortsActionButtonsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/actionbuttons/ShortsActionButtonsPatch.kt new file mode 100644 index 0000000000..1f1dac4913 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/actionbuttons/ShortsActionButtonsPatch.kt @@ -0,0 +1,104 @@ +package app.revanced.patches.youtube.layout.actionbuttons + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.patch.PatchList.CUSTOM_SHORTS_ACTION_BUTTONS +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.ResourceGroup +import app.revanced.util.copyResources +import app.revanced.util.lowerCaseOrThrow + +private const val DEFAULT_ICON = "cairo" +private const val YOUTUBE_ICON = "youtube" + +@Suppress("unused") +val shortsActionButtonsPatch = resourcePatch( + CUSTOM_SHORTS_ACTION_BUTTONS.title, + CUSTOM_SHORTS_ACTION_BUTTONS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + val iconType = stringOption( + key = "iconType", + default = DEFAULT_ICON, + values = mapOf( + "Cairo" to DEFAULT_ICON, + "Outline" to "outline", + "OutlineCircle" to "outlinecircle", + "Round" to "round", + "YoutubeOutline" to "youtubeoutline", + "YouTube" to YOUTUBE_ICON + ), + title = "Shorts icon style ", + description = "The style of the icons for the action buttons in the Shorts player.", + required = true, + ) + + execute { + + // Check patch options first. + val iconType = iconType + .lowerCaseOrThrow() + + if (iconType == YOUTUBE_ICON) { + println("INFO: Shorts action buttons will remain unchanged as it matches the original.") + addPreference(CUSTOM_SHORTS_ACTION_BUTTONS) + return@execute + } + + arrayOf( + "xxxhdpi", + "xxhdpi", + "xhdpi", + "hdpi", + "mdpi" + ).forEach { dpi -> + copyResources( + "youtube/shorts/actionbuttons/$iconType", + ResourceGroup( + "drawable-$dpi", + "ic_remix_filled_white_shadowed.webp", + "ic_right_comment_shadowed.webp", + "ic_right_dislike_off_shadowed.webp", + "ic_right_dislike_on_shadowed.webp", + "ic_right_like_off_shadowed.webp", + "ic_right_like_on_shadowed.webp", + "ic_right_share_shadowed.webp", + + // for older versions only + "ic_remix_filled_white_24.webp", + "ic_right_dislike_on_32c.webp", + "ic_right_like_on_32c.webp" + ), + ResourceGroup( + "drawable", + "ic_right_comment_32c.xml", + "ic_right_dislike_off_32c.xml", + "ic_right_like_off_32c.xml", + "ic_right_share_32c.xml" + ) + ) + } + + addPreference(CUSTOM_SHORTS_ACTION_BUTTONS) + + if (iconType == DEFAULT_ICON) { + return@execute + } + + copyResources( + "youtube/shorts/actionbuttons/shared", + ResourceGroup( + "drawable", + "reel_camera_bold_24dp.xml", + "reel_more_vertical_bold_24dp.xml", + "reel_search_bold_24dp.xml" + ) + ) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/branding/icon/CustomBrandingIconPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/branding/icon/CustomBrandingIconPatch.kt new file mode 100644 index 0000000000..d63b025066 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/branding/icon/CustomBrandingIconPatch.kt @@ -0,0 +1,185 @@ +package app.revanced.patches.youtube.layout.branding.icon + +import app.revanced.patcher.patch.booleanOption +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.patch.PatchList.CUSTOM_BRANDING_ICON_FOR_YOUTUBE +import app.revanced.patches.youtube.utils.settings.ResourceUtils.updatePatchStatusIcon +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.ResourceGroup +import app.revanced.util.Utils.trimIndentMultiline +import app.revanced.util.copyFile +import app.revanced.util.copyResources +import app.revanced.util.copyXmlNode +import app.revanced.util.getResourceGroup +import app.revanced.util.underBarOrThrow + +private const val DEFAULT_ICON = "revancify_blue" + +private val availableIcon = mapOf( + "AFN Blue" to "afn_blue", + "AFN Red" to "afn_red", + "MMT" to "mmt", + "Revancify Blue" to DEFAULT_ICON, + "Revancify Red" to "revancify_red", + "YouTube" to "youtube" +) + +private val sizeArray = arrayOf( + "xxxhdpi", + "xxhdpi", + "xhdpi", + "hdpi", + "mdpi" +) + +private val drawableDirectories = sizeArray.map { "drawable-$it" } + +private val mipmapDirectories = sizeArray.map { "mipmap-$it" } + +private val launcherIconResourceFileNames = arrayOf( + "adaptiveproduct_youtube_background_color_108", + "adaptiveproduct_youtube_foreground_color_108", + "ic_launcher", + "ic_launcher_round" +).map { "$it.png" }.toTypedArray() + +private val splashIconResourceFileNames = arrayOf( + "product_logo_youtube_color_24", + "product_logo_youtube_color_36", + "product_logo_youtube_color_144", + "product_logo_youtube_color_192" +).map { "$it.png" }.toTypedArray() + +private val oldSplashAnimationResourceFileNames = arrayOf( + "\$\$avd_anim__1__0", + "\$\$avd_anim__1__1", + "\$\$avd_anim__2__0", + "\$\$avd_anim__2__1", + "\$\$avd_anim__3__0", + "\$\$avd_anim__3__1", + "\$avd_anim__0", + "\$avd_anim__1", + "\$avd_anim__2", + "\$avd_anim__3", + "\$avd_anim__4", + "avd_anim" +).map { "$it.xml" }.toTypedArray() + +private val launcherIconResourceGroups = + mipmapDirectories.getResourceGroup(launcherIconResourceFileNames) + +private val splashIconResourceGroups = + drawableDirectories.getResourceGroup(splashIconResourceFileNames) + +private val oldSplashAnimationResourceGroups = + listOf("drawable").getResourceGroup(oldSplashAnimationResourceFileNames) + +@Suppress("unused") +val customBrandingIconPatch = resourcePatch( + CUSTOM_BRANDING_ICON_FOR_YOUTUBE.title, + CUSTOM_BRANDING_ICON_FOR_YOUTUBE.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + + val appIconOption = stringOption( + key = "appIcon", + default = DEFAULT_ICON, + values = availableIcon, + title = "App icon", + description = """ + The icon to apply to the app. + + If a path to a folder is provided, the folder must contain the following folders: + + ${mipmapDirectories.joinToString("\n") { "- $it" }} + + Each of these folders must contain the following files: + + ${launcherIconResourceFileNames.joinToString("\n") { "- $it" }} + """.trimIndentMultiline(), + required = true, + ) + + val changeSplashIconOption by booleanOption( + key = "changeSplashIcon", + default = true, + title = "Change splash icons", + description = "Apply the custom branding icon to the splash screen.", + required = true + ) + + val restoreOldSplashAnimationOption by booleanOption( + key = "restoreOldSplashAnimation", + default = true, + title = "Restore old splash animation", + description = "Restore the old style splash animation.", + required = true, + ) + + execute { + // Check patch options first. + val appIcon = appIconOption.underBarOrThrow() + + val appIconResourcePath = "youtube/branding/$appIcon" + + + // Check if a custom path is used in the patch options. + if (!availableIcon.containsValue(appIcon)) { + val copiedFiles = copyFile( + launcherIconResourceGroups, + appIcon, + "WARNING: Invalid app icon path: $appIcon. Does not apply patches." + ) + if (copiedFiles) + updatePatchStatusIcon("custom") + } else { + // Change launcher icon. + launcherIconResourceGroups.let { resourceGroups -> + resourceGroups.forEach { + copyResources("$appIconResourcePath/launcher", it) + } + } + + // Change monochrome icon. + arrayOf( + ResourceGroup( + "drawable", + "adaptive_monochrome_ic_youtube_launcher.xml" + ) + ).forEach { resourceGroup -> + copyResources("$appIconResourcePath/monochrome", resourceGroup) + } + + // Change splash icon. + if (changeSplashIconOption == true) { + splashIconResourceGroups.let { resourceGroups -> + resourceGroups.forEach { + copyResources("$appIconResourcePath/splash", it) + } + } + } + + // Change splash screen. + if (restoreOldSplashAnimationOption == true) { + oldSplashAnimationResourceGroups.let { resourceGroups -> + resourceGroups.forEach { + copyResources("$appIconResourcePath/splash", it) + } + } + + copyXmlNode( + "$appIconResourcePath/splash", + "values-v31/styles.xml", + "resources" + ) + } + + updatePatchStatusIcon(appIcon) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/branding/name/CustomBrandingNamePatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/branding/name/CustomBrandingNamePatch.kt new file mode 100644 index 0000000000..a1fe0d202a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/branding/name/CustomBrandingNamePatch.kt @@ -0,0 +1,59 @@ +package app.revanced.patches.youtube.layout.branding.name + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.patch.PatchList.CUSTOM_BRANDING_NAME_FOR_YOUTUBE +import app.revanced.patches.youtube.utils.settings.ResourceUtils.updatePatchStatusLabel +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.removeStringsElements +import app.revanced.util.valueOrThrow + +private const val APP_NAME = "RVX" + +@Suppress("unused") +val customBrandingNamePatch = resourcePatch( + CUSTOM_BRANDING_NAME_FOR_YOUTUBE.title, + CUSTOM_BRANDING_NAME_FOR_YOUTUBE.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + val appNameOption = stringOption( + key = "appName", + default = APP_NAME, + values = mapOf( + "ReVanced Extended" to "ReVanced Extended", + "RVX" to APP_NAME, + "YouTube RVX" to "YouTube RVX", + "YouTube" to "YouTube", + ), + title = "App name", + description = "The name of the app.", + required = true, + ) + + execute { + // Check patch options first. + val appName = appNameOption + .valueOrThrow() + + removeStringsElements( + arrayOf("application_name") + ) + + document("res/values/strings.xml").use { document -> + val stringElement = document.createElement("string") + + stringElement.setAttribute("name", "application_name") + stringElement.textContent = appName + + document.getElementsByTagName("resources").item(0) + .appendChild(stringElement) + } + + updatePatchStatusLabel(appName) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/dimming/ShortsDimmingPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/dimming/ShortsDimmingPatch.kt new file mode 100644 index 0000000000..a0c3aed8e5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/dimming/ShortsDimmingPatch.kt @@ -0,0 +1,34 @@ +package app.revanced.patches.youtube.layout.dimming + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.patch.PatchList.HIDE_SHORTS_DIMMING +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.removeOverlayBackground + +@Suppress("unused") +val shortsDimmingPatch = resourcePatch( + HIDE_SHORTS_DIMMING.title, + HIDE_SHORTS_DIMMING.summary, + false, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + + removeOverlayBackground( + arrayOf("reel_player_overlay_scrims.xml"), + arrayOf("reel_player_overlay_v2_scrims_vertical") + ) + removeOverlayBackground( + arrayOf("reel_watch_fragment.xml"), + arrayOf("reel_scrim_shorts_while_top") + ) + + addPreference(HIDE_SHORTS_DIMMING) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/doubletaplength/DoubleTapLengthPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/doubletaplength/DoubleTapLengthPatch.kt new file mode 100644 index 0000000000..f9348f918b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/doubletaplength/DoubleTapLengthPatch.kt @@ -0,0 +1,80 @@ +package app.revanced.patches.youtube.layout.doubletaplength + +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.patch.PatchList.CUSTOM_DOUBLE_TAP_LENGTH +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.ResourceGroup +import app.revanced.util.addEntryValues +import app.revanced.util.copyResources +import app.revanced.util.valueOrThrow +import java.nio.file.Files + +@Suppress("unused") +val doubleTapLengthPatch = resourcePatch( + CUSTOM_DOUBLE_TAP_LENGTH.title, + CUSTOM_DOUBLE_TAP_LENGTH.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + val doubleTapLengthArraysOption = stringOption( + key = "doubleTapLengthArrays", + default = "3, 5, 10, 15, 20, 30, 60, 120, 180", + title = "Double-tap to seek values", + description = "A list of custom Double-tap to seek lengths to be added, separated by commas.", + required = true, + ) + + execute { + // Check patch options first. + val doubleTapLengthArrays = doubleTapLengthArraysOption + .valueOrThrow() + + // Check patch options first. + val splits = doubleTapLengthArrays + .replace(" ", "") + .split(",") + if (splits.isEmpty()) throw PatchException("Invalid double-tap length elements") + val lengthElements = splits.map { it } + + val arrayPath = "res/values-v21/arrays.xml" + val entriesName = "double_tap_length_entries" + val entryValueName = "double_tap_length_values" + + val valuesV21Directory = get("res").resolve("values-v21") + if (!valuesV21Directory.isDirectory) + Files.createDirectories(valuesV21Directory.toPath()) + + /** + * Copy arrays + */ + copyResources( + "youtube/doubletap", + ResourceGroup( + "values-v21", + "arrays.xml" + ) + ) + + for (index in 0 until splits.count()) { + addEntryValues( + entryValueName, + lengthElements[index], + path = arrayPath + ) + addEntryValues( + entriesName, + lengthElements[index], + path = arrayPath + ) + } + + addPreference(CUSTOM_DOUBLE_TAP_LENGTH) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/header/ChangeHeaderPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/header/ChangeHeaderPatch.kt new file mode 100644 index 0000000000..93ae7b70d3 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/header/ChangeHeaderPatch.kt @@ -0,0 +1,171 @@ +package app.revanced.patches.youtube.layout.header + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.patch.PatchList.CUSTOM_HEADER_FOR_YOUTUBE +import app.revanced.patches.youtube.utils.settings.ResourceUtils.getIconType +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.ResourceGroup +import app.revanced.util.Utils.trimIndentMultiline +import app.revanced.util.copyFile +import app.revanced.util.copyResources +import app.revanced.util.underBarOrThrow +import java.io.File +import java.nio.file.Files +import kotlin.io.path.copyTo +import kotlin.io.path.exists + +private const val GENERIC_HEADER_FILE_NAME = "yt_wordmark_header" +private const val PREMIUM_HEADER_FILE_NAME = "yt_premium_wordmark_header" + +private const val NEW_GENERIC_HEADER_FILE_NAME = "yt_ringo2_wordmark_header" +private const val NEW_PREMIUM_HEADER_FILE_NAME = "yt_ringo2_premium_wordmark_header" + +private const val DEFAULT_HEADER_KEY = "Custom branding icon" +private const val DEFAULT_HEADER_VALUE = "custom_branding_icon" + +private val genericHeaderResourceDirectoryNames = mapOf( + "xxxhdpi" to "488px x 192px", + "xxhdpi" to "366px x 144px", + "xhdpi" to "244px x 96px", + "hdpi" to "184px x 72px", + "mdpi" to "122px x 48px", +).map { (dpi, dim) -> + "drawable-$dpi" to dim +}.toMap() + +private val premiumHeaderResourceDirectoryNames = mapOf( + "xxxhdpi" to "516px x 192px", + "xxhdpi" to "387px x 144px", + "xhdpi" to "258px x 96px", + "hdpi" to "194px x 72px", + "mdpi" to "129px x 48px", +).map { (dpi, dim) -> + "drawable-$dpi" to dim +}.toMap() + +private val variants = arrayOf("light", "dark") + +private val headerIconResourceGroups = + premiumHeaderResourceDirectoryNames.keys.map { directory -> + ResourceGroup( + directory, + *variants.map { variant -> "${GENERIC_HEADER_FILE_NAME}_$variant.png" } + .toTypedArray(), + *variants.map { variant -> "${PREMIUM_HEADER_FILE_NAME}_$variant.png" } + .toTypedArray(), + ) + } + +@Suppress("unused") +val changeHeaderPatch = resourcePatch( + CUSTOM_HEADER_FOR_YOUTUBE.title, + CUSTOM_HEADER_FOR_YOUTUBE.summary, + false, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + val customHeaderOption = stringOption( + key = "customHeader", + default = DEFAULT_HEADER_VALUE, + values = mapOf( + DEFAULT_HEADER_KEY to DEFAULT_HEADER_VALUE + ), + title = "Custom header", + description = """ + The header to apply to the app. + + Patch option '$DEFAULT_HEADER_KEY' applies only when: + + 1. Patch 'Custom branding icon for YouTube' is included. + 2. Patch option for 'Custom branding icon for YouTube' is selected from the preset. + + If a path to a folder is provided, the folder must contain one or more of the following folders, depending on the DPI of the device: + + ${premiumHeaderResourceDirectoryNames.keys.joinToString("\n") { "- $it" }} + + Each of the folders must contain all of the following files: + + [Generic header] + + ${variants.joinToString("\n") { variant -> "- ${GENERIC_HEADER_FILE_NAME}_$variant.png" }} + + The image dimensions must be as follows: + + ${ + genericHeaderResourceDirectoryNames.map { (dpi, dim) -> "- $dpi: $dim" } + .joinToString("\n") + } + + [Premium header] + + ${variants.joinToString("\n") { variant -> "- ${PREMIUM_HEADER_FILE_NAME}_$variant.png" }} + + The image dimensions must be as follows: + ${ + premiumHeaderResourceDirectoryNames.map { (dpi, dim) -> "- $dpi: $dim" } + .joinToString("\n") + } + """.trimIndentMultiline(), + required = true, + ) + + execute { + // Check patch options first. + val customHeader = customHeaderOption + .underBarOrThrow() + + val customBrandingIconType = getIconType() + val customBrandingIconIncluded = + customBrandingIconType != "default" && customBrandingIconType != "custom" + + val warnings = "WARNING: Invalid header path: $customHeader. Does not apply patches." + + if (customHeader != DEFAULT_HEADER_VALUE) { + copyFile( + headerIconResourceGroups, + customHeader, + warnings + ) + } else if (customBrandingIconIncluded) { + headerIconResourceGroups.let { resourceGroups -> + resourceGroups.forEach { + copyResources("youtube/branding/$customBrandingIconType/header", it) + } + } + } else { + println(warnings) + return@execute + } + + // The size of the new header is the same, only the file name is different. + // So if custom headers were used the patch will copy them to the new headers. + mapOf( + PREMIUM_HEADER_FILE_NAME to NEW_PREMIUM_HEADER_FILE_NAME, + GENERIC_HEADER_FILE_NAME to NEW_GENERIC_HEADER_FILE_NAME + ).forEach { (original, replacement) -> + premiumHeaderResourceDirectoryNames.keys.forEach { + get("res").resolve(it).takeIf(File::exists)?.toPath()?.let { path -> + variants.forEach { mode -> + val newHeaderPath = path.resolve("${replacement}_$mode.webp") + + if (newHeaderPath.exists()) { + val fromPath = path.resolve("${original}_$mode.png") + val toPath = path.resolve("${replacement}_$mode.png") + + fromPath.copyTo(toPath, true) + + // If the original file is in webp file format, a compilation error will occur. + // Remove it to prevent compilation errors. + Files.delete(newHeaderPath) + } + } + } + } + } + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/playerbuttonbg/PlayerButtonBackgroundPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/playerbuttonbg/PlayerButtonBackgroundPatch.kt new file mode 100644 index 0000000000..fc3df243a5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/playerbuttonbg/PlayerButtonBackgroundPatch.kt @@ -0,0 +1,36 @@ +package app.revanced.patches.youtube.layout.playerbuttonbg + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.patch.PatchList.FORCE_HIDE_PLAYER_BUTTONS_BACKGROUND +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.doRecursively +import org.w3c.dom.Element + +@Suppress("unused") +val playerButtonBackgroundPatch = resourcePatch( + FORCE_HIDE_PLAYER_BUTTONS_BACKGROUND.title, + FORCE_HIDE_PLAYER_BUTTONS_BACKGROUND.summary, + false, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + document("res/drawable/player_button_circle_background.xml").use { document -> + + document.doRecursively node@{ node -> + if (node !is Element) return@node + + node.getAttributeNode("android:color")?.let { attribute -> + attribute.textContent = "@android:color/transparent" + } + } + } + + addPreference(FORCE_HIDE_PLAYER_BUTTONS_BACKGROUND) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/shortcut/ShortcutPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/shortcut/ShortcutPatch.kt new file mode 100644 index 0000000000..47de5f2d9c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/shortcut/ShortcutPatch.kt @@ -0,0 +1,87 @@ +package app.revanced.patches.youtube.layout.shortcut + +import app.revanced.patcher.patch.booleanOption +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.patch.PatchList.HIDE_SHORTCUTS +import app.revanced.patches.youtube.utils.playservice.is_19_44_or_greater +import app.revanced.patches.youtube.utils.playservice.versionCheckPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.findElementByAttributeValueOrThrow +import org.w3c.dom.Element + +@Suppress("unused") +val shortcutPatch = resourcePatch( + HIDE_SHORTCUTS.title, + HIDE_SHORTCUTS.summary, + false, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + settingsPatch, + versionCheckPatch + ) + + val explore = booleanOption( + key = "explore", + default = false, + title = "Hide Explore", + description = "Hide Explore from shortcuts.", + required = true + ) + + val subscriptions = booleanOption( + key = "subscriptions", + default = false, + title = "Hide Subscriptions", + description = "Hide Subscriptions from shortcuts.", + required = true + ) + + val search = booleanOption( + key = "search", + default = false, + title = "Hide Search", + description = "Hide Search from shortcuts.", + required = true + ) + + val shorts = booleanOption( + key = "shorts", + default = true, + title = "Hide Shorts", + description = "Hide Shorts from shortcuts.", + required = true + ) + + execute { + var options = listOf( + subscriptions, + search, + shorts + ) + + if (!is_19_44_or_greater) { + options += explore + } + + options.forEach { option -> + if (option.value == true) { + document("res/xml/main_shortcuts.xml").use { document -> + val shortcuts = document.getElementsByTagName("shortcuts").item(0) as Element + val shortsItem = shortcuts.getElementsByTagName("shortcut") + .findElementByAttributeValueOrThrow( + "android:shortcutId", + "${option.key}-shortcut" + ) + shortsItem.parentNode.removeChild(shortsItem) + } + } + } + + addPreference(HIDE_SHORTCUTS) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/MaterialYouPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/MaterialYouPatch.kt new file mode 100644 index 0000000000..21585d2a4b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/MaterialYouPatch.kt @@ -0,0 +1,53 @@ +package app.revanced.patches.youtube.layout.theme + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.patch.PatchList.MATERIALYOU +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.ResourceUtils.updatePatchStatusTheme +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.ResourceGroup +import app.revanced.util.copyResources +import app.revanced.util.copyXmlNode + +@Suppress("unused") +val materialYouPatch = resourcePatch( + MATERIALYOU.title, + MATERIALYOU.summary, + false, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + sharedThemePatch, + settingsPatch, + ) + + execute { + arrayOf( + ResourceGroup( + "drawable-night-v31", + "new_content_dot_background.xml" + ), + ResourceGroup( + "drawable-v31", + "new_content_count_background.xml", + "new_content_dot_background.xml" + ), + ResourceGroup( + "layout-v31", + "new_content_count.xml" + ) + ).forEach { + get("res/${it.resourceDirectoryName}").mkdirs() + copyResources("youtube/materialyou", it) + } + + copyXmlNode("youtube/materialyou/host", "values-v31/colors.xml", "resources") + + updatePatchStatusTheme("MaterialYou") + + addPreference(MATERIALYOU) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/SharedThemePatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/SharedThemePatch.kt new file mode 100644 index 0000000000..89d7f11330 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/SharedThemePatch.kt @@ -0,0 +1,141 @@ +package app.revanced.patches.youtube.layout.theme + +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.shared.drawable.addDrawableColorHook +import app.revanced.patches.shared.drawable.drawableColorHookPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.UTILS_PATH +import app.revanced.patches.youtube.utils.playservice.is_19_32_or_greater +import app.revanced.patches.youtube.utils.playservice.versionCheckPatch +import org.w3c.dom.Element + +private const val SPLASH_SCREEN_COLOR_NAME = "splashScreenColor" +private const val SPLASH_SCREEN_COLOR_ATTRIBUTE = "?attr/$SPLASH_SCREEN_COLOR_NAME" + +val sharedThemePatch = resourcePatch( + description = "sharedThemePatch" +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + drawableColorHookPatch, + versionCheckPatch, + ) + + execute { + addDrawableColorHook("$UTILS_PATH/DrawableColorPatch;->getColor(I)I") + + // edit the resource files to change the splash screen color + val attrsResourceFile = "res/values/attrs.xml" + + document(attrsResourceFile).use { document -> + (document.getElementsByTagName("resources").item(0) as Element).appendChild( + document.createElement("attr").apply { + setAttribute("format", "reference") + setAttribute("name", SPLASH_SCREEN_COLOR_NAME) + } + ) + } + + setOf( + "res/values/styles.xml", + "res/values-v31/styles.xml" + ).forEachIndexed { pathIndex, stylesPath -> + document(stylesPath).use { document -> + val childNodes = + (document.getElementsByTagName("resources").item(0) as Element).childNodes + + for (i in 0 until childNodes.length) { + val node = childNodes.item(i) as? Element ?: continue + val nodeAttributeName = node.getAttribute("name") + + document.createElement("item").apply { + setAttribute( + "name", + when (pathIndex) { + 0 -> "splashScreenColor" + 1 -> "android:windowSplashScreenBackground" + else -> "null" + } + ) + + appendChild( + document.createTextNode( + when (pathIndex) { + 0 -> when (nodeAttributeName) { + "Base.Theme.YouTube.Launcher.Dark" -> "@color/yt_black1" + "Base.Theme.YouTube.Launcher.Light" -> "@color/yt_white1" + else -> "null" + } + + 1 -> when (nodeAttributeName) { + "Base.Theme.YouTube.Launcher" -> SPLASH_SCREEN_COLOR_ATTRIBUTE + else -> "null" + } + + else -> "null" + } + ) + ) + + if (this.textContent != "null") + node.appendChild(this) + } + } + } + } + + setOf( + "res/drawable/quantum_launchscreen_youtube.xml", + "res/drawable-sw600dp/quantum_launchscreen_youtube.xml" + ).forEach editSplashScreen@{ resourceFile -> + document(resourceFile).use { document -> + val layerList = document.getElementsByTagName("layer-list").item(0) as Element + + val childNodes = layerList.childNodes + for (i in 0 until childNodes.length) { + val node = childNodes.item(i) + if (node is Element && node.hasAttribute("android:drawable")) { + node.setAttribute("android:drawable", SPLASH_SCREEN_COLOR_ATTRIBUTE) + return@editSplashScreen + } + } + + throw PatchException("Failed to modify launch screen") + } + } + + if (is_19_32_or_greater) { + // Fix the splash screen dark mode background color. + // In earlier versions of the app this is white and makes no sense for dark mode. + // This is only required for 19.32 and greater, but is applied to all targets. + // Only dark mode needs this fix as light mode correctly uses the custom color. + document("res/values-night/styles.xml").use { document -> + val resourcesNode = document.getElementsByTagName("resources").item(0) as Element + val childNodes = resourcesNode.childNodes + + for (i in 0 until childNodes.length) { + val node = childNodes.item(i) as? Element ?: continue + val nodeAttributeName = node.getAttribute("name") + if (nodeAttributeName == "Theme.YouTube.Launcher" || nodeAttributeName == "Theme.YouTube.Launcher.Cairo") { + val nodeAttributeParent = node.getAttribute("parent") + + val style = document.createElement("style") + style.setAttribute("name", "Theme.YouTube.Home") + style.setAttribute("parent", nodeAttributeParent) + + val windowItem = document.createElement("item") + windowItem.setAttribute("name", "android:windowBackground") + windowItem.textContent = "@color/yt_black1" + style.appendChild(windowItem) + + resourcesNode.removeChild(node) + resourcesNode.appendChild(style) + } + } + } + } + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/ThemePatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/ThemePatch.kt new file mode 100644 index 0000000000..bb94e8a30f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/ThemePatch.kt @@ -0,0 +1,130 @@ +package app.revanced.patches.youtube.layout.theme + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.patch.PatchList.MATERIALYOU +import app.revanced.patches.youtube.utils.patch.PatchList.THEME +import app.revanced.patches.youtube.utils.settings.ResourceUtils.updatePatchStatusTheme +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.valueOrThrow +import org.w3c.dom.Element + +@Suppress("unused") +val themePatch = resourcePatch( + THEME.title, + THEME.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + sharedThemePatch, + settingsPatch, + ) + + val amoledBlackColor = "@android:color/black" + val whiteColor = "@android:color/white" + + val availableDarkTheme = mapOf( + "Amoled Black" to amoledBlackColor, + "Classic (Old YouTube)" to "#FF212121", + "Catppuccin (Mocha)" to "#FF181825", + "Dark Pink" to "#FF290025", + "Dark Blue" to "#FF001029", + "Dark Green" to "#FF002905", + "Dark Yellow" to "#FF282900", + "Dark Orange" to "#FF291800", + "Dark Red" to "#FF290000", + ) + + val availableLightTheme = mapOf( + "White" to whiteColor, + "Catppuccin (Latte)" to "#FFE6E9EF", + "Light Pink" to "#FFFCCFF3", + "Light Blue" to "#FFD1E0FF", + "Light Green" to "#FFCCFFCC", + "Light Yellow" to "#FFFDFFCC", + "Light Orange" to "#FFFFE6CC", + "Light Red" to "#FFFFD6D6", + ) + + val darkThemeBackgroundColor = stringOption( + key = "darkThemeBackgroundColor", + default = amoledBlackColor, + values = availableDarkTheme, + title = "Dark theme background color", + description = "Can be a hex color (#AARRGGBB) or a color resource reference.", + ) + + val lightThemeBackgroundColor = stringOption( + key = "lightThemeBackgroundColor", + default = whiteColor, + values = availableLightTheme, + title = "Light theme background color", + description = "Can be a hex color (#AARRGGBB) or a color resource reference.", + ) + + execute { + + // Check patch options first. + val darkThemeColor = darkThemeBackgroundColor + .valueOrThrow() + + val lightThemeColor = lightThemeBackgroundColor + .valueOrThrow() + + arrayOf("values", "values-v31").forEach { path -> + document("res/$path/colors.xml").use { document -> + val resourcesNode = document.getElementsByTagName("resources").item(0) as Element + + for (i in 0 until resourcesNode.childNodes.length) { + val node = resourcesNode.childNodes.item(i) as? Element ?: continue + + node.textContent = when (node.getAttribute("name")) { + "yt_black0", "yt_black1", "yt_black1_opacity95", "yt_black1_opacity98", "yt_black2", "yt_black3", + "yt_black4", "yt_status_bar_background_dark", "material_grey_850" -> darkThemeColor + + else -> continue + } + } + } + } + + document("res/values/colors.xml").use { document -> + val resourcesNode = document.getElementsByTagName("resources").item(0) as Element + + val children = resourcesNode.childNodes + for (i in 0 until children.length) { + val node = children.item(i) as? Element ?: continue + + node.textContent = when (node.getAttribute("name")) { + "yt_white1", "yt_white1_opacity95", "yt_white1_opacity98", + "yt_white2", "yt_white3", "yt_white4", + -> lightThemeColor + + else -> continue + } + } + } + + var darkThemeString = "Custom" + var lightThemeString = "Custom" + availableDarkTheme.forEach { (k, v) -> + if (v == darkThemeColor) darkThemeString = k + } + availableLightTheme.forEach { (k, v) -> + if (v == lightThemeColor) lightThemeString = k + } + val themeString = if (lightThemeColor != whiteColor) + "$lightThemeString + $darkThemeString" + else + darkThemeString + val currentTheme = if (MATERIALYOU.included == true) + "MaterialYou + $themeString" + else + themeString + + updatePatchStatusTheme(currentTheme) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/translations/TranslationsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/translations/TranslationsPatch.kt new file mode 100644 index 0000000000..a72aa6957f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/translations/TranslationsPatch.kt @@ -0,0 +1,68 @@ +package app.revanced.patches.youtube.layout.translations + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patches.shared.translations.APP_LANGUAGES +import app.revanced.patches.shared.translations.baseTranslationsPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.patch.PatchList.TRANSLATIONS_FOR_YOUTUBE +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch + +// Array of supported translations, each represented by its language code. +private val SUPPORTED_TRANSLATIONS = setOf( + "ar", "bg-rBG", "de-rDE", "el-rGR", "es-rES", "fr-rFR", "hu-rHU", "it-rIT", "ja-rJP", "ko-rKR", + "pl-rPL", "pt-rBR", "ru-rRU", "tr-rTR", "uk-rUA", "vi-rVN", "zh-rCN", "zh-rTW" +) + +@Suppress("unused") +val translationsPatch = resourcePatch( + TRANSLATIONS_FOR_YOUTUBE.title, + TRANSLATIONS_FOR_YOUTUBE.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + val customTranslations by stringOption( + key = "customTranslations", + default = "", + title = "Custom translations", + description = """ + The path to the 'strings.xml' file. + Please note that applying the 'strings.xml' file will overwrite all existing translations. + """.trimIndent(), + required = true, + ) + + val selectedTranslations by stringOption( + key = "selectedTranslations", + default = SUPPORTED_TRANSLATIONS.joinToString(", "), + title = "Translations to add", + description = "A list of translations to be added for the RVX settings, separated by commas.", + required = true, + ) + + val selectedStringResources by stringOption( + key = "selectedStringResources", + default = APP_LANGUAGES.joinToString(", "), + title = "String resources to keep", + description = """ + A list of string resources to be kept, separated by commas. + String resources not in the list will be removed from the app. + + Default string resource, English, is not removed. + """.trimIndent(), + required = true, + ) + + execute { + baseTranslationsPatch( + customTranslations, selectedTranslations, selectedStringResources, + SUPPORTED_TRANSLATIONS, "youtube" + ) + + addPreference(TRANSLATIONS_FOR_YOUTUBE) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/visual/VisualPreferencesIconsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/visual/VisualPreferencesIconsPatch.kt new file mode 100644 index 0000000000..6104811435 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/visual/VisualPreferencesIconsPatch.kt @@ -0,0 +1,446 @@ +package app.revanced.patches.youtube.layout.visual + +import app.revanced.patcher.patch.booleanOption +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patches.youtube.layout.branding.icon.customBrandingIconPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.patch.PatchList.VISUAL_PREFERENCES_ICONS_FOR_YOUTUBE +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.ResourceGroup +import app.revanced.util.Utils.trimIndentMultiline +import app.revanced.util.copyResources +import app.revanced.util.doRecursively +import app.revanced.util.getStringOptionValue +import app.revanced.util.underBarOrThrow +import org.w3c.dom.Element + +private const val DEFAULT_ICON = "extension" +private const val EMPTY_ICON = "empty_icon" + +@Suppress("unused") +val visualPreferencesIconsPatch = resourcePatch( + VISUAL_PREFERENCES_ICONS_FOR_YOUTUBE.title, + VISUAL_PREFERENCES_ICONS_FOR_YOUTUBE.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + val settingsMenuIconOption = stringOption( + key = "settingsMenuIcon", + default = DEFAULT_ICON, + values = mapOf( + "Custom branding icon" to "custom_branding_icon", + "Extension" to DEFAULT_ICON, + "Gear" to "gear", + "YT alt" to "yt_alt", + "ReVanced" to "revanced", + "ReVanced Colored" to "revanced_colored", + ), + title = "RVX settings menu icon", + description = "The icon for the RVX settings menu.", + required = true, + ) + + val applyToAll by booleanOption( + key = "applyToAll", + default = false, + title = "Apply to all settings menu", + description = """ + Whether to apply Visual preferences icons to all settings menus. + + If true: icons are applied to the parent PreferenceScreen of YouTube settings, the parent PreferenceScreen of RVX settings and the RVX sub-settings (if supported). + + If false: icons are applied only to the parent PreferenceScreen of YouTube settings and RVX settings. + """.trimIndentMultiline(), + required = true + ) + + lateinit var preferenceIcon: Map + + fun Set.setPreferenceIcon() = associateWith { title -> + when (title) { + // Main RVX settings + "revanced_preference_screen_general" -> "general_key_icon" + "revanced_preference_screen_sb" -> "sb_enable_create_segment_icon" + + // Internal RVX settings + "revanced_alt_thumbnail_home" -> "revanced_hide_navigation_home_button_icon" + "revanced_alt_thumbnail_library" -> "revanced_preference_screen_video_icon" + "revanced_alt_thumbnail_player" -> "revanced_preference_screen_player_icon" + "revanced_alt_thumbnail_search" -> "revanced_hide_shorts_shelf_search_icon" + "revanced_alt_thumbnail_subscriptions" -> "revanced_hide_navigation_subscriptions_button_icon" + "revanced_change_share_sheet" -> "revanced_hide_shorts_share_button_icon" + "revanced_custom_player_overlay_opacity" -> "revanced_swipe_overlay_background_alpha_icon" + "revanced_default_app_settings" -> "revanced_preference_screen_settings_menu_icon" + "revanced_default_playback_speed" -> "revanced_overlay_button_speed_dialog_icon" + "revanced_enable_old_quality_layout" -> "revanced_default_video_quality_wifi_icon" + "revanced_enable_watch_panel_gestures" -> "revanced_preference_screen_swipe_controls_icon" + "revanced_hide_download_button" -> "revanced_overlay_button_external_downloader_icon" + "revanced_hide_keyword_content_comments" -> "revanced_hide_quick_actions_comment_button_icon" + "revanced_hide_keyword_content_home" -> "revanced_hide_navigation_home_button_icon" + "revanced_hide_keyword_content_search" -> "revanced_hide_shorts_shelf_search_icon" + "revanced_hide_keyword_content_subscriptions" -> "revanced_hide_navigation_subscriptions_button_icon" + "revanced_hide_like_dislike_button" -> "sb_enable_voting_icon" + "revanced_hide_navigation_library_button" -> "revanced_preference_screen_video_icon" + "revanced_hide_navigation_notifications_button" -> "notification_key_icon" + "revanced_hide_navigation_shorts_button" -> "revanced_preference_screen_shorts_icon" + "revanced_hide_player_autoplay_button" -> "revanced_change_player_flyout_menu_toggle_icon" + "revanced_hide_player_captions_button" -> "captions_key_icon" + "revanced_hide_player_flyout_menu_ambient_mode" -> "revanced_preference_screen_ambient_mode_icon" + "revanced_hide_player_flyout_menu_captions" -> "captions_key_icon" + "revanced_hide_player_flyout_menu_listen_with_youtube_music" -> "revanced_hide_player_youtube_music_button_icon" + "revanced_hide_player_flyout_menu_loop_video" -> "revanced_overlay_button_always_repeat_icon" + "revanced_hide_player_flyout_menu_more_info" -> "about_key_icon" + "revanced_hide_player_flyout_menu_pip" -> "offline_key_icon" + "revanced_hide_player_flyout_menu_premium_controls" -> "premium_early_access_browse_page_key_icon" + "revanced_hide_player_flyout_menu_quality_header" -> "revanced_default_video_quality_wifi_icon" + "revanced_hide_player_flyout_menu_report" -> "revanced_hide_report_button_icon" + "revanced_hide_player_fullscreen_button" -> "revanced_preference_screen_fullscreen_icon" + "revanced_hide_quick_actions_dislike_button" -> "revanced_preference_screen_ryd_icon" + "revanced_hide_quick_actions_live_chat_button" -> "live_chat_key_icon" + "revanced_hide_quick_actions_save_to_playlist_button" -> "revanced_hide_playlist_button_icon" + "revanced_hide_quick_actions_share_button" -> "revanced_hide_shorts_share_button_icon" + "revanced_hide_remix_button" -> "revanced_hide_shorts_remix_button_icon" + "revanced_hide_share_button" -> "revanced_hide_shorts_share_button_icon" + "revanced_hide_shorts_comments_button" -> "revanced_hide_quick_actions_comment_button_icon" + "revanced_hide_shorts_dislike_button" -> "revanced_preference_screen_ryd_icon" + "revanced_hide_shorts_like_button" -> "revanced_hide_quick_actions_like_button_icon" + "revanced_hide_shorts_navigation_bar" -> "revanced_preference_screen_navigation_bar_icon" + "revanced_hide_shorts_shelf_home_related_videos" -> "revanced_hide_navigation_home_button_icon" + "revanced_hide_shorts_shelf_subscriptions" -> "revanced_hide_navigation_subscriptions_button_icon" + "revanced_hide_shorts_toolbar" -> "revanced_preference_screen_toolbar_icon" + "revanced_hide_toolbar_cast_button" -> "revanced_hide_player_cast_button_icon" + "revanced_hide_toolbar_create_button" -> "revanced_hide_navigation_create_button_icon" + "revanced_hide_toolbar_notification_button" -> "notification_key_icon" + "revanced_preference_screen_account_menu" -> "account_switcher_key_icon" + "revanced_preference_screen_channel_bar" -> "account_switcher_key_icon" + "revanced_preference_screen_channel_profile" -> "account_switcher_key_icon" + "revanced_preference_screen_comments" -> "revanced_hide_quick_actions_comment_button_icon" + "revanced_preference_screen_feed_flyout_menu" -> "revanced_preference_screen_player_flyout_menu_icon" + "revanced_preference_screen_haptic_feedback" -> "revanced_enable_swipe_haptic_feedback_icon" + "revanced_preference_screen_hook_buttons" -> "revanced_preference_screen_import_export_icon" + "revanced_preference_screen_miniplayer" -> "offline_key_icon" + "revanced_preference_screen_patch_information" -> "about_key_icon" + "revanced_preference_screen_shorts_player" -> "revanced_preference_screen_shorts_icon" + "revanced_preference_screen_video_filter" -> "revanced_preference_screen_video_icon" + "revanced_preference_screen_watch_history" -> "history_key_icon" + "revanced_swipe_gestures_lock_mode" -> "revanced_hide_player_flyout_menu_lock_screen_icon" + "revanced_disable_hdr_auto_brightness" -> "revanced_disable_hdr_video_icon" + else -> "${title}_icon" + } + } + + execute { + // Check patch options first. + val selectedIconType = settingsMenuIconOption + .underBarOrThrow() + + val appIconOption = customBrandingIconPatch + .getStringOptionValue("appIcon") + + val customBrandingIconType = appIconOption + .underBarOrThrow() + + if (applyToAll == true) { + preferenceKey += rvxPreferenceKey + } + + preferenceIcon = preferenceKey.setPreferenceIcon() + + // region copy shared resources. + + arrayOf( + ResourceGroup( + "drawable", + *preferenceIcon.values.map { "$it.xml" }.toTypedArray() + ), + ResourceGroup( + "drawable-xxhdpi", + "$EMPTY_ICON.png" + ), + ).forEach { resourceGroup -> + copyResources("youtube/visual/shared", resourceGroup) + } + + // endregion. + + // region copy RVX settings menu icon. + + val fallbackIconPath = "youtube/visual/icons/extension" + val iconPath = when (selectedIconType) { + "custom_branding_icon" -> "youtube/branding/$customBrandingIconType/settings" + else -> "youtube/visual/icons/$selectedIconType" + } + val resourceGroup = ResourceGroup( + "drawable", + "revanced_extended_settings_key_icon.xml" + ) + + try { + copyResources(iconPath, resourceGroup) + } catch (_: Exception) { + // Ignore if resource copy fails + + // Add a fallback extended icon + // It's needed if someone provides custom path to icon(s) folder + // but custom branding icons for Extended setting are predefined, + // so it won't copy custom branding icon + // and will raise an error without fallback icon + copyResources(fallbackIconPath, resourceGroup) + } + + // endregion. + + addPreference(VISUAL_PREFERENCES_ICONS_FOR_YOUTUBE) + + } + + finalize { + // region set visual preferences icon. + + arrayOf( + "res/xml/revanced_prefs.xml", + "res/xml/settings_fragment.xml" + ).forEach { xmlFile -> + document(xmlFile).use { document -> + document.doRecursively loop@{ node -> + if (node !is Element) return@loop + + node.getAttributeNode("android:key") + ?.textContent + ?.removePrefix("@string/") + ?.let { title -> + val drawableName = when (title) { + in preferenceKey -> preferenceIcon[title] + + // Add custom RVX settings menu icon + in intentKey -> intentIcon[title] + in emptyTitles -> EMPTY_ICON + else -> null + } + if (drawableName == EMPTY_ICON && + applyToAll == false + ) return@loop + + drawableName?.let { + node.setAttribute("android:icon", "@drawable/$it") + } + } + } + } + } + + // endregion. + } +} + +private var preferenceKey = setOf( + // YouTube settings. + "about_key", + "accessibility_settings_key", + "account_switcher_key", + "auto_play_key", + "billing_and_payment_key", + "captions_key", + "connected_accounts_browse_page_key", + "data_saving_settings_key", + "general_key", + "history_key", + "live_chat_key", + "notification_key", + "offline_key", + "pair_with_tv_key", + "parent_tools_key", + "premium_early_access_browse_page_key", + "privacy_key", + "subscription_product_setting_key", + "video_quality_settings_key", + "your_data_key", + + // RVX settings. + "revanced_preference_screen_ads", + "revanced_preference_screen_alt_thumbnails", + "revanced_preference_screen_feed", + "revanced_preference_screen_general", + "revanced_preference_screen_player", + "revanced_preference_screen_shorts", + "revanced_preference_screen_swipe_controls", + "revanced_preference_screen_video", + "revanced_preference_screen_ryd", + "revanced_preference_screen_return_youtube_username", + "revanced_preference_screen_sb", + "revanced_preference_screen_misc", +) + +private var rvxPreferenceKey = setOf( + // Internal RVX settings (items without prefix are listed first, others are sorted alphabetically) + "gms_core_settings", + "sb_enable_create_segment", + "sb_enable_voting", + + "revanced_alt_thumbnail_home", + "revanced_alt_thumbnail_library", + "revanced_alt_thumbnail_player", + "revanced_alt_thumbnail_search", + "revanced_alt_thumbnail_subscriptions", + "revanced_change_share_sheet", + "revanced_change_shorts_repeat_state", + "revanced_custom_player_overlay_opacity", + "revanced_default_app_settings", + "revanced_default_playback_speed", + "revanced_default_video_quality_wifi", + "revanced_disable_default_playback_speed_music", + "revanced_disable_hdr_auto_brightness", + "revanced_disable_hdr_video", + "revanced_disable_quic_protocol", + "revanced_enable_debug_logging", + "revanced_enable_default_playback_speed_shorts", + "revanced_enable_external_browser", + "revanced_enable_old_quality_layout", + "revanced_enable_open_links_directly", + "revanced_enable_opus_codec", + "revanced_enable_save_and_restore_brightness", + "revanced_enable_swipe_brightness", + "revanced_enable_swipe_haptic_feedback", + "revanced_enable_swipe_lowest_value_auto_brightness", + "revanced_enable_swipe_press_to_engage", + "revanced_enable_swipe_to_switch_video", + "revanced_enable_swipe_volume", + "revanced_enable_watch_panel_gestures", + "revanced_hide_clip_button", + "revanced_hide_download_button", + "revanced_hide_keyword_content_comments", + "revanced_hide_keyword_content_home", + "revanced_hide_keyword_content_search", + "revanced_hide_keyword_content_subscriptions", + "revanced_hide_like_dislike_button", + "revanced_hide_navigation_create_button", + "revanced_hide_navigation_home_button", + "revanced_hide_navigation_library_button", + "revanced_hide_navigation_notifications_button", + "revanced_hide_navigation_shorts_button", + "revanced_hide_navigation_subscriptions_button", + "revanced_hide_player_autoplay_button", + "revanced_hide_player_captions_button", + "revanced_hide_player_cast_button", + "revanced_hide_player_collapse_button", + "revanced_hide_player_flyout_menu_ambient_mode", + "revanced_hide_player_flyout_menu_audio_track", + "revanced_hide_player_flyout_menu_captions", + "revanced_hide_player_flyout_menu_help", + "revanced_hide_player_flyout_menu_listen_with_youtube_music", + "revanced_hide_player_flyout_menu_lock_screen", + "revanced_hide_player_flyout_menu_loop_video", + "revanced_hide_player_flyout_menu_more_info", + "revanced_hide_player_flyout_menu_pip", + "revanced_hide_player_flyout_menu_premium_controls", + "revanced_hide_player_flyout_menu_playback_speed", + "revanced_hide_player_flyout_menu_quality_header", + "revanced_hide_player_flyout_menu_report", + "revanced_hide_player_flyout_menu_stable_volume", + "revanced_hide_player_flyout_menu_stats_for_nerds", + "revanced_hide_player_flyout_menu_watch_in_vr", + "revanced_hide_player_fullscreen_button", + "revanced_hide_player_previous_next_button", + "revanced_hide_player_youtube_music_button", + "revanced_hide_playlist_button", + "revanced_hide_quick_actions_comment_button", + "revanced_hide_quick_actions_dislike_button", + "revanced_hide_quick_actions_like_button", + "revanced_hide_quick_actions_live_chat_button", + "revanced_hide_quick_actions_more_button", + "revanced_hide_quick_actions_save_to_playlist_button", + "revanced_hide_quick_actions_share_button", + "revanced_hide_remix_button", + "revanced_hide_report_button", + "revanced_hide_rewards_button", + "revanced_hide_share_button", + "revanced_hide_shop_button", + "revanced_hide_shorts_comments_button", + "revanced_hide_shorts_dislike_button", + "revanced_hide_shorts_like_button", + "revanced_hide_shorts_navigation_bar", + "revanced_hide_shorts_remix_button", + "revanced_hide_shorts_share_button", + "revanced_hide_shorts_shelf_history", + "revanced_hide_shorts_shelf_home_related_videos", + "revanced_hide_shorts_shelf_search", + "revanced_hide_shorts_shelf_subscriptions", + "revanced_hide_shorts_toolbar", + "revanced_hide_thanks_button", + "revanced_hide_toolbar_cast_button", + "revanced_hide_toolbar_create_button", + "revanced_hide_toolbar_notification_button", + "revanced_overlay_button_always_repeat", + "revanced_overlay_button_copy_video_url", + "revanced_overlay_button_copy_video_url_timestamp", + "revanced_overlay_button_mute_volume", + "revanced_overlay_button_external_downloader", + "revanced_overlay_button_play_all", + "revanced_overlay_button_speed_dialog", + "revanced_overlay_button_whitelist", + "revanced_preference_screen_account_menu", + "revanced_preference_screen_action_buttons", + "revanced_preference_screen_ambient_mode", + "revanced_preference_screen_category_bar", + "revanced_preference_screen_channel_bar", + "revanced_preference_screen_channel_profile", + "revanced_preference_screen_comments", + "revanced_preference_screen_community_posts", + "revanced_preference_screen_custom_filter", + "revanced_preference_screen_feed_flyout_menu", + "revanced_preference_screen_fullscreen", + "revanced_preference_screen_haptic_feedback", + "revanced_preference_screen_hook_buttons", + "revanced_preference_screen_import_export", + "revanced_preference_screen_miniplayer", + "revanced_preference_screen_navigation_bar", + "revanced_preference_screen_patch_information", + "revanced_preference_screen_player_buttons", + "revanced_preference_screen_player_flyout_menu", + "revanced_preference_screen_seekbar", + "revanced_preference_screen_settings_menu", + "revanced_preference_screen_shorts_player", + "revanced_preference_screen_spoof_streaming_data", + "revanced_preference_screen_toolbar", + "revanced_preference_screen_video_description", + "revanced_preference_screen_video_filter", + "revanced_preference_screen_watch_history", + "revanced_sanitize_sharing_links", + "revanced_swipe_gestures_lock_mode", + "revanced_swipe_magnitude_threshold", + "revanced_swipe_overlay_background_alpha", + "revanced_swipe_overlay_rect_size", + "revanced_swipe_overlay_text_size", + "revanced_swipe_overlay_timeout", + "revanced_switch_create_with_notifications_button", + "revanced_change_player_flyout_menu_toggle", +) + +private val intentKey = setOf( + "revanced_extended_settings_key", +) + +val intentIcon = intentKey.associateWith { "${it}_icon" } + +private val emptyTitles = setOf( + "revanced_custom_playback_speeds", + "revanced_custom_playback_speed_menu_type", + "revanced_default_video_quality_mobile", + "revanced_disable_like_dislike_glow", + "revanced_disable_default_playback_speed_live", + "revanced_enable_custom_playback_speed", + "revanced_hide_shorts_comments_disabled_button", + "revanced_hide_player_flyout_menu_captions_footer", + "revanced_hide_player_flyout_menu_quality_footer", + "revanced_remember_playback_speed_last_selected", + "revanced_remember_playback_speed_last_selected_toast", + "revanced_remember_video_quality_last_selected", + "revanced_remember_video_quality_last_selected_toast", + "revanced_restore_old_video_quality_menu", + "revanced_enable_debug_buffer_logging", + "revanced_whitelist_settings", +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/backgroundplayback/BackgroundPlaybackPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/backgroundplayback/BackgroundPlaybackPatch.kt new file mode 100644 index 0000000000..06d763e8cc --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/backgroundplayback/BackgroundPlaybackPatch.kt @@ -0,0 +1,105 @@ +package app.revanced.patches.youtube.misc.backgroundplayback + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.MISC_PATH +import app.revanced.patches.youtube.utils.patch.PatchList.REMOVE_BACKGROUND_PLAYBACK_RESTRICTIONS +import app.revanced.patches.youtube.utils.playertype.playerTypeHookPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.findInstructionIndicesReversedOrThrow +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getWalkerMethod +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +@Suppress("unused") +val backgroundPlaybackPatch = bytecodePatch( + REMOVE_BACKGROUND_PLAYBACK_RESTRICTIONS.title, + REMOVE_BACKGROUND_PLAYBACK_RESTRICTIONS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + playerTypeHookPatch, + settingsPatch, + ) + + execute { + + backgroundPlaybackManagerFingerprint.methodOrThrow().apply { + findInstructionIndicesReversedOrThrow(Opcode.RETURN).forEach { index -> + val register = getInstruction(index).registerA + + // Replace to preserve control flow label. + replaceInstruction( + index, + "invoke-static { v$register }, $MISC_PATH/BackgroundPlaybackPatch;->allowBackgroundPlayback(Z)Z" + ) + + addInstructions( + index + 1, + """ + move-result v$register + return v$register + """ + ) + } + } + + // Enable background playback option in YouTube settings + backgroundPlaybackSettingsFingerprint.methodOrThrow().apply { + val booleanCalls = implementation!!.instructions.withIndex() + .filter { instruction -> + ((instruction.value as? ReferenceInstruction)?.reference as? MethodReference)?.returnType == "Z" + } + + val booleanIndex = booleanCalls.elementAt(1).index + val booleanMethod = getWalkerMethod(booleanIndex) + + booleanMethod.addInstructions( + 0, """ + const/4 v0, 0x1 + return v0 + """ + ) + } + + // Force allowing background play for videos labeled for kids. + kidsBackgroundPlaybackPolicyControllerFingerprint.methodOrThrow( + kidsBackgroundPlaybackPolicyControllerParentFingerprint + ).addInstruction( + 0, + "return-void" + ) + + pipControllerFingerprint.matchOrThrow().let { + val targetMethod = + it.getWalkerMethod(it.patternMatch!!.endIndex) + + targetMethod.apply { + val targetRegister = getInstruction(0).registerA + + addInstruction( + 1, + "const/4 v$targetRegister, 0x1" + ) + } + } + + // region add settings + + addPreference(REMOVE_BACKGROUND_PLAYBACK_RESTRICTIONS) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/backgroundplayback/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/backgroundplayback/Fingerprints.kt new file mode 100644 index 0000000000..5ef3d2c08c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/backgroundplayback/Fingerprints.kt @@ -0,0 +1,69 @@ +package app.revanced.patches.youtube.misc.backgroundplayback + +import app.revanced.patches.youtube.utils.PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.resourceid.backgroundCategory +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +internal val backgroundPlaybackManagerFingerprint = legacyFingerprint( + name = "backgroundPlaybackManagerFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("L"), + opcodes = listOf(Opcode.AND_INT_LIT16), + literals = listOf(64657230L), +) + +internal val backgroundPlaybackSettingsFingerprint = legacyFingerprint( + name = "backgroundPlaybackSettingsFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_EQZ, + Opcode.IF_NEZ, + Opcode.GOTO + ), + literals = listOf(backgroundCategory), +) + +internal val kidsBackgroundPlaybackPolicyControllerFingerprint = legacyFingerprint( + name = "kidsBackgroundPlaybackPolicyControllerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("I", "L", "L"), + literals = listOf(5L), +) + +internal val kidsBackgroundPlaybackPolicyControllerParentFingerprint = legacyFingerprint( + name = "kidsBackgroundPlaybackPolicyControllerParentFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf(PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR), + customFingerprint = { method, _ -> + method.indexOfFirstInstruction { + opcode == Opcode.SGET_OBJECT + && getReference()?.name == "miniplayerRenderer" + } >= 0 + } +) + +internal val pipControllerFingerprint = legacyFingerprint( + name = "pipControllerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf( + Opcode.IF_NEZ, + Opcode.INVOKE_DIRECT + ), + literals = listOf(151635310L), +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/codecs/OpusCodecPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/codecs/OpusCodecPatch.kt new file mode 100644 index 0000000000..341dcea9ab --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/codecs/OpusCodecPatch.kt @@ -0,0 +1,40 @@ +package app.revanced.patches.youtube.misc.codecs + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.opus.baseOpusCodecsPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.MISC_PATH +import app.revanced.patches.youtube.utils.patch.PatchList.ENABLE_OPUS_CODEC +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch + +@Suppress("unused") +val opusCodecPatch = bytecodePatch( + ENABLE_OPUS_CODEC.title, + ENABLE_OPUS_CODEC.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + baseOpusCodecsPatch( + "$MISC_PATH/OpusCodecPatch;->enableOpusCodec()Z" + ), + settingsPatch, + ) + + execute { + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_CATEGORY: MISC_EXPERIMENTAL_FLAGS", + "SETTINGS: ENABLE_OPUS_CODEC" + ), + ENABLE_OPUS_CODEC + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/debugging/DebuggingPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/debugging/DebuggingPatch.kt new file mode 100644 index 0000000000..0cdede5f73 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/debugging/DebuggingPatch.kt @@ -0,0 +1,32 @@ +package app.revanced.patches.youtube.misc.debugging + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.patch.PatchList.ENABLE_DEBUG_LOGGING +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch + +@Suppress("unused") +val debuggingPatch = bytecodePatch( + ENABLE_DEBUG_LOGGING.title, + ENABLE_DEBUG_LOGGING.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + + // region add settings + + addPreference( + arrayOf( + "SETTINGS: ENABLE_DEBUG_LOGGING" + ), + ENABLE_DEBUG_LOGGING + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/externalbrowser/OpenLinksExternallyPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/externalbrowser/OpenLinksExternallyPatch.kt new file mode 100644 index 0000000000..020594c8ab --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/externalbrowser/OpenLinksExternallyPatch.kt @@ -0,0 +1,62 @@ +package app.revanced.patches.youtube.misc.externalbrowser + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.transformation.transformInstructionsPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.MISC_PATH +import app.revanced.patches.youtube.utils.patch.PatchList.ENABLE_EXTERNAL_BROWSER +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.StringReference + +@Suppress("unused") +val openLinksExternallyPatch = bytecodePatch( + ENABLE_EXTERNAL_BROWSER.title, + ENABLE_EXTERNAL_BROWSER.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + transformInstructionsPatch( + filterMap = filterMap@{ _, _, instruction, instructionIndex -> + if (instruction !is ReferenceInstruction) return@filterMap null + val reference = instruction.reference as? StringReference ?: return@filterMap null + + if (reference.string != "android.support.customtabs.action.CustomTabsService") return@filterMap null + + return@filterMap instructionIndex to (instruction as OneRegisterInstruction).registerA + }, + transform = { mutableMethod, entry -> + val (intentStringIndex, register) = entry + + // Hook the intent string. + mutableMethod.addInstructions( + intentStringIndex + 1, + """ + invoke-static {v$register}, $MISC_PATH/ExternalBrowserPatch;->enableExternalBrowser(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$register + """, + ) + }, + ), + settingsPatch, + ) + + execute { + + // region add settings + + addPreference( + arrayOf( + "SETTINGS: ENABLE_EXTERNAL_BROWSER" + ), + ENABLE_EXTERNAL_BROWSER + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/openlinksdirectly/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/openlinksdirectly/Fingerprints.kt new file mode 100644 index 0000000000..1b33eeab63 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/openlinksdirectly/Fingerprints.kt @@ -0,0 +1,47 @@ +package app.revanced.patches.youtube.misc.openlinksdirectly + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +internal val openLinksDirectlyFingerprintPrimary = legacyFingerprint( + name = "openLinksDirectlyFingerprintPrimary", + returnType = "Ljava/lang/Object", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Ljava/lang/Object"), + opcodes = listOf( + Opcode.CHECK_CAST, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.RETURN_OBJECT + ), + customFingerprint = { method, _ -> + method.name == "a" && + method.implementation + ?.instructions + ?.withIndex() + ?.filter { (_, instruction) -> + val reference = (instruction as? ReferenceInstruction)?.reference + reference is FieldReference && + instruction.opcode == Opcode.SGET_OBJECT && + reference.name == "webviewEndpoint" + } + ?.map { (index, _) -> index } + ?.size == 1 + } +) + +internal val openLinksDirectlyFingerprintSecondary = legacyFingerprint( + name = "openLinksDirectlyFingerprintSecondary", + returnType = "Landroid/net/Uri", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("Ljava/lang/String"), + opcodes = listOf( + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT + ), + strings = listOf("://") +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/openlinksdirectly/OpenLinksDirectlyPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/openlinksdirectly/OpenLinksDirectlyPatch.kt new file mode 100644 index 0000000000..61ffa6c1d2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/openlinksdirectly/OpenLinksDirectlyPatch.kt @@ -0,0 +1,60 @@ +package app.revanced.patches.youtube.misc.openlinksdirectly + +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.MISC_PATH +import app.revanced.patches.youtube.utils.patch.PatchList.ENABLE_OPEN_LINKS_DIRECTLY +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +@Suppress("unused") +val openLinksDirectlyPatch = bytecodePatch( + ENABLE_OPEN_LINKS_DIRECTLY.title, + ENABLE_OPEN_LINKS_DIRECTLY.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + + arrayOf( + openLinksDirectlyFingerprintPrimary, + openLinksDirectlyFingerprintSecondary + ).forEach { fingerprint -> + fingerprint.methodOrThrow().apply { + val insertIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_STATIC && + getReference()?.name == "parse" + } + val insertRegister = + getInstruction(insertIndex).registerC + + replaceInstruction( + insertIndex, + "invoke-static {v$insertRegister}, $MISC_PATH/OpenLinksDirectlyPatch;->enableBypassRedirect(Ljava/lang/String;)Landroid/net/Uri;" + ) + } + } + + // region add settings + + addPreference( + arrayOf( + "SETTINGS: ENABLE_OPEN_LINKS_DIRECTLY" + ), + ENABLE_OPEN_LINKS_DIRECTLY + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/quic/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/quic/Fingerprints.kt new file mode 100644 index 0000000000..807b46700f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/quic/Fingerprints.kt @@ -0,0 +1,28 @@ +@file:Suppress("SpellCheckingInspection") + +package app.revanced.patches.youtube.misc.quic + +import app.revanced.util.fingerprint.legacyFingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val cronetEngineBuilderFingerprint = legacyFingerprint( + name = "cronetEngineBuilderFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC.value, + parameters = listOf("Z"), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/CronetEngine\$Builder;") && + method.name == "enableQuic" + } +) + +internal val experimentalCronetEngineBuilderFingerprint = legacyFingerprint( + name = "experimentalCronetEngineBuilderFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC.value, + parameters = listOf("Z"), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/ExperimentalCronetEngine\$Builder;") && + method.name == "enableQuic" + } +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/quic/QUICProtocolPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/quic/QUICProtocolPatch.kt new file mode 100644 index 0000000000..5c0ba3aaae --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/quic/QUICProtocolPatch.kt @@ -0,0 +1,47 @@ +package app.revanced.patches.youtube.misc.quic + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.MISC_PATH +import app.revanced.patches.youtube.utils.patch.PatchList.DISABLE_QUIC_PROTOCOL +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.fingerprint.methodOrThrow + +@Suppress("unused") +val quicProtocolPatch = bytecodePatch( + DISABLE_QUIC_PROTOCOL.title, + DISABLE_QUIC_PROTOCOL.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + + arrayOf( + cronetEngineBuilderFingerprint, + experimentalCronetEngineBuilderFingerprint + ).forEach { + it.methodOrThrow().addInstructions( + 0, """ + invoke-static {p1}, $MISC_PATH/QUICProtocolPatch;->disableQUICProtocol(Z)Z + move-result p1 + """ + ) + } + + // region add settings + + addPreference( + arrayOf( + "SETTINGS: DISABLE_QUIC_PROTOCOL" + ), + DISABLE_QUIC_PROTOCOL + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/share/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/share/Fingerprints.kt new file mode 100644 index 0000000000..a01217b029 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/share/Fingerprints.kt @@ -0,0 +1,38 @@ +package app.revanced.patches.youtube.misc.share + +import app.revanced.patches.youtube.utils.resourceid.bottomSheetRecyclerView +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +internal val bottomSheetRecyclerViewFingerprint = legacyFingerprint( + name = "bottomSheetRecyclerViewFingerprint", + returnType = "Lj${'$'}/util/Optional;", + accessFlags = AccessFlags.PROTECTED or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(bottomSheetRecyclerView), +) + +internal val updateShareSheetCommandFingerprint = legacyFingerprint( + name = "updateShareSheetCommandFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "Ljava/util/Map;"), + opcodes = listOf( + Opcode.IF_EQZ, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + Opcode.IGET_OBJECT + ), + customFingerprint = { method, _ -> + method.indexOfFirstInstruction { + opcode == Opcode.SGET_OBJECT && + getReference()?.name == "updateShareSheetCommand" + } >= 0 + } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/share/ShareSheetPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/share/ShareSheetPatch.kt new file mode 100644 index 0000000000..58fb7cbc5c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/share/ShareSheetPatch.kt @@ -0,0 +1,88 @@ +package app.revanced.patches.youtube.misc.share + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.youtube.utils.extension.Constants.MISC_PATH +import app.revanced.patches.youtube.utils.patch.PatchList.CHANGE_SHARE_SHEET +import app.revanced.patches.youtube.utils.resourceid.bottomSheetRecyclerView +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$MISC_PATH/ShareSheetPatch;" + +private const val FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/ShareSheetMenuFilter;" + +@Suppress("unused") +val shareSheetPatch = bytecodePatch( + CHANGE_SHARE_SHEET.title, + CHANGE_SHARE_SHEET.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + lithoFilterPatch, + sharedResourceIdPatch, + settingsPatch, + ) + + execute { + + // Detects that the Share sheet panel has been invoked. + bottomSheetRecyclerViewFingerprint.methodOrThrow().apply { + val constIndex = indexOfFirstLiteralInstructionOrThrow(bottomSheetRecyclerView) + val targetIndex = indexOfFirstInstructionOrThrow(constIndex, Opcode.CHECK_CAST) + val targetRegister = getInstruction(targetIndex).registerA + + addInstruction( + targetIndex + 1, + "invoke-static {v$targetRegister}, $EXTENSION_CLASS_DESCRIPTOR->onShareSheetMenuCreate(Landroid/support/v7/widget/RecyclerView;)V" + ) + } + + // Remove the app list from the Share sheet panel on YouTube. + updateShareSheetCommandFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = it.patternMatch!!.endIndex + val targetRegister = getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, """ + invoke-static {v$targetRegister}, $EXTENSION_CLASS_DESCRIPTOR->overridePackageName(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$targetRegister + """ + ) + } + } + + addLithoFilter(FILTER_CLASS_DESCRIPTOR) + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_CATEGORY: MISC_EXPERIMENTAL_FLAGS", + "SETTINGS: CHANGE_SHARE_SHEET" + ), + CHANGE_SHARE_SHEET + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/tracking/SanitizeUrlQueryPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/tracking/SanitizeUrlQueryPatch.kt new file mode 100644 index 0000000000..6e8a824d11 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/tracking/SanitizeUrlQueryPatch.kt @@ -0,0 +1,36 @@ +package app.revanced.patches.youtube.misc.tracking + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.tracking.baseSanitizeUrlQueryPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.patch.PatchList.SANITIZE_SHARING_LINKS +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch + +@Suppress("unused") +val sanitizeUrlQueryPatch = bytecodePatch( + SANITIZE_SHARING_LINKS.title, + SANITIZE_SHARING_LINKS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + baseSanitizeUrlQueryPatch, + settingsPatch, + ) + + execute { + + // region add settings + + addPreference( + arrayOf( + "SETTINGS: SANITIZE_SHARING_LINKS" + ), + SANITIZE_SHARING_LINKS + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/watchhistory/WatchHistoryPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/watchhistory/WatchHistoryPatch.kt new file mode 100644 index 0000000000..5916130c90 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/watchhistory/WatchHistoryPatch.kt @@ -0,0 +1,40 @@ +package app.revanced.patches.youtube.misc.watchhistory + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.MISC_PATH +import app.revanced.patches.youtube.utils.patch.PatchList.WATCH_HISTORY +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.patches.youtube.utils.trackingurlhook.hookTrackingUrl +import app.revanced.patches.youtube.utils.trackingurlhook.trackingUrlHookPatch + +@Suppress("unused") +val watchHistoryPatch = bytecodePatch( + WATCH_HISTORY.title, + WATCH_HISTORY.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + settingsPatch, + trackingUrlHookPatch, + ) + + execute { + + hookTrackingUrl("$MISC_PATH/WatchHistoryPatch;->replaceTrackingUrl(Landroid/net/Uri;)Landroid/net/Uri;") + + // region add settings + + addPreference( + arrayOf( + "SETTINGS: WATCH_HISTORY" + ), + WATCH_HISTORY + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/action/ActionButtonsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/action/ActionButtonsPatch.kt new file mode 100644 index 0000000000..df6df8817f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/action/ActionButtonsPatch.kt @@ -0,0 +1,43 @@ +package app.revanced.patches.youtube.player.action + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.youtube.utils.patch.PatchList.HIDE_ACTION_BUTTONS +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch + +private const val FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/ActionButtonsFilter;" + +@Suppress("unused") +val actionButtonsPatch = bytecodePatch( + HIDE_ACTION_BUTTONS.title, + HIDE_ACTION_BUTTONS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + lithoFilterPatch, + settingsPatch, + ) + + execute { + addLithoFilter(FILTER_CLASS_DESCRIPTOR) + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: PLAYER", + "SETTINGS: HIDE_ACTION_BUTTONS" + ), + HIDE_ACTION_BUTTONS + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/ambientmode/AmbientModeSwitchPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/ambientmode/AmbientModeSwitchPatch.kt new file mode 100644 index 0000000000..6f99c8fda4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/ambientmode/AmbientModeSwitchPatch.kt @@ -0,0 +1,106 @@ +package app.revanced.patches.youtube.player.ambientmode + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.PLAYER_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.patch.PatchList.AMBIENT_MODE_CONTROL +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import app.revanced.util.indexOfFirstStringInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +@Suppress("unused") +val ambientModeSwitchPatch = bytecodePatch( + AMBIENT_MODE_CONTROL.title, + AMBIENT_MODE_CONTROL.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + // region patch for bypass ambient mode restrictions + + var syntheticClassList = emptyArray() + + mapOf( + powerSaveModeBroadcastReceiverFingerprint to false, + powerSaveModeSyntheticFingerprint to true + ).forEach { (fingerprint, reversed) -> + fingerprint.methodOrThrow().apply { + val stringIndex = + indexOfFirstStringInstructionOrThrow("android.os.action.POWER_SAVE_MODE_CHANGED") + val targetIndex = + if (reversed) + indexOfFirstInstructionReversedOrThrow(stringIndex, Opcode.INVOKE_DIRECT) + else + indexOfFirstInstructionOrThrow(stringIndex, Opcode.INVOKE_DIRECT) + val targetClass = + (getInstruction(targetIndex).reference as MethodReference).definingClass + + syntheticClassList += targetClass + } + } + + syntheticClassList.distinct().forEach { className -> + findMethodOrThrow(className) { + name == "accept" + }.apply { + implementation!!.instructions + .withIndex() + .filter { (_, instruction) -> + val reference = (instruction as? ReferenceInstruction)?.reference + instruction.opcode == Opcode.INVOKE_VIRTUAL && + reference is MethodReference && + reference.name == "isPowerSaveMode" + } + .map { (index, _) -> index } + .reversed() + .forEach { index -> + val register = getInstruction(index + 1).registerA + + addInstructions( + index + 2, """ + invoke-static {v$register}, $PLAYER_CLASS_DESCRIPTOR->bypassAmbientModeRestrictions(Z)Z + move-result v$register + """ + ) + } + } + } + + // endregion + + // region patch for disable ambient mode in fullscreen + + ambientModeInFullscreenFingerprint.injectLiteralInstructionBooleanCall( + 45389368L, + "$PLAYER_CLASS_DESCRIPTOR->disableAmbientModeInFullscreen()Z" + ) + + // endregion + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: PLAYER", + "SETTINGS: AMBIENT_MODE_CONTROLS" + ), + AMBIENT_MODE_CONTROL + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/ambientmode/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/ambientmode/Fingerprints.kt new file mode 100644 index 0000000000..901532aee6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/ambientmode/Fingerprints.kt @@ -0,0 +1,33 @@ +package app.revanced.patches.youtube.player.ambientmode + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val ambientModeInFullscreenFingerprint = legacyFingerprint( + name = "ambientModeInFullscreenFingerprint", + returnType = "V", + literals = listOf(45389368L), +) + +internal val powerSaveModeBroadcastReceiverFingerprint = legacyFingerprint( + name = "powerSaveModeBroadcastReceiverFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Landroid/content/Context;", "Landroid/content/Intent;"), + strings = listOf("android.os.action.POWER_SAVE_MODE_CHANGED"), + // There are two classes that inherit [BroadcastReceiver]. + // Check the method count to find the correct class. + customFingerprint = { _, classDef -> + classDef.superclass == "Landroid/content/BroadcastReceiver;" && + classDef.methods.count() == 2 + } +) + +internal val powerSaveModeSyntheticFingerprint = legacyFingerprint( + name = "powerSaveModeSyntheticFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Ljava/lang/Object;"), + strings = listOf("android.os.action.POWER_SAVE_MODE_CHANGED") +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/buttons/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/buttons/Fingerprints.kt new file mode 100644 index 0000000000..5a73bf4cde --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/buttons/Fingerprints.kt @@ -0,0 +1,78 @@ +package app.revanced.patches.youtube.player.buttons + +import app.revanced.patches.youtube.utils.resourceid.cfFullscreenButton +import app.revanced.patches.youtube.utils.resourceid.fadeDurationFast +import app.revanced.patches.youtube.utils.resourceid.fullScreenButton +import app.revanced.patches.youtube.utils.resourceid.musicAppDeeplinkButtonView +import app.revanced.patches.youtube.utils.resourceid.playerCollapseButton +import app.revanced.patches.youtube.utils.resourceid.titleAnchor +import app.revanced.patches.youtube.utils.resourceid.youTubeControlsOverlaySubtitleButton +import app.revanced.util.containsLiteralInstruction +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val fullScreenButtonFingerprint = legacyFingerprint( + name = "fullScreenButtonFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Landroid/view/View;"), + customFingerprint = handler@{ method, _ -> + if (!method.containsLiteralInstruction(fullScreenButton)) + return@handler false + + method.containsLiteralInstruction(fadeDurationFast) // YouTube 18.29.38 ~ YouTube 19.18.41 + || method.containsLiteralInstruction(cfFullscreenButton) // YouTube 19.19.39 ~ + }, +) + +/** + * Added in YouTube v18.31.40 + * + * When this value is TRUE, litho subtitle button is used. + * In this case, the empty area remains, so set this value to FALSE. + */ +internal val lithoSubtitleButtonConfigFingerprint = legacyFingerprint( + name = "lithoSubtitleButtonConfigFingerprint", + returnType = "Z", + literals = listOf(45421555L), +) + +internal val musicAppDeeplinkButtonFingerprint = legacyFingerprint( + name = "musicAppDeeplinkButtonFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Z", "Z") +) + +internal val musicAppDeeplinkButtonParentFingerprint = legacyFingerprint( + name = "musicAppDeeplinkButtonParentFingerprint", + returnType = "V", + literals = listOf(musicAppDeeplinkButtonView), +) + +internal val playerControlsVisibilityModelFingerprint = legacyFingerprint( + name = "playerControlsVisibilityModelFingerprint", + opcodes = listOf(Opcode.INVOKE_DIRECT_RANGE), + strings = listOf("Missing required properties:", "hasNext", "hasPrevious") +) + +internal val titleAnchorFingerprint = legacyFingerprint( + name = "titleAnchorFingerprint", + returnType = "V", + literals = listOf(playerCollapseButton, titleAnchor), +) + +/** + * The parameters of the method have changed in YouTube v18.31.40. + * Therefore, this fingerprint does not check the method's parameters. + * + * This fingerprint is compatible from YouTube v18.25.40 to YouTube v18.45.43 + */ +internal val youtubeControlsOverlaySubtitleButtonFingerprint = legacyFingerprint( + name = "youtubeControlsOverlaySubtitleButtonFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + literals = listOf(youTubeControlsOverlaySubtitleButton), +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/buttons/PlayerButtonsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/buttons/PlayerButtonsPatch.kt new file mode 100644 index 0000000000..34b42a8995 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/buttons/PlayerButtonsPatch.kt @@ -0,0 +1,214 @@ +package app.revanced.patches.youtube.player.buttons + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.youtube.utils.castbutton.castButtonPatch +import app.revanced.patches.youtube.utils.castbutton.hookPlayerCastButton +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.PLAYER_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.fix.bottomui.cfBottomUIPatch +import app.revanced.patches.youtube.utils.layoutConstructorFingerprint +import app.revanced.patches.youtube.utils.patch.PatchList.HIDE_PLAYER_BUTTONS +import app.revanced.patches.youtube.utils.playservice.is_18_31_or_greater +import app.revanced.patches.youtube.utils.playservice.versionCheckPatch +import app.revanced.patches.youtube.utils.resourceid.autoNavToggle +import app.revanced.patches.youtube.utils.resourceid.fullScreenButton +import app.revanced.patches.youtube.utils.resourceid.playerCollapseButton +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.resourceid.titleAnchor +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.RegisterRangeInstruction +import com.android.tools.smali.dexlib2.iface.instruction.WideLiteralInstruction + +private const val HAS_NEXT = 5 +private const val HAS_PREVIOUS = 6 + +@Suppress("unused") +val playerButtonsPatch = bytecodePatch( + HIDE_PLAYER_BUTTONS.title, + HIDE_PLAYER_BUTTONS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + castButtonPatch, + cfBottomUIPatch, + sharedResourceIdPatch, + settingsPatch, + versionCheckPatch, + ) + + execute { + + // region patch for hide autoplay button + + layoutConstructorFingerprint.methodOrThrow().apply { + val constIndex = indexOfFirstLiteralInstructionOrThrow(autoNavToggle) + val constRegister = getInstruction(constIndex).registerA + val jumpIndex = + indexOfFirstInstructionOrThrow(constIndex + 2, Opcode.INVOKE_VIRTUAL) + 1 + + addInstructionsWithLabels( + constIndex, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->hideAutoPlayButton()Z + move-result v$constRegister + if-nez v$constRegister, :hidden + """, ExternalLabel("hidden", getInstruction(jumpIndex)) + ) + } + + // endregion + + // region patch for hide captions button + + if (is_18_31_or_greater) { + lithoSubtitleButtonConfigFingerprint.methodOrThrow().apply { + val insertIndex = implementation!!.instructions.lastIndex + val insertRegister = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $PLAYER_CLASS_DESCRIPTOR->hideCaptionsButton(Z)Z + move-result v$insertRegister + """ + ) + } + } + + + youtubeControlsOverlaySubtitleButtonFingerprint.methodOrThrow().apply { + val insertIndex = implementation!!.instructions.lastIndex + val insertRegister = getInstruction(insertIndex).registerA + + addInstruction( + insertIndex, + "invoke-static {v$insertRegister}, $PLAYER_CLASS_DESCRIPTOR->hideCaptionsButton(Landroid/view/View;)V" + ) + } + + // endregion + + // region patch for hide cast button + + hookPlayerCastButton() + + // endregion + + // region patch for hide collapse button + + titleAnchorFingerprint.methodOrThrow().apply { + val titleAnchorConstIndex = indexOfFirstLiteralInstructionOrThrow(titleAnchor) + val titleAnchorIndex = + indexOfFirstInstructionOrThrow(titleAnchorConstIndex, Opcode.MOVE_RESULT_OBJECT) + val titleAnchorRegister = + getInstruction(titleAnchorIndex).registerA + + addInstruction( + titleAnchorIndex + 1, + "invoke-static {v$titleAnchorRegister}, $PLAYER_CLASS_DESCRIPTOR->setTitleAnchorStartMargin(Landroid/view/View;)V" + ) + + val playerCollapseButtonConstIndex = + indexOfFirstLiteralInstructionOrThrow(playerCollapseButton) + val playerCollapseButtonIndex = + indexOfFirstInstructionOrThrow(playerCollapseButtonConstIndex, Opcode.CHECK_CAST) + val playerCollapseButtonRegister = + getInstruction(playerCollapseButtonIndex).registerA + + addInstruction( + playerCollapseButtonIndex + 1, + "invoke-static {v$playerCollapseButtonRegister}, $PLAYER_CLASS_DESCRIPTOR->hideCollapseButton(Landroid/widget/ImageView;)V" + ) + } + + // endregion + + // region patch for hide fullscreen button + + fullScreenButtonFingerprint.matchOrThrow().let { + it.method.apply { + val buttonCalls = implementation!!.instructions.withIndex() + .filter { instruction -> + (instruction.value as? WideLiteralInstruction)?.wideLiteral == fullScreenButton + } + val constIndex = buttonCalls.elementAt(buttonCalls.size - 1).index + val castIndex = indexOfFirstInstructionOrThrow(constIndex, Opcode.CHECK_CAST) + val insertIndex = castIndex + 1 + val insertRegister = getInstruction(castIndex).registerA + + addInstructionsWithLabels( + insertIndex, """ + invoke-static {v$insertRegister}, $PLAYER_CLASS_DESCRIPTOR->hideFullscreenButton(Landroid/widget/ImageView;)Landroid/widget/ImageView; + move-result-object v$insertRegister + if-nez v$insertRegister, :show + return-void + """, ExternalLabel("show", getInstruction(insertIndex)) + ) + } + } + + // endregion + + // region patch for hide previous and next button + + playerControlsVisibilityModelFingerprint.methodOrThrow().apply { + val callIndex = indexOfFirstInstructionOrThrow(Opcode.INVOKE_DIRECT_RANGE) + val callInstruction = getInstruction(callIndex) + + val hasNextParameterRegister = callInstruction.startRegister + HAS_NEXT + val hasPreviousParameterRegister = callInstruction.startRegister + HAS_PREVIOUS + + addInstructions( + callIndex, """ + invoke-static { v$hasNextParameterRegister }, $PLAYER_CLASS_DESCRIPTOR->hidePreviousNextButton(Z)Z + move-result v$hasNextParameterRegister + invoke-static { v$hasPreviousParameterRegister }, $PLAYER_CLASS_DESCRIPTOR->hidePreviousNextButton(Z)Z + move-result v$hasPreviousParameterRegister + """ + ) + } + + // endregion + + // region patch for hide youtube music button + + musicAppDeeplinkButtonFingerprint.methodOrThrow(musicAppDeeplinkButtonParentFingerprint) + .apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->hideMusicButton()Z + move-result v0 + if-nez v0, :hidden + """, + ExternalLabel("hidden", getInstruction(implementation!!.instructions.lastIndex)) + ) + } + + // endregion + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: PLAYER", + "PREFERENCE_SCREENS: PLAYER_BUTTONS", + "SETTINGS: HIDE_PLAYER_BUTTONS" + ), + HIDE_PLAYER_BUTTONS + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/comments/CommentsComponentPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/comments/CommentsComponentPatch.kt new file mode 100644 index 0000000000..a90668b88a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/comments/CommentsComponentPatch.kt @@ -0,0 +1,99 @@ +package app.revanced.patches.youtube.player.comments + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.patches.shared.spans.addSpanFilter +import app.revanced.patches.shared.spans.inclusiveSpanPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.youtube.utils.extension.Constants.PLAYER_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.extension.Constants.SPANS_PATH +import app.revanced.patches.youtube.utils.patch.PatchList.HIDE_COMMENTS_COMPONENTS +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +private const val COMMENTS_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/CommentsFilter;" +private const val SEARCH_LINKS_FILTER_CLASS_DESCRIPTOR = + "$SPANS_PATH/SearchLinksFilter;" + +@Suppress("unused") +val commentsComponentPatch = bytecodePatch( + HIDE_COMMENTS_COMPONENTS.title, + HIDE_COMMENTS_COMPONENTS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + inclusiveSpanPatch, + lithoFilterPatch, + sharedResourceIdPatch, + settingsPatch, + ) + + execute { + + // region patch for emoji picker button in shorts + + shortsLiveStreamEmojiPickerOpacityFingerprint.methodOrThrow().apply { + val insertIndex = implementation!!.instructions.lastIndex + val insertRegister = getInstruction(insertIndex).registerA + + addInstruction( + insertIndex, + "invoke-static {v$insertRegister}, $PLAYER_CLASS_DESCRIPTOR->changeEmojiPickerOpacity(Landroid/widget/ImageView;)V" + ) + } + + shortsLiveStreamEmojiPickerOnClickListenerFingerprint.methodOrThrow().apply { + val emojiPickerEndpointIndex = + indexOfFirstLiteralInstructionOrThrow(126326492L) + val emojiPickerOnClickListenerIndex = + indexOfFirstInstructionOrThrow(emojiPickerEndpointIndex, Opcode.INVOKE_DIRECT) + val emojiPickerOnClickListenerMethod = + getWalkerMethod(emojiPickerOnClickListenerIndex) + + emojiPickerOnClickListenerMethod.apply { + val insertIndex = indexOfFirstInstructionOrThrow(Opcode.IF_EQZ) + val insertRegister = + getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $PLAYER_CLASS_DESCRIPTOR->disableEmojiPickerOnClickListener(Ljava/lang/Object;)Ljava/lang/Object; + move-result-object v$insertRegister + """ + ) + } + } + + // endregion + + addSpanFilter(SEARCH_LINKS_FILTER_CLASS_DESCRIPTOR) + addLithoFilter(COMMENTS_FILTER_CLASS_DESCRIPTOR) + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: PLAYER", + "SETTINGS: HIDE_COMMENTS_COMPONENTS" + ), + HIDE_COMMENTS_COMPONENTS + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/comments/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/comments/Fingerprints.kt new file mode 100644 index 0000000000..95bb87ec9f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/comments/Fingerprints.kt @@ -0,0 +1,22 @@ +package app.revanced.patches.youtube.player.comments + +import app.revanced.patches.youtube.utils.resourceid.emojiPickerIcon +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val shortsLiveStreamEmojiPickerOnClickListenerFingerprint = legacyFingerprint( + name = "shortsLiveStreamEmojiPickerOnClickListenerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC.value, + parameters = listOf("L"), + literals = listOf(126326492L), +) + +internal val shortsLiveStreamEmojiPickerOpacityFingerprint = legacyFingerprint( + name = "shortsLiveStreamEmojiPickerOpacityFingerprint", + returnType = "Landroid/widget/ImageView;", + accessFlags = AccessFlags.PROTECTED or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(emojiPickerIcon), +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/components/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/components/Fingerprints.kt new file mode 100644 index 0000000000..d0a493b919 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/components/Fingerprints.kt @@ -0,0 +1,281 @@ +@file:Suppress("SpellCheckingInspection") + +package app.revanced.patches.youtube.player.components + +import app.revanced.patches.youtube.utils.resourceid.componentLongClickListener +import app.revanced.patches.youtube.utils.resourceid.darkBackground +import app.revanced.patches.youtube.utils.resourceid.donationCompanion +import app.revanced.patches.youtube.utils.resourceid.easySeekEduContainer +import app.revanced.patches.youtube.utils.resourceid.endScreenElementLayoutCircle +import app.revanced.patches.youtube.utils.resourceid.endScreenElementLayoutIcon +import app.revanced.patches.youtube.utils.resourceid.endScreenElementLayoutVideo +import app.revanced.patches.youtube.utils.resourceid.notice +import app.revanced.patches.youtube.utils.resourceid.offlineActionsVideoDeletedUndoSnackbarText +import app.revanced.patches.youtube.utils.resourceid.scrubbing +import app.revanced.patches.youtube.utils.resourceid.seekEasyHorizontalTouchOffsetToStartScrubbing +import app.revanced.patches.youtube.utils.resourceid.suggestedAction +import app.revanced.patches.youtube.utils.resourceid.tapBloomView +import app.revanced.patches.youtube.utils.resourceid.touchArea +import app.revanced.patches.youtube.utils.resourceid.videoZoomSnapIndicator +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val horizontalTouchOffsetConstructorFingerprint = legacyFingerprint( + name = "horizontalTouchOffsetConstructorFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + literals = listOf(seekEasyHorizontalTouchOffsetToStartScrubbing), +) + +internal val nextGenWatchLayoutFingerprint = legacyFingerprint( + name = "nextGenWatchLayoutFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + customFingerprint = handler@{ method, _ -> + if (method.definingClass != "Lcom/google/android/apps/youtube/app/watch/nextgenwatch/ui/NextGenWatchLayout;") + return@handler false + + method.indexOfFirstInstruction { + getReference()?.name == "booleanValue" + } >= 0 + } +) + +/** + * This value restores the 'Slide to seek' behavior. + * Deprecated in YouTube v19.18.41+. + */ +internal val restoreSlideToSeekBehaviorFingerprint = legacyFingerprint( + name = "restoreSlideToSeekBehaviorFingerprint", + returnType = "Z", + parameters = emptyList(), + opcodes = listOf(Opcode.MOVE_RESULT), + literals = listOf(45411329L), +) + +internal val slideToSeekMotionEventFingerprint = legacyFingerprint( + name = "slideToSeekMotionEventFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Landroid/view/View;", "Landroid/view/MotionEvent;"), + opcodes = listOf( + Opcode.SUB_FLOAT_2ADDR, + Opcode.INVOKE_VIRTUAL, // SlideToSeek Boolean method + Opcode.MOVE_RESULT, + Opcode.IF_NEZ, + Opcode.IGET_OBJECT, // insert index + Opcode.INVOKE_VIRTUAL + ) +) + +/** + * This value disables 'Playing at 2x speed' while holding down. + * Deprecated in YouTube v19.18.41+. + */ +internal val speedOverlayFingerprint = legacyFingerprint( + name = "speedOverlayFingerprint", + returnType = "Z", + parameters = emptyList(), + opcodes = listOf(Opcode.MOVE_RESULT), + literals = listOf(45411330L), +) + +/** + * This value is the key for the playback speed overlay value. + * Deprecated in YouTube v19.18.41+. + */ +internal val speedOverlayFloatValueFingerprint = legacyFingerprint( + name = "speedOverlayFloatValueFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + opcodes = listOf(Opcode.DOUBLE_TO_FLOAT), + literals = listOf(45411328L), +) + +internal val speedOverlayTextValueFingerprint = legacyFingerprint( + name = "speedOverlayTextValueFingerprint", + returnType = "V", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf(Opcode.CONST_WIDE_HIGH16), + customFingerprint = { method, _ -> + method.indexOfFirstInstruction { + getReference()?.toString() == "Ljava/math/BigDecimal;->signum()I" + } >= 0 + } +) + +internal val crowdfundingBoxFingerprint = legacyFingerprint( + name = "crowdfundingBoxFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IPUT_OBJECT + ), + literals = listOf(donationCompanion), +) + +internal val filmStripOverlayConfigFingerprint = legacyFingerprint( + name = "filmStripOverlayConfigFingerprint", + returnType = "Z", + parameters = emptyList(), + literals = listOf(45381958L), +) + +internal val filmStripOverlayInteractionFingerprint = legacyFingerprint( + name = "filmStripOverlayInteractionFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("L") +) + +internal val filmStripOverlayParentFingerprint = legacyFingerprint( + name = "filmStripOverlayParentFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + literals = listOf(scrubbing), +) + +internal val filmStripOverlayPreviewFingerprint = legacyFingerprint( + name = "filmStripOverlayPreviewFingerprint", + returnType = "Z", + parameters = listOf("F"), + opcodes = listOf( + Opcode.SUB_FLOAT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT + ) +) + +internal val infoCardsIncognitoFingerprint = legacyFingerprint( + name = "infoCardsIncognitoFingerprint", + returnType = "Ljava/lang/Boolean;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "J"), + opcodes = listOf(Opcode.IGET_BOOLEAN), + strings = listOf("vibrator") +) + +internal val layoutCircleFingerprint = legacyFingerprint( + name = "layoutCircleFingerprint", + returnType = "Landroid/view/View;", + opcodes = listOf( + Opcode.CONST, + Opcode.CONST_4, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + ), + literals = listOf(endScreenElementLayoutCircle), +) + +internal val layoutIconFingerprint = legacyFingerprint( + name = "layoutIconFingerprint", + returnType = "Landroid/view/View;", + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + ), + literals = listOf(endScreenElementLayoutIcon), +) + +internal val layoutVideoFingerprint = legacyFingerprint( + name = "layoutVideoFingerprint", + returnType = "Landroid/view/View;", + opcodes = listOf( + Opcode.CONST, + Opcode.CONST_4, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + ), + literals = listOf(endScreenElementLayoutVideo), +) + +internal val lithoComponentOnClickListenerFingerprint = legacyFingerprint( + name = "lithoComponentOnClickListenerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PRIVATE or AccessFlags.STATIC, + parameters = listOf("L"), + literals = listOf(componentLongClickListener), +) + +internal val noticeOnClickListenerFingerprint = legacyFingerprint( + name = "noticeOnClickListenerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + literals = listOf(notice), +) + +internal val offlineActionsOnClickListenerFingerprint = legacyFingerprint( + name = "offlineActionsOnClickListenerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Ljava/lang/String;"), + literals = listOf(offlineActionsVideoDeletedUndoSnackbarText), +) + +internal val quickSeekOverlayFingerprint = legacyFingerprint( + name = "quickSeekOverlayFingerprint", + returnType = "V", + parameters = emptyList(), + literals = listOf(darkBackground, tapBloomView), +) + +internal val seekEduContainerFingerprint = legacyFingerprint( + name = "seekEduContainerFingerprint", + returnType = "V", + literals = listOf(easySeekEduContainer), +) + +internal val suggestedActionsFingerprint = legacyFingerprint( + name = "suggestedActionsFingerprint", + returnType = "V", + opcodes = listOf( + Opcode.CONST, + Opcode.CONST_4, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT + ), + literals = listOf(suggestedAction), +) + +internal val touchAreaOnClickListenerFingerprint = legacyFingerprint( + name = "touchAreaOnClickListenerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + literals = listOf(touchArea), +) + +internal val videoZoomSnapIndicatorFingerprint = legacyFingerprint( + name = "videoZoomSnapIndicatorFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + literals = listOf(videoZoomSnapIndicator), +) + +internal val watermarkFingerprint = legacyFingerprint( + name = "watermarkFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "L"), + opcodes = listOf( + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, + Opcode.IGET_BOOLEAN + ) +) + +internal val watermarkParentFingerprint = legacyFingerprint( + name = "watermarkParentFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + strings = listOf("player_overlay_in_video_programming") +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/components/PlayerComponentsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/components/PlayerComponentsPatch.kt new file mode 100644 index 0000000000..5b379ed4e2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/components/PlayerComponentsPatch.kt @@ -0,0 +1,660 @@ +package app.revanced.patches.youtube.player.components + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.patches.shared.spans.addSpanFilter +import app.revanced.patches.shared.spans.inclusiveSpanPatch +import app.revanced.patches.shared.startVideoInformerFingerprint +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.controlsoverlay.controlsOverlayConfigPatch +import app.revanced.patches.youtube.utils.engagementPanelBuilderFingerprint +import app.revanced.patches.youtube.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.youtube.utils.extension.Constants.PLAYER_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.extension.Constants.SPANS_PATH +import app.revanced.patches.youtube.utils.fix.suggestedvideoendscreen.suggestedVideoEndScreenPatch +import app.revanced.patches.youtube.utils.patch.PatchList.PLAYER_COMPONENTS +import app.revanced.patches.youtube.utils.playertype.playerTypeHookPatch +import app.revanced.patches.youtube.utils.resourceid.darkBackground +import app.revanced.patches.youtube.utils.resourceid.fadeDurationFast +import app.revanced.patches.youtube.utils.resourceid.scrimOverlay +import app.revanced.patches.youtube.utils.resourceid.seekUndoEduOverlayStub +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.resourceid.tapBloomView +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.patches.youtube.utils.youtubeControlsOverlayFingerprint +import app.revanced.patches.youtube.video.information.hookVideoInformation +import app.revanced.patches.youtube.video.information.videoInformationPatch +import app.revanced.util.REGISTER_TEMPLATE_REPLACEMENT +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.injectLiteralInstructionViewCall +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.mutableClassOrThrow +import app.revanced.util.fingerprint.resolvable +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.NarrowLiteralInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.WideLiteralInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private val speedOverlayPatch = bytecodePatch( + description = "speedOverlayPatch" +) { + dependsOn(sharedResourceIdPatch) + + execute { + fun MutableMethod.hookSpeedOverlay( + insertIndex: Int, + insertRegister: Int, + jumpIndex: Int + ) { + addInstructionsWithLabels( + insertIndex, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->disableSpeedOverlay()Z + move-result v$insertRegister + if-eqz v$insertRegister, :disable + """, ExternalLabel("disable", getInstruction(jumpIndex)) + ) + } + + val resolvable = restoreSlideToSeekBehaviorFingerprint.resolvable() && + speedOverlayFingerprint.resolvable() && + speedOverlayFloatValueFingerprint.resolvable() + + if (resolvable) { + // Used on YouTube 18.29.38 ~ YouTube 19.17.41 + + // region patch for Disable speed overlay (Enable slide to seek) + + mapOf( + restoreSlideToSeekBehaviorFingerprint to 45411329L, + speedOverlayFingerprint to 45411330L + ).forEach { (fingerprint, literal) -> + fingerprint.injectLiteralInstructionBooleanCall( + literal, + "$PLAYER_CLASS_DESCRIPTOR->disableSpeedOverlay(Z)Z" + ) + } + + // endregion + + // region patch for Custom speed overlay float value + + speedOverlayFloatValueFingerprint.matchOrThrow().let { + it.method.apply { + val index = it.patternMatch!!.startIndex + val register = getInstruction(index).registerA + + addInstructions( + index + 1, """ + invoke-static {v$register}, $PLAYER_CLASS_DESCRIPTOR->speedOverlayValue(F)F + move-result v$register + """ + ) + } + } + + // endregion + + } else { + // Used on YouTube 19.18.41~ + + // region patch for Disable speed overlay (Enable slide to seek) + + nextGenWatchLayoutFingerprint.methodOrThrow().apply { + val booleanValueIndex = indexOfFirstInstructionOrThrow { + getReference()?.name == "booleanValue" + } + val insertIndex = indexOfFirstInstructionOrThrow(booleanValueIndex - 10) { + opcode == Opcode.IGET_OBJECT && + getReference()?.definingClass == definingClass + } + val insertInstruction = getInstruction(insertIndex) + val insertReference = getInstruction(insertIndex).reference + + addInstruction( + insertIndex + 1, + "iget-object v${insertInstruction.registerA}, v${insertInstruction.registerB}, $insertReference" + ) + + val jumpIndex = indexOfFirstInstructionOrThrow(booleanValueIndex) { + opcode == Opcode.IGET_OBJECT && + getReference()?.definingClass == definingClass + } + + hookSpeedOverlay(insertIndex + 1, insertInstruction.registerA, jumpIndex) + } + + val (slideToSeekBooleanMethod, slideToSeekSyntheticMethod) = + slideToSeekMotionEventFingerprint.matchOrThrow( + horizontalTouchOffsetConstructorFingerprint + ).let { + with(it.method) { + val patternMatch = it.patternMatch!! + val jumpIndex = patternMatch.endIndex + 1 + val insertIndex = patternMatch.endIndex - 1 + val insertRegister = + getInstruction(insertIndex).registerA + + hookSpeedOverlay(insertIndex, insertRegister, jumpIndex) + + val slideToSeekBooleanMethod = + getWalkerMethod(patternMatch.startIndex + 1) + + val slideToSeekConstructorMethod = + findMethodOrThrow(slideToSeekBooleanMethod.definingClass) + + val slideToSeekSyntheticIndex = slideToSeekConstructorMethod + .indexOfFirstInstructionReversedOrThrow { + opcode == Opcode.NEW_INSTANCE + } + + val slideToSeekSyntheticClass = slideToSeekConstructorMethod + .getInstruction(slideToSeekSyntheticIndex) + .reference + .toString() + + val slideToSeekSyntheticMethod = + findMethodOrThrow(slideToSeekSyntheticClass) { + name == "run" + } + + Pair(slideToSeekBooleanMethod, slideToSeekSyntheticMethod) + } + } + + slideToSeekBooleanMethod.apply { + val insertIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.IGET_OBJECT + } + val insertRegister = getInstruction(insertIndex).registerA + val jumpIndex = indexOfFirstInstructionReversedOrThrow { + opcode == Opcode.INVOKE_VIRTUAL + } + + hookSpeedOverlay(insertIndex, insertRegister, jumpIndex) + } + + slideToSeekSyntheticMethod.apply { + val speedOverlayFloatValueIndex = indexOfFirstInstructionOrThrow { + (this as? NarrowLiteralInstruction)?.narrowLiteral == 2.0f.toRawBits() + } + val insertIndex = + indexOfFirstInstructionReversedOrThrow(speedOverlayFloatValueIndex) { + getReference()?.name == "removeCallbacks" + } + 1 + val insertRegister = + getInstruction(insertIndex - 1).registerC + val jumpIndex = + indexOfFirstInstructionOrThrow( + speedOverlayFloatValueIndex, + Opcode.RETURN_VOID + ) + 1 + + hookSpeedOverlay(insertIndex, insertRegister, jumpIndex) + } + + // endregion + + // region patch for Custom speed overlay float value + + slideToSeekSyntheticMethod.apply { + val speedOverlayFloatValueIndex = indexOfFirstInstructionOrThrow { + (this as? NarrowLiteralInstruction)?.narrowLiteral == 2.0f.toRawBits() + } + val speedOverlayFloatValueRegister = + getInstruction(speedOverlayFloatValueIndex).registerA + + addInstructions( + speedOverlayFloatValueIndex + 1, """ + invoke-static {v$speedOverlayFloatValueRegister}, $PLAYER_CLASS_DESCRIPTOR->speedOverlayValue(F)F + move-result v$speedOverlayFloatValueRegister + """ + ) + } + + speedOverlayTextValueFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = it.patternMatch!!.startIndex + val targetRegister = + getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->speedOverlayValue()D + move-result-wide v$targetRegister + """ + ) + } + } + + // endregion + + } + } +} + +private const val PLAYER_COMPONENTS_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/PlayerComponentsFilter;" +private const val SANITIZE_VIDEO_SUBTITLE_FILTER_CLASS_DESCRIPTOR = + "$SPANS_PATH/SanitizeVideoSubtitleFilter;" + +@Suppress("unused") +val playerComponentsPatch = bytecodePatch( + PLAYER_COMPONENTS.title, + PLAYER_COMPONENTS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + controlsOverlayConfigPatch, + inclusiveSpanPatch, + lithoFilterPatch, + playerTypeHookPatch, + sharedResourceIdPatch, + settingsPatch, + speedOverlayPatch, + suggestedVideoEndScreenPatch, + videoInformationPatch, + ) + + execute { + fun MutableMethod.getAllLiteralComponent( + startIndex: Int, + endIndex: Int + ): String { + var literalComponent = "" + for (index in startIndex..endIndex) { + val opcode = getInstruction(index).opcode + if (opcode != Opcode.CONST_16 && opcode != Opcode.CONST_4) + continue + + val register = getInstruction(index).registerA + val value = getInstruction(index).wideLiteral.toInt() + + val line = """ + const/16 v$register, $value + + """.trimIndent() + + literalComponent += line + } + + return literalComponent + } + + fun MutableMethod.getFirstLiteralComponent( + startIndex: Int, + endIndex: Int + ): String { + val constRegister = + getInstruction(endIndex).registerE + + for (index in endIndex downTo startIndex) { + val instruction = getInstruction(index) + if (instruction.opcode != Opcode.CONST_16 && instruction.opcode != Opcode.CONST_4) + continue + + if ((instruction as OneRegisterInstruction).registerA != constRegister) + continue + + val constValue = (instruction as WideLiteralInstruction).wideLiteral.toInt() + + return "const/16 v$constRegister, $constValue" + } + return "" + } + + fun MutableMethod.hookFilmstripOverlay() { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->hideFilmstripOverlay()Z + move-result v0 + if-eqz v0, :shown + const/4 v0, 0x0 + return v0 + """, ExternalLabel("shown", getInstruction(0)) + ) + } + + // region patch for custom player overlay opacity + + youtubeControlsOverlayFingerprint.methodOrThrow().apply { + val constIndex = indexOfFirstLiteralInstructionOrThrow(scrimOverlay) + val targetIndex = indexOfFirstInstructionOrThrow(constIndex, Opcode.CHECK_CAST) + val targetParameter = getInstruction(targetIndex).reference + val targetRegister = getInstruction(targetIndex).registerA + + if (!targetParameter.toString().endsWith("Landroid/widget/ImageView;")) + throw PatchException("Method signature parameter did not match: $targetParameter") + + addInstruction( + targetIndex + 1, + "invoke-static {v$targetRegister}, $PLAYER_CLASS_DESCRIPTOR->changeOpacity(Landroid/widget/ImageView;)V" + ) + } + + // endregion + + // region patch for disable auto player popup panels + + fun MutableMethod.hookInitVideoPanel(initVideoPanel: Int) = + addInstructions( + 0, """ + const/4 v0, $initVideoPanel + invoke-static {v0}, $PLAYER_CLASS_DESCRIPTOR->setInitVideoPanel(Z)V + """ + ) + + arrayOf( + lithoComponentOnClickListenerFingerprint, + noticeOnClickListenerFingerprint, + offlineActionsOnClickListenerFingerprint, + startVideoInformerFingerprint, + ).forEach { fingerprint -> + fingerprint.methodOrThrow().apply { + if (fingerprint == startVideoInformerFingerprint) { + hookInitVideoPanel(1) + } else { + val syntheticIndex = + indexOfFirstInstruction(Opcode.NEW_INSTANCE) + if (syntheticIndex >= 0) { + val syntheticReference = + getInstruction(syntheticIndex).reference.toString() + + findMethodOrThrow(syntheticReference) { + name == "onClick" + }.hookInitVideoPanel(0) + } + } + } + } + + engagementPanelBuilderFingerprint.methodOrThrow().apply { + addInstructionsWithLabels( + 0, """ + move/from16 v0, p4 + invoke-static {v0}, $PLAYER_CLASS_DESCRIPTOR->disableAutoPlayerPopupPanels(Z)Z + move-result v0 + if-eqz v0, :shown + const/4 v0, 0x0 + return-object v0 + """, ExternalLabel("shown", getInstruction(0)) + ) + } + + // endregion + + // region patch for disable auto switch mix playlists + + hookVideoInformation("$PLAYER_CLASS_DESCRIPTOR->disableAutoSwitchMixPlaylists(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V") + + // endregion + + // region patch for hide channel watermark + + watermarkFingerprint.matchOrThrow(watermarkParentFingerprint).let { + it.method.apply { + val insertIndex = it.patternMatch!!.endIndex + val register = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex + 1, """ + invoke-static {v$register}, $PLAYER_CLASS_DESCRIPTOR->hideChannelWatermark(Z)Z + move-result v$register + """ + ) + } + } + + // endregion + + // region patch for hide crowdfunding box + + crowdfundingBoxFingerprint.matchOrThrow().let { + it.method.apply { + val insertIndex = it.patternMatch!!.endIndex + val register = getInstruction(insertIndex).registerA + + addInstruction( + insertIndex, + "invoke-static {v$register}, $PLAYER_CLASS_DESCRIPTOR->hideCrowdfundingBox(Landroid/view/View;)V" + ) + } + } + + // endregion + + // region patch for hide double-tap overlay filter + + val smaliInstruction = """ + invoke-static {v$REGISTER_TEMPLATE_REPLACEMENT}, $PLAYER_CLASS_DESCRIPTOR->hideDoubleTapOverlayFilter(Landroid/view/View;)V + """ + + arrayOf( + darkBackground, + tapBloomView + ).forEach { literal -> + quickSeekOverlayFingerprint.injectLiteralInstructionViewCall( + literal, + smaliInstruction + ) + } + + // endregion + + // region patch for hide end screen cards + + listOf( + layoutCircleFingerprint, + layoutIconFingerprint, + layoutVideoFingerprint + ).forEach { fingerprint -> + fingerprint.matchOrThrow().let { + it.method.apply { + val insertIndex = it.patternMatch!!.endIndex + val viewRegister = getInstruction(insertIndex).registerA + + addInstruction( + insertIndex + 1, + "invoke-static { v$viewRegister }, $PLAYER_CLASS_DESCRIPTOR->hideEndScreenCards(Landroid/view/View;)V" + ) + } + } + } + + // endregion + + // region patch for hide filmstrip overlay + + arrayOf( + filmStripOverlayConfigFingerprint, + filmStripOverlayInteractionFingerprint, + filmStripOverlayPreviewFingerprint + ).forEach { fingerprint -> + fingerprint.methodOrThrow(filmStripOverlayParentFingerprint).hookFilmstripOverlay() + } + + youtubeControlsOverlayFingerprint.methodOrThrow().apply { + val constIndex = indexOfFirstLiteralInstructionOrThrow(fadeDurationFast) + val constRegister = getInstruction(constIndex).registerA + val insertIndex = + indexOfFirstInstructionReversedOrThrow(constIndex, Opcode.INVOKE_VIRTUAL) + 1 + val jumpIndex = implementation!!.instructions.let { instruction -> + insertIndex + instruction.subList(insertIndex, instruction.size - 1) + .indexOfFirst { instructions -> + instructions.opcode == Opcode.GOTO || instructions.opcode == Opcode.GOTO_16 + } + } + + val replaceInstruction = getInstruction(insertIndex) + val replaceReference = + getInstruction(insertIndex).reference + + addInstructionsWithLabels( + insertIndex + 1, getAllLiteralComponent(insertIndex, jumpIndex - 1) + """ + const v$constRegister, $fadeDurationFast + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->hideFilmstripOverlay()Z + move-result v${replaceInstruction.registerA} + if-nez v${replaceInstruction.registerA}, :hidden + iget-object v${replaceInstruction.registerA}, v${replaceInstruction.registerB}, $replaceReference + """, ExternalLabel("hidden", getInstruction(jumpIndex)) + ) + removeInstruction(insertIndex) + } + + // endregion + + // region patch for hide info cards + + infoCardsIncognitoFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = it.patternMatch!!.startIndex + val targetRegister = + getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, """ + invoke-static {v$targetRegister}, $PLAYER_CLASS_DESCRIPTOR->hideInfoCard(Z)Z + move-result v$targetRegister + """ + ) + } + } + + // endregion + + // region patch for hide seek message + + seekEduContainerFingerprint.methodOrThrow().apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->hideSeekMessage()Z + move-result v0 + if-eqz v0, :default + return-void + """, ExternalLabel("default", getInstruction(0)) + ) + } + + youtubeControlsOverlayFingerprint.methodOrThrow().apply { + val insertIndex = + indexOfFirstLiteralInstructionOrThrow(seekUndoEduOverlayStub) + val insertRegister = getInstruction(insertIndex).registerA + + val onClickListenerIndex = indexOfFirstInstructionOrThrow(insertIndex) { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "setOnClickListener" + } + val constComponent = getFirstLiteralComponent(insertIndex, onClickListenerIndex - 1) + + if (constComponent.isNotEmpty()) { + addInstruction( + onClickListenerIndex + 2, + constComponent + ) + } + addInstructionsWithLabels( + insertIndex, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->hideSeekUndoMessage()Z + move-result v$insertRegister + if-nez v$insertRegister, :default + """, ExternalLabel("default", getInstruction(onClickListenerIndex + 1)) + ) + } + + // endregion + + // region patch for hide suggested actions + + suggestedActionsFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = it.patternMatch!!.endIndex + val targetRegister = getInstruction(targetIndex).registerA + + addInstruction( + targetIndex + 1, + "invoke-static {v$targetRegister}, $PLAYER_CLASS_DESCRIPTOR->hideSuggestedActions(Landroid/view/View;)V" + + ) + } + } + + // endregion + + // region patch for skip autoplay countdown + + // This patch works fine when the [SuggestedVideoEndScreenPatch] patch is included. + touchAreaOnClickListenerFingerprint.mutableClassOrThrow().let { + it.methods.find { method -> + method.parameters == listOf("Landroid/view/View${'$'}OnClickListener;") + }?.apply { + val setOnClickListenerIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "setOnClickListener" + } + val setOnClickListenerRegister = + getInstruction(setOnClickListenerIndex).registerC + + addInstruction( + setOnClickListenerIndex + 1, + "invoke-static {v$setOnClickListenerRegister}, $PLAYER_CLASS_DESCRIPTOR->skipAutoPlayCountdown(Landroid/view/View;)V" + ) + } ?: throw PatchException("Failed to find setOnClickListener method") + } + + // endregion + + // region patch for hide video zoom overlay + + videoZoomSnapIndicatorFingerprint.methodOrThrow().apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->hideZoomOverlay()Z + move-result v0 + if-eqz v0, :shown + return-void + """, ExternalLabel("shown", getInstruction(0)) + ) + } + + // endregion + + addSpanFilter(SANITIZE_VIDEO_SUBTITLE_FILTER_CLASS_DESCRIPTOR) + addLithoFilter(PLAYER_COMPONENTS_FILTER_CLASS_DESCRIPTOR) + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: PLAYER", + "SETTINGS: PLAYER_COMPONENTS" + ), + PLAYER_COMPONENTS + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/descriptions/DescriptionComponentsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/descriptions/DescriptionComponentsPatch.kt new file mode 100644 index 0000000000..060a88a85e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/descriptions/DescriptionComponentsPatch.kt @@ -0,0 +1,136 @@ +package app.revanced.patches.youtube.player.descriptions + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.youtube.utils.extension.Constants.PLAYER_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.patch.PatchList.DESCRIPTION_COMPONENTS +import app.revanced.patches.youtube.utils.playertype.playerTypeHookPatch +import app.revanced.patches.youtube.utils.playservice.is_18_49_or_greater +import app.revanced.patches.youtube.utils.playservice.is_19_02_or_greater +import app.revanced.patches.youtube.utils.playservice.versionCheckPatch +import app.revanced.patches.youtube.utils.recyclerview.bottomSheetRecyclerViewHook +import app.revanced.patches.youtube.utils.recyclerview.bottomSheetRecyclerViewPatch +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.rollingNumberTextViewAnimationUpdateFingerprint +import app.revanced.patches.youtube.utils.rollingNumberTextViewFingerprint +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private const val FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/DescriptionsFilter;" + +@Suppress("unused") +val descriptionComponentsPatch = bytecodePatch( + DESCRIPTION_COMPONENTS.title, + DESCRIPTION_COMPONENTS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + bottomSheetRecyclerViewPatch, + lithoFilterPatch, + playerTypeHookPatch, + sharedResourceIdPatch, + settingsPatch, + versionCheckPatch, + ) + + execute { + + var settingArray = arrayOf( + "PREFERENCE_SCREEN: PLAYER", + "SETTINGS: DESCRIPTION_COMPONENTS" + ) + + // region patch for disable rolling number animation + + // RollingNumber is applied to YouTube v18.49.37+. + // In order to maintain compatibility with YouTube v18.48.39 or previous versions, + // This patch is applied only to the version after YouTube v18.49.37. + if (is_18_49_or_greater) { + rollingNumberTextViewAnimationUpdateFingerprint.matchOrThrow( + rollingNumberTextViewFingerprint + ).let { + it.method.apply { + val freeRegister = implementation!!.registerCount - parameters.size - 2 + val imageSpanIndex = it.patternMatch!!.startIndex + val setTextIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "setText" + } + addInstruction(setTextIndex, "nop") + addInstructionsWithLabels( + imageSpanIndex, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->disableRollingNumberAnimations()Z + move-result v$freeRegister + if-nez v$freeRegister, :disable_animations + """, ExternalLabel("disable_animations", getInstruction(setTextIndex)) + ) + } + } + + settingArray += "SETTINGS: DISABLE_ROLLING_NUMBER_ANIMATIONS" + } + + // endregion + + // region patch for disable video description interaction and expand video description + + // since these patches are still A/B tested, they are classified as 'Experimental flags'. + if (is_19_02_or_greater) { + textViewComponentFingerprint.methodOrThrow().apply { + val insertIndex = indexOfTextIsSelectableInstruction(this) + val insertInstruction = getInstruction(insertIndex) + + replaceInstruction( + insertIndex, + "invoke-static {v${insertInstruction.registerC}, v${insertInstruction.registerD}}, " + + "$PLAYER_CLASS_DESCRIPTOR->disableVideoDescriptionInteraction(Landroid/widget/TextView;Z)V" + ) + } + + engagementPanelTitleFingerprint.methodOrThrow(engagementPanelTitleParentFingerprint) + .apply { + val contentDescriptionIndex = indexOfContentDescriptionInstruction(this) + val contentDescriptionRegister = + getInstruction(contentDescriptionIndex).registerD + + addInstruction( + contentDescriptionIndex, + "invoke-static {v$contentDescriptionRegister}," + + "$PLAYER_CLASS_DESCRIPTOR->setContentDescription(Ljava/lang/String;)V" + ) + } + + bottomSheetRecyclerViewHook("$PLAYER_CLASS_DESCRIPTOR->onVideoDescriptionCreate(Landroid/support/v7/widget/RecyclerView;)V") + + settingArray += "SETTINGS: DESCRIPTION_INTERACTION" + } + + // endregion + + addLithoFilter(FILTER_CLASS_DESCRIPTOR) + + // region add settings + + addPreference(settingArray, DESCRIPTION_COMPONENTS) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/descriptions/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/descriptions/Fingerprints.kt new file mode 100644 index 0000000000..82fd8fd5fb --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/descriptions/Fingerprints.kt @@ -0,0 +1,50 @@ +package app.revanced.patches.youtube.player.descriptions + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.indexOfFirstInstructionReversed +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val engagementPanelTitleFingerprint = legacyFingerprint( + name = "engagementPanelTitleFingerprint", + strings = listOf(". "), + customFingerprint = { method, _ -> + indexOfContentDescriptionInstruction(method) >= 0 + } +) + +internal fun indexOfContentDescriptionInstruction(method: Method) = + method.indexOfFirstInstructionReversed { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "setContentDescription" + } + +internal val engagementPanelTitleParentFingerprint = legacyFingerprint( + name = "engagementPanelTitleParentFingerprint", + strings = listOf("[EngagementPanelTitleHeader] Cannot remove action buttons from header as the child count is out of sync. Buttons to remove exceed current header child count.") +) + +/** + * This fingerprint is compatible with YouTube v18.35.xx~ + * Nonetheless, the patch works in YouTube v19.02.xx~ + */ +internal val textViewComponentFingerprint = legacyFingerprint( + name = "textViewComponentFingerprint", + returnType = "V", + opcodes = listOf(Opcode.CMPL_FLOAT), + customFingerprint = { method, _ -> + method.implementation != null && + indexOfTextIsSelectableInstruction(method) >= 0 + }, +) + +internal fun indexOfTextIsSelectableInstruction(method: Method) = + method.indexOfFirstInstruction { + val reference = getReference() + opcode == Opcode.INVOKE_VIRTUAL && + reference?.name == "setTextIsSelectable" && + reference.definingClass != "Landroid/widget/TextView;" + } \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/flyoutmenu/hide/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/flyoutmenu/hide/Fingerprints.kt new file mode 100644 index 0000000000..254670acdd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/flyoutmenu/hide/Fingerprints.kt @@ -0,0 +1,115 @@ +package app.revanced.patches.youtube.player.flyoutmenu.hide + +import app.revanced.patches.youtube.utils.resourceid.bottomSheetFooterText +import app.revanced.patches.youtube.utils.resourceid.subtitleMenuSettingsFooterInfo +import app.revanced.patches.youtube.utils.resourceid.videoQualityBottomSheet +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import app.revanced.util.parametersEqual +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val advancedQualityBottomSheetFingerprint = legacyFingerprint( + name = "advancedQualityBottomSheetFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "L", "L"), + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.CONST, + Opcode.CONST_4, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CONST_16, + Opcode.INVOKE_VIRTUAL, + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IGET_OBJECT, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IGET_OBJECT, + Opcode.CONST_STRING + ), + literals = listOf(videoQualityBottomSheet), +) + +internal val captionsBottomSheetFingerprint = legacyFingerprint( + name = "captionsBottomSheetFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + literals = listOf(bottomSheetFooterText, subtitleMenuSettingsFooterInfo), +) + +/** + * This fingerprint is compatible with YouTube v18.39.xx+ + */ +internal val pipModeConfigFingerprint = legacyFingerprint( + name = "pipModeConfigFingerprint", + literals = listOf(45427407L), +) + +internal val videoQualityArrayFingerprint = legacyFingerprint( + name = "videoQualityArrayFingerprint", + returnType = "[Lcom/google/android/libraries/youtube/innertube/model/media/VideoQuality;", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + // 18.29 and earlier parameters are: + // "Ljava/util/List;", + // "Ljava/lang/String;" + // "L" + + // 18.31+ parameters are: + // "Ljava/util/List;", + // "Ljava/util/Collection;", + // "Ljava/lang/String;" + // "L" + customFingerprint = custom@{ method, _ -> + val parameterTypes = method.parameterTypes + val parameterSize = parameterTypes.size + if (parameterSize != 3 && parameterSize != 4) { + return@custom false + } + + val startsWithMethodParameterList = parameterTypes.slice(0..0) + val endsWithMethodParameterList = parameterTypes.slice(parameterSize - 2..= 0 + } +) + +private val VIDEO_QUALITY_ARRAY_STARTS_WITH_PARAMETER_LIST = listOf( + "Ljava/util/List;" +) +private val VIDEO_QUALITY_ARRAY_ENDS_WITH_PARAMETER_LIST = listOf( + "Ljava/lang/String;", + "L" +) + +internal fun indexOfQualityLabelInstruction(method: Method) = + method.indexOfFirstInstruction { + val reference = getReference() + opcode == Opcode.INVOKE_VIRTUAL && + reference?.returnType == "Ljava/lang/String;" && + reference.parameterTypes.size == 0 && + reference.definingClass == "Lcom/google/android/libraries/youtube/innertube/model/media/FormatStreamModel;" + } diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/flyoutmenu/hide/PlayerFlyoutMenuPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/flyoutmenu/hide/PlayerFlyoutMenuPatch.kt new file mode 100644 index 0000000000..03c96914c0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/flyoutmenu/hide/PlayerFlyoutMenuPatch.kt @@ -0,0 +1,137 @@ +package app.revanced.patches.youtube.player.flyoutmenu.hide + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.youtube.utils.extension.Constants.PLAYER_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.patch.PatchList.HIDE_PLAYER_FLYOUT_MENU +import app.revanced.patches.youtube.utils.playertype.playerTypeHookPatch +import app.revanced.patches.youtube.utils.playservice.is_18_39_or_greater +import app.revanced.patches.youtube.utils.playservice.versionCheckPatch +import app.revanced.patches.youtube.utils.qualityMenuViewInflateFingerprint +import app.revanced.patches.youtube.utils.resourceid.bottomSheetFooterText +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.REGISTER_TEMPLATE_REPLACEMENT +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.injectLiteralInstructionViewCall +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private const val PANELS_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/PlayerFlyoutMenuFilter;" + +@Suppress("unused") +val playerFlyoutMenuPatch = bytecodePatch( + HIDE_PLAYER_FLYOUT_MENU.title, + HIDE_PLAYER_FLYOUT_MENU.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + lithoFilterPatch, + playerTypeHookPatch, + sharedResourceIdPatch, + settingsPatch, + versionCheckPatch + ) + + execute { + var settingArray = arrayOf( + "PREFERENCE_SCREEN: PLAYER", + "PREFERENCE_SCREENS: FLYOUT_MENU", + "SETTINGS: HIDE_PLAYER_FLYOUT_MENU" + ) + + // region hide player flyout menu header, footer (non-litho) + + mapOf( + advancedQualityBottomSheetFingerprint to "hidePlayerFlyoutMenuQualityFooter", + captionsBottomSheetFingerprint to "hidePlayerFlyoutMenuCaptionsFooter", + qualityMenuViewInflateFingerprint to "hidePlayerFlyoutMenuQualityFooter" + ).forEach { (fingerprint, name) -> + val smaliInstruction = """ + invoke-static {v$REGISTER_TEMPLATE_REPLACEMENT}, $PLAYER_CLASS_DESCRIPTOR->$name(Landroid/view/View;)V + """ + fingerprint.injectLiteralInstructionViewCall(bottomSheetFooterText, smaliInstruction) + } + + arrayOf( + advancedQualityBottomSheetFingerprint, + qualityMenuViewInflateFingerprint + ).forEach { fingerprint -> + fingerprint.methodOrThrow().apply { + val insertIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "addHeaderView" + } + val insertRegister = getInstruction(insertIndex).registerD + + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $PLAYER_CLASS_DESCRIPTOR->hidePlayerFlyoutMenuQualityHeader(Landroid/view/View;)Landroid/view/View; + move-result-object v$insertRegister + """ + ) + } + } + + // endregion + + // region patch for hide '1080p Premium' label + + videoQualityArrayFingerprint.methodOrThrow().apply { + val qualityLabelIndex = indexOfQualityLabelInstruction(this) + 1 + val qualityLabelRegister = + getInstruction(qualityLabelIndex).registerA + val jumpIndex = indexOfFirstInstructionReversedOrThrow(qualityLabelIndex) { + opcode == Opcode.INVOKE_INTERFACE && + getReference()?.name == "hasNext" + } + + addInstructionsWithLabels( + qualityLabelIndex + 1, """ + invoke-static {v$qualityLabelRegister}, $PLAYER_CLASS_DESCRIPTOR->hidePlayerFlyoutMenuEnhancedBitrate(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$qualityLabelRegister + if-eqz v$qualityLabelRegister, :jump + """, ExternalLabel("jump", getInstruction(jumpIndex)) + ) + } + + // endregion + + // region patch for hide pip mode menu + + if (is_18_39_or_greater) { + pipModeConfigFingerprint.injectLiteralInstructionBooleanCall( + 45427407L, + "$PLAYER_CLASS_DESCRIPTOR->hidePiPModeMenu(Z)Z" + ) + settingArray += "SETTINGS: HIDE_PIP_MODE_MENU" + } + + // endregion + + addLithoFilter(PANELS_FILTER_CLASS_DESCRIPTOR) + + // region add settings + + addPreference(settingArray, HIDE_PLAYER_FLYOUT_MENU) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/flyoutmenu/toggle/ChangeTogglePatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/flyoutmenu/toggle/ChangeTogglePatch.kt new file mode 100644 index 0000000000..3c94fcd09d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/flyoutmenu/toggle/ChangeTogglePatch.kt @@ -0,0 +1,183 @@ +package app.revanced.patches.youtube.player.flyoutmenu.toggle + +import app.revanced.patcher.Fingerprint +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.PLAYER_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.patch.PatchList.CHANGE_PLAYER_FLYOUT_MENU_TOGGLES +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.resolvable +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import app.revanced.util.indexOfFirstStringInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +@Suppress("unused") +val changeTogglePatch = bytecodePatch( + CHANGE_PLAYER_FLYOUT_MENU_TOGGLES.title, + CHANGE_PLAYER_FLYOUT_MENU_TOGGLES.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + fun changeToggleCinematicLightingHook() { + val stableVolumeMethod = stableVolumeFingerprint.methodOrThrow() + + val stringReferenceIndex = stableVolumeMethod.indexOfFirstInstruction { + opcode == Opcode.INVOKE_VIRTUAL && + (this as ReferenceInstruction).reference.toString() + .endsWith("(Ljava/lang/String;Ljava/lang/String;)V") + } + if (stringReferenceIndex < 0) + throw PatchException("Target reference was not found in stableVolumeFingerprint.") + + val stringReference = + stableVolumeMethod.getInstruction(stringReferenceIndex).reference + + cinematicLightingFingerprint.methodOrThrow().apply { + val iGetIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.IGET && + getReference()?.definingClass == definingClass + } + val classRegister = getInstruction(iGetIndex).registerB + + val stringIndex = + indexOfFirstStringInstructionOrThrow("menu_item_cinematic_lighting") + + val checkCastIndex = + indexOfFirstInstructionReversedOrThrow(stringIndex, Opcode.CHECK_CAST) + val iGetObjectPrimaryIndex = + indexOfFirstInstructionReversedOrThrow(checkCastIndex, Opcode.IGET_OBJECT) + val iGetObjectSecondaryIndex = + indexOfFirstInstructionOrThrow(checkCastIndex, Opcode.IGET_OBJECT) + + val checkCastReference = + getInstruction(checkCastIndex).reference + val iGetObjectPrimaryReference = + getInstruction(iGetObjectPrimaryIndex).reference + val iGetObjectSecondaryReference = + getInstruction(iGetObjectSecondaryIndex).reference + + val invokeVirtualIndex = + indexOfFirstInstructionOrThrow(stringIndex, Opcode.INVOKE_VIRTUAL) + val invokeVirtualInstruction = + getInstruction(invokeVirtualIndex) + val freeRegisterC = invokeVirtualInstruction.registerC + val freeRegisterD = invokeVirtualInstruction.registerD + val freeRegisterE = invokeVirtualInstruction.registerE + + val insertIndex = indexOfFirstInstructionOrThrow(stringIndex, Opcode.RETURN_VOID) + + addInstructionsWithLabels( + insertIndex, """ + const/4 v$freeRegisterC, 0x1 + invoke-static {v$freeRegisterC}, $PLAYER_CLASS_DESCRIPTOR->changeSwitchToggle(Z)Z + move-result v$freeRegisterC + if-nez v$freeRegisterC, :ignore + sget-object v$freeRegisterC, Ljava/lang/Boolean;->FALSE:Ljava/lang/Boolean; + if-eq v$freeRegisterC, v$freeRegisterE, :toggle_off + const-string v$freeRegisterE, "stable_volume_on" + invoke-static {v$freeRegisterE}, $PLAYER_CLASS_DESCRIPTOR->getToggleString(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$freeRegisterE + goto :set_string + :toggle_off + const-string v$freeRegisterE, "stable_volume_off" + invoke-static {v$freeRegisterE}, $PLAYER_CLASS_DESCRIPTOR->getToggleString(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$freeRegisterE + :set_string + iget-object v$freeRegisterC, v$classRegister, $iGetObjectPrimaryReference + check-cast v$freeRegisterC, $checkCastReference + iget-object v$freeRegisterC, v$freeRegisterC, $iGetObjectSecondaryReference + const-string v$freeRegisterD, "menu_item_cinematic_lighting" + invoke-virtual {v$freeRegisterC, v$freeRegisterD, v$freeRegisterE}, $stringReference + """, ExternalLabel("ignore", getInstruction(insertIndex)) + ) + } + } + + fun changeToggleHook( + fingerprint: Pair, + methodToCall: String + ) { + val method = if (fingerprint == playbackLoopInitFingerprint) + fingerprint.methodOrThrow(playbackLoopOnClickListenerFingerprint) + else + fingerprint.methodOrThrow() + + method.apply { + val referenceIndex = indexOfFirstInstruction { + opcode == Opcode.INVOKE_VIRTUAL && + (this as ReferenceInstruction).reference.toString() + .endsWith(methodToCall) + } + if (referenceIndex > 0) { + val insertRegister = + getInstruction(referenceIndex + 1).registerA + + addInstructions( + referenceIndex + 2, """ + invoke-static {v$insertRegister}, $PLAYER_CLASS_DESCRIPTOR->changeSwitchToggle(Z)Z + move-result v$insertRegister + """ + ) + } else { + if (fingerprint == cinematicLightingFingerprint) + changeToggleCinematicLightingHook() + else + throw PatchException("Target reference was not found in ${fingerprint.first}.") + } + } + } + + + val additionalSettingsConfigMethod = + additionalSettingsConfigFingerprint.methodOrThrow() + val methodToCall = + additionalSettingsConfigMethod.definingClass + "->" + additionalSettingsConfigMethod.name + "()Z" + + var fingerprintArray = arrayOf( + cinematicLightingFingerprint, + playbackLoopInitFingerprint, + playbackLoopOnClickListenerFingerprint, + stableVolumeFingerprint + ) + + if (pipFingerprint.resolvable()) { + fingerprintArray += pipFingerprint + } + + fingerprintArray.forEach { fingerprint -> + changeToggleHook(fingerprint, methodToCall) + } + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: PLAYER", + "PREFERENCE_SCREENS: FLYOUT_MENU", + "SETTINGS: CHANGE_PLAYER_FLYOUT_MENU_TOGGLE" + ), + CHANGE_PLAYER_FLYOUT_MENU_TOGGLES + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/flyoutmenu/toggle/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/flyoutmenu/toggle/Fingerprints.kt new file mode 100644 index 0000000000..ea57bf81e2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/flyoutmenu/toggle/Fingerprints.kt @@ -0,0 +1,54 @@ +package app.revanced.patches.youtube.player.flyoutmenu.toggle + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val additionalSettingsConfigFingerprint = legacyFingerprint( + name = "additionalSettingsConfigFingerprint", + returnType = "Z", + literals = listOf(45412662L), +) + +internal val cinematicLightingFingerprint = legacyFingerprint( + name = "cinematicLightingFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Ljava/lang/Object;"), + strings = listOf("menu_item_cinematic_lighting") +) + +internal val pipFingerprint = legacyFingerprint( + name = "pipFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + strings = listOf("menu_item_picture_in_picture"), + customFingerprint = { _, classDef -> + classDef.methods.count() > 5 + } +) + +internal val playbackLoopInitFingerprint = legacyFingerprint( + name = "playbackLoopInitFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + strings = listOf("menu_item_single_video_playback_loop") +) + +internal val playbackLoopOnClickListenerFingerprint = legacyFingerprint( + name = "playbackLoopOnClickListenerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("I", "Z"), + strings = listOf("menu_item_single_video_playback_loop") +) + +internal val stableVolumeFingerprint = legacyFingerprint( + name = "stableVolumeFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Ljava/lang/Object;"), + strings = listOf("menu_item_stable_volume") +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/fullscreen/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/fullscreen/Fingerprints.kt new file mode 100644 index 0000000000..d89496da62 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/fullscreen/Fingerprints.kt @@ -0,0 +1,81 @@ +package app.revanced.patches.youtube.player.fullscreen + +import app.revanced.patches.youtube.utils.resourceid.appRelatedEndScreenResults +import app.revanced.patches.youtube.utils.resourceid.fullScreenEngagementPanel +import app.revanced.patches.youtube.utils.resourceid.playerVideoTitleView +import app.revanced.patches.youtube.utils.resourceid.quickActionsElementContainer +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val broadcastReceiverFingerprint = legacyFingerprint( + name = "broadcastReceiverFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Landroid/content/Context;", "Landroid/content/Intent;"), + strings = listOf( + "android.intent.action.SCREEN_ON", + "android.intent.action.SCREEN_OFF", + "android.intent.action.BATTERY_CHANGED" + ), + customFingerprint = { _, classDef -> + classDef.superclass == "Landroid/content/BroadcastReceiver;" + } +) + +internal val clientSettingEndpointFingerprint = legacyFingerprint( + name = "clientSettingEndpointFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "Ljava/util/Map;"), + strings = listOf( + "OVERRIDE_EXIT_FULLSCREEN_TO_MAXIMIZED", + "force_fullscreen", + "start_watch_minimized", + "watch" + ) +) + +internal val engagementPanelFingerprint = legacyFingerprint( + name = "engagementPanelFingerprint", + returnType = "L", + parameters = listOf("L"), + literals = listOf(fullScreenEngagementPanel), +) + +/** + * This fingerprint is compatible with YouTube v18.42.41+ + */ +internal val landScapeModeConfigFingerprint = legacyFingerprint( + name = "landScapeModeConfigFingerprint", + returnType = "Z", + literals = listOf(45446428L), +) + +internal val playerTitleViewFingerprint = legacyFingerprint( + name = "playerTitleViewFingerprint", + returnType = "V", + literals = listOf(playerVideoTitleView), +) + +internal val quickActionsElementFingerprint = legacyFingerprint( + name = "quickActionsElementFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Landroid/view/View;"), + literals = listOf(quickActionsElementContainer), +) + +internal val relatedEndScreenResultsFingerprint = legacyFingerprint( + name = "relatedEndScreenResultsFingerprint", + returnType = "V", + literals = listOf(appRelatedEndScreenResults), +) + +internal val videoPortraitParentFingerprint = legacyFingerprint( + name = "videoPortraitParentFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "Ljava/util/Map;"), + strings = listOf("Acquiring NetLatencyActionLogger failed. taskId=") +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/fullscreen/FullscreenComponentsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/fullscreen/FullscreenComponentsPatch.kt new file mode 100644 index 0000000000..f87d20ea4b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/fullscreen/FullscreenComponentsPatch.kt @@ -0,0 +1,335 @@ +package app.revanced.patches.youtube.player.fullscreen + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.patches.shared.mainactivity.onConfigurationChangedMethod +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.youtube.utils.extension.Constants.PATCH_STATUS_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.extension.Constants.PLAYER_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.layoutConstructorFingerprint +import app.revanced.patches.youtube.utils.mainactivity.mainActivityResolvePatch +import app.revanced.patches.youtube.utils.patch.PatchList.FULLSCREEN_COMPONENTS +import app.revanced.patches.youtube.utils.playservice.is_18_42_or_greater +import app.revanced.patches.youtube.utils.playservice.versionCheckPatch +import app.revanced.patches.youtube.utils.resourceid.autoNavPreviewStub +import app.revanced.patches.youtube.utils.resourceid.fullScreenEngagementPanel +import app.revanced.patches.youtube.utils.resourceid.quickActionsElementContainer +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.patches.youtube.utils.youtubeControlsOverlayFingerprint +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.mutableClassOrThrow +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import app.revanced.util.indexOfFirstStringInstructionOrThrow +import app.revanced.util.updatePatchStatus +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.WideLiteralInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private const val FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/QuickActionFilter;" + +@Suppress("unused") +val fullscreenComponentsPatch = bytecodePatch( + FULLSCREEN_COMPONENTS.title, + FULLSCREEN_COMPONENTS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + lithoFilterPatch, + mainActivityResolvePatch, + sharedResourceIdPatch, + settingsPatch, + versionCheckPatch, + ) + + execute { + + var settingArray = arrayOf( + "PREFERENCE_SCREEN: PLAYER", + "SETTINGS: FULLSCREEN_COMPONENTS" + ) + + // region patch for disable engagement panel + + engagementPanelFingerprint.methodOrThrow().apply { + val literalIndex = + indexOfFirstLiteralInstructionOrThrow(fullScreenEngagementPanel) + val targetIndex = indexOfFirstInstructionOrThrow(literalIndex, Opcode.CHECK_CAST) + val targetRegister = getInstruction(targetIndex).registerA + + addInstruction( + targetIndex + 1, + "invoke-static {v$targetRegister}, " + + "$PLAYER_CLASS_DESCRIPTOR->disableEngagementPanels(Landroidx/coordinatorlayout/widget/CoordinatorLayout;)V" + ) + + } + + playerTitleViewFingerprint.methodOrThrow().apply { + val insertIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "addView" + } + val insertReference = + getInstruction(insertIndex).reference.toString() + if (!insertReference.startsWith("Landroid/widget/FrameLayout;")) + throw PatchException("Reference does not match: $insertReference") + val insertInstruction = getInstruction(insertIndex) + + replaceInstruction( + insertIndex, + "invoke-static { v${insertInstruction.registerC}, v${insertInstruction.registerD} }, " + + "$PLAYER_CLASS_DESCRIPTOR->showVideoTitleSection(Landroid/widget/FrameLayout;Landroid/view/View;)V" + ) + } + + // endregion + + // region patch for hide autoplay preview + + layoutConstructorFingerprint.methodOrThrow().apply { + val constIndex = indexOfFirstLiteralInstructionOrThrow(autoNavPreviewStub) + val constRegister = getInstruction(constIndex).registerA + val jumpIndex = + indexOfFirstInstructionOrThrow(constIndex + 2, Opcode.INVOKE_VIRTUAL) + 1 + + addInstructionsWithLabels( + constIndex, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->hideAutoPlayPreview()Z + move-result v$constRegister + if-nez v$constRegister, :hidden + """, ExternalLabel("hidden", getInstruction(jumpIndex)) + ) + } + + // endregion + + // region patch for hide related video overlay + + relatedEndScreenResultsFingerprint.mutableClassOrThrow().let { + it.methods.find { method -> method.parameters == listOf("I", "Z", "I") } + ?.apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->hideRelatedVideoOverlay()Z + move-result v0 + if-eqz v0, :show + return-void + """, ExternalLabel("show", getInstruction(0)) + ) + } ?: throw PatchException("Could not find targetMethod") + } + + // endregion + + // region patch for quick actions + + quickActionsElementFingerprint.methodOrThrow().apply { + val containerCalls = implementation!!.instructions.withIndex() + .filter { instruction -> + (instruction.value as? WideLiteralInstruction)?.wideLiteral == quickActionsElementContainer + } + val constIndex = containerCalls.elementAt(containerCalls.size - 1).index + + val checkCastIndex = indexOfFirstInstructionOrThrow(constIndex, Opcode.CHECK_CAST) + val insertRegister = + getInstruction(checkCastIndex).registerA + + addInstruction( + checkCastIndex + 1, + "invoke-static {v$insertRegister}, $PLAYER_CLASS_DESCRIPTOR->setQuickActionMargin(Landroid/view/View;)V" + ) + + addInstruction( + checkCastIndex, + "invoke-static {v$insertRegister}, $PLAYER_CLASS_DESCRIPTOR->hideQuickActions(Landroid/view/View;)V" + ) + } + + updatePatchStatus(PATCH_STATUS_CLASS_DESCRIPTOR, "QuickActions") + + // endregion + + // region patch for compact control overlay + + youtubeControlsOverlayFingerprint.methodOrThrow().apply { + val targetIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "setFocusableInTouchMode" + } + val walkerIndex = indexOfFirstInstructionOrThrow(targetIndex, Opcode.INVOKE_STATIC) + + val walkerMethod = getWalkerMethod(walkerIndex) + walkerMethod.apply { + val insertIndex = implementation!!.instructions.size - 1 + val targetRegister = + getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, """ + invoke-static {v$targetRegister}, $PLAYER_CLASS_DESCRIPTOR->enableCompactControlsOverlay(Z)Z + move-result v$targetRegister + """ + ) + } + } + + // endregion + + // region patch for force fullscreen + + clientSettingEndpointFingerprint.methodOrThrow().apply { + val getActivityIndex = indexOfFirstStringInstructionOrThrow("watch") + 2 + val getActivityReference = + getInstruction(getActivityIndex).reference + val classRegister = + getInstruction(getActivityIndex).registerB + + val watchDescriptorMethodIndex = + indexOfFirstStringInstructionOrThrow("start_watch_minimized") - 1 + val watchDescriptorRegister = + getInstruction(watchDescriptorMethodIndex).registerD + + addInstructions( + watchDescriptorMethodIndex, """ + invoke-static {v$watchDescriptorRegister}, $PLAYER_CLASS_DESCRIPTOR->forceFullscreen(Z)Z + move-result v$watchDescriptorRegister + """ + ) + + // hooks Activity. + val insertIndex = indexOfFirstStringInstructionOrThrow("force_fullscreen") + val freeRegister = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, """ + iget-object v$freeRegister, v$classRegister, $getActivityReference + check-cast v$freeRegister, Landroid/app/Activity; + invoke-static {v$freeRegister}, $PLAYER_CLASS_DESCRIPTOR->setWatchDescriptorActivity(Landroid/app/Activity;)V + """ + ) + } + + videoPortraitParentFingerprint.methodOrThrow().apply { + val stringIndex = + indexOfFirstStringInstructionOrThrow("Acquiring NetLatencyActionLogger failed. taskId=") + val invokeIndex = + indexOfFirstInstructionOrThrow(stringIndex, Opcode.INVOKE_INTERFACE) + val targetIndex = indexOfFirstInstructionOrThrow(invokeIndex, Opcode.CHECK_CAST) + val targetClass = + getInstruction(targetIndex).reference.toString() + + // add an instruction to check the vertical video + findMethodOrThrow(targetClass) { + parameters == listOf("I", "I", "Z") + }.addInstruction( + 1, + "invoke-static {p1, p2}, $PLAYER_CLASS_DESCRIPTOR->setVideoPortrait(II)V" + ) + } + + // endregion + + // region patch for disable landscape mode + + onConfigurationChangedMethod.apply { + val walkerIndex = indexOfFirstInstructionOrThrow { + val reference = getReference() + reference?.parameterTypes == listOf("Landroid/content/res/Configuration;") && + reference.returnType == "V" && + reference.name != "onConfigurationChanged" + } + + val walkerMethod = getWalkerMethod(walkerIndex) + val constructorMethod = + findMethodOrThrow(walkerMethod.definingClass) { + name == "" && + parameterTypes == listOf("Landroid/app/Activity;") + } + + arrayOf( + walkerMethod, + constructorMethod + ).forEach { method -> + method.apply { + val index = indexOfFirstInstructionOrThrow { + val reference = getReference() + reference?.parameterTypes == listOf("Landroid/content/Context;") && + reference.returnType == "Z" + } + 1 + val register = getInstruction(index).registerA + + addInstructions( + index + 1, """ + invoke-static {v$register}, $PLAYER_CLASS_DESCRIPTOR->disableLandScapeMode(Z)Z + move-result v$register + """ + ) + } + } + } + + // endregion + + // region patch for keep landscape mode + + if (is_18_42_or_greater) { + landScapeModeConfigFingerprint.methodOrThrow().apply { + val insertIndex = implementation!!.instructions.lastIndex + val insertRegister = + getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $PLAYER_CLASS_DESCRIPTOR->keepFullscreen(Z)Z + move-result v$insertRegister + """ + ) + } + broadcastReceiverFingerprint.methodOrThrow().apply { + val stringIndex = + indexOfFirstStringInstructionOrThrow("android.intent.action.SCREEN_ON") + val insertIndex = + indexOfFirstInstructionOrThrow(stringIndex, Opcode.IF_EQZ) + 1 + + addInstruction( + insertIndex, + "invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->setScreenOn()V" + ) + } + + settingArray += "SETTINGS: KEEP_LANDSCAPE_MODE" + } + + // endregion + + addLithoFilter(FILTER_CLASS_DESCRIPTOR) + + // region add settings + + addPreference(settingArray, FULLSCREEN_COMPONENTS) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/hapticfeedback/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/hapticfeedback/Fingerprints.kt new file mode 100644 index 0000000000..48b63bb9e0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/hapticfeedback/Fingerprints.kt @@ -0,0 +1,36 @@ +package app.revanced.patches.youtube.player.hapticfeedback + +import app.revanced.util.fingerprint.legacyFingerprint +import com.android.tools.smali.dexlib2.Opcode + +internal val markerHapticsFingerprint = legacyFingerprint( + name = "markerHapticsFingerprint", + returnType = "V", + strings = listOf("Failed to execute markers haptics vibrate.") +) + +internal val scrubbingHapticsFingerprint = legacyFingerprint( + name = "scrubbingHapticsFingerprint", + returnType = "V", + strings = listOf("Failed to haptics vibrate for fine scrubbing.") +) + +internal val seekHapticsFingerprint = legacyFingerprint( + name = "seekHapticsFingerprint", + returnType = "V", + opcodes = listOf(Opcode.SGET), + strings = listOf("Failed to easy seek haptics vibrate."), + customFingerprint = { method, _ -> method.name == "run" } +) + +internal val seekUndoHapticsFingerprint = legacyFingerprint( + name = "seekUndoHapticsFingerprint", + returnType = "V", + strings = listOf("Failed to execute seek undo haptics vibrate.") +) + +internal val zoomHapticsFingerprint = legacyFingerprint( + name = "zoomHapticsFingerprint", + returnType = "V", + strings = listOf("Failed to haptics vibrate for video zoom") +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/hapticfeedback/hapticFeedbackPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/hapticfeedback/hapticFeedbackPatch.kt new file mode 100644 index 0000000000..22888a4800 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/hapticfeedback/hapticFeedbackPatch.kt @@ -0,0 +1,71 @@ +package app.revanced.patches.youtube.player.hapticfeedback + +import app.revanced.patcher.Fingerprint +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.PLAYER_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.patch.PatchList.DISABLE_HAPTIC_FEEDBACK +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +@Suppress("unused") +val hapticFeedbackPatch = bytecodePatch( + DISABLE_HAPTIC_FEEDBACK.title, + DISABLE_HAPTIC_FEEDBACK.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + fun Pair.hookHapticFeedback(methodName: String) = + methodOrThrow().apply { + var index = 0 + var register = 0 + + if (name == "run") { + index = indexOfFirstInstructionOrThrow(Opcode.SGET) + register = getInstruction(index).registerA + } + + addInstructionsWithLabels( + index, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->$methodName()Z + move-result v$register + if-eqz v$register, :vibrate + return-void + """, ExternalLabel("vibrate", getInstruction(index)) + ) + } + + arrayOf( + seekHapticsFingerprint to "disableSeekVibrate", + seekUndoHapticsFingerprint to "disableSeekUndoVibrate", + scrubbingHapticsFingerprint to "disableScrubbingVibrate", + markerHapticsFingerprint to "disableChapterVibrate", + zoomHapticsFingerprint to "disableZoomVibrate" + ).map { (fingerprint, methodName) -> + fingerprint.hookHapticFeedback(methodName) + } + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: PLAYER", + "SETTINGS: DISABLE_HAPTIC_FEEDBACK" + ), + DISABLE_HAPTIC_FEEDBACK + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/overlaybuttons/OverlayButtonsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/overlaybuttons/OverlayButtonsPatch.kt new file mode 100644 index 0000000000..00b239fedf --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/overlaybuttons/OverlayButtonsPatch.kt @@ -0,0 +1,303 @@ +package app.revanced.patches.youtube.player.overlaybuttons + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.booleanOption +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.OVERLAY_BUTTONS_PATH +import app.revanced.patches.youtube.utils.extension.Constants.UTILS_PATH +import app.revanced.patches.youtube.utils.fix.bottomui.cfBottomUIPatch +import app.revanced.patches.youtube.utils.patch.PatchList.OVERLAY_BUTTONS +import app.revanced.patches.youtube.utils.pip.pipStateHookPatch +import app.revanced.patches.youtube.utils.playercontrols.hookBottomControlButton +import app.revanced.patches.youtube.utils.playercontrols.playerControlsPatch +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.patches.youtube.video.information.videoEndMethod +import app.revanced.patches.youtube.video.information.videoInformationPatch +import app.revanced.util.ResourceGroup +import app.revanced.util.copyResources +import app.revanced.util.copyXmlNode +import app.revanced.util.doRecursively +import app.revanced.util.lowerCaseOrThrow +import org.w3c.dom.Element + +private const val EXTENSION_ALWAYS_REPEAT_CLASS_DESCRIPTOR = + "$UTILS_PATH/AlwaysRepeatPatch;" + +private val overlayButtonsBytecodePatch = bytecodePatch( + description = "overlayButtonsBytecodePatch" +) { + dependsOn(videoInformationPatch) + + execute { + + // region patch for always repeat + + videoEndMethod.apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $EXTENSION_ALWAYS_REPEAT_CLASS_DESCRIPTOR->alwaysRepeat()Z + move-result v0 + if-eqz v0, :end + return-void + """, ExternalLabel("end", getInstruction(0)) + ) + } + + // endregion + + } +} + +private const val MARGIN_NONE = "0.0dip" +private const val MARGIN_DEFAULT = "2.5dip" +private const val MARGIN_WIDER = "5.0dip" + +private const val DEFAULT_ICON = "bold" + +@Suppress("unused") +val overlayButtonsPatch = resourcePatch( + OVERLAY_BUTTONS.title, + OVERLAY_BUTTONS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + overlayButtonsBytecodePatch, + cfBottomUIPatch, + pipStateHookPatch, + playerControlsPatch, + sharedResourceIdPatch, + settingsPatch, + ) + + val iconTypeOption = stringOption( + key = "iconType", + default = DEFAULT_ICON, + values = mapOf( + "Bold" to DEFAULT_ICON, + "Rounded" to "rounded", + "Thin" to "thin" + ), + title = "Icon type", + description = "The icon type.", + required = true + ) + + val bottomMarginOption = stringOption( + key = "bottomMargin", + default = MARGIN_DEFAULT, + values = mapOf( + "Default" to MARGIN_DEFAULT, + "None" to MARGIN_NONE, + "Wider" to MARGIN_WIDER, + ), + title = "Bottom margin", + description = "The bottom margin for the overlay buttons and timestamp.", + required = true + ) + + val widerButtonsSpace by booleanOption( + key = "widerButtonsSpace", + default = false, + title = "Wider between-buttons space", + description = "Prevent adjacent button presses by increasing the horizontal spacing between buttons.", + required = true + ) + + val changeTopButtons by booleanOption( + key = "changeTopButtons", + default = false, + title = "Change top buttons", + description = "Change the icons at the top of the player.", + required = true + ) + + execute { + + // Check patch options first. + val iconType = iconTypeOption + .lowerCaseOrThrow() + + val marginBottom = bottomMarginOption + .lowerCaseOrThrow() + + // Inject hooks for overlay buttons. + setOf( + "AlwaysRepeat;", + "CopyVideoUrl;", + "CopyVideoUrlTimestamp;", + "MuteVolume;", + "ExternalDownload;", + "PlayAll;", + "SpeedDialog;", + "Whitelists;" + ).forEach { className -> + hookBottomControlButton("$OVERLAY_BUTTONS_PATH/$className") + } + + // Copy necessary resources for the overlay buttons. + copyResources( + "youtube/overlaybuttons/shared", + ResourceGroup( + "drawable", + "playlist_repeat_button.xml", + "playlist_shuffle_button.xml", + "revanced_repeat_button.xml", + "revanced_mute_volume_button.xml", + ) + ) + + // Apply the selected icon type to the overlay buttons. + arrayOf( + "xxxhdpi", + "xxhdpi", + "xhdpi", + "hdpi", + "mdpi" + ).forEach { dpi -> + copyResources( + "youtube/overlaybuttons/$iconType", + ResourceGroup( + "drawable-$dpi", + "ic_vr.png", + "quantum_ic_fullscreen_exit_grey600_24.png", + "quantum_ic_fullscreen_exit_white_24.png", + "quantum_ic_fullscreen_grey600_24.png", + "quantum_ic_fullscreen_white_24.png", + "revanced_copy_button.png", + "revanced_copy_timestamp_button.png", + "revanced_download_button.png", + "revanced_play_all_button.png", + "revanced_speed_button.png", + "revanced_volume_muted_button.png", + "revanced_volume_unmuted_button.png", + "revanced_whitelist_button.png", + "yt_fill_arrow_repeat_white_24.png", + "yt_outline_arrow_repeat_1_white_24.png", + "yt_outline_arrow_shuffle_1_white_24.png", + "yt_outline_screen_full_exit_white_24.png", + "yt_outline_screen_full_white_24.png", + "yt_outline_screen_full_vd_theme_24.png", + "yt_outline_screen_vertical_vd_theme_24.png" + ), + ResourceGroup( + "drawable", + "yt_outline_screen_vertical_vd_theme_24.xml" + ) + ) + } + + // Merge XML nodes from the host to their respective XML files. + copyXmlNode( + "youtube/overlaybuttons/shared/host", + "layout/youtube_controls_bottom_ui_container.xml", + "android.support.constraint.ConstraintLayout" + ) + + // Modify the layout of fullscreen button for newer YouTube versions (19.09.xx+) + arrayOf( + "youtube_controls_bottom_ui_container.xml", + "youtube_controls_fullscreen_button.xml", + "youtube_controls_cf_fullscreen_button.xml", + ).forEach { xmlFile -> + val targetXml = get("res").resolve("layout").resolve(xmlFile) + if (targetXml.exists()) { + document("res/layout/$xmlFile").use { document -> + document.doRecursively loop@{ node -> + if (node !is Element) return@loop + + // Change the relationship between buttons + node.getAttributeNode("yt:layout_constraintRight_toLeftOf") + ?.let { attribute -> + if (attribute.textContent == "@id/fullscreen_button") { + attribute.textContent = "@+id/speed_dialog_button" + } + } + + val (id, height, width) = Triple( + node.getAttribute("android:id"), + node.getAttribute("android:layout_height"), + node.getAttribute("android:layout_width") + ) + val (heightIsNotZero, widthIsNotZero, isButton) = Triple( + height != "0.0dip", + width != "0.0dip", + id.endsWith("_button") || id == "@id/youtube_controls_fullscreen_button_stub" + ) + + // Adjust TimeBar and Chapter bottom padding + val timBarItem = mutableMapOf( + "@id/time_bar_chapter_title" to "16.0dip", + "@id/timestamps_container" to "14.0dip" + ) + + val layoutHeightWidth = if (widerButtonsSpace == true) + "56.0dip" + else + "48.0dip" + + if (isButton) { + node.setAttribute("android:layout_marginBottom", marginBottom) + node.setAttribute("android:paddingLeft", "0.0dip") + node.setAttribute("android:paddingRight", "0.0dip") + node.setAttribute("android:paddingBottom", "22.0dip") + if (heightIsNotZero && widthIsNotZero) { + node.setAttribute("android:layout_height", layoutHeightWidth) + node.setAttribute("android:layout_width", layoutHeightWidth) + } + } else if (timBarItem.containsKey(id)) { + node.setAttribute("android:layout_marginBottom", marginBottom) + if (widerButtonsSpace != true) { + node.setAttribute("android:paddingBottom", timBarItem.getValue(id)) + } + } + } + } + } + } + + if (changeTopButtons == true) { + // Apply the selected icon type to the top buttons. + arrayOf( + "xxxhdpi", + "xxhdpi", + "xhdpi", + "hdpi", + "mdpi" + ).forEach { dpi -> + copyResources( + "youtube/overlaybuttons/$iconType", + ResourceGroup( + "drawable-$dpi", + "yt_outline_gear_white_24.png", + "yt_outline_chevron_down_white_24.png", + "quantum_ic_closed_caption_off_grey600_24.png", + "quantum_ic_closed_caption_off_white_24.png", + "quantum_ic_closed_caption_white_24.png" + ) + ) + } + } + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: PLAYER", + "PREFERENCE_SCREENS: PLAYER_BUTTONS", + "SETTINGS: OVERLAY_BUTTONS" + ), + OVERLAY_BUTTONS + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/seekbar/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/seekbar/Fingerprints.kt new file mode 100644 index 0000000000..089e7a15bd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/seekbar/Fingerprints.kt @@ -0,0 +1,87 @@ +package app.revanced.patches.youtube.player.seekbar + +import app.revanced.patches.youtube.utils.resourceid.reelTimeBarPlayedColor +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val cairoSeekbarConfigFingerprint = legacyFingerprint( + name = "cairoSeekbarConfigFingerprint", + returnType = "Z", + parameters = emptyList(), + literals = listOf(45617850L), +) + +internal val controlsOverlayStyleFingerprint = legacyFingerprint( + name = "controlsOverlayStyleFingerprint", + opcodes = listOf(Opcode.CONST_HIGH16), + strings = listOf("YOUTUBE", "PREROLL", "POSTROLL"), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/ControlsOverlayStyle;") + } +) + +internal val seekbarTappingFingerprint = legacyFingerprint( + name = "seekbarTappingFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + opcodes = listOf( + Opcode.IPUT_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_EQZ, + Opcode.INVOKE_VIRTUAL, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_EQZ, + Opcode.INT_TO_FLOAT, + Opcode.INT_TO_FLOAT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_EQZ + ), + customFingerprint = { method, _ -> method.name == "onTouchEvent" } +) + +internal val seekbarThumbnailsQualityFingerprint = legacyFingerprint( + name = "seekbarThumbnailsQualityFingerprint", + returnType = "Z", + parameters = emptyList(), + literals = listOf(45399684L), +) + +internal val shortsSeekbarColorFingerprint = legacyFingerprint( + name = "shortsSeekbarColorFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + literals = listOf(reelTimeBarPlayedColor), +) + +internal val thumbnailPreviewConfigFingerprint = legacyFingerprint( + name = "thumbnailPreviewConfigFingerprint", + returnType = "Z", + parameters = emptyList(), + literals = listOf(45398577L), +) + +internal val timeCounterFingerprint = legacyFingerprint( + name = "timeCounterFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + returnType = "V", + opcodes = listOf( + Opcode.SUB_LONG_2ADDR, + Opcode.IGET_WIDE, + Opcode.SUB_LONG_2ADDR + ) +) + +internal val timelineMarkerArrayFingerprint = legacyFingerprint( + name = "timelineMarkerArrayFingerprint", + returnType = "[Lcom/google/android/libraries/youtube/player/features/overlay/timebar/TimelineMarker;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/seekbar/SeekbarComponentsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/seekbar/SeekbarComponentsPatch.kt new file mode 100644 index 0000000000..3465db4798 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/seekbar/SeekbarComponentsPatch.kt @@ -0,0 +1,325 @@ +package app.revanced.patches.youtube.player.seekbar + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.shared.drawable.addDrawableColorHook +import app.revanced.patches.shared.drawable.drawableColorHookPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.PATCH_STATUS_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.extension.Constants.PLAYER_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.flyoutmenu.flyoutMenuHookPatch +import app.revanced.patches.youtube.utils.patch.PatchList.SEEKBAR_COMPONENTS +import app.revanced.patches.youtube.utils.playerButtonsVisibilityFingerprint +import app.revanced.patches.youtube.utils.playerSeekbarColorFingerprint +import app.revanced.patches.youtube.utils.playservice.is_19_23_or_greater +import app.revanced.patches.youtube.utils.playservice.versionCheckPatch +import app.revanced.patches.youtube.utils.resourceid.inlineTimeBarColorizedBarPlayedColorDark +import app.revanced.patches.youtube.utils.resourceid.inlineTimeBarPlayedNotHighlightedColor +import app.revanced.patches.youtube.utils.resourceid.reelTimeBarPlayedColor +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.seekbarFingerprint +import app.revanced.patches.youtube.utils.seekbarOnDrawFingerprint +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.ResourceUtils.getContext +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.patches.youtube.utils.totalTimeFingerprint +import app.revanced.patches.youtube.video.information.videoInformationPatch +import app.revanced.util.findMethodsOrThrow +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.resolvable +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import app.revanced.util.updatePatchStatus +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.NarrowLiteralInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import org.w3c.dom.Element + +@Suppress("unused") +val seekbarComponentsPatch = bytecodePatch( + SEEKBAR_COMPONENTS.title, + SEEKBAR_COMPONENTS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + drawableColorHookPatch, + flyoutMenuHookPatch, + sharedResourceIdPatch, + settingsPatch, + videoInformationPatch, + versionCheckPatch, + ) + + execute { + + var settingArray = arrayOf( + "PREFERENCE_SCREEN: PLAYER", + "SETTINGS: SEEKBAR_COMPONENTS" + ) + + // region patch for enable seekbar tapping patch + + seekbarTappingFingerprint.matchOrThrow().let { + it.method.apply { + val tapSeekIndex = it.patternMatch!!.startIndex + 1 + val tapSeekClass = getInstruction(tapSeekIndex) + .getReference()!! + .definingClass + + val tapSeekMethods = findMethodsOrThrow(tapSeekClass) + var pMethodCall = "" + var oMethodCall = "" + + for (method in tapSeekMethods) { + if (method.implementation == null) + continue + + val instructions = method.implementation!!.instructions + // here we make sure we actually find the method because it has more than 7 instructions + if (instructions.count() != 10) + continue + + // we know that the 7th instruction has the opcode CONST_4 + val instruction = instructions.elementAt(6) + if (instruction.opcode != Opcode.CONST_4) + continue + + // the literal for this instruction has to be either 1 or 2 + val literal = (instruction as NarrowLiteralInstruction).narrowLiteral + + // method founds + if (literal == 1) + pMethodCall = "${method.definingClass}->${method.name}(I)V" + else if (literal == 2) + oMethodCall = "${method.definingClass}->${method.name}(I)V" + } + + if (pMethodCall.isEmpty()) { + throw PatchException("pMethod not found") + } + if (oMethodCall.isEmpty()) { + throw PatchException("oMethod not found") + } + + val insertIndex = it.patternMatch!!.startIndex + 2 + + addInstructionsWithLabels( + insertIndex, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->enableSeekbarTapping()Z + move-result v0 + if-eqz v0, :disabled + invoke-virtual { p0, v2 }, $pMethodCall + invoke-virtual { p0, v2 }, $oMethodCall + """, ExternalLabel("disabled", getInstruction(insertIndex)) + ) + } + } + + // endregion + + // region patch for append time stamps information + + totalTimeFingerprint.methodOrThrow().apply { + val charSequenceIndex = indexOfFirstInstructionOrThrow { + getReference()?.name == "getString" + } + 1 + val charSequenceRegister = + getInstruction(charSequenceIndex).registerA + val textViewIndex = indexOfFirstInstructionOrThrow { + getReference()?.name == "getText" + } + val textViewRegister = + getInstruction(textViewIndex).registerC + + addInstructions( + textViewIndex, """ + invoke-static {v$textViewRegister}, $PLAYER_CLASS_DESCRIPTOR->setContainerClickListener(Landroid/view/View;)V + invoke-static {v$charSequenceRegister}, $PLAYER_CLASS_DESCRIPTOR->appendTimeStampInformation(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$charSequenceRegister + """ + ) + } + + // endregion + + // region patch for seekbar color + + fun MutableMethod.hookSeekbarColor(literal: Long) { + val insertIndex = indexOfFirstLiteralInstructionOrThrow(literal) + 2 + val insertRegister = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex + 1, """ + invoke-static {v$insertRegister}, $PLAYER_CLASS_DESCRIPTOR->overrideSeekbarColor(I)I + move-result v$insertRegister + """ + ) + } + + + playerSeekbarColorFingerprint.methodOrThrow().apply { + hookSeekbarColor(inlineTimeBarColorizedBarPlayedColorDark) + hookSeekbarColor(inlineTimeBarPlayedNotHighlightedColor) + } + + shortsSeekbarColorFingerprint.methodOrThrow().apply { + hookSeekbarColor(reelTimeBarPlayedColor) + } + + controlsOverlayStyleFingerprint.matchOrThrow().let { + val walkerMethod = + it.getWalkerMethod(it.patternMatch!!.startIndex + 1) + walkerMethod.apply { + val colorRegister = getInstruction(0).registerA + + addInstructions( + 0, """ + invoke-static {v$colorRegister}, $PLAYER_CLASS_DESCRIPTOR->getSeekbarClickedColorValue(I)I + move-result v$colorRegister + """ + ) + } + } + + addDrawableColorHook("$PLAYER_CLASS_DESCRIPTOR->getColor(I)I") + + getContext().document("res/drawable/resume_playback_progressbar_drawable.xml") + .use { document -> + val layerList = document.getElementsByTagName("layer-list").item(0) as Element + val progressNode = layerList.getElementsByTagName("item").item(1) as Element + if (!progressNode.getAttributeNode("android:id").value.endsWith("progress")) { + throw PatchException("Could not find progress bar") + } + val scaleNode = progressNode.getElementsByTagName("scale").item(0) as Element + val shapeNode = scaleNode.getElementsByTagName("shape").item(0) as Element + val replacementNode = document.createElement( + "app.revanced.extension.youtube.patches.utils.ProgressBarDrawable" + ) + scaleNode.replaceChild(replacementNode, shapeNode) + } + + // endregion + + // region patch for high quality thumbnails + + // TODO: This will be added when support for newer YouTube versions is added. + // seekbarThumbnailsQualityFingerprint.injectLiteralInstructionBooleanCall( + // 45399684L, + // "$PLAYER_CLASS_DESCRIPTOR->enableHighQualityFullscreenThumbnails()Z" + // ) + + // endregion + + // region patch for hide chapter + + timelineMarkerArrayFingerprint.methodOrThrow().apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->disableSeekbarChapters()Z + move-result v0 + if-eqz v0, :show + const/4 v0, 0x0 + new-array v0, v0, [Lcom/google/android/libraries/youtube/player/features/overlay/timebar/TimelineMarker; + return-object v0 + """, ExternalLabel("show", getInstruction(0)) + ) + } + + playerButtonsVisibilityFingerprint.methodOrThrow(playerButtonsVisibilityFingerprint).apply { + val freeRegister = implementation!!.registerCount - parameters.size - 2 + val viewIndex = indexOfFirstInstructionOrThrow(Opcode.INVOKE_INTERFACE) + val viewRegister = getInstruction(viewIndex).registerD + + addInstructionsWithLabels( + viewIndex, """ + invoke-static {v$viewRegister}, $PLAYER_CLASS_DESCRIPTOR->hideSeekbarChapterLabel(Landroid/view/View;)Z + move-result v$freeRegister + if-eqz v$freeRegister, :ignore + return-void + """, ExternalLabel("ignore", getInstruction(viewIndex)) + ) + } + + // endregion + + // region patch for hide seekbar + + seekbarOnDrawFingerprint.methodOrThrow(seekbarFingerprint).apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->hideSeekbar()Z + move-result v0 + if-eqz v0, :show + return-void + """, ExternalLabel("show", getInstruction(0)) + ) + } + + // endregion + + // region patch for hide time stamp + + timeCounterFingerprint.methodOrThrow(playerSeekbarColorFingerprint).apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->hideTimeStamp()Z + move-result v0 + if-eqz v0, :show + return-void + """, ExternalLabel("show", getInstruction(0)) + ) + } + + // endregion + + // region patch for restore old seekbar thumbnails + + if (thumbnailPreviewConfigFingerprint.resolvable()) { + thumbnailPreviewConfigFingerprint.injectLiteralInstructionBooleanCall( + 45398577L, + "$PLAYER_CLASS_DESCRIPTOR->restoreOldSeekbarThumbnails()Z" + ) + + settingArray += "SETTINGS: RESTORE_OLD_SEEKBAR_THUMBNAILS" + + updatePatchStatus(PATCH_STATUS_CLASS_DESCRIPTOR, "OldSeekbarThumbnailsDefaultBoolean") + } else { + println("WARNING: Restore old seekbar thumbnails setting is not supported in this version. Use YouTube 19.16.39 or earlier.") + } + + // endregion + + // region patch for enable cairo seekbar + + if (is_19_23_or_greater) { + cairoSeekbarConfigFingerprint.injectLiteralInstructionBooleanCall( + 45617850L, + "$PLAYER_CLASS_DESCRIPTOR->enableCairoSeekbar()Z" + ) + + settingArray += "SETTINGS: ENABLE_CAIRO_SEEKBAR" + } + + // endregion + + // region add settings + + addPreference(settingArray, SEEKBAR_COMPONENTS) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/components/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/components/Fingerprints.kt new file mode 100644 index 0000000000..fae0d2e8d6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/components/Fingerprints.kt @@ -0,0 +1,155 @@ +package app.revanced.patches.youtube.shorts.components + +import app.revanced.patches.youtube.utils.resourceid.badgeLabel +import app.revanced.patches.youtube.utils.resourceid.bottomBarContainer +import app.revanced.patches.youtube.utils.resourceid.metaPanel +import app.revanced.patches.youtube.utils.resourceid.reelDynRemix +import app.revanced.patches.youtube.utils.resourceid.reelDynShare +import app.revanced.patches.youtube.utils.resourceid.reelFeedbackLike +import app.revanced.patches.youtube.utils.resourceid.reelFeedbackPause +import app.revanced.patches.youtube.utils.resourceid.reelFeedbackPlay +import app.revanced.patches.youtube.utils.resourceid.reelForcedMuteButton +import app.revanced.patches.youtube.utils.resourceid.reelPlayerFooter +import app.revanced.patches.youtube.utils.resourceid.reelRightDislikeIcon +import app.revanced.patches.youtube.utils.resourceid.reelRightLikeIcon +import app.revanced.patches.youtube.utils.resourceid.reelVodTimeStampsContainer +import app.revanced.patches.youtube.utils.resourceid.rightComment +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +// multiFingerprint +internal val bottomBarContainerHeightFingerprint = legacyFingerprint( + name = "bottomBarContainerHeightFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Landroid/view/View;", "Landroid/os/Bundle;"), + strings = listOf("r_pfvc"), + literals = listOf(bottomBarContainer), +) + +internal val reelEnumConstructorFingerprint = legacyFingerprint( + name = "reelEnumConstructorFingerprint", + returnType = "V", + strings = listOf( + "REEL_LOOP_BEHAVIOR_SINGLE_PLAY", + "REEL_LOOP_BEHAVIOR_REPEAT", + "REEL_LOOP_BEHAVIOR_END_SCREEN" + ) +) + +internal val reelEnumStaticFingerprint = legacyFingerprint( + name = "reelEnumStaticFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("I"), + returnType = "L" +) + +internal val reelFeedbackFingerprint = legacyFingerprint( + name = "reelFeedbackFingerprint", + returnType = "V", + literals = listOf(reelFeedbackLike, reelFeedbackPause, reelFeedbackPlay), +) + +internal val shortsButtonFingerprint = legacyFingerprint( + name = "shortsButtonFingerprint", + returnType = "V", + literals = listOf( + reelDynRemix, + reelDynShare, + reelRightDislikeIcon, + reelRightLikeIcon, + rightComment + ), +) + +/** + * The method by which patches are applied is different between the minimum supported version and the maximum supported version. + * There are two classes where R.id.badge_label[badgeLabel] is used, + * but due to the structure of ReVanced Patcher, the patch is applied to the method found first. + */ +internal val shortsPaidPromotionFingerprint = legacyFingerprint( + name = "shortsPaidPromotionFingerprint", + literals = listOf(badgeLabel), +) + +internal val shortsPausedHeaderFingerprint = legacyFingerprint( + name = "shortsPausedHeaderFingerprint", + returnType = "Landroid/view/View;", + opcodes = listOf( + Opcode.IF_NEZ, + Opcode.IGET_OBJECT, + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT + ), + strings = listOf("r_pfcv") +) + +internal val shortsPivotLegacyFingerprint = legacyFingerprint( + name = "shortsPivotLegacyFingerprint", + returnType = "V", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + parameters = listOf("Z", "Z", "L"), + literals = listOf(reelForcedMuteButton), +) + +internal val shortsSubscriptionsTabletFingerprint = legacyFingerprint( + name = "shortsSubscriptionsTabletFingerprint", + returnType = "V", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + parameters = listOf("L", "L", "Z"), + opcodes = listOf( + Opcode.INVOKE_STATIC, + Opcode.IGET, + Opcode.IF_EQZ + ) +) + +internal val shortsSubscriptionsTabletParentFingerprint = legacyFingerprint( + name = "shortsSubscriptionsTabletParentFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + literals = listOf(reelPlayerFooter), +) + +internal val shortsTimeStampConstructorFingerprint = legacyFingerprint( + name = "shortsTimeStampConstructorFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + literals = listOf(reelVodTimeStampsContainer), +) + +internal val shortsTimeStampMetaPanelFingerprint = legacyFingerprint( + name = "shortsTimeStampMetaPanelFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + literals = listOf(metaPanel), +) + +internal val shortsTimeStampPrimaryFingerprint = legacyFingerprint( + name = "shortsTimeStampPrimaryFingerprint", + returnType = "I", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("I"), + literals = listOf(45627350L, 45638282L, 10002L), +) + +internal val shortsTimeStampSecondaryFingerprint = legacyFingerprint( + name = "shortsTimeStampSecondaryFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + literals = listOf(45638187L), +) + +internal val shortsToolBarFingerprint = legacyFingerprint( + name = "shortsToolBarFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + opcodes = listOf(Opcode.IPUT_BOOLEAN), + strings = listOf("Null topBarButtons"), + customFingerprint = { method, _ -> + method.parameterTypes.firstOrNull() == "Z" + } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/components/ShortsComponentPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/components/ShortsComponentPatch.kt new file mode 100644 index 0000000000..8f60b06872 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/components/ShortsComponentPatch.kt @@ -0,0 +1,645 @@ +package app.revanced.patches.youtube.shorts.components + +import app.revanced.patcher.Fingerprint +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.patches.shared.textcomponent.hookSpannableString +import app.revanced.patches.shared.textcomponent.textComponentPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.youtube.utils.extension.Constants.SHORTS_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.extension.Constants.SHORTS_PATH +import app.revanced.patches.youtube.utils.extension.Constants.UTILS_PATH +import app.revanced.patches.youtube.utils.lottie.LOTTIE_ANIMATION_VIEW_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.lottie.lottieAnimationViewHookPatch +import app.revanced.patches.youtube.utils.navigation.addBottomBarContainerHook +import app.revanced.patches.youtube.utils.navigation.navigationBarHookPatch +import app.revanced.patches.youtube.utils.patch.PatchList.SHORTS_COMPONENTS +import app.revanced.patches.youtube.utils.playertype.playerTypeHookPatch +import app.revanced.patches.youtube.utils.playservice.is_18_31_or_greater +import app.revanced.patches.youtube.utils.playservice.is_19_25_or_greater +import app.revanced.patches.youtube.utils.playservice.is_19_28_or_greater +import app.revanced.patches.youtube.utils.playservice.versionCheckPatch +import app.revanced.patches.youtube.utils.resourceid.bottomBarContainer +import app.revanced.patches.youtube.utils.resourceid.metaPanel +import app.revanced.patches.youtube.utils.resourceid.reelDynRemix +import app.revanced.patches.youtube.utils.resourceid.reelDynShare +import app.revanced.patches.youtube.utils.resourceid.reelFeedbackLike +import app.revanced.patches.youtube.utils.resourceid.reelFeedbackPause +import app.revanced.patches.youtube.utils.resourceid.reelFeedbackPlay +import app.revanced.patches.youtube.utils.resourceid.reelForcedMuteButton +import app.revanced.patches.youtube.utils.resourceid.reelPlayerFooter +import app.revanced.patches.youtube.utils.resourceid.reelPlayerRightPivotV2Size +import app.revanced.patches.youtube.utils.resourceid.reelRightDislikeIcon +import app.revanced.patches.youtube.utils.resourceid.reelRightLikeIcon +import app.revanced.patches.youtube.utils.resourceid.reelVodTimeStampsContainer +import app.revanced.patches.youtube.utils.resourceid.rightComment +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.ResourceUtils.getContext +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.patches.youtube.video.information.hookShortsVideoInformation +import app.revanced.patches.youtube.video.information.videoInformationPatch +import app.revanced.util.REGISTER_TEMPLATE_REPLACEMENT +import app.revanced.util.ResourceGroup +import app.revanced.util.copyResources +import app.revanced.util.findMutableMethodOf +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.resolvable +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import app.revanced.util.indexOfFirstLiteralInstruction +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import app.revanced.util.indexOfFirstStringInstruction +import app.revanced.util.indexOfFirstStringInstructionOrThrow +import app.revanced.util.injectLiteralInstructionViewCall +import app.revanced.util.or +import app.revanced.util.replaceLiteralInstructionCall +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.util.MethodUtil + +private const val EXTENSION_ANIMATION_FEEDBACK_CLASS_DESCRIPTOR = + "$SHORTS_PATH/AnimationFeedbackPatch;" + +private val shortsAnimationPatch = bytecodePatch( + description = "shortsAnimationPatch" +) { + dependsOn( + lottieAnimationViewHookPatch, + settingsPatch, + ) + + execute { + reelFeedbackFingerprint.methodOrThrow().apply { + mapOf( + reelFeedbackLike to "setShortsLikeFeedback", + reelFeedbackPause to "setShortsPauseFeedback", + reelFeedbackPlay to "setShortsPlayFeedback", + ).forEach { (literal, methodName) -> + val literalIndex = indexOfFirstLiteralInstructionOrThrow(literal) + val viewIndex = indexOfFirstInstructionOrThrow(literalIndex) { + opcode == Opcode.CHECK_CAST && + (this as? ReferenceInstruction)?.reference?.toString() == LOTTIE_ANIMATION_VIEW_CLASS_DESCRIPTOR + } + val viewRegister = getInstruction(viewIndex).registerA + val methodCall = "invoke-static {v$viewRegister}, " + + EXTENSION_ANIMATION_FEEDBACK_CLASS_DESCRIPTOR + + "->" + + methodName + + "($LOTTIE_ANIMATION_VIEW_CLASS_DESCRIPTOR)V" + + addInstruction( + viewIndex + 1, + methodCall + ) + } + } + + getContext().copyResources( + "youtube/shorts/feedback", + ResourceGroup( + "raw", + "like_tap_feedback_cairo.json", + "like_tap_feedback_heart.json", + "like_tap_feedback_heart_tint.json", + "like_tap_feedback_hidden.json", + "pause_tap_feedback_hidden.json", + "play_tap_feedback_hidden.json" + ) + ) + } +} + +private val shortsNavigationBarPatch = bytecodePatch( + description = "shortsNavigationBarPatch" +) { + dependsOn( + navigationBarHookPatch, + playerTypeHookPatch, + ) + + execute { + var count = 0 + classes.forEach { classDef -> + classDef.methods.filter { method -> + method.returnType == "V" && + method.accessFlags == AccessFlags.PUBLIC or AccessFlags.FINAL && + method.parameters == listOf("Landroid/view/View;", "Landroid/os/Bundle;") && + method.indexOfFirstStringInstruction("r_pfvc") >= 0 && + method.indexOfFirstLiteralInstruction(bottomBarContainer) >= 0 + }.forEach { method -> + proxy(classDef) + .mutableClass + .findMutableMethodOf(method).apply { + val constIndex = indexOfFirstLiteralInstruction(bottomBarContainer) + val targetIndex = indexOfFirstInstructionOrThrow(constIndex) { + getReference()?.name == "getHeight" + } + 1 + val heightRegister = + getInstruction(targetIndex).registerA + addInstructions( + targetIndex + 1, """ + invoke-static {v$heightRegister}, $SHORTS_CLASS_DESCRIPTOR->setNavigationBarHeight(I)I + move-result v$heightRegister + """ + ) + count++ + } + } + } + + if (count == 0) throw PatchException("shortsNavigationBarPatch failed") + + addBottomBarContainerHook("$SHORTS_CLASS_DESCRIPTOR->setNavigationBar(Landroid/view/View;)V") + } +} + +private val shortsRepeatPatch = bytecodePatch( + description = "shortsRepeatPatch" +) { + execute { + reelEnumConstructorFingerprint.methodOrThrow().apply { + arrayOf( + "REEL_LOOP_BEHAVIOR_END_SCREEN" to "endScreen", + "REEL_LOOP_BEHAVIOR_REPEAT" to "repeat", + "REEL_LOOP_BEHAVIOR_SINGLE_PLAY" to "singlePlay" + ).map { (enumName, fieldName) -> + val stringIndex = indexOfFirstStringInstructionOrThrow(enumName) + val insertIndex = indexOfFirstInstructionOrThrow(stringIndex, Opcode.SPUT_OBJECT) + val insertRegister = getInstruction(insertIndex).registerA + + addInstruction( + insertIndex + 1, + "sput-object v$insertRegister, $SHORTS_CLASS_DESCRIPTOR->$fieldName:Ljava/lang/Enum;" + ) + } + + val endScreenStringIndex = + indexOfFirstStringInstructionOrThrow("REEL_LOOP_BEHAVIOR_END_SCREEN") + val endScreenReferenceIndex = + indexOfFirstInstructionOrThrow(endScreenStringIndex, Opcode.SPUT_OBJECT) + val endScreenReference = + getInstruction(endScreenReferenceIndex).reference.toString() + + val enumMethod = reelEnumStaticFingerprint.methodOrThrow(reelEnumConstructorFingerprint) + + classes.forEach { classDef -> + classDef.methods.filter { method -> + method.parameters.size == 1 && + method.parameters[0].startsWith("L") && + method.returnType == "V" && + method.indexOfFirstInstruction { + getReference()?.toString() == endScreenReference + } >= 0 + }.forEach { targetMethod -> + proxy(classDef) + .mutableClass + .findMutableMethodOf(targetMethod) + .apply { + implementation!!.instructions + .withIndex() + .filter { (_, instruction) -> + val reference = + (instruction as? ReferenceInstruction)?.reference + reference is MethodReference && + MethodUtil.methodSignaturesMatch(enumMethod, reference) + } + .map { (index, _) -> index } + .reversed() + .forEach { index -> + val register = + getInstruction(index + 1).registerA + + addInstructions( + index + 2, """ + invoke-static {v$register}, $SHORTS_CLASS_DESCRIPTOR->changeShortsRepeatState(Ljava/lang/Enum;)Ljava/lang/Enum; + move-result-object v$register + """ + ) + } + } + } + } + } + } +} + +private val shortsTimeStampPatch = bytecodePatch( + description = "shortsTimeStampPatch" +) { + dependsOn(versionCheckPatch) + + execute { + + if (!is_19_25_or_greater || is_19_28_or_greater) return@execute + + // region patch for enable time stamp + + mapOf( + shortsTimeStampPrimaryFingerprint to 45627350L, + shortsTimeStampPrimaryFingerprint to 45638282L, + shortsTimeStampSecondaryFingerprint to 45638187L + ).forEach { (fingerprint, literal) -> + fingerprint.injectLiteralInstructionBooleanCall( + literal, + "$SHORTS_CLASS_DESCRIPTOR->enableShortsTimeStamp(Z)Z" + ) + } + + shortsTimeStampPrimaryFingerprint.methodOrThrow().apply { + val literalIndex = indexOfFirstLiteralInstructionOrThrow(10002L) + val literalRegister = getInstruction(literalIndex).registerA + + addInstructions( + literalIndex + 1, """ + invoke-static {v$literalRegister}, $SHORTS_CLASS_DESCRIPTOR->enableShortsTimeStamp(I)I + move-result v$literalRegister + """ + ) + } + + // endregion + + // region patch for timestamp long press action and meta panel bottom margin + + listOf( + Triple( + shortsTimeStampConstructorFingerprint.methodOrThrow(), + reelVodTimeStampsContainer, + "setShortsTimeStampChangeRepeatState" + ), + Triple( + shortsTimeStampMetaPanelFingerprint.methodOrThrow( + shortsTimeStampConstructorFingerprint + ), + metaPanel, + "setShortsMetaPanelBottomMargin" + ) + ).forEach { (method, literalValue, methodName) -> + val smaliInstruction = """ + invoke-static {v$REGISTER_TEMPLATE_REPLACEMENT}, $SHORTS_CLASS_DESCRIPTOR->$methodName(Landroid/view/View;)V + """ + + method.injectLiteralInstructionViewCall(literalValue, smaliInstruction) + } + } +} + +private val shortsToolBarPatch = bytecodePatch( + description = "shortsToolBarPatch" +) { + execute { + shortsToolBarFingerprint.matchOrThrow().let { + it.method.apply { + val insertIndex = it.patternMatch!!.startIndex + val insertRegister = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $SHORTS_CLASS_DESCRIPTOR->hideShortsToolBar(Z)Z + move-result v$insertRegister + """ + ) + } + } + } +} + +private const val EXTENSION_RETURN_YOUTUBE_CHANNEL_NAME_CLASS_DESCRIPTOR = + "$UTILS_PATH/ReturnYouTubeChannelNamePatch;" + +private const val BUTTON_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/ShortsButtonFilter;" +private const val SHELF_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/ShortsShelfFilter;" +private const val RETURN_YOUTUBE_CHANNEL_NAME_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/ReturnYouTubeChannelNameFilterPatch;" + +@Suppress("unused") +val shortsComponentPatch = bytecodePatch( + SHORTS_COMPONENTS.title, + SHORTS_COMPONENTS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + shortsAnimationPatch, + shortsNavigationBarPatch, + shortsRepeatPatch, + shortsTimeStampPatch, + shortsToolBarPatch, + + lithoFilterPatch, + playerTypeHookPatch, + sharedResourceIdPatch, + settingsPatch, + textComponentPatch, + versionCheckPatch, + videoInformationPatch, + ) + + execute { + fun MutableMethod.hideButtons( + insertIndex: Int, + descriptor: String + ) { + val insertRegister = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex + 1, """ + invoke-static {v$insertRegister}, $SHORTS_CLASS_DESCRIPTOR->$descriptor + move-result-object v$insertRegister + """ + ) + } + + fun Pair.hideButton( + id: Long, + descriptor: String, + reversed: Boolean + ) = + methodOrThrow().apply { + val constIndex = indexOfFirstLiteralInstructionOrThrow(id) + val insertIndex = if (reversed) + indexOfFirstInstructionReversedOrThrow(constIndex, Opcode.CHECK_CAST) + else + indexOfFirstInstructionOrThrow(constIndex, Opcode.CHECK_CAST) + val insertRegister = getInstruction(insertIndex).registerA + + addInstruction( + insertIndex, + "invoke-static {v$insertRegister}, $SHORTS_CLASS_DESCRIPTOR->$descriptor(Landroid/view/View;)V" + ) + } + + fun Pair.hideButtons( + id: Long, + descriptor: String + ) = + methodOrThrow().apply { + val constIndex = indexOfFirstLiteralInstructionOrThrow(id) + val insertIndex = indexOfFirstInstructionOrThrow(constIndex, Opcode.CHECK_CAST) + + hideButtons(insertIndex, descriptor) + } + + var settingArray = arrayOf( + "PREFERENCE_SCREEN: SHORTS", + "SETTINGS: SHORTS_COMPONENTS" + ) + + if (is_19_25_or_greater && !is_19_28_or_greater) { + settingArray += "SETTINGS: SHORTS_TIME_STAMP" + } + + // region patch for hide comments button (non-litho) + + shortsButtonFingerprint.hideButton(rightComment, "hideShortsCommentsButton", false) + + // endregion + + // region patch for hide dislike button (non-litho) + + shortsButtonFingerprint.methodOrThrow().apply { + val constIndex = + indexOfFirstLiteralInstructionOrThrow(reelRightDislikeIcon) + val constRegister = getInstruction(constIndex).registerA + + val jumpIndex = indexOfFirstInstructionOrThrow(constIndex, Opcode.CONST_CLASS) + 2 + + addInstructionsWithLabels( + constIndex + 1, """ + invoke-static {}, $SHORTS_CLASS_DESCRIPTOR->hideShortsDislikeButton()Z + move-result v$constRegister + if-nez v$constRegister, :hide + const v$constRegister, $reelRightDislikeIcon + """, ExternalLabel("hide", getInstruction(jumpIndex)) + ) + } + + // endregion + + // region patch for hide like button (non-litho) + + shortsButtonFingerprint.methodOrThrow().apply { + val insertIndex = indexOfFirstLiteralInstructionOrThrow(reelRightLikeIcon) + val insertRegister = getInstruction(insertIndex).registerA + val jumpIndex = indexOfFirstInstructionOrThrow(insertIndex, Opcode.CONST_CLASS) + 2 + + addInstructionsWithLabels( + insertIndex + 1, """ + invoke-static {}, $SHORTS_CLASS_DESCRIPTOR->hideShortsLikeButton()Z + move-result v$insertRegister + if-nez v$insertRegister, :hide + const v$insertRegister, $reelRightLikeIcon + """, ExternalLabel("hide", getInstruction(jumpIndex)) + ) + } + + // endregion + + // region patch for hide sound button + + if (shortsPivotLegacyFingerprint.resolvable()) { + // Legacy method. + shortsPivotLegacyFingerprint.methodOrThrow().apply { + val targetIndex = + indexOfFirstLiteralInstructionOrThrow(reelForcedMuteButton) + val targetRegister = getInstruction(targetIndex).registerA + + val insertIndex = indexOfFirstInstructionReversedOrThrow(targetIndex, Opcode.IF_EQZ) + val jumpIndex = indexOfFirstInstructionOrThrow(targetIndex, Opcode.GOTO) + + addInstructionsWithLabels( + insertIndex, """ + invoke-static {}, $SHORTS_CLASS_DESCRIPTOR->hideShortsSoundButton()Z + move-result v$targetRegister + if-nez v$targetRegister, :hide + """, ExternalLabel("hide", getInstruction(jumpIndex)) + ) + } + } else if (reelPlayerRightPivotV2Size != -1L) { + // Invoke Sound button dimen into extension. + val smaliInstruction = """ + invoke-static {v$REGISTER_TEMPLATE_REPLACEMENT}, $SHORTS_CLASS_DESCRIPTOR->getShortsSoundButtonDimenId(I)I + move-result v$REGISTER_TEMPLATE_REPLACEMENT + """ + + replaceLiteralInstructionCall( + reelPlayerRightPivotV2Size, + smaliInstruction + ) + } else { + throw PatchException("ReelPlayerRightPivotV2Size is not found") + } + + // endregion + + // region patch for hide remix button (non-litho) + + shortsButtonFingerprint.hideButton(reelDynRemix, "hideShortsRemixButton", true) + + // endregion + + // region patch for hide share button (non-litho) + + shortsButtonFingerprint.hideButton(reelDynShare, "hideShortsShareButton", true) + + // endregion + + // region patch for hide paid promotion label (non-litho) + + shortsPaidPromotionFingerprint.methodOrThrow().apply { + when (returnType) { + "Landroid/widget/TextView;" -> { + val insertIndex = implementation!!.instructions.lastIndex + val insertRegister = + getInstruction(insertIndex).registerA + + addInstructions( + insertIndex + 1, """ + invoke-static {v$insertRegister}, $SHORTS_CLASS_DESCRIPTOR->hideShortsPaidPromotionLabel(Landroid/widget/TextView;)V + return-object v$insertRegister + """ + ) + removeInstruction(insertIndex) + } + + "V" -> { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $SHORTS_CLASS_DESCRIPTOR->hideShortsPaidPromotionLabel()Z + move-result v0 + if-eqz v0, :show + return-void + """, ExternalLabel("show", getInstruction(0)) + ) + } + + else -> { + throw PatchException("Unknown returnType: $returnType") + } + } + } + + // endregion + + // region patch for hide subscribe button (non-litho) + + // This method is deprecated since YouTube v18.31.xx. + if (!is_18_31_or_greater) { + val subscriptionFieldReference = + with(shortsSubscriptionsTabletParentFingerprint.methodOrThrow()) { + val targetIndex = + indexOfFirstLiteralInstructionOrThrow(reelPlayerFooter) - 1 + (getInstruction(targetIndex)).reference as FieldReference + } + shortsSubscriptionsTabletFingerprint.methodOrThrow( + shortsSubscriptionsTabletParentFingerprint + ).apply { + implementation!!.instructions.filter { instruction -> + val fieldReference = + (instruction as? ReferenceInstruction)?.reference as? FieldReference + instruction.opcode == Opcode.IGET && + fieldReference == subscriptionFieldReference + }.forEach { instruction -> + val insertIndex = implementation!!.instructions.indexOf(instruction) + 1 + val register = (instruction as TwoRegisterInstruction).registerA + + addInstructions( + insertIndex, """ + invoke-static {v$register}, $SHORTS_CLASS_DESCRIPTOR->hideShortsSubscribeButton(I)I + move-result v$register + """ + ) + } + } + } + + // endregion + + // region patch for hide paused header + + shortsPausedHeaderFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = it.patternMatch!!.endIndex + 1 + val targetInstruction = getInstruction(targetIndex) + val targetReference = + (targetInstruction as? ReferenceInstruction)?.reference as? MethodReference + val useMethodWalker = targetInstruction.opcode == Opcode.INVOKE_VIRTUAL && + targetReference?.returnType == "V" && + targetReference.parameterTypes.firstOrNull() == "Landroid/view/View;" + + if (useMethodWalker) { + // YouTube 18.29.38 ~ YouTube 19.28.42 + getWalkerMethod(targetIndex).apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $SHORTS_CLASS_DESCRIPTOR->hideShortsPausedHeader()Z + move-result v0 + if-eqz v0, :show + return-void + """, ExternalLabel("show", getInstruction(0)) + ) + } + } else { + // YouTube 19.29.42 ~ + val insertIndex = it.patternMatch!!.startIndex + val insertRegister = + getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $SHORTS_CLASS_DESCRIPTOR->hideShortsPausedHeader(Z)Z + move-result v$insertRegister + """ + ) + } + } + } + + // endregion + + // region patch for return shorts channel name + + hookSpannableString( + EXTENSION_RETURN_YOUTUBE_CHANNEL_NAME_CLASS_DESCRIPTOR, + "onCharSequenceLoaded" + ) + + hookShortsVideoInformation("$EXTENSION_RETURN_YOUTUBE_CHANNEL_NAME_CLASS_DESCRIPTOR->newShortsVideoStarted(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V") + + // endregion + + addLithoFilter(BUTTON_FILTER_CLASS_DESCRIPTOR) + addLithoFilter(SHELF_FILTER_CLASS_DESCRIPTOR) + addLithoFilter(RETURN_YOUTUBE_CHANNEL_NAME_FILTER_CLASS_DESCRIPTOR) + + // region add settings + + addPreference(settingArray, SHORTS_COMPONENTS) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/startupshortsreset/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/startupshortsreset/Fingerprints.kt new file mode 100644 index 0000000000..1ce5d5dde2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/startupshortsreset/Fingerprints.kt @@ -0,0 +1,36 @@ +package app.revanced.patches.youtube.shorts.startupshortsreset + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +/** + * This fingerprint is compatible with all YouTube versions after v18.15.40. + */ +internal val userWasInShortsABConfigFingerprint = legacyFingerprint( + name = "userWasInShortsABConfigFingerprint", + returnType = "V", + strings = listOf("Failed to get offline response: "), + customFingerprint = { method, _ -> + indexOfOptionalInstruction(method) >= 0 + } +) + +internal fun indexOfOptionalInstruction(method: Method) = + method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_STATIC && + getReference().toString() == "Lj${'$'}/util/Optional;->of(Ljava/lang/Object;)Lj${'$'}/util/Optional;" + } + +internal val userWasInShortsFingerprint = legacyFingerprint( + name = "userWasInShortsFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Ljava/lang/Object;"), + strings = listOf("Failed to read user_was_in_shorts proto after successful warmup") +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/startupshortsreset/ResumingShortsOnStartupPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/startupshortsreset/ResumingShortsOnStartupPatch.kt new file mode 100644 index 0000000000..ea7fe70ea4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/startupshortsreset/ResumingShortsOnStartupPatch.kt @@ -0,0 +1,105 @@ +package app.revanced.patches.youtube.shorts.startupshortsreset + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.SHORTS_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.patch.PatchList.DISABLE_RESUMING_SHORTS_ON_STARTUP +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +@Suppress("unused") +val resumingShortsOnStartupPatch = bytecodePatch( + DISABLE_RESUMING_SHORTS_ON_STARTUP.title, + DISABLE_RESUMING_SHORTS_ON_STARTUP.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(settingsPatch) + + execute { + + userWasInShortsABConfigFingerprint.methodOrThrow().apply { + val startIndex = indexOfOptionalInstruction(this) + val walkerIndex = implementation!!.instructions.let { + val subListIndex = + it.subList(startIndex, startIndex + 20).indexOfFirst { instruction -> + val reference = instruction.getReference() + instruction.opcode == Opcode.INVOKE_VIRTUAL && + reference?.returnType == "Z" && + reference.definingClass != "Lj${'$'}/util/Optional;" && + reference.parameterTypes.isEmpty() + } + if (subListIndex < 0) + throw PatchException("subListIndex not found") + + startIndex + subListIndex + } + val walkerMethod = getWalkerMethod(walkerIndex) + + // This method will only be called for the user being A/B tested. + // Presumably a method that processes the ProtoDataStore value (boolean) for the 'user_was_in_shorts' key. + walkerMethod.apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $SHORTS_CLASS_DESCRIPTOR->disableResumingStartupShortsPlayer()Z + move-result v0 + if-eqz v0, :show + const/4 v0, 0x0 + return v0 + """, ExternalLabel("show", getInstruction(0)) + ) + } + } + + userWasInShortsFingerprint.methodOrThrow().apply { + val listenableInstructionIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_INTERFACE && + getReference()?.definingClass == "Lcom/google/common/util/concurrent/ListenableFuture;" && + getReference()?.name == "isDone" + } + val originalInstructionRegister = + getInstruction(listenableInstructionIndex).registerC + val freeRegister = + getInstruction(listenableInstructionIndex + 1).registerA + + addInstructionsWithLabels( + listenableInstructionIndex + 1, + """ + invoke-static {}, $SHORTS_CLASS_DESCRIPTOR->disableResumingStartupShortsPlayer()Z + move-result v$freeRegister + if-eqz v$freeRegister, :show + return-void + :show + invoke-interface {v$originalInstructionRegister}, Lcom/google/common/util/concurrent/ListenableFuture;->isDone()Z + """ + ) + removeInstruction(listenableInstructionIndex) + } + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: SHORTS", + "SETTINGS: DISABLE_RESUMING_SHORTS_PLAYER" + ), + DISABLE_RESUMING_SHORTS_ON_STARTUP + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/swipe/controls/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/swipe/controls/Fingerprints.kt new file mode 100644 index 0000000000..4c5555a518 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/swipe/controls/Fingerprints.kt @@ -0,0 +1,46 @@ +package app.revanced.patches.youtube.swipe.controls + +import app.revanced.patches.youtube.utils.extension.Constants.EXTENSION_PATH +import app.revanced.patches.youtube.utils.resourceid.fullScreenEngagementOverlay +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val fullScreenEngagementOverlayFingerprint = legacyFingerprint( + name = "fullScreenEngagementOverlayFingerprint", + returnType = "V", + literals = listOf(fullScreenEngagementOverlay), +) + +internal val hdrBrightnessFingerprint = legacyFingerprint( + name = "hdrBrightnessFingerprint", + returnType = "V", + strings = listOf("mediaViewambientBrightnessSensor") +) + +internal val swipeControlsHostActivityFingerprint = legacyFingerprint( + name = "swipeControlsHostActivityFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + parameters = emptyList(), + customFingerprint = { _, classDef -> + classDef.type == "$EXTENSION_PATH/swipecontrols/SwipeControlsHostActivity;" + } +) + +/** + * This fingerprint is compatible with YouTube v19.19.39+ + */ +internal val swipeToSwitchVideoFingerprint = legacyFingerprint( + name = "swipeToSwitchVideoFingerprint", + returnType = "V", + literals = listOf(45631116L), +) + +/** + * This fingerprint is compatible with YouTube v18.29.38+ + */ +internal val watchPanelGesturesFingerprint = legacyFingerprint( + name = "watchPanelGesturesFingerprint", + returnType = "V", + literals = listOf(45372793L), +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/swipe/controls/SwipeControlsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/swipe/controls/SwipeControlsPatch.kt new file mode 100644 index 0000000000..ed84af9ec5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/swipe/controls/SwipeControlsPatch.kt @@ -0,0 +1,170 @@ +package app.revanced.patches.youtube.swipe.controls + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.shared.mainactivity.mainActivityMutableClass +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.SWIPE_PATH +import app.revanced.patches.youtube.utils.lockmodestate.lockModeStateHookPatch +import app.revanced.patches.youtube.utils.mainactivity.mainActivityResolvePatch +import app.revanced.patches.youtube.utils.patch.PatchList.SWIPE_CONTROLS +import app.revanced.patches.youtube.utils.playertype.playerTypeHookPatch +import app.revanced.patches.youtube.utils.resourceid.fullScreenEngagementOverlay +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.ResourceUtils.getContext +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.ResourceGroup +import app.revanced.util.copyResources +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.mutableClassOrThrow +import app.revanced.util.fingerprint.resolvable +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import app.revanced.util.transformMethods +import app.revanced.util.traverseClassHierarchy +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.immutable.ImmutableMethod + +private const val EXTENSION_SWIPE_CONTROLS_PATCH_CLASS_DESCRIPTOR = + "$SWIPE_PATH/SwipeControlsPatch;" + +@Suppress("unused") +val swipeControlsPatch = bytecodePatch( + SWIPE_CONTROLS.title, + SWIPE_CONTROLS.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + lockModeStateHookPatch, + mainActivityResolvePatch, + playerTypeHookPatch, + sharedResourceIdPatch, + settingsPatch, + ) + + execute { + + // region patch for swipe controls patch + + val hostActivityClass = swipeControlsHostActivityFingerprint.mutableClassOrThrow() + val mainActivityClass = mainActivityMutableClass + + // inject the wrapper class from extension into the class hierarchy of MainActivity (WatchWhileActivity) + hostActivityClass.setSuperClass(mainActivityClass.superclass) + mainActivityClass.setSuperClass(hostActivityClass.type) + + // ensure all classes and methods in the hierarchy are non-final, so we can override them in extension + traverseClassHierarchy(mainActivityClass) { + accessFlags = accessFlags and AccessFlags.FINAL.value.inv() + transformMethods { + ImmutableMethod( + definingClass, + name, + parameters, + returnType, + accessFlags and AccessFlags.FINAL.value.inv(), + annotations, + hiddenApiRestrictions, + implementation + ).toMutable() + } + } + + fullScreenEngagementOverlayFingerprint.methodOrThrow().apply { + val viewIndex = + indexOfFirstLiteralInstructionOrThrow(fullScreenEngagementOverlay) + 3 + val viewRegister = getInstruction(viewIndex).registerA + + addInstruction( + viewIndex + 1, + "invoke-static {v$viewRegister}, $EXTENSION_SWIPE_CONTROLS_PATCH_CLASS_DESCRIPTOR->setFullscreenEngagementOverlayView(Landroid/view/View;)V" + ) + } + + // endregion + + var settingArray = arrayOf( + "PREFERENCE_SCREEN: SWIPE_CONTROLS" + ) + + // region patch for disable HDR auto brightness + + // Since it does not support all versions, + // add settings only if the patch is successful. + if (hdrBrightnessFingerprint.resolvable()) { + hdrBrightnessFingerprint.methodOrThrow().apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $EXTENSION_SWIPE_CONTROLS_PATCH_CLASS_DESCRIPTOR->disableHDRAutoBrightness()Z + move-result v0 + if-eqz v0, :default + return-void + """, ExternalLabel("default", getInstruction(0)) + ) + settingArray += "SETTINGS: DISABLE_HDR_BRIGHTNESS" + } + } + + // endregion + + // region patch for enable swipe to switch video + + // Since it does not support all versions, + // add settings only if the patch is successful. + + if (swipeToSwitchVideoFingerprint.resolvable()) { + swipeToSwitchVideoFingerprint.injectLiteralInstructionBooleanCall( + 45631116L, + "$EXTENSION_SWIPE_CONTROLS_PATCH_CLASS_DESCRIPTOR->enableSwipeToSwitchVideo()Z" + ) + + settingArray += "SETTINGS: ENABLE_SWIPE_TO_SWITCH_VIDEO" + } + + // endregion + + // region patch for enable watch panel gestures + + // Since it does not support all versions, + // add settings only if the patch is successful. + if (watchPanelGesturesFingerprint.resolvable()) { + watchPanelGesturesFingerprint.injectLiteralInstructionBooleanCall( + 45372793L, + "$EXTENSION_SWIPE_CONTROLS_PATCH_CLASS_DESCRIPTOR->enableWatchPanelGestures()Z" + ) + + settingArray += "SETTINGS: ENABLE_WATCH_PANEL_GESTURES" + } + + // endregion + + // region copy resources + + getContext().copyResources( + "youtube/swipecontrols", + ResourceGroup( + "drawable", + "ic_sc_brightness_auto.xml", + "ic_sc_brightness_manual.xml", + "ic_sc_volume_mute.xml", + "ic_sc_volume_normal.xml" + ) + ) + + // endregion + + // region add settings + + addPreference(settingArray, SWIPE_CONTROLS) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/Fingerprints.kt new file mode 100644 index 0000000000..fba851b851 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/Fingerprints.kt @@ -0,0 +1,202 @@ +package app.revanced.patches.youtube.utils + +import app.revanced.patches.youtube.player.components.playerComponentsPatch +import app.revanced.patches.youtube.utils.resourceid.fadeDurationFast +import app.revanced.patches.youtube.utils.resourceid.inlineTimeBarColorizedBarPlayedColorDark +import app.revanced.patches.youtube.utils.resourceid.inlineTimeBarPlayedNotHighlightedColor +import app.revanced.patches.youtube.utils.resourceid.insetOverlayViewLayout +import app.revanced.patches.youtube.utils.resourceid.scrimOverlay +import app.revanced.patches.youtube.utils.resourceid.seekUndoEduOverlayStub +import app.revanced.patches.youtube.utils.resourceid.totalTime +import app.revanced.patches.youtube.utils.resourceid.varispeedUnavailableTitle +import app.revanced.patches.youtube.utils.resourceid.videoQualityBottomSheet +import app.revanced.patches.youtube.utils.sponsorblock.sponsorBlockBytecodePatch +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val engagementPanelBuilderFingerprint = legacyFingerprint( + name = "engagementPanelBuilderFingerprint", + returnType = "L", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + parameters = listOf("L", "L", "Z", "Z"), + strings = listOf( + "EngagementPanelController: cannot show EngagementPanel before EngagementPanelController.init() has been called.", + "[EngagementPanel] Cannot show EngagementPanel before EngagementPanelController.init() has been called." + ) +) + +internal val layoutConstructorFingerprint = legacyFingerprint( + name = "layoutConstructorFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + strings = listOf("1.0x") +) + +internal val playbackRateBottomSheetBuilderFingerprint = legacyFingerprint( + name = "playbackRateBottomSheetBuilderFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf( + Opcode.IGET_BOOLEAN, + Opcode.IF_EQZ, + ), + literals = listOf(varispeedUnavailableTitle), +) + +internal val playerButtonsResourcesFingerprint = legacyFingerprint( + name = "playerButtonsResourcesFingerprint", + returnType = "I", + parameters = listOf("Landroid/content/res/Resources;"), + literals = listOf(17694721L), +) + +internal val playerButtonsVisibilityFingerprint = legacyFingerprint( + name = "playerButtonsVisibilityFingerprint", + returnType = "V", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.IGET_OBJECT, + Opcode.INVOKE_INTERFACE + ), + parameters = listOf("Z", "Z") +) + +internal val playerSeekbarColorFingerprint = legacyFingerprint( + name = "playerSeekbarColorFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + literals = listOf( + inlineTimeBarColorizedBarPlayedColorDark, + inlineTimeBarPlayedNotHighlightedColor + ), +) + +internal val qualityMenuViewInflateFingerprint = legacyFingerprint( + name = "qualityMenuViewInflateFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "L", "L"), + opcodes = listOf( + Opcode.INVOKE_SUPER, + Opcode.CONST, + Opcode.CONST_4, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CONST_16, + Opcode.INVOKE_VIRTUAL, + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST + ), + literals = listOf(videoQualityBottomSheet), +) + +internal val rollingNumberTextViewAnimationUpdateFingerprint = legacyFingerprint( + name = "rollingNumberTextViewAnimationUpdateFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Landroid/graphics/Bitmap;"), + opcodes = listOf( + Opcode.NEW_INSTANCE, // bitmap ImageSpan + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT + ) +) + +/** + * This fingerprint is compatible with YouTube v18.32.39+ + */ +internal val rollingNumberTextViewFingerprint = legacyFingerprint( + name = "rollingNumberTextViewFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "F", "F"), + opcodes = listOf( + Opcode.IPUT, + null, // invoke-direct or invoke-virtual + Opcode.IPUT_OBJECT, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN_VOID + ), + customFingerprint = custom@{ _, classDef -> + classDef.superclass == "Landroid/support/v7/widget/AppCompatTextView;" + || classDef.superclass == "Lcom/google/android/libraries/youtube/rendering/ui/spec/typography/YouTubeAppCompatTextView;" + } +) + +internal val scrollTopParentFingerprint = legacyFingerprint( + name = "scrollTopParentFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + opcodes = listOf( + Opcode.IPUT_OBJECT, + Opcode.IPUT_OBJECT, + Opcode.IPUT_OBJECT, + Opcode.IPUT_OBJECT, + Opcode.CONST_16, + Opcode.INVOKE_VIRTUAL, + Opcode.NEW_INSTANCE, + Opcode.INVOKE_DIRECT, + Opcode.IPUT_OBJECT, + Opcode.RETURN_VOID + ), + customFingerprint = { method, _ -> method.name == "" } +) + +internal val seekbarFingerprint = legacyFingerprint( + name = "seekbarFingerprint", + returnType = "V", + strings = listOf("timed_markers_width") +) + +internal val seekbarOnDrawFingerprint = legacyFingerprint( + name = "seekbarOnDrawFingerprint", + customFingerprint = { method, _ -> method.name == "onDraw" } +) + +internal val totalTimeFingerprint = legacyFingerprint( + name = "totalTimeFingerprint", + returnType = "V", + literals = listOf(totalTime), +) + +internal val videoEndFingerprint = legacyFingerprint( + name = "videoEndFingerprint", + strings = listOf("Attempting to seek during an ad"), + literals = listOf(45368273L), +) + +/** + * Several instructions are added to this method by different patches. + * Therefore, patches using this fingerprint should not use the [Opcode] pattern, + * and must access the index through the resourceId. + * + * The patches and resourceIds that use this fingerprint are as follows: + * - [playerComponentsPatch] uses [fadeDurationFast], [scrimOverlay] and [seekUndoEduOverlayStub]. + * - [sponsorBlockBytecodePatch] uses [insetOverlayViewLayout]. + */ +internal val youtubeControlsOverlayFingerprint = legacyFingerprint( + name = "youtubeControlsOverlayFingerprint", + returnType = "V", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf( + fadeDurationFast, + insetOverlayViewLayout, + scrimOverlay, + seekUndoEduOverlayStub + ), +) + +const val PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR = + "Lcom/google/android/libraries/youtube/innertube/model/player/PlayerResponseModel;" \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/bottomsheet/BottomSheetHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/bottomsheet/BottomSheetHookPatch.kt new file mode 100644 index 0000000000..568e118f6d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/bottomsheet/BottomSheetHookPatch.kt @@ -0,0 +1,31 @@ +package app.revanced.patches.youtube.utils.bottomsheet + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.extension.Constants.UTILS_PATH +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.definingClassOrThrow + +private const val EXTENSION_BOTTOM_SHEET_HOOK_CLASS_DESCRIPTOR = + "$UTILS_PATH/BottomSheetHookPatch;" + +val bottomSheetHookPatch = bytecodePatch( + description = "bottomSheetHookPatch" +) { + execute { + val bottomSheetClass = + bottomSheetBehaviorFingerprint.definingClassOrThrow() + + arrayOf( + "onAttachedToWindow", + "onDetachedFromWindow" + ).forEach { methodName -> + findMethodOrThrow(bottomSheetClass) { + name == methodName + }.addInstruction( + 1, + "invoke-static {}, $EXTENSION_BOTTOM_SHEET_HOOK_CLASS_DESCRIPTOR->$methodName()V" + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/bottomsheet/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/bottomsheet/Fingerprints.kt new file mode 100644 index 0000000000..fa50d07915 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/bottomsheet/Fingerprints.kt @@ -0,0 +1,10 @@ +package app.revanced.patches.youtube.utils.bottomsheet + +import app.revanced.patches.youtube.utils.resourceid.designBottomSheet +import app.revanced.util.fingerprint.legacyFingerprint + +internal val bottomSheetBehaviorFingerprint = legacyFingerprint( + name = "bottomSheetBehaviorFingerprint", + returnType = "V", + literals = listOf(designBottomSheet), +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/castbutton/CastButtonPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/castbutton/CastButtonPatch.kt new file mode 100644 index 0000000000..cce33c8d2d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/castbutton/CastButtonPatch.kt @@ -0,0 +1,97 @@ +@file:Suppress("CONTEXT_RECEIVERS_DEPRECATED") + +package app.revanced.patches.youtube.utils.castbutton + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.patch.BytecodePatchContext +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.extension.Constants.PATCH_STATUS_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.extension.Constants.PLAYER_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.extension.Constants.UTILS_PATH +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.updatePatchStatus +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$UTILS_PATH/CastButtonPatch;" + +private lateinit var playerButtonMethod: MutableMethod +private lateinit var toolbarMenuItemInitializeMethod: MutableMethod +private lateinit var toolbarMenuItemVisibilityMethod: MutableMethod + +val castButtonPatch = bytecodePatch( + description = "castButtonPatch" +) { + dependsOn(sharedResourceIdPatch) + + execute { + toolbarMenuItemInitializeMethod = menuItemInitializeFingerprint.methodOrThrow() + toolbarMenuItemVisibilityMethod = + menuItemVisibilityFingerprint.methodOrThrow(menuItemInitializeFingerprint) + + playerButtonMethod = playerButtonFingerprint.methodOrThrow() + + findMethodOrThrow("Landroidx/mediarouter/app/MediaRouteButton;") { + name == "setVisibility" + }.addInstructions( + 0, """ + invoke-static {p1}, $EXTENSION_CLASS_DESCRIPTOR->hideCastButton(I)I + move-result p1 + """ + ) + } +} + +context(BytecodePatchContext) +internal fun hookPlayerCastButton() { + playerButtonMethod.apply { + val index = indexOfFirstInstructionOrThrow { + getReference()?.name == "setVisibility" + } + val instruction = getInstruction(index) + val viewRegister = instruction.registerC + val visibilityRegister = instruction.registerD + val reference = getInstruction(index).reference + + addInstructions( + index + 1, """ + invoke-static {v$visibilityRegister}, $PLAYER_CLASS_DESCRIPTOR->hideCastButton(I)I + move-result v$visibilityRegister + invoke-virtual {v$viewRegister, v$visibilityRegister}, $reference + """ + ) + removeInstruction(index) + } + updatePatchStatus(PATCH_STATUS_CLASS_DESCRIPTOR, "PlayerButtons") +} + +context(BytecodePatchContext) +internal fun hookToolBarCastButton() { + toolbarMenuItemInitializeMethod.apply { + val index = indexOfFirstInstructionOrThrow { + getReference()?.name == "setShowAsAction" + } + 1 + addInstruction( + index, + "invoke-static {p1}, $GENERAL_CLASS_DESCRIPTOR->hideCastButton(Landroid/view/MenuItem;)V" + ) + } + toolbarMenuItemVisibilityMethod.addInstructions( + 0, """ + invoke-static {p1}, $GENERAL_CLASS_DESCRIPTOR->hideCastButton(Z)Z + move-result p1 + """ + ) + updatePatchStatus(PATCH_STATUS_CLASS_DESCRIPTOR, "ToolBarComponents") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/castbutton/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/castbutton/Fingerprints.kt new file mode 100644 index 0000000000..e4a6039273 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/castbutton/Fingerprints.kt @@ -0,0 +1,37 @@ +package app.revanced.patches.youtube.utils.castbutton + +import app.revanced.patches.youtube.utils.resourceid.castMediaRouteButton +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val menuItemInitializeFingerprint = legacyFingerprint( + name = "menuItemInitializeFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Landroid/view/MenuItem;"), + literals = listOf(castMediaRouteButton), +) + +internal val menuItemVisibilityFingerprint = legacyFingerprint( + name = "menuItemVisibilityFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Z"), + customFingerprint = { method, _ -> + method.indexOfFirstInstruction { + getReference()?.name == "setVisible" + } >= 0 + } +) + +internal val playerButtonFingerprint = legacyFingerprint( + name = "playerButtonFingerprint", + returnType = "V", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(11208L), +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/compatibility/Constants.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/compatibility/Constants.kt new file mode 100644 index 0000000000..39b1a72593 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/compatibility/Constants.kt @@ -0,0 +1,20 @@ +package app.revanced.patches.youtube.utils.compatibility + +import app.revanced.patcher.patch.PackageName +import app.revanced.patcher.patch.VersionName + +internal object Constants { + internal const val YOUTUBE_PACKAGE_NAME = "com.google.android.youtube" + + val COMPATIBLE_PACKAGE: Pair?> = Pair( + YOUTUBE_PACKAGE_NAME, + setOf( + "18.29.38", // This is the last version where the 'Zoomed to fill' setting works. + "18.33.40", // This is the last version that do not use litho components in Shorts. + "18.38.44", // This is the last version with no delay in applying video quality on the server side. + "18.48.39", // This is the last version that do not use Rolling Number. + "19.05.36", // This is the last version with the least YouTube experimental flag. + "19.16.39", // This is the latest version supported by the RVX patch. + ) + ) +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/controlsoverlay/ControlsOverlayConfigPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/controlsoverlay/ControlsOverlayConfigPatch.kt new file mode 100644 index 0000000000..2e585519cf --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/controlsoverlay/ControlsOverlayConfigPatch.kt @@ -0,0 +1,33 @@ +package app.revanced.patches.youtube.utils.controlsoverlay + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.resolvable +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +val controlsOverlayConfigPatch = bytecodePatch( + description = "controlsOverlayConfigPatch" +) { + + execute { + /** + * Added in YouTube v18.39.41 + * + * No exception even if fail to resolve fingerprints. + * For compatibility with YouTube v18.25.40 ~ YouTube v18.38.44. + */ + if (controlsOverlayConfigFingerprint.resolvable()) { + controlsOverlayConfigFingerprint.methodOrThrow().apply { + val targetIndex = implementation!!.instructions.size - 1 + val targetRegister = getInstruction(targetIndex).registerA + + addInstruction( + targetIndex, + "const/4 v$targetRegister, 0x0" + ) + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/controlsoverlay/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/controlsoverlay/Fingerprints.kt new file mode 100644 index 0000000000..83d494511e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/controlsoverlay/Fingerprints.kt @@ -0,0 +1,15 @@ +package app.revanced.patches.youtube.utils.controlsoverlay + +import app.revanced.util.fingerprint.legacyFingerprint + +/** + * Added in YouTube v18.39.41 + * + * When this value is TRUE, new control overlay is used. + * In this case, the associated patches no longer work, so set this value to FALSE. + */ +internal val controlsOverlayConfigFingerprint = legacyFingerprint( + name = "controlsOverlayConfigFingerprint", + returnType = "Z", + literals = listOf(45427491L), +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/extension/Constants.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/extension/Constants.kt new file mode 100644 index 0000000000..b0c20cdfe6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/extension/Constants.kt @@ -0,0 +1,32 @@ +package app.revanced.patches.youtube.utils.extension + +@Suppress("MemberVisibilityCanBePrivate") +internal object Constants { + const val EXTENSION_PATH = "Lapp/revanced/extension/youtube" + const val SHARED_PATH = "$EXTENSION_PATH/shared" + const val PATCHES_PATH = "$EXTENSION_PATH/patches" + + const val ADS_PATH = "$PATCHES_PATH/ads" + const val ALTERNATIVE_THUMBNAILS_PATH = "$PATCHES_PATH/alternativethumbnails" + const val COMPONENTS_PATH = "$PATCHES_PATH/components" + const val FEED_PATH = "$PATCHES_PATH/feed" + const val GENERAL_PATH = "$PATCHES_PATH/general" + const val MISC_PATH = "$PATCHES_PATH/misc" + const val OVERLAY_BUTTONS_PATH = "$PATCHES_PATH/overlaybutton" + const val PLAYER_PATH = "$PATCHES_PATH/player" + const val SHORTS_PATH = "$PATCHES_PATH/shorts" + const val SPANS_PATH = "$PATCHES_PATH/spans" + const val SWIPE_PATH = "$PATCHES_PATH/swipe" + const val UTILS_PATH = "$PATCHES_PATH/utils" + const val VIDEO_PATH = "$PATCHES_PATH/video" + + const val ADS_CLASS_DESCRIPTOR = "$ADS_PATH/AdsPatch;" + const val ALTERNATIVE_THUMBNAILS_CLASS_DESCRIPTOR = + "$ALTERNATIVE_THUMBNAILS_PATH/AlternativeThumbnailsPatch;" + const val FEED_CLASS_DESCRIPTOR = "$FEED_PATH/FeedPatch;" + const val GENERAL_CLASS_DESCRIPTOR = "$GENERAL_PATH/GeneralPatch;" + const val PLAYER_CLASS_DESCRIPTOR = "$PLAYER_PATH/PlayerPatch;" + const val SHORTS_CLASS_DESCRIPTOR = "$SHORTS_PATH/ShortsPatch;" + + const val PATCH_STATUS_CLASS_DESCRIPTOR = "$UTILS_PATH/PatchStatus;" +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/extension/SharedExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/extension/SharedExtensionPatch.kt new file mode 100644 index 0000000000..bab6ee3da8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/extension/SharedExtensionPatch.kt @@ -0,0 +1,9 @@ +package app.revanced.patches.youtube.utils.extension + +import app.revanced.patches.shared.extension.sharedExtensionPatch +import app.revanced.patches.youtube.utils.extension.hooks.applicationInitHook + +// TODO: Move this to a "Hook.kt" file. Same for other extension hook patches. +val sharedExtensionPatch = sharedExtensionPatch( + applicationInitHook, +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/extension/hooks/ApplicationInitHook.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/extension/hooks/ApplicationInitHook.kt new file mode 100644 index 0000000000..82a9254dc9 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/extension/hooks/ApplicationInitHook.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.youtube.utils.extension.hooks + +import app.revanced.patches.shared.extension.extensionHook + +/** + * Hooks the context when the app is launched as a regular application (and is not an embedded video playback). + */ +// Extension context is the Activity itself. +internal val applicationInitHook = extensionHook { + strings("Application creation", "Application.onCreate") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/bottomui/CfBottomUIPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/bottomui/CfBottomUIPatch.kt new file mode 100644 index 0000000000..9729757cd0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/bottomui/CfBottomUIPatch.kt @@ -0,0 +1,28 @@ +package app.revanced.patches.youtube.utils.fix.bottomui + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.resolvable + +val cfBottomUIPatch = bytecodePatch( + description = "cfBottomUIPatch" +) { + execute { + /** + * This issue only affects some versions of YouTube. + * Therefore, this patch only applies to versions that can resolve this fingerprint. + */ + mapOf( + exploderControlsFingerprint to 45643739L, + fullscreenButtonViewStubFingerprint to 45617294L, + fullscreenButtonPositionFingerprint to 45627640L + ).forEach { (fingerprint, literalValue) -> + if (fingerprint.resolvable()) { + fingerprint.injectLiteralInstructionBooleanCall( + literalValue, + "0x0" + ) + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/bottomui/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/bottomui/Fingerprints.kt new file mode 100644 index 0000000000..9f42e20cab --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/bottomui/Fingerprints.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.youtube.utils.fix.bottomui + +import app.revanced.util.fingerprint.legacyFingerprint + +internal val exploderControlsFingerprint = legacyFingerprint( + name = "exploderControlsFingerprint", + returnType = "Z", + literals = listOf(45643739L), +) + +internal val fullscreenButtonPositionFingerprint = legacyFingerprint( + name = "fullscreenButtonPositionFingerprint", + returnType = "Z", + literals = listOf(45627640L), +) + +internal val fullscreenButtonViewStubFingerprint = legacyFingerprint( + name = "fullscreenButtonViewStubFingerprint", + returnType = "Z", + literals = listOf(45617294L), +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/cairo/CairoSettingsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/cairo/CairoSettingsPatch.kt new file mode 100644 index 0000000000..751f987c8a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/cairo/CairoSettingsPatch.kt @@ -0,0 +1,28 @@ +package app.revanced.patches.youtube.utils.fix.cairo + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.resolvable + +val cairoSettingsPatch = bytecodePatch( + description = "cairoSettingsPatch" +) { + execute { + /** + * Cairo Fragment was added since YouTube v19.04.38. + * Disable this for the following reasons: + * 1. [backgroundPlaybackPatch] does not activate the Minimized playback setting of Cairo Fragment. + * 2. Some patches implemented in RVX do not yet support Cairo Fragments. + * + * See ReVanced_Extended#2099 + * or uYouPlus#1468 + * for screenshots of the Cairo Fragment. + */ + if (carioFragmentConfigFingerprint.resolvable()) { + carioFragmentConfigFingerprint.injectLiteralInstructionBooleanCall( + 45532100L, + "0x0" + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/cairo/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/cairo/Fingerprints.kt new file mode 100644 index 0000000000..73f157247f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/cairo/Fingerprints.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.youtube.utils.fix.cairo + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +/** + * Added in YouTube v19.04.38 + * + * When this value is TRUE, Cairo Fragment is used. + * In this case, some of patches may be broken, so set this value to FALSE. + */ +internal val carioFragmentConfigFingerprint = legacyFingerprint( + name = "carioFragmentConfigFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + literals = listOf(45532100L), +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/doublebacktoclose/DoubleBackToClosePatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/doublebacktoclose/DoubleBackToClosePatch.kt new file mode 100644 index 0000000000..b937e3e068 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/doublebacktoclose/DoubleBackToClosePatch.kt @@ -0,0 +1,55 @@ +package app.revanced.patches.youtube.utils.fix.doublebacktoclose + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patches.shared.mainactivity.injectOnBackPressedMethodCall +import app.revanced.patches.youtube.utils.extension.Constants.UTILS_PATH +import app.revanced.patches.youtube.utils.scrollTopParentFingerprint +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.getWalkerMethod + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$UTILS_PATH/DoubleBackToClosePatch;" + +val doubleBackToClosePatch = bytecodePatch( + description = "doubleBackToClosePatch" +) { + execute { + fun MutableMethod.injectScrollView( + index: Int, + descriptor: String + ) = addInstruction( + index, + "invoke-static {}, $EXTENSION_CLASS_DESCRIPTOR->$descriptor()V" + ) + + /** + * Hook onBackPressed method inside MainActivity (WatchWhileActivity) + */ + injectOnBackPressedMethodCall( + EXTENSION_CLASS_DESCRIPTOR, + "closeActivityOnBackPressed" + ) + + /** + * Inject the methods which start of ScrollView + */ + scrollPositionFingerprint.matchOrThrow().let { + val walkerMethod = + it.getWalkerMethod(it.patternMatch!!.startIndex + 1) + val insertIndex = walkerMethod.implementation!!.instructions.size - 1 - 1 + + walkerMethod.injectScrollView(insertIndex, "onStartScrollView") + } + + /** + * Inject the methods which stop of ScrollView + */ + scrollTopFingerprint.matchOrThrow(scrollTopParentFingerprint).let { + val insertIndex = it.patternMatch!!.endIndex + + it.method.injectScrollView(insertIndex, "onStopScrollView") + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/doublebacktoclose/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/doublebacktoclose/Fingerprints.kt new file mode 100644 index 0000000000..d844c4a96e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/doublebacktoclose/Fingerprints.kt @@ -0,0 +1,34 @@ +package app.revanced.patches.youtube.utils.fix.doublebacktoclose + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val scrollPositionFingerprint = legacyFingerprint( + name = "scrollPositionFingerprint", + returnType = "V", + accessFlags = AccessFlags.PROTECTED or AccessFlags.FINAL, + parameters = listOf("L"), + opcodes = listOf( + Opcode.IF_NEZ, + Opcode.INVOKE_DIRECT, + Opcode.RETURN_VOID + ), + strings = listOf("scroll_position") +) + +internal val scrollTopFingerprint = legacyFingerprint( + name = "scrollTopFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf( + Opcode.CHECK_CAST, + Opcode.CONST_4, + Opcode.INVOKE_VIRTUAL, + Opcode.GOTO, + Opcode.IGET_OBJECT, + Opcode.INVOKE_INTERFACE + ) +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/shortsplayback/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/shortsplayback/Fingerprints.kt new file mode 100644 index 0000000000..0abb3acf2c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/shortsplayback/Fingerprints.kt @@ -0,0 +1,12 @@ +package app.revanced.patches.youtube.utils.fix.shortsplayback + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val shortsPlaybackFingerprint = legacyFingerprint( + name = "shortsPlaybackFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + literals = listOf(45387052L), +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/shortsplayback/ShortsPlaybackPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/shortsplayback/ShortsPlaybackPatch.kt new file mode 100644 index 0000000000..12255cd7dd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/shortsplayback/ShortsPlaybackPatch.kt @@ -0,0 +1,25 @@ +package app.revanced.patches.youtube.utils.fix.shortsplayback + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.resolvable + +val shortsPlaybackPatch = bytecodePatch( + description = "shortsPlaybackPatch" +) { + + execute { + /** + * This issue only affects some versions of YouTube. + * Therefore, this patch only applies to versions that can resolve this fingerprint. + * + * RVX applies default video quality to Shorts as well, so this patch is required. + */ + if (shortsPlaybackFingerprint.resolvable()) { + shortsPlaybackFingerprint.injectLiteralInstructionBooleanCall( + 45387052L, + "0x0" + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/streamingdata/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/streamingdata/Fingerprints.kt new file mode 100644 index 0000000000..79758b285f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/streamingdata/Fingerprints.kt @@ -0,0 +1,134 @@ +package app.revanced.patches.youtube.utils.fix.streamingdata + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val buildBrowseRequestFingerprint = legacyFingerprint( + name = "buildBrowseRequestFingerprint", + customFingerprint = { method, _ -> + method.implementation != null && + indexOfRequestFinishedListenerInstruction(method) >= 0 && + !method.definingClass.startsWith("Lorg/") && + indexOfNewUrlRequestBuilderInstruction(method) >= 0 && + // YouTube 17.34.36 ~ YouTube 18.35.36 + (indexOfEntrySetInstruction(method) >= 0 || + // YouTube 18.36.39 ~ + method.parameters[1].type == "Ljava/util/Map;") + } +) + +internal fun indexOfRequestFinishedListenerInstruction(method: Method) = + method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "setRequestFinishedListener" + } + +internal fun indexOfNewUrlRequestBuilderInstruction(method: Method) = + method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_VIRTUAL && + getReference().toString() == "Lorg/chromium/net/CronetEngine;->newUrlRequestBuilder(Ljava/lang/String;Lorg/chromium/net/UrlRequest${'$'}Callback;Ljava/util/concurrent/Executor;)Lorg/chromium/net/UrlRequest${'$'}Builder;" + } + +internal fun indexOfEntrySetInstruction(method: Method) = + method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_INTERFACE && + getReference().toString() == "Ljava/util/Map;->entrySet()Ljava/util/Set;" + } + +internal val buildInitPlaybackRequestFingerprint = legacyFingerprint( + name = "buildInitPlaybackRequestFingerprint", + returnType = "Lorg/chromium/net/UrlRequest\$Builder;", + opcodes = listOf( + Opcode.MOVE_RESULT_OBJECT, + Opcode.IGET_OBJECT, // Moves the request URI string to a register to build the request with. + ), + strings = listOf( + "Content-Type", + "Range", + ), +) + +internal val buildMediaDataSourceFingerprint = legacyFingerprint( + name = "buildMediaDataSourceFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + returnType = "V", + parameters = listOf( + "Landroid/net/Uri;", + "J", + "I", + "[B", + "Ljava/util/Map;", + "J", + "J", + "Ljava/lang/String;", + "I", + "Ljava/lang/Object;" + ) +) + +internal val buildPlayerRequestURIFingerprint = legacyFingerprint( + name = "buildPlayerRequestURIFingerprint", + returnType = "Ljava/lang/String;", + strings = listOf( + "key", + "asig", + ), + customFingerprint = { method, _ -> + indexOfToStringInstruction(method) >= 0 + }, +) + +internal fun indexOfToStringInstruction(method: Method) = + method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_VIRTUAL && + getReference().toString() == "Landroid/net/Uri;->toString()Ljava/lang/String;" + } + +internal val createStreamingDataFingerprint = legacyFingerprint( + name = "createStreamingDataFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + returnType = "V", + parameters = listOf("L"), + opcodes = listOf( + Opcode.IPUT_OBJECT, + Opcode.IGET_OBJECT, + Opcode.IF_NEZ, + Opcode.SGET_OBJECT, + Opcode.IPUT_OBJECT + ), + customFingerprint = { method, _ -> + method.indexOfFirstInstruction { + opcode == Opcode.SGET_OBJECT && + getReference()?.name == "playerThreedRenderer" + } >= 0 + }, +) + +internal val nerdsStatsVideoFormatBuilderFingerprint = legacyFingerprint( + name = "nerdsStatsVideoFormatBuilderFingerprint", + returnType = "Ljava/lang/String;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("Lcom/google/android/libraries/youtube/innertube/model/media/FormatStreamModel;"), + strings = listOf("codecs=\""), +) + +internal val protobufClassParseByteBufferFingerprint = legacyFingerprint( + name = "protobufClassParseByteBufferFingerprint", + accessFlags = AccessFlags.PROTECTED or AccessFlags.STATIC, + parameters = listOf("L", "Ljava/nio/ByteBuffer;"), + returnType = "L", + opcodes = listOf( + Opcode.SGET_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.RETURN_OBJECT, + ), + customFingerprint = { method, _ -> method.name == "parseFrom" }, +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/streamingdata/SpoofStreamingDataPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/streamingdata/SpoofStreamingDataPatch.kt new file mode 100644 index 0000000000..0a7ddf7a64 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/streamingdata/SpoofStreamingDataPatch.kt @@ -0,0 +1,230 @@ +package app.revanced.patches.youtube.utils.fix.streamingdata + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.instructions +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.shared.spoof.useragent.baseSpoofUserAgentPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.MISC_PATH +import app.revanced.patches.youtube.utils.patch.PatchList.SPOOF_STREAMING_DATA +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.findInstructionIndicesReversedOrThrow +import app.revanced.util.fingerprint.definingClassOrThrow +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$MISC_PATH/SpoofStreamingDataPatch;" + +val spoofStreamingDataPatch = bytecodePatch( + SPOOF_STREAMING_DATA.title, + SPOOF_STREAMING_DATA.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + baseSpoofUserAgentPatch("com.google.android.youtube"), + settingsPatch + ) + + execute { + // region Block /get_watch requests to fall back to /player requests. + + buildPlayerRequestURIFingerprint.methodOrThrow().apply { + val invokeToStringIndex = indexOfToStringInstruction(this) + val uriRegister = + getInstruction(invokeToStringIndex).registerC + + addInstructions( + invokeToStringIndex, + """ + invoke-static { v$uriRegister }, $EXTENSION_CLASS_DESCRIPTOR->blockGetWatchRequest(Landroid/net/Uri;)Landroid/net/Uri; + move-result-object v$uriRegister + """, + ) + } + + // endregion + + // region Block /initplayback requests to fall back to /get_watch requests. + + buildInitPlaybackRequestFingerprint.matchOrThrow().let { + it.method.apply { + val moveUriStringIndex = it.patternMatch!!.startIndex + val targetRegister = + getInstruction(moveUriStringIndex).registerA + + addInstructions( + moveUriStringIndex + 1, + """ + invoke-static { v$targetRegister }, $EXTENSION_CLASS_DESCRIPTOR->blockInitPlaybackRequest(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$targetRegister + """, + ) + } + } + + // endregion + + // region Fetch replacement streams. + + buildBrowseRequestFingerprint.methodOrThrow().apply { + val newRequestBuilderIndex = indexOfNewUrlRequestBuilderInstruction(this) + val urlRegister = + getInstruction(newRequestBuilderIndex).registerD + + val entrySetIndex = indexOfEntrySetInstruction(this) + val mapRegister = if (entrySetIndex < 0) + urlRegister + 1 + else + getInstruction(entrySetIndex).registerC + + var smaliInstructions = + "invoke-static { v$urlRegister, v$mapRegister }, " + + "$EXTENSION_CLASS_DESCRIPTOR->" + + "fetchStreams(Ljava/lang/String;Ljava/util/Map;)V" + + if (entrySetIndex < 0) smaliInstructions = """ + move-object/from16 v$mapRegister, p1 + + """ + smaliInstructions + + // Copy request headers for streaming data fetch. + addInstructions(newRequestBuilderIndex + 2, smaliInstructions) + } + + // endregion + + // region Replace the streaming data. + + createStreamingDataFingerprint.matchOrThrow().let { result -> + result.method.apply { + val setStreamingDataIndex = result.patternMatch!!.startIndex + val setStreamingDataField = + getInstruction(setStreamingDataIndex).getReference().toString() + + val playerProtoClass = + getInstruction(setStreamingDataIndex + 1).getReference()!!.definingClass + val protobufClass = + protobufClassParseByteBufferFingerprint.definingClassOrThrow() + + val getStreamingDataField = instructions.find { instruction -> + instruction.opcode == Opcode.IGET_OBJECT && + instruction.getReference()?.definingClass == playerProtoClass + }?.getReference() + ?: throw PatchException("Could not find getStreamingDataField") + + val videoDetailsIndex = result.patternMatch!!.endIndex + val videoDetailsClass = + getInstruction(videoDetailsIndex).getReference()!!.type + + val insertIndex = videoDetailsIndex + 1 + val videoDetailsRegister = + getInstruction(videoDetailsIndex).registerA + + val overrideRegister = getInstruction(insertIndex).registerA + val freeRegister = implementation!!.registerCount - parameters.size - 2 + + addInstructionsWithLabels( + insertIndex, + """ + invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->isSpoofingEnabled()Z + move-result v$freeRegister + if-eqz v$freeRegister, :disabled + + # Get video id. + # From YouTube 17.34.36 to YouTube 19.16.39, the field names and field types are the same. + iget-object v$freeRegister, v$videoDetailsRegister, $videoDetailsClass->c:Ljava/lang/String; + if-eqz v$freeRegister, :disabled + + # Get streaming data. + invoke-static { v$freeRegister }, $EXTENSION_CLASS_DESCRIPTOR->getStreamingData(Ljava/lang/String;)Ljava/nio/ByteBuffer; + move-result-object v$freeRegister + if-eqz v$freeRegister, :disabled + + # Parse streaming data. + sget-object v$overrideRegister, $playerProtoClass->a:$playerProtoClass + invoke-static { v$overrideRegister, v$freeRegister }, $protobufClass->parseFrom(${protobufClass}Ljava/nio/ByteBuffer;)$protobufClass + move-result-object v$freeRegister + check-cast v$freeRegister, $playerProtoClass + + # Set streaming data. + iget-object v$freeRegister, v$freeRegister, $getStreamingDataField + if-eqz v$freeRegister, :disabled + iput-object v$freeRegister, p0, $setStreamingDataField + + """, + ExternalLabel("disabled", getInstruction(insertIndex)) + ) + } + } + + // endregion + + // region Remove /videoplayback request body to fix playback. + // This is needed when using iOS client as streaming data source. + + buildMediaDataSourceFingerprint.methodOrThrow().apply { + val targetIndex = instructions.lastIndex + + addInstructions( + targetIndex, + """ + # Field a: Stream uri. + # Field c: Http method. + # Field d: Post data. + # From YouTube 17.34.36 to YouTube 19.16.39, the field names and field types are the same. + move-object/from16 v0, p0 + iget-object v1, v0, $definingClass->a:Landroid/net/Uri; + iget v2, v0, $definingClass->c:I + iget-object v3, v0, $definingClass->d:[B + invoke-static { v1, v2, v3 }, $EXTENSION_CLASS_DESCRIPTOR->removeVideoPlaybackPostBody(Landroid/net/Uri;I[B)[B + move-result-object v1 + iput-object v1, v0, $definingClass->d:[B + """, + ) + } + + // endregion + + // region Append spoof info. + + nerdsStatsVideoFormatBuilderFingerprint.methodOrThrow().apply { + findInstructionIndicesReversedOrThrow(Opcode.RETURN_OBJECT).forEach { index -> + val register = getInstruction(index).registerA + + addInstructions( + index, """ + invoke-static {v$register}, $EXTENSION_CLASS_DESCRIPTOR->appendSpoofedClient(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$register + """ + ) + } + } + + // endregion + + // region add settings + + addPreference( + arrayOf( + "SETTINGS: SPOOF_STREAMING_DATA" + ), + SPOOF_STREAMING_DATA + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/suggestedvideoendscreen/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/suggestedvideoendscreen/Fingerprints.kt new file mode 100644 index 0000000000..210b9d45ff --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/suggestedvideoendscreen/Fingerprints.kt @@ -0,0 +1,45 @@ +package app.revanced.patches.youtube.utils.fix.suggestedvideoendscreen + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val autoNavConstructorFingerprint = legacyFingerprint( + name = "autoNavConstructorFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + strings = listOf("main_app_autonav"), +) + +internal val autoNavStatusFingerprint = legacyFingerprint( + name = "autoNavStatusFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + returnType = "Z", + parameters = emptyList() +) + +/** + * This fingerprint is also compatible with very old YouTube versions. + * Tested on YouTube v16.40.36, v18.29.38, v19.16.39. + */ +internal val removeOnLayoutChangeListenerFingerprint = legacyFingerprint( + name = "removeOnLayoutChangeListenerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf( + Opcode.IPUT, + Opcode.INVOKE_VIRTUAL + ), + // This is the only reference present in the entire smali. + customFingerprint = { method, _ -> + method.indexOfFirstInstruction { + getReference()?.toString() + ?.endsWith("YouTubePlayerOverlaysLayout;->removeOnLayoutChangeListener(Landroid/view/View${'$'}OnLayoutChangeListener;)V") == true + } >= 0 + } +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/suggestedvideoendscreen/SuggestedVideoEndScreenPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/suggestedvideoendscreen/SuggestedVideoEndScreenPatch.kt new file mode 100644 index 0000000000..422b64f361 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/suggestedvideoendscreen/SuggestedVideoEndScreenPatch.kt @@ -0,0 +1,78 @@ +package app.revanced.patches.youtube.utils.fix.suggestedvideoendscreen + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.youtube.utils.extension.Constants.PLAYER_CLASS_DESCRIPTOR +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +val suggestedVideoEndScreenPatch = bytecodePatch( + description = "suggestedVideoEndScreenPatch" +) { + execute { + + /** + * The reasons why this patch is classified as a patch that fixes a 'bug' are as follows: + * 1. In YouTube v18.29.38, the suggested video end screen was only shown when the autoplay setting was turned on. + * 2. Starting from YouTube v18.35.36, the suggested video end screen is shown regardless of whether autoplay setting was turned on or off. + * + * This patch changes the suggested video end screen to be shown only when the autoplay setting is turned on. + * Automatically closing the suggested video end screen is not appropriate as it will disable the autoplay behavior. + */ + removeOnLayoutChangeListenerFingerprint.matchOrThrow().let { + val walkerIndex = + it.getWalkerMethod(it.patternMatch!!.endIndex) + + walkerIndex.apply { + val autoNavStatusMethodName = + autoNavStatusFingerprint.methodOrThrow(autoNavConstructorFingerprint).name + val invokeIndex = + indexOfFirstInstructionOrThrow { + val reference = getReference() + reference?.returnType == "Z" && + reference.parameterTypes.isEmpty() && + reference.name == autoNavStatusMethodName + } + val iGetObjectIndex = + indexOfFirstInstructionReversedOrThrow(invokeIndex, Opcode.IGET_OBJECT) + + val invokeReference = getInstruction(invokeIndex).reference + val iGetObjectReference = + getInstruction(iGetObjectIndex).reference + val opcodeName = getInstruction(invokeIndex).opcode.name + + addInstructionsWithLabels( + 0, + """ + invoke-static {}, $PLAYER_CLASS_DESCRIPTOR->hideSuggestedVideoEndScreen()Z + move-result v0 + if-eqz v0, :show_suggested_video_end_screen + + iget-object v0, p0, $iGetObjectReference + + # This reference checks whether autoplay is turned on. + $opcodeName {v0}, $invokeReference + move-result v0 + + # Hide suggested video end screen only when autoplay is turned off. + if-nez v0, :show_suggested_video_end_screen + return-void + """, + ExternalLabel( + "show_suggested_video_end_screen", + getInstruction(0) + ) + ) + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/swiperefresh/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/swiperefresh/Fingerprints.kt new file mode 100644 index 0000000000..2e8c41796a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/swiperefresh/Fingerprints.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.youtube.utils.fix.swiperefresh + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val swipeRefreshLayoutFingerprint = legacyFingerprint( + name = "swipeRefreshLayoutFingerprint", + returnType = "Z", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf( + Opcode.RETURN, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.RETURN + ), + customFingerprint = { method, _ -> method.definingClass.endsWith("/SwipeRefreshLayout;") } +) + diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/swiperefresh/SwipeRefreshPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/swiperefresh/SwipeRefreshPatch.kt new file mode 100644 index 0000000000..7086ff0408 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/fix/swiperefresh/SwipeRefreshPatch.kt @@ -0,0 +1,27 @@ +package app.revanced.patches.youtube.utils.fix.swiperefresh + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.util.fingerprint.matchOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +val swipeRefreshPatch = bytecodePatch( + description = "swipeRefreshPatch" +) { + execute { + + swipeRefreshLayoutFingerprint.matchOrThrow().let { + it.method.apply { + val insertIndex = it.patternMatch!!.endIndex + val register = getInstruction(insertIndex).registerA + + addInstruction( + insertIndex, + "const/4 v$register, 0x0" + ) + } + } + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/flyoutmenu/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/flyoutmenu/Fingerprints.kt new file mode 100644 index 0000000000..3465a90447 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/flyoutmenu/Fingerprints.kt @@ -0,0 +1,14 @@ +package app.revanced.patches.youtube.utils.flyoutmenu + +import app.revanced.patches.youtube.utils.resourceid.videoQualityUnavailableAnnouncement +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val videoQualityBottomSheetClassFingerprint = legacyFingerprint( + name = "videoQualityBottomSheetClassFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Z"), + literals = listOf(videoQualityUnavailableAnnouncement), +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/flyoutmenu/FlyoutMenuHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/flyoutmenu/FlyoutMenuHookPatch.kt new file mode 100644 index 0000000000..fdcd07b289 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/flyoutmenu/FlyoutMenuHookPatch.kt @@ -0,0 +1,59 @@ +package app.revanced.patches.youtube.utils.flyoutmenu + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.EXTENSION_PATH +import app.revanced.patches.youtube.utils.playbackRateBottomSheetBuilderFingerprint +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.util.addStaticFieldToExtension +import app.revanced.util.fingerprint.methodOrThrow + +private const val EXTENSION_VIDEO_UTILS_CLASS_DESCRIPTOR = + "$EXTENSION_PATH/utils/VideoUtils;" + +val flyoutMenuHookPatch = bytecodePatch( + description = "flyoutMenuHookPatch", +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn(sharedResourceIdPatch) + + execute { + playbackRateBottomSheetBuilderFingerprint.methodOrThrow().apply { + val smaliInstructions = + """ + if-eqz v0, :ignore + invoke-virtual {v0}, $definingClass->$name()V + :ignore + return-void + """ + + addStaticFieldToExtension( + EXTENSION_VIDEO_UTILS_CLASS_DESCRIPTOR, + "showPlaybackSpeedFlyoutMenu", + "playbackRateBottomSheetClass", + definingClass, + smaliInstructions + ) + } + + videoQualityBottomSheetClassFingerprint.methodOrThrow().apply { + val smaliInstructions = + """ + if-eqz v0, :ignore + const/4 v1, 0x1 + invoke-virtual {v0, v1}, $definingClass->$name(Z)V + :ignore + return-void + """ + + addStaticFieldToExtension( + EXTENSION_VIDEO_UTILS_CLASS_DESCRIPTOR, + "showVideoQualityFlyoutMenu", + "videoQualityBottomSheetClass", + definingClass, + smaliInstructions + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/gms/GmsCoreSupportPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/gms/GmsCoreSupportPatch.kt new file mode 100644 index 0000000000..46fb90184d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/gms/GmsCoreSupportPatch.kt @@ -0,0 +1,61 @@ +package app.revanced.patches.youtube.utils.gms + +import app.revanced.patcher.patch.Option +import app.revanced.patches.shared.gms.gmsCoreSupportPatch +import app.revanced.patches.shared.spoof.useragent.baseSpoofUserAgentPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.compatibility.Constants.YOUTUBE_PACKAGE_NAME +import app.revanced.patches.youtube.utils.extension.sharedExtensionPatch +import app.revanced.patches.youtube.utils.fix.streamingdata.spoofStreamingDataPatch +import app.revanced.patches.youtube.utils.mainactivity.mainActivityFingerprint +import app.revanced.patches.youtube.utils.patch.PatchList.GMSCORE_SUPPORT +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.ResourceUtils.updateGmsCorePackageName +import app.revanced.patches.youtube.utils.settings.ResourceUtils.updatePackageName +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.util.valueOrThrow + +@Suppress("unused") +val gmsCoreSupportPatch = gmsCoreSupportPatch( + fromPackageName = YOUTUBE_PACKAGE_NAME, + mainActivityOnCreateFingerprint = mainActivityFingerprint.second, + extensionPatch = sharedExtensionPatch, + gmsCoreSupportResourcePatchFactory = ::gmsCoreSupportResourcePatch, +) { + compatibleWith(COMPATIBLE_PACKAGE) +} + +private fun gmsCoreSupportResourcePatch( + gmsCoreVendorGroupIdOption: Option, + packageNameYouTubeOption: Option, + packageNameYouTubeMusicOption: Option, +) = app.revanced.patches.shared.gms.gmsCoreSupportResourcePatch( + fromPackageName = YOUTUBE_PACKAGE_NAME, + spoofedPackageSignature = "afb0fed5eeaebdd86f56a97742f4b6b33ef59875", + gmsCoreVendorGroupIdOption = gmsCoreVendorGroupIdOption, + packageNameYouTubeOption = packageNameYouTubeOption, + packageNameYouTubeMusicOption = packageNameYouTubeMusicOption, + executeBlock = { + updatePackageName( + YOUTUBE_PACKAGE_NAME, + packageNameYouTubeOption.valueOrThrow(), + packageNameYouTubeMusicOption.valueOrThrow() + ) + updateGmsCorePackageName( + "app.revanced", + gmsCoreVendorGroupIdOption.valueOrThrow() + ) + addPreference( + arrayOf( + "PREFERENCE: GMS_CORE_SETTINGS" + ), + GMSCORE_SUPPORT + ) + }, +) { + dependsOn( + baseSpoofUserAgentPatch(YOUTUBE_PACKAGE_NAME), + spoofStreamingDataPatch, + settingsPatch, + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/lockmodestate/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/lockmodestate/Fingerprints.kt new file mode 100644 index 0000000000..ee4d987f50 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/lockmodestate/Fingerprints.kt @@ -0,0 +1,16 @@ +package app.revanced.patches.youtube.utils.lockmodestate + +import app.revanced.util.fingerprint.legacyFingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val lockModeStateFingerprint = legacyFingerprint( + name = "lockModeStateFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC.value, + parameters = emptyList(), + opcodes = listOf(Opcode.RETURN_OBJECT), + customFingerprint = { method, _ -> + method.name == "getLockModeStateEnum" + } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/lockmodestate/LockModeStateHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/lockmodestate/LockModeStateHookPatch.kt new file mode 100644 index 0000000000..26fba91a49 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/lockmodestate/LockModeStateHookPatch.kt @@ -0,0 +1,36 @@ +package app.revanced.patches.youtube.utils.lockmodestate + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.extension.Constants.UTILS_PATH +import app.revanced.util.fingerprint.matchOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$UTILS_PATH/LockModeStateHookPatch;" + +val lockModeStateHookPatch = bytecodePatch( + description = "lockModeStateHookPatch" +) { + + execute { + + lockModeStateFingerprint.matchOrThrow().let { + it.method.apply { + val insertIndex = it.patternMatch!!.endIndex + val insertRegister = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex + 1, """ + invoke-static {v$insertRegister}, $EXTENSION_CLASS_DESCRIPTOR->setLockModeState(Ljava/lang/Enum;)V + return-object v$insertRegister + """ + ) + removeInstruction(insertIndex) + } + } + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/lottie/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/lottie/Fingerprints.kt new file mode 100644 index 0000000000..39bf96cb9c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/lottie/Fingerprints.kt @@ -0,0 +1,24 @@ +package app.revanced.patches.youtube.utils.lottie + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal const val LOTTIE_ANIMATION_VIEW_CLASS_DESCRIPTOR = + "Lcom/airbnb/lottie/LottieAnimationView;" + +internal val setAnimationFingerprint = legacyFingerprint( + name = "setAnimationFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("I"), + opcodes = listOf( + Opcode.IF_EQZ, + Opcode.NEW_INSTANCE, + Opcode.NEW_INSTANCE, + ), + customFingerprint = { method, _ -> + method.definingClass == LOTTIE_ANIMATION_VIEW_CLASS_DESCRIPTOR + } +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/lottie/LottieAnimationViewHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/lottie/LottieAnimationViewHookPatch.kt new file mode 100644 index 0000000000..2af67d85e4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/lottie/LottieAnimationViewHookPatch.kt @@ -0,0 +1,29 @@ +package app.revanced.patches.youtube.utils.lottie + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.extension.Constants.UTILS_PATH +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.methodOrThrow + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$UTILS_PATH/LottieAnimationViewPatch;" + +val lottieAnimationViewHookPatch = bytecodePatch( + description = "lottieAnimationViewHookPatch", +) { + execute { + + findMethodOrThrow(EXTENSION_CLASS_DESCRIPTOR) { + name == "setAnimation" + }.addInstruction( + 0, + "invoke-virtual {p0, p1}, " + + LOTTIE_ANIMATION_VIEW_CLASS_DESCRIPTOR + + "->" + + setAnimationFingerprint.methodOrThrow().name + + "(I)V" + ) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/mainactivity/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/mainactivity/Fingerprints.kt new file mode 100644 index 0000000000..07cb445ec1 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/mainactivity/Fingerprints.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.youtube.utils.mainactivity + +import app.revanced.util.fingerprint.legacyFingerprint + +/** + * 'WatchWhileActivity' has been renamed to 'MainActivity' in YouTube v18.48.xx+ + * This fingerprint was added to prepare for YouTube v18.48.xx+ + */ +internal val mainActivityFingerprint = legacyFingerprint( + name = "mainActivityFingerprint", + returnType = "V", + parameters = listOf("Landroid/os/Bundle;"), + strings = listOf("PostCreateCalledKey"), + customFingerprint = { method, _ -> + method.definingClass.endsWith("Activity;") + && method.name == "onCreate" + } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/mainactivity/MainActivityResolvePatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/mainactivity/MainActivityResolvePatch.kt new file mode 100644 index 0000000000..c1003b93ee --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/mainactivity/MainActivityResolvePatch.kt @@ -0,0 +1,5 @@ +package app.revanced.patches.youtube.utils.mainactivity + +import app.revanced.patches.shared.mainactivity.baseMainActivityResolvePatch + +val mainActivityResolvePatch = baseMainActivityResolvePatch(mainActivityFingerprint) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/navigation/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/navigation/Fingerprints.kt new file mode 100644 index 0000000000..7b218c8f0c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/navigation/Fingerprints.kt @@ -0,0 +1,114 @@ +package app.revanced.patches.youtube.utils.navigation + +import app.revanced.patches.youtube.general.navigation.navigationBarComponentsPatch +import app.revanced.patches.youtube.utils.resourceid.bottomBarContainer +import app.revanced.patches.youtube.utils.resourceid.imageOnlyTab +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val initializeBottomBarContainerFingerprint = legacyFingerprint( + name = "initializeBottomBarContainerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + literals = listOf(bottomBarContainer), + customFingerprint = { method, classDef -> + AccessFlags.SYNTHETIC.isSet(classDef.accessFlags) && + indexOfLayoutChangeListenerInstruction(method) >= 0 + }, +) + +internal fun indexOfLayoutChangeListenerInstruction(method: Method) = + method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.toString() == "Landroid/view/View;->addOnLayoutChangeListener(Landroid/view/View${'$'}OnLayoutChangeListener;)V" + } + +internal val initializeButtonsFingerprint = legacyFingerprint( + name = "initializeButtonsFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + literals = listOf(imageOnlyTab), +) + +/** + * Extension method, used for callback into to other patches. + * Specifically, [navigationBarComponentsPatch]. + */ +internal val navigationBarHookCallbackFingerprint = legacyFingerprint( + name = "navigationBarHookCallbackFingerprint", + accessFlags = AccessFlags.PRIVATE or AccessFlags.STATIC, + returnType = "V", + parameters = listOf(EXTENSION_NAVIGATION_BUTTON_DESCRIPTOR, "Landroid/view/View;"), + customFingerprint = { method, _ -> + method.name == "navigationTabCreatedCallback" && + method.definingClass == EXTENSION_CLASS_DESCRIPTOR + } +) + +/** + * Resolves to the Enum class that looks up ordinal -> instance. + */ +internal val navigationEnumFingerprint = legacyFingerprint( + name = "navigationEnumFingerprint", + accessFlags = AccessFlags.STATIC or AccessFlags.CONSTRUCTOR, + strings = listOf( + "PIVOT_HOME", + "TAB_SHORTS", + "CREATION_TAB_LARGE", + "PIVOT_SUBSCRIPTIONS", + "TAB_ACTIVITY", + "VIDEO_LIBRARY_WHITE", + "INCOGNITO_CIRCLE" + ) +) + +internal val pivotBarButtonsCreateDrawableViewFingerprint = legacyFingerprint( + name = "pivotBarButtonsCreateDrawableViewFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + // Method has different number of parameters in some app targets. + // Parameters are checked in custom fingerprint. + returnType = "Landroid/view/View;", + customFingerprint = { method, classDef -> + classDef.type == "Lcom/google/android/libraries/youtube/rendering/ui/pivotbar/PivotBar;" && + // Only one method has a Drawable parameter. + method.parameterTypes.firstOrNull() == "Landroid/graphics/drawable/Drawable;" + } +) + +internal val pivotBarButtonsCreateResourceViewFingerprint = legacyFingerprint( + name = "pivotBarButtonsCreateResourceViewFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "Z", "I", "L"), + returnType = "Landroid/view/View;", + customFingerprint = { _, classDef -> + classDef.type == "Lcom/google/android/libraries/youtube/rendering/ui/pivotbar/PivotBar;" + } +) + +internal fun indexOfSetViewSelectedInstruction(method: Method) = method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_VIRTUAL && getReference()?.name == "setSelected" +} + +internal val pivotBarButtonsViewSetSelectedFingerprint = legacyFingerprint( + name = "pivotBarButtonsViewSetSelectedFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("I", "Z"), + customFingerprint = { method, _ -> + indexOfSetViewSelectedInstruction(method) >= 0 && + method.definingClass == "Lcom/google/android/libraries/youtube/rendering/ui/pivotbar/PivotBar;" + } +) + +internal val pivotBarConstructorFingerprint = legacyFingerprint( + name = "pivotBarConstructorFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + strings = listOf("com.google.android.apps.youtube.app.endpoint.flags") +) + diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/navigation/NavigationBarHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/navigation/NavigationBarHookPatch.kt new file mode 100644 index 0000000000..27a267ce9d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/navigation/NavigationBarHookPatch.kt @@ -0,0 +1,140 @@ +package app.revanced.patches.youtube.utils.navigation + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.instructions +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patches.shared.mainactivity.injectOnBackPressedMethodCall +import app.revanced.patches.youtube.utils.extension.Constants.SHARED_PATH +import app.revanced.patches.youtube.utils.extension.sharedExtensionPatch +import app.revanced.patches.youtube.utils.mainactivity.mainActivityResolvePatch +import app.revanced.patches.youtube.utils.playertype.playerTypeHookPatch +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.mutableClassOrThrow +import app.revanced.util.getReference +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.Instruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.util.MethodUtil + +internal const val EXTENSION_CLASS_DESCRIPTOR = + "$SHARED_PATH/NavigationBar;" +internal const val EXTENSION_NAVIGATION_BUTTON_DESCRIPTOR = + "$SHARED_PATH/NavigationBar\$NavigationButton;" + +private lateinit var bottomBarContainerMethod: MutableMethod +private var bottomBarContainerOffset = 0 + +lateinit var hookNavigationButtonCreated: (String) -> Unit + +val navigationBarHookPatch = bytecodePatch( + description = "navigationBarHookPatch", +) { + dependsOn( + sharedExtensionPatch, + mainActivityResolvePatch, + playerTypeHookPatch, + sharedResourceIdPatch, + ) + + execute { + fun MutableMethod.addHook(hook: Hook, insertPredicate: Instruction.() -> Boolean) { + val filtered = instructions.filter(insertPredicate) + if (filtered.isEmpty()) throw PatchException("Could not find insert indexes") + filtered.forEach { + val insertIndex = it.location.index + 2 + val register = getInstruction(insertIndex - 1).registerA + + addInstruction( + insertIndex, + "invoke-static { v$register }, " + + "$EXTENSION_CLASS_DESCRIPTOR->${hook.methodName}(${hook.parameters})V", + ) + } + } + + initializeButtonsFingerprint.methodOrThrow(pivotBarConstructorFingerprint).apply { + // Hook the current navigation bar enum value. Note, the 'You' tab does not have an enum value. + val navigationEnumClassName = navigationEnumFingerprint.mutableClassOrThrow().type + addHook(Hook.SET_LAST_APP_NAVIGATION_ENUM) { + opcode == Opcode.INVOKE_STATIC && + getReference()?.definingClass == navigationEnumClassName + } + + // Hook the creation of navigation tab views. + val drawableTabMethod = + pivotBarButtonsCreateDrawableViewFingerprint.methodOrThrow() + addHook(Hook.NAVIGATION_TAB_LOADED) predicate@{ + MethodUtil.methodSignaturesMatch( + getReference() ?: return@predicate false, + drawableTabMethod, + ) + } + + val imageResourceTabMethod = + pivotBarButtonsCreateResourceViewFingerprint.methodOrThrow() + addHook(Hook.NAVIGATION_IMAGE_RESOURCE_TAB_LOADED) predicate@{ + MethodUtil.methodSignaturesMatch( + getReference() ?: return@predicate false, + imageResourceTabMethod, + ) + } + } + + pivotBarButtonsViewSetSelectedFingerprint.methodOrThrow().apply { + val index = indexOfSetViewSelectedInstruction(this) + val instruction = getInstruction(index) + val viewRegister = instruction.registerC + val isSelectedRegister = instruction.registerD + + addInstruction( + index + 1, + "invoke-static { v$viewRegister, v$isSelectedRegister }, " + + "$EXTENSION_CLASS_DESCRIPTOR->navigationTabSelected(Landroid/view/View;Z)V", + ) + } + + injectOnBackPressedMethodCall( + EXTENSION_CLASS_DESCRIPTOR, + "onBackPressed" + ) + + bottomBarContainerMethod = initializeBottomBarContainerFingerprint.methodOrThrow() + + hookNavigationButtonCreated = { extensionClassDescriptor -> + navigationBarHookCallbackFingerprint.methodOrThrow().addInstruction( + 0, + "invoke-static { p0, p1 }, " + + "$extensionClassDescriptor->navigationTabCreated" + + "(${EXTENSION_NAVIGATION_BUTTON_DESCRIPTOR}Landroid/view/View;)V", + ) + } + } +} + +fun addBottomBarContainerHook(descriptor: String) { + bottomBarContainerMethod.apply { + val layoutChangeListenerIndex = indexOfLayoutChangeListenerInstruction(this) + val bottomBarContainerRegister = + getInstruction(layoutChangeListenerIndex).registerC + + addInstruction( + layoutChangeListenerIndex + bottomBarContainerOffset--, + "invoke-static { v$bottomBarContainerRegister }, $descriptor" + ) + } +} + +private enum class Hook(val methodName: String, val parameters: String) { + SET_LAST_APP_NAVIGATION_ENUM("setLastAppNavigationEnum", "Ljava/lang/Enum;"), + NAVIGATION_TAB_LOADED("navigationTabLoaded", "Landroid/view/View;"), + NAVIGATION_IMAGE_RESOURCE_TAB_LOADED( + "navigationImageResourceTabLoaded", + "Landroid/view/View;" + ), +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/patch/PatchList.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/patch/PatchList.kt new file mode 100644 index 0000000000..8201842fba --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/patch/PatchList.kt @@ -0,0 +1,256 @@ +package app.revanced.patches.youtube.utils.patch + +internal enum class PatchList( + val title: String, + val summary: String, + var included: Boolean? = false +) { + ALTERNATIVE_THUMBNAILS( + "Alternative thumbnails", + "Adds options to replace video thumbnails using the DeArrow API or image captures from the video." + ), + AMBIENT_MODE_CONTROL( + "Ambient mode control", + "Adds options to disable Ambient mode and to bypass Ambient mode restrictions." + ), + BYPASS_IMAGE_REGION_RESTRICTIONS( + "Bypass image region restrictions", + "Adds an option to use a different host for static images, so that images blocked in some countries can be received." + ), + CHANGE_PLAYER_FLYOUT_MENU_TOGGLES( + "Change player flyout menu toggles", + "Adds an option to use text toggles instead of switch toggles within the additional settings menu." + ), + CHANGE_SHARE_SHEET( + "Change share sheet", + "Add option to change from in-app share sheet to system share sheet." + ), + CHANGE_START_PAGE( + "Change start page", + "Adds an option to set which page the app opens in instead of the homepage." + ), + CUSTOM_SHORTS_ACTION_BUTTONS( + "Custom Shorts action buttons", + "Changes, at compile time, the icon of the action buttons of the Shorts player." + ), + CUSTOM_BRANDING_ICON_FOR_YOUTUBE( + "Custom branding icon for YouTube", + "Changes the YouTube app icon to the icon specified in patch options." + ), + CUSTOM_BRANDING_NAME_FOR_YOUTUBE( + "Custom branding name for YouTube", + "Renames the YouTube app to the name specified in patch options." + ), + CUSTOM_DOUBLE_TAP_LENGTH( + "Custom double tap length", + "Adds Double-tap to seek values that are specified in patch options." + ), + CUSTOM_HEADER_FOR_YOUTUBE( + "Custom header for YouTube", + "Applies a custom header in the top left corner within the app." + ), + DESCRIPTION_COMPONENTS( + "Description components", + "Adds options to hide and disable description components." + ), + DISABLE_QUIC_PROTOCOL( + "Disable QUIC protocol", + "Adds an option to disable CronetEngine's QUIC protocol." + ), + DISABLE_AUTO_AUDIO_TRACKS( + "Disable auto audio tracks", + "Adds an option to disable audio tracks from being automatically enabled." + ), + DISABLE_AUTO_CAPTIONS( + "Disable auto captions", + "Adds an option to disable captions from being automatically enabled." + ), + DISABLE_HAPTIC_FEEDBACK( + "Disable haptic feedback", + "Adds options to disable haptic feedback when swiping in the video player." + ), + DISABLE_RESUMING_SHORTS_ON_STARTUP( + "Disable resuming Shorts on startup", + "Adds an option to disable the Shorts player from resuming on app startup when Shorts were last being watched." + ), + DISABLE_SPLASH_ANIMATION( + "Disable splash animation", + "Adds an option to disable the splash animation on app startup." + ), + ENABLE_OPUS_CODEC( + "Enable OPUS codec", + "Adds an options to enable the OPUS audio codec if the player response includes." + ), + ENABLE_DEBUG_LOGGING( + "Enable debug logging", + "Adds an option to enable debug logging." + ), + ENABLE_EXTERNAL_BROWSER( + "Enable external browser", + "Adds an option to always open links in your browser instead of in the in-app-browser." + ), + ENABLE_GRADIENT_LOADING_SCREEN( + "Enable gradient loading screen", + "Adds an option to enable the gradient loading screen." + ), + ENABLE_OPEN_LINKS_DIRECTLY( + "Enable open links directly", + "Adds an option to skip over redirection URLs in external links." + ), + FORCE_HIDE_PLAYER_BUTTONS_BACKGROUND( + "Force hide player buttons background", + "Removes, at compile time, the dark background surrounding the video player controls." + ), + FULLSCREEN_COMPONENTS( + "Fullscreen components", + "Adds options to hide or change components related to fullscreen." + ), + GMSCORE_SUPPORT( + "GmsCore support", + "Allows patched Google apps to run without root and under a different package name by using GmsCore instead of Google Play Services." + ), + HIDE_SHORTS_DIMMING( + "Hide Shorts dimming", + "Removes, at compile time, the dimming effect at the top and bottom of Shorts videos." + ), + HIDE_ACTION_BUTTONS( + "Hide action buttons", + "Adds options to hide action buttons under videos." + ), + HIDE_ADS( + "Hide ads", + "Adds options to hide ads." + ), + HIDE_COMMENTS_COMPONENTS( + "Hide comments components", + "Adds options to hide components related to comments." + ), + HIDE_FEED_COMPONENTS( + "Hide feed components", + "Adds options to hide components related to feeds." + ), + HIDE_FEED_FLYOUT_MENU( + "Hide feed flyout menu", + "Adds the ability to hide feed flyout menu components using a custom filter." + ), + HIDE_LAYOUT_COMPONENTS( + "Hide layout components", + "Adds options to hide general layout components." + ), + HIDE_PLAYER_BUTTONS( + "Hide player buttons", + "Adds options to hide buttons in the video player." + ), + HIDE_PLAYER_FLYOUT_MENU( + "Hide player flyout menu", + "Adds options to hide player flyout menu components." + ), + HIDE_SHORTCUTS( + "Hide shortcuts", + "Remove, at compile time, the app shortcuts that appears when app icon is long pressed." + ), + HOOK_YOUTUBE_MUSIC_ACTIONS( + "Hook YouTube Music actions", + "Adds support for opening music in RVX Music using the in-app YouTube Music button." + ), + HOOK_DOWNLOAD_ACTIONS( + "Hook download actions", + "Adds support to download videos with an external downloader app using the in-app download button." + ), + LAYOUT_SWITCH( + "Layout switch", + "Adds an option to spoof the dpi in order to use a tablet or phone layout." + ), + MATERIALYOU( + "MaterialYou", + "Applies the MaterialYou theme for Android 12+ devices." + ), + MINIPLAYER( + "Miniplayer", + "Adds options to change the in app minimized player, and if patching target 19.16+ adds options to use modern miniplayers." + ), + NAVIGATION_BAR_COMPONENTS( + "Navigation bar components", + "Adds options to hide or change components related to the navigation bar." + ), + OVERLAY_BUTTONS( + "Overlay buttons", + "Adds options to display overlay buttons in the video player." + ), + PLAYER_COMPONENTS( + "Player components", + "Adds options to hide or change components related to the video player." + ), + REMOVE_BACKGROUND_PLAYBACK_RESTRICTIONS( + "Remove background playback restrictions", + "Removes restrictions on background playback, including for music and kids videos." + ), + REMOVE_VIEWER_DISCRETION_DIALOG( + "Remove viewer discretion dialog", + "Adds an option to remove the dialog that appears when opening a video that has been age-restricted by accepting it automatically. This does not bypass the age restriction." + ), + RETURN_YOUTUBE_DISLIKE( + "Return YouTube Dislike", + "Adds an option to show the dislike count of videos using the Return YouTube Dislike API." + ), + RETURN_YOUTUBE_USERNAME( + "Return YouTube Username", + "Adds an option to replace YouTube handles with usernames in comments using YouTube Data API v3." + ), + SANITIZE_SHARING_LINKS( + "Sanitize sharing links", + "Adds an option to remove tracking query parameters from URLs when sharing links." + ), + SEEKBAR_COMPONENTS( + "Seekbar components", + "Adds options to hide or change components related to the seekbar." + ), + SETTINGS_FOR_YOUTUBE( + "Settings for YouTube", + "Applies mandatory patches to implement ReVanced Extended settings into the application." + ), + SHORTS_COMPONENTS( + "Shorts components", + "Adds options to hide or change components related to YouTube Shorts." + ), + SPONSORBLOCK( + "SponsorBlock", + "Adds options to enable and configure SponsorBlock, which can skip undesired video segments, such as sponsored content." + ), + SPOOF_APP_VERSION( + "Spoof app version", + "Adds options to spoof the YouTube client version. This can be used to restore old UI elements and features." + ), + SPOOF_STREAMING_DATA( + "Spoof streaming data", + "Adds options to spoof the streaming data to allow video playback." + ), + SWIPE_CONTROLS( + "Swipe controls", + "Adds options for controlling volume and brightness with swiping, and whether to enter fullscreen when swiping down below the player." + ), + THEME( + "Theme", + "Changes the app's theme to the values specified in patch options." + ), + TOOLBAR_COMPONENTS( + "Toolbar components", + "Adds options to hide or change components located on the toolbar, such as toolbar buttons, search bar, and header." + ), + TRANSLATIONS_FOR_YOUTUBE( + "Translations for YouTube", + "Add translations or remove string resources." + ), + VIDEO_PLAYBACK( + "Video playback", + "Adds options to customize settings related to video playback, such as default video quality and playback speed." + ), + VISUAL_PREFERENCES_ICONS_FOR_YOUTUBE( + "Visual preferences icons for YouTube", + "Adds icons to specific preferences in the settings." + ), + WATCH_HISTORY( + "Watch history", + "Adds an option to change the domain of the watch history or check its status." + ) +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/pip/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/pip/Fingerprints.kt new file mode 100644 index 0000000000..6eea42b607 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/pip/Fingerprints.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.youtube.utils.pip + +import app.revanced.patches.youtube.utils.PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR +import app.revanced.util.fingerprint.legacyFingerprint +import com.android.tools.smali.dexlib2.Opcode + +internal val pipPlaybackFingerprint = legacyFingerprint( + name = "pipPlaybackFingerprint", + returnType = "Z", + parameters = listOf(PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR), + opcodes = listOf( + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT, + Opcode.IF_NEZ + ) +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/pip/PiPStateHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/pip/PiPStateHookPatch.kt new file mode 100644 index 0000000000..3ddff71dd4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/pip/PiPStateHookPatch.kt @@ -0,0 +1,31 @@ +package app.revanced.patches.youtube.utils.pip + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.extension.Constants.EXTENSION_PATH +import app.revanced.util.fingerprint.matchOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +private const val EXTENSION_VIDEO_UTILS_CLASS_DESCRIPTOR = + "$EXTENSION_PATH/utils/VideoUtils;" + +val pipStateHookPatch = bytecodePatch( + description = "pipStateHookPatch", +) { + execute { + pipPlaybackFingerprint.matchOrThrow().let { + it.method.apply { + val insertIndex = it.patternMatch!!.endIndex + val insertRegister = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $EXTENSION_VIDEO_UTILS_CLASS_DESCRIPTOR->getExternalDownloaderLaunchedState(Z)Z + move-result v$insertRegister + """ + ) + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playercontrols/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playercontrols/Fingerprints.kt new file mode 100644 index 0000000000..390a7d7b6b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playercontrols/Fingerprints.kt @@ -0,0 +1,72 @@ +package app.revanced.patches.youtube.utils.playercontrols + +import app.revanced.patches.youtube.utils.resourceid.bottomUiContainerStub +import app.revanced.patches.youtube.utils.resourceid.controlsLayoutStub +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionReversed +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + + +internal val bottomControlsInflateFingerprint = legacyFingerprint( + name = "bottomControlsInflateFingerprint", + returnType = "Ljava/lang/Object;", + parameters = emptyList(), + opcodes = listOf( + Opcode.CHECK_CAST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT + ), + literals = listOf(bottomUiContainerStub), +) + +internal val controlsLayoutInflateFingerprint = legacyFingerprint( + name = "controlsLayoutInflateFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT + ), + literals = listOf(controlsLayoutStub), +) + +internal val motionEventFingerprint = legacyFingerprint( + name = "motionEventFingerprint", + returnType = "V", + parameters = listOf("Landroid/view/MotionEvent;"), + customFingerprint = { method, _ -> + indexOfTranslationInstruction(method) >= 0 + } +) + +internal fun indexOfTranslationInstruction(method: Method) = + method.indexOfFirstInstructionReversed { + getReference()?.name == "setTranslationY" + } + +internal val playerControlsVisibilityEntityModelFingerprint = legacyFingerprint( + name = "playerControlsVisibilityEntityModelFingerprint", + accessFlags = AccessFlags.PUBLIC.value, + parameters = emptyList(), + opcodes = listOf( + Opcode.IGET, + Opcode.INVOKE_STATIC + ), + customFingerprint = { method, _ -> method.name == "getPlayerControlsVisibility" } +) + +internal val playerControlsVisibilityFingerprint = legacyFingerprint( + name = "playerControlsVisibilityFingerprint", + returnType = "V", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + parameters = listOf("Z", "Z") +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playercontrols/PlayerControlsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playercontrols/PlayerControlsPatch.kt new file mode 100644 index 0000000000..0348564358 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playercontrols/PlayerControlsPatch.kt @@ -0,0 +1,233 @@ +package app.revanced.patches.youtube.utils.playercontrols + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patches.youtube.utils.extension.Constants.UTILS_PATH +import app.revanced.patches.youtube.utils.playerButtonsResourcesFingerprint +import app.revanced.patches.youtube.utils.playerButtonsVisibilityFingerprint +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.youtubeControlsOverlayFingerprint +import app.revanced.util.copyXmlNode +import app.revanced.util.findElementByAttributeValueOrThrow +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.inputStreamFromBundledResourceOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction + +private const val EXTENSION_PLAYER_CONTROLS_CLASS_DESCRIPTOR = + "$UTILS_PATH/PlayerControlsPatch;" + +private const val EXTENSION_PLAYER_CONTROLS_VISIBILITY_HOOK_CLASS_DESCRIPTOR = + "$UTILS_PATH/PlayerControlsVisibilityHookPatch;" + +lateinit var changeVisibilityMethod: MutableMethod +lateinit var changeVisibilityNegatedImmediatelyMethod: MutableMethod +lateinit var initializeBottomControlButtonMethod: MutableMethod +lateinit var initializeTopControlButtonMethod: MutableMethod + +private val playerControlsBytecodePatch = bytecodePatch( + description = "playerControlsBytecodePatch" +) { + dependsOn( + sharedResourceIdPatch + ) + + execute { + + // region patch for hook player controls visibility + + playerControlsVisibilityEntityModelFingerprint.matchOrThrow().let { + it.method.apply { + val startIndex = it.patternMatch!!.startIndex + val iGetReference = getInstruction(startIndex).reference + val staticReference = getInstruction(startIndex + 1).reference + + it.classDef.methods.find { method -> method.name == "" }?.apply { + val targetIndex = indexOfFirstInstructionOrThrow(Opcode.IPUT_OBJECT) + val targetRegister = + getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, """ + iget v$targetRegister, v$targetRegister, $iGetReference + invoke-static {v$targetRegister}, $staticReference + move-result-object v$targetRegister + invoke-static {v$targetRegister}, $EXTENSION_PLAYER_CONTROLS_VISIBILITY_HOOK_CLASS_DESCRIPTOR->setPlayerControlsVisibility(Ljava/lang/Enum;)V + """ + ) + } ?: throw PatchException("Constructor method not found") + } + } + + // endregion + + // region patch for hook visibility of play control buttons (e.g. pause, play button, etc) + + playerButtonsVisibilityFingerprint.methodOrThrow(playerButtonsResourcesFingerprint).apply { + val viewIndex = indexOfFirstInstructionOrThrow(Opcode.INVOKE_INTERFACE) + val viewRegister = getInstruction(viewIndex).registerD + + addInstruction( + viewIndex + 1, + "invoke-static {p1, p2, v$viewRegister}, $EXTENSION_PLAYER_CONTROLS_CLASS_DESCRIPTOR->changeVisibility(ZZLandroid/view/View;)V" + ) + } + + // endregion + + // region patch for hook visibility of play controls layout + + playerControlsVisibilityFingerprint.methodOrThrow(youtubeControlsOverlayFingerprint) + .addInstruction( + 0, + "invoke-static {p1}, $EXTENSION_PLAYER_CONTROLS_CLASS_DESCRIPTOR->changeVisibility(Z)V" + ) + + // endregion + + // region patch for detecting motion events in play controls layout + + motionEventFingerprint.methodOrThrow(youtubeControlsOverlayFingerprint).apply { + val insertIndex = indexOfTranslationInstruction(this) + 1 + + addInstruction( + insertIndex, + "invoke-static {}, $EXTENSION_PLAYER_CONTROLS_CLASS_DESCRIPTOR->changeVisibilityNegatedImmediate()V" + ) + } + + // endregion + + // region patch initialize of overlay button or SponsorBlock button + + mapOf( + bottomControlsInflateFingerprint to "initializeBottomControlButton", + controlsLayoutInflateFingerprint to "initializeTopControlButton" + ).forEach { (fingerprint, methodName) -> + fingerprint.matchOrThrow().let { + it.method.apply { + val endIndex = it.patternMatch!!.endIndex + val viewRegister = getInstruction(endIndex).registerA + + addInstruction( + endIndex + 1, + "invoke-static {v$viewRegister}, $EXTENSION_PLAYER_CONTROLS_CLASS_DESCRIPTOR->$methodName(Landroid/view/View;)V" + ) + } + } + } + + // endregion + + // region set methods to inject into extension + + changeVisibilityMethod = + findMethodOrThrow(EXTENSION_PLAYER_CONTROLS_CLASS_DESCRIPTOR) { + name == "changeVisibility" + && parameters == listOf("Z", "Z") + } + + changeVisibilityNegatedImmediatelyMethod = + findMethodOrThrow(EXTENSION_PLAYER_CONTROLS_CLASS_DESCRIPTOR) { + name == "changeVisibilityNegatedImmediately" + } + + initializeBottomControlButtonMethod = + findMethodOrThrow(EXTENSION_PLAYER_CONTROLS_CLASS_DESCRIPTOR) { + name == "initializeBottomControlButton" + } + + initializeTopControlButtonMethod = + findMethodOrThrow(EXTENSION_PLAYER_CONTROLS_CLASS_DESCRIPTOR) { + name == "initializeTopControlButton" + } + + // endregion + } +} + +private fun MutableMethod.initializeHook(classDescriptor: String) = + addInstruction( + 0, + "invoke-static {p0}, $classDescriptor->initialize(Landroid/view/View;)V" + ) + +private fun changeVisibilityHook(classDescriptor: String) = + changeVisibilityMethod.addInstruction( + 0, + "invoke-static {p0, p1}, $classDescriptor->changeVisibility(ZZ)V" + ) + +private fun changeVisibilityNegatedImmediateHook(classDescriptor: String) = + changeVisibilityNegatedImmediatelyMethod.addInstruction( + 0, + "invoke-static {}, $classDescriptor->changeVisibilityNegatedImmediate()V" + ) + +fun hookBottomControlButton(classDescriptor: String) { + initializeBottomControlButtonMethod.initializeHook(classDescriptor) + changeVisibilityHook(classDescriptor) + changeVisibilityNegatedImmediateHook(classDescriptor) +} + +fun hookTopControlButton(classDescriptor: String) { + initializeTopControlButtonMethod.initializeHook(classDescriptor) + changeVisibilityHook(classDescriptor) + changeVisibilityNegatedImmediateHook(classDescriptor) +} + +/** + * Add a new top to the bottom of the YouTube player. + * + * @param resourceDirectoryName The name of the directory containing the hosting resource. + */ +@Suppress("KDocUnresolvedReference") +// Internal until this is modified to work with any patch (and not just SponsorBlock). +internal lateinit var addTopControl: (String) -> Unit + private set + +val playerControlsPatch = resourcePatch( + description = "playerControlsPatch" +) { + dependsOn(playerControlsBytecodePatch) + + execute { + addTopControl = { resourceDirectoryName -> + val resourceFileName = "shared/host/layout/youtube_controls_layout.xml" + val hostingResourceStream = inputStreamFromBundledResourceOrThrow( + resourceDirectoryName, + resourceFileName, + ) + + val document = document("res/layout/youtube_controls_layout.xml") + + "RelativeLayout".copyXmlNode( + document(hostingResourceStream), + document, + ).use { + val element = document.childNodes.findElementByAttributeValueOrThrow( + "android:id", + "@id/player_video_heading", + ) + + // FIXME: This uses hard coded values that only works with SponsorBlock. + // If other top buttons are added by other patches, this code must be changed. + // voting button id from the voting button view from the youtube_controls_layout.xml host file + val votingButtonId = "@+id/revanced_sb_voting_button" + element.attributes.getNamedItem("android:layout_toStartOf").nodeValue = + votingButtonId + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playertype/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playertype/Fingerprints.kt new file mode 100644 index 0000000000..e54f60ab30 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playertype/Fingerprints.kt @@ -0,0 +1,76 @@ +package app.revanced.patches.youtube.utils.playertype + +import app.revanced.patches.youtube.utils.resourceid.actionBarSearchResultsViewMic +import app.revanced.patches.youtube.utils.resourceid.reelWatchPlayer +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val actionBarSearchResultsFingerprint = legacyFingerprint( + name = "actionBarSearchResultsFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + returnType = "Landroid/view/View;", + literals = listOf(actionBarSearchResultsViewMic), + customFingerprint = { method, _ -> + indexOfLayoutDirectionInstruction(method) >= 0 + } +) + +internal fun indexOfLayoutDirectionInstruction(method: Method) = + method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_VIRTUAL && + getReference().toString() == "Landroid/view/View;->setLayoutDirection(I)V" + } + +internal val browseIdClassFingerprint = legacyFingerprint( + name = "browseIdClassFingerprint", + returnType = "Ljava/lang/Object;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL or AccessFlags.SYNTHETIC, + parameters = listOf("Ljava/lang/Object;", "L"), + strings = listOf("VL") +) + +internal val playerTypeFingerprint = legacyFingerprint( + name = "playerTypeFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + opcodes = listOf( + Opcode.IF_NE, + Opcode.RETURN_VOID + ), + customFingerprint = { method, _ -> + method.definingClass.endsWith("/YouTubePlayerOverlaysLayout;") + } +) + +internal val reelWatchPagerFingerprint = legacyFingerprint( + name = "reelWatchPagerFingerprint", + returnType = "Landroid/view/View;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + literals = listOf(reelWatchPlayer), +) + +internal val videoStateFingerprint = legacyFingerprint( + name = "videoStateFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Lcom/google/android/libraries/youtube/player/features/overlay/controls/ControlsState;"), + opcodes = listOf( + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, // obfuscated parameter field name + Opcode.IGET_OBJECT, + Opcode.IF_NE, + ), + customFingerprint = { method, _ -> + method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "equals" + } >= 0 + }, +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playertype/PlayerTypeHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playertype/PlayerTypeHookPatch.kt new file mode 100644 index 0000000000..9fe31de239 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playertype/PlayerTypeHookPatch.kt @@ -0,0 +1,157 @@ +package app.revanced.patches.youtube.utils.playertype + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.patches.youtube.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.youtube.utils.extension.Constants.SHARED_PATH +import app.revanced.patches.youtube.utils.extension.Constants.UTILS_PATH +import app.revanced.patches.youtube.utils.extension.sharedExtensionPatch +import app.revanced.patches.youtube.utils.resourceid.reelWatchPlayer +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.util.addStaticFieldToExtension +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import app.revanced.util.indexOfFirstStringInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +private const val EXTENSION_PLAYER_TYPE_HOOK_CLASS_DESCRIPTOR = + "$UTILS_PATH/PlayerTypeHookPatch;" + +private const val EXTENSION_ROOT_VIEW_HOOK_CLASS_DESCRIPTOR = + "$SHARED_PATH/RootView;" + +private const val FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/RelatedVideoFilter;" + +val playerTypeHookPatch = bytecodePatch( + description = "playerTypeHookPatch" +) { + dependsOn( + sharedExtensionPatch, + sharedResourceIdPatch, + lithoFilterPatch, + ) + + execute { + + // region patch for set player type + + playerTypeFingerprint.methodOrThrow().addInstruction( + 0, + "invoke-static {p1}, " + + "$EXTENSION_PLAYER_TYPE_HOOK_CLASS_DESCRIPTOR->setPlayerType(Ljava/lang/Enum;)V" + ) + + // endregion + + // region patch for set shorts player state + + reelWatchPagerFingerprint.methodOrThrow().apply { + val literIndex = indexOfFirstLiteralInstructionOrThrow(reelWatchPlayer) + 2 + val registerIndex = indexOfFirstInstructionOrThrow(literIndex) { + opcode == Opcode.MOVE_RESULT_OBJECT + } + val viewRegister = getInstruction(registerIndex).registerA + + addInstruction( + registerIndex + 1, + "invoke-static {v$viewRegister}, " + + "$EXTENSION_PLAYER_TYPE_HOOK_CLASS_DESCRIPTOR->onShortsCreate(Landroid/view/View;)V" + ) + } + + // endregion + + // region patch for set video state + + videoStateFingerprint.matchOrThrow().let { + it.method.apply { + val endIndex = it.patternMatch!!.startIndex + 1 + val videoStateFieldName = + getInstruction(endIndex).reference + + addInstructions( + 0, """ + iget-object v0, p1, $videoStateFieldName # copyvideoState parameter field + invoke-static {v0}, $EXTENSION_PLAYER_TYPE_HOOK_CLASS_DESCRIPTOR->setVideoState(Ljava/lang/Enum;)V + """ + ) + } + } + + // endregion + + // region patch for hook browse id + + browseIdClassFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = indexOfFirstStringInstructionOrThrow("VL") - 1 + val targetClass = getInstruction(targetIndex) + .getReference() + ?.definingClass + ?: throw PatchException("Could not find browseId class") + + findMethodOrThrow(targetClass).apply { + val browseIdFieldReference = getInstruction( + indexOfFirstInstructionOrThrow(Opcode.IPUT_OBJECT) + ).reference + val browseIdFieldName = (browseIdFieldReference as FieldReference).name + + val smaliInstructions = + """ + if-eqz v0, :ignore + iget-object v0, v0, $definingClass->$browseIdFieldName:Ljava/lang/String; + if-eqz v0, :ignore + return-object v0 + :ignore + const-string v0, "" + return-object v0 + """ + + addStaticFieldToExtension( + EXTENSION_ROOT_VIEW_HOOK_CLASS_DESCRIPTOR, + "getBrowseId", + "browseIdClass", + definingClass, + smaliInstructions + ) + } + } + } + + // endregion + + // region patch for hook search bar + + //two different layouts are used at the hooked code. + //insert before the firstviewGroup method call after inflating, + // so this works regardless which layout is used. + actionBarSearchResultsFingerprint.methodOrThrow().apply { + val instructionIndex = indexOfLayoutDirectionInstruction(this) + val viewRegister = getInstruction(instructionIndex).registerC + + addInstruction( + instructionIndex, + "invoke-static { v$viewRegister }, " + + "$EXTENSION_ROOT_VIEW_HOOK_CLASS_DESCRIPTOR->searchBarResultsViewLoaded(Landroid/view/View;)V", + ) + } + + // endregion + + addLithoFilter(FILTER_CLASS_DESCRIPTOR) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playservice/VersionCheckPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playservice/VersionCheckPatch.kt new file mode 100644 index 0000000000..71cecfc5cb --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playservice/VersionCheckPatch.kt @@ -0,0 +1,60 @@ +@file:Suppress("ktlint:standard:property-naming") + +package app.revanced.patches.youtube.utils.playservice + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.util.findElementByAttributeValueOrThrow + +var is_18_31_or_greater = false + private set +var is_18_34_or_greater = false + private set +var is_18_39_or_greater = false + private set +var is_18_42_or_greater = false + private set +var is_18_49_or_greater = false + private set +var is_19_02_or_greater = false + private set +var is_19_15_or_greater = false + private set +var is_19_23_or_greater = false + private set +var is_19_25_or_greater = false + private set +var is_19_28_or_greater = false + private set +var is_19_32_or_greater = false + private set +var is_19_44_or_greater = false + private set + +val versionCheckPatch = resourcePatch( + description = "versionCheckPatch", +) { + execute { + // The app version is missing from the decompiled manifest, + // so instead use the Google Play services version and compare against specific releases. + val playStoreServicesVersion = document("res/values/integers.xml").use { document -> + document.documentElement.childNodes.findElementByAttributeValueOrThrow( + "name", + "google_play_services_version", + ).textContent.toInt() + } + + // All bug fix releases always seem to use the same play store version as the minor version. + is_18_31_or_greater = 233200000 <= playStoreServicesVersion + is_18_34_or_greater = 233500000 <= playStoreServicesVersion + is_18_39_or_greater = 234000000 <= playStoreServicesVersion + is_18_42_or_greater = 234302000 <= playStoreServicesVersion + is_18_49_or_greater = 235000000 <= playStoreServicesVersion + is_19_02_or_greater = 240204000 < playStoreServicesVersion + is_19_15_or_greater = 241602000 <= playStoreServicesVersion + is_19_23_or_greater = 242402000 <= playStoreServicesVersion + is_19_25_or_greater = 242599000 <= playStoreServicesVersion + is_19_28_or_greater = 242905000 <= playStoreServicesVersion + is_19_32_or_greater = 243305000 <= playStoreServicesVersion + is_19_44_or_greater = 244505000 <= playStoreServicesVersion + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/recyclerview/BottomSheetRecyclerViewPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/recyclerview/BottomSheetRecyclerViewPatch.kt new file mode 100644 index 0000000000..1fa445e42e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/recyclerview/BottomSheetRecyclerViewPatch.kt @@ -0,0 +1,52 @@ +package app.revanced.patches.youtube.utils.recyclerview + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.resolvable +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +private lateinit var recyclerViewTreeObserverMutableMethod: MutableMethod +private var recyclerViewTreeObserverInsertIndex = 0 + +val bottomSheetRecyclerViewPatch = bytecodePatch( + description = "bottomSheetRecyclerViewPatch" +) { + execute { + /** + * If this value is false, OldQualityLayoutPatch and OldSpeedLayoutPatch will not work. + * This value is usually true so this patch is not strictly necessary, + * But in very rare cases this value may be false. + * Therefore, we need to force this to be true. + */ + if (bottomSheetRecyclerViewBuilderFingerprint.resolvable()) { + bottomSheetRecyclerViewBuilderFingerprint.injectLiteralInstructionBooleanCall( + 45382015L, + "0x1" + ) + } + + recyclerViewTreeObserverFingerprint.methodOrThrow().apply { + recyclerViewTreeObserverMutableMethod = this + + val onDrawListenerIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.IPUT_OBJECT + && getReference()?.type == "Landroid/view/ViewTreeObserver${'$'}OnDrawListener;" + } + recyclerViewTreeObserverInsertIndex = + indexOfFirstInstructionReversedOrThrow(onDrawListenerIndex, Opcode.CHECK_CAST) + 1 + } + } +} + +fun bottomSheetRecyclerViewHook(descriptor: String) = + recyclerViewTreeObserverMutableMethod.addInstruction( + recyclerViewTreeObserverInsertIndex++, + "invoke-static/range { p2 .. p2 }, $descriptor" + ) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/recyclerview/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/recyclerview/Fingerprints.kt new file mode 100644 index 0000000000..def021552f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/recyclerview/Fingerprints.kt @@ -0,0 +1,17 @@ +package app.revanced.patches.youtube.utils.recyclerview + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags + +internal val bottomSheetRecyclerViewBuilderFingerprint = legacyFingerprint( + name = "bottomSheetRecyclerViewBuilderFingerprint", + literals = listOf(45382015L), +) + +internal val recyclerViewTreeObserverFingerprint = legacyFingerprint( + name = "recyclerViewTreeObserverFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + strings = listOf("LithoRVSLCBinder") +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/resourceid/SharedResourceIdPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/resourceid/SharedResourceIdPatch.kt new file mode 100644 index 0000000000..ae8fa384be --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/resourceid/SharedResourceIdPatch.kt @@ -0,0 +1,662 @@ +package app.revanced.patches.youtube.utils.resourceid + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.shared.mapping.ResourceType.ATTR +import app.revanced.patches.shared.mapping.ResourceType.COLOR +import app.revanced.patches.shared.mapping.ResourceType.DIMEN +import app.revanced.patches.shared.mapping.ResourceType.DRAWABLE +import app.revanced.patches.shared.mapping.ResourceType.ID +import app.revanced.patches.shared.mapping.ResourceType.INTEGER +import app.revanced.patches.shared.mapping.ResourceType.LAYOUT +import app.revanced.patches.shared.mapping.ResourceType.STRING +import app.revanced.patches.shared.mapping.ResourceType.STYLE +import app.revanced.patches.shared.mapping.get +import app.revanced.patches.shared.mapping.resourceMappingPatch +import app.revanced.patches.shared.mapping.resourceMappings + +var accountSwitcherAccessibility = -1L + private set +var actionBarRingo = -1L + private set +var actionBarRingoBackground = -1L + private set +var actionBarSearchResultsViewMic = -1L + private set +var adAttribution = -1L + private set +var appearance = -1L + private set +var appRelatedEndScreenResults = -1L + private set +var autoNavPreviewStub = -1L + private set +var autoNavToggle = -1L + private set +var backgroundCategory = -1L + private set +var badgeLabel = -1L + private set +var bar = -1L + private set +var barContainerHeight = -1L + private set +var bottomBarContainer = -1L + private set +var bottomSheetFooterText = -1L + private set +var bottomSheetRecyclerView = -1L + private set +var bottomUiContainerStub = -1L + private set +var captionToggleContainer = -1L + private set +var castMediaRouteButton = -1L + private set +var cfFullscreenButton = -1L + private set +var channelListSubMenu = -1L + private set +var compactLink = -1L + private set +var compactListItem = -1L + private set +var componentLongClickListener = -1L + private set +var contentPill = -1L + private set +var controlsLayoutStub = -1L + private set +var darkBackground = -1L + private set +var darkSplashAnimation = -1L + private set +var designBottomSheet = -1L + private set +var donationCompanion = -1L + private set +var drawerContentView = -1L + private set +var drawerResults = -1L + private set +var easySeekEduContainer = -1L + private set +var editSettingsAction = -1L + private set +var endScreenElementLayoutCircle = -1L + private set +var endScreenElementLayoutIcon = -1L + private set +var endScreenElementLayoutVideo = -1L + private set +var emojiPickerIcon = -1L + private set +var expandButtonDown = -1L + private set +var fab = -1L + private set +var fadeDurationFast = -1L + private set +var filterBarHeight = -1L + private set +var floatyBarTopMargin = -1L + private set +var fullScreenButton = -1L + private set +var fullScreenEngagementOverlay = -1L + private set +var fullScreenEngagementPanel = -1L + private set +var horizontalCardList = -1L + private set +var imageOnlyTab = -1L + private set +var inlineTimeBarColorizedBarPlayedColorDark = -1L + private set +var inlineTimeBarPlayedNotHighlightedColor = -1L + private set +var insetOverlayViewLayout = -1L + private set +var interstitialsContainer = -1L + private set +var menuItemView = -1L + private set +var metaPanel = -1L + private set +var modernMiniPlayerClose = -1L + private set +var modernMiniPlayerExpand = -1L + private set +var modernMiniPlayerForwardButton = -1L + private set +var modernMiniPlayerRewindButton = -1L + private set +var musicAppDeeplinkButtonView = -1L + private set +var notice = -1L + private set +var notificationBigPictureIconWidth = -1L + private set +var offlineActionsVideoDeletedUndoSnackbarText = -1L + private set +var playerCollapseButton = -1L + private set +var playerVideoTitleView = -1L + private set +var posterArtWidthDefault = -1L + private set +var qualityAuto = -1L + private set +var quickActionsElementContainer = -1L + private set +var reelDynRemix = -1L + private set +var reelDynShare = -1L + private set +var reelFeedbackLike = -1L + private set +var reelFeedbackPause = -1L + private set +var reelFeedbackPlay = -1L + private set +var reelForcedMuteButton = -1L + private set +var reelPlayerFooter = -1L + private set +var reelPlayerRightPivotV2Size = -1L + private set +var reelRightDislikeIcon = -1L + private set +var reelRightLikeIcon = -1L + private set +var reelTimeBarPlayedColor = -1L + private set +var reelVodTimeStampsContainer = -1L + private set +var reelWatchPlayer = -1L + private set +var relatedChipCloudMargin = -1L + private set +var rightComment = -1L + private set +var scrimOverlay = -1L + private set +var scrubbing = -1L + private set +var seekEasyHorizontalTouchOffsetToStartScrubbing = -1L + private set +var seekUndoEduOverlayStub = -1L + private set +var slidingDialogAnimation = -1L + private set +var subtitleMenuSettingsFooterInfo = -1L + private set +var suggestedAction = -1L + private set +var tapBloomView = -1L + private set +var titleAnchor = -1L + private set +var toolTipContentView = -1L + private set +var totalTime = -1L + private set +var touchArea = -1L + private set +var videoQualityBottomSheet = -1L + private set +var varispeedUnavailableTitle = -1L + private set +var videoQualityUnavailableAnnouncement = -1L + private set +var videoZoomSnapIndicator = -1L + private set +var voiceSearch = -1L + private set +var youTubeControlsOverlaySubtitleButton = -1L + private set +var youTubeLogo = -1L + private set +var ytOutlinePictureInPictureWhite = -1L + private set +var ytOutlineVideoCamera = -1L + private set +var ytOutlineXWhite = -1L + private set +var ytPremiumWordMarkHeader = -1L + private set +var ytWordMarkHeader = -1L + private set + + +internal val sharedResourceIdPatch = resourcePatch( + description = "sharedResourceIdPatch" +) { + dependsOn(resourceMappingPatch) + + execute { + accountSwitcherAccessibility = resourceMappings[ + STRING, + "account_switcher_accessibility_label" + ] + actionBarRingo = resourceMappings[ + LAYOUT, + "action_bar_ringo" + ] + actionBarRingoBackground = resourceMappings[ + LAYOUT, + "action_bar_ringo_background" + ] + actionBarSearchResultsViewMic = resourceMappings[ + LAYOUT, + "action_bar_search_results_view_mic" + ] + adAttribution = resourceMappings[ + ID, + "ad_attribution" + ] + appearance = resourceMappings[ + STRING, + "app_theme_appearance_dark" + ] + appRelatedEndScreenResults = resourceMappings[ + LAYOUT, + "app_related_endscreen_results" + ] + autoNavPreviewStub = resourceMappings[ + ID, + "autonav_preview_stub" + ] + autoNavToggle = resourceMappings[ + ID, + "autonav_toggle" + ] + backgroundCategory = resourceMappings[ + STRING, + "pref_background_and_offline_category" + ] + badgeLabel = resourceMappings[ + ID, + "badge_label" + ] + bar = resourceMappings[ + LAYOUT, + "bar" + ] + barContainerHeight = resourceMappings[ + DIMEN, + "bar_container_height" + ] + bottomBarContainer = resourceMappings[ + ID, + "bottom_bar_container" + ] + bottomSheetFooterText = resourceMappings[ + ID, + "bottom_sheet_footer_text" + ] + bottomSheetRecyclerView = resourceMappings[ + LAYOUT, + "bottom_sheet_recycler_view" + ] + bottomUiContainerStub = resourceMappings[ + ID, + "bottom_ui_container_stub" + ] + captionToggleContainer = resourceMappings[ + ID, + "caption_toggle_container" + ] + castMediaRouteButton = resourceMappings[ + LAYOUT, + "castmediaroutebutton" + ] + cfFullscreenButton = resourceMappings[ + ID, + "cf_fullscreen_button" + ] + channelListSubMenu = resourceMappings[ + LAYOUT, + "channel_list_sub_menu" + ] + compactLink = resourceMappings[ + LAYOUT, + "compact_link" + ] + compactListItem = resourceMappings[ + LAYOUT, + "compact_list_item" + ] + componentLongClickListener = resourceMappings[ + ID, + "component_long_click_listener" + ] + contentPill = resourceMappings[ + LAYOUT, + "content_pill" + ] + controlsLayoutStub = resourceMappings[ + ID, + "controls_layout_stub" + ] + darkBackground = resourceMappings[ + ID, + "dark_background" + ] + darkSplashAnimation = resourceMappings[ + ID, + "dark_splash_animation" + ] + designBottomSheet = resourceMappings[ + ID, + "design_bottom_sheet" + ] + donationCompanion = resourceMappings[ + LAYOUT, + "donation_companion" + ] + drawerContentView = resourceMappings[ + ID, + "drawer_content_view" + ] + drawerResults = resourceMappings[ + ID, + "drawer_results" + ] + easySeekEduContainer = resourceMappings[ + ID, + "easy_seek_edu_container" + ] + editSettingsAction = resourceMappings[ + STRING, + "edit_settings_action" + ] + endScreenElementLayoutCircle = resourceMappings[ + LAYOUT, + "endscreen_element_layout_circle" + ] + endScreenElementLayoutIcon = resourceMappings[ + LAYOUT, + "endscreen_element_layout_icon" + ] + endScreenElementLayoutVideo = resourceMappings[ + LAYOUT, + "endscreen_element_layout_video" + ] + emojiPickerIcon = resourceMappings[ + ID, + "emoji_picker_icon" + ] + expandButtonDown = resourceMappings[ + LAYOUT, + "expand_button_down" + ] + fab = resourceMappings[ + ID, + "fab" + ] + fadeDurationFast = resourceMappings[ + INTEGER, + "fade_duration_fast" + ] + filterBarHeight = resourceMappings[ + DIMEN, + "filter_bar_height" + ] + floatyBarTopMargin = resourceMappings[ + DIMEN, + "floaty_bar_button_top_margin" + ] + fullScreenButton = resourceMappings[ + ID, + "fullscreen_button" + ] + fullScreenEngagementOverlay = resourceMappings[ + LAYOUT, + "fullscreen_engagement_overlay" + ] + fullScreenEngagementPanel = resourceMappings[ + ID, + "fullscreen_engagement_panel_holder" + ] + horizontalCardList = resourceMappings[ + LAYOUT, + "horizontal_card_list" + ] + imageOnlyTab = resourceMappings[ + LAYOUT, + "image_only_tab" + ] + inlineTimeBarColorizedBarPlayedColorDark = resourceMappings[ + COLOR, + "inline_time_bar_colorized_bar_played_color_dark" + ] + inlineTimeBarPlayedNotHighlightedColor = resourceMappings[ + COLOR, + "inline_time_bar_played_not_highlighted_color" + ] + insetOverlayViewLayout = resourceMappings[ + ID, + "inset_overlay_view_layout" + ] + interstitialsContainer = resourceMappings[ + ID, + "interstitials_container" + ] + menuItemView = resourceMappings[ + ID, + "menu_item_view" + ] + metaPanel = resourceMappings[ + ID, + "metapanel" + ] + modernMiniPlayerClose = resourceMappings[ + ID, + "modern_miniplayer_close" + ] + modernMiniPlayerExpand = resourceMappings[ + ID, + "modern_miniplayer_expand" + ] + modernMiniPlayerForwardButton = resourceMappings[ + ID, + "modern_miniplayer_forward_button" + ] + modernMiniPlayerRewindButton = resourceMappings[ + ID, + "modern_miniplayer_rewind_button" + ] + musicAppDeeplinkButtonView = resourceMappings[ + ID, + "music_app_deeplink_button_view" + ] + notice = resourceMappings[ + ID, + "notice" + ] + notificationBigPictureIconWidth = resourceMappings[ + DIMEN, + "notification_big_picture_icon_width" + ] + offlineActionsVideoDeletedUndoSnackbarText = resourceMappings[ + STRING, + "offline_actions_video_deleted_undo_snackbar_text" + ] + playerCollapseButton = resourceMappings[ + ID, + "player_collapse_button" + ] + playerVideoTitleView = resourceMappings[ + ID, + "player_video_title_view" + ] + posterArtWidthDefault = resourceMappings[ + DIMEN, + "poster_art_width_default" + ] + qualityAuto = resourceMappings[ + STRING, + "quality_auto" + ] + quickActionsElementContainer = resourceMappings[ + ID, + "quick_actions_element_container" + ] + reelDynRemix = resourceMappings[ + ID, + "reel_dyn_remix" + ] + reelDynShare = resourceMappings[ + ID, + "reel_dyn_share" + ] + reelFeedbackLike = resourceMappings[ + ID, + "reel_feedback_like" + ] + reelFeedbackPause = resourceMappings[ + ID, + "reel_feedback_pause" + ] + reelFeedbackPlay = resourceMappings[ + ID, + "reel_feedback_play" + ] + reelForcedMuteButton = resourceMappings[ + ID, + "reel_player_forced_mute_button" + ] + reelPlayerFooter = resourceMappings[ + LAYOUT, + "reel_player_dyn_footer_vert_stories3" + ] + reelPlayerRightPivotV2Size = resourceMappings[ + DIMEN, + "reel_player_right_pivot_v2_size" + ] + reelRightDislikeIcon = resourceMappings[ + DRAWABLE, + "reel_right_dislike_icon" + ] + reelRightLikeIcon = resourceMappings[ + DRAWABLE, + "reel_right_like_icon" + ] + reelTimeBarPlayedColor = resourceMappings[ + COLOR, + "reel_time_bar_played_color" + ] + reelVodTimeStampsContainer = resourceMappings[ + ID, + "reel_vod_timestamps_container" + ] + reelWatchPlayer = resourceMappings[ + ID, + "reel_watch_player" + ] + relatedChipCloudMargin = resourceMappings[ + LAYOUT, + "related_chip_cloud_reduced_margins" + ] + rightComment = resourceMappings[ + DRAWABLE, + "ic_right_comment_32c" + ] + scrimOverlay = resourceMappings[ + ID, + "scrim_overlay" + ] + scrubbing = resourceMappings[ + DIMEN, + "vertical_touch_offset_to_enter_fine_scrubbing" + ] + seekEasyHorizontalTouchOffsetToStartScrubbing = resourceMappings[ + DIMEN, + "seek_easy_horizontal_touch_offset_to_start_scrubbing" + ] + seekUndoEduOverlayStub = resourceMappings[ + ID, + "seek_undo_edu_overlay_stub" + ] + slidingDialogAnimation = resourceMappings[ + STYLE, + "SlidingDialogAnimation" + ] + subtitleMenuSettingsFooterInfo = resourceMappings[ + STRING, + "subtitle_menu_settings_footer_info" + ] + suggestedAction = resourceMappings[ + LAYOUT, + "suggested_action" + ] + tapBloomView = resourceMappings[ + ID, + "tap_bloom_view" + ] + titleAnchor = resourceMappings[ + ID, + "title_anchor" + ] + toolTipContentView = resourceMappings[ + LAYOUT, + "tooltip_content_view" + ] + totalTime = resourceMappings[ + STRING, + "total_time" + ] + touchArea = resourceMappings[ + ID, + "touch_area" + ] + videoQualityBottomSheet = resourceMappings[ + LAYOUT, + "video_quality_bottom_sheet_list_fragment_title" + ] + varispeedUnavailableTitle = resourceMappings[ + STRING, + "varispeed_unavailable_title" + ] + videoQualityUnavailableAnnouncement = resourceMappings[ + STRING, + "video_quality_unavailable_announcement" + ] + videoZoomSnapIndicator = resourceMappings[ + ID, + "video_zoom_snap_indicator" + ] + voiceSearch = resourceMappings[ + ID, + "voice_search" + ] + youTubeControlsOverlaySubtitleButton = resourceMappings[ + LAYOUT, + "youtube_controls_overlay_subtitle_button" + ] + youTubeLogo = resourceMappings[ + ID, + "youtube_logo" + ] + ytOutlinePictureInPictureWhite = resourceMappings[ + DRAWABLE, + "yt_outline_picture_in_picture_white_24" + ] + ytOutlineVideoCamera = resourceMappings[ + DRAWABLE, + "yt_outline_video_camera_black_24" + ] + ytOutlineXWhite = resourceMappings[ + DRAWABLE, + "yt_outline_x_white_24" + ] + ytPremiumWordMarkHeader = resourceMappings[ + ATTR, + "ytPremiumWordmarkHeader" + ] + ytWordMarkHeader = resourceMappings[ + ATTR, + "ytWordmarkHeader" + ] + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/returnyoutubedislike/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/returnyoutubedislike/Fingerprints.kt new file mode 100644 index 0000000000..0a8ea07c63 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/returnyoutubedislike/Fingerprints.kt @@ -0,0 +1,100 @@ +package app.revanced.patches.youtube.utils.returnyoutubedislike + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +/** + * This fingerprint is compatible with YouTube v18.30.xx+ + */ +internal val rollingNumberMeasureAnimatedTextFingerprint = legacyFingerprint( + name = "rollingNumberMeasureAnimatedTextFingerprint", + opcodes = listOf( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.ADD_FLOAT_2ADDR, // measuredTextWidth + Opcode.ADD_INT_LIT8, + Opcode.GOTO + ), + customFingerprint = { method, _ -> + method.indexOfFirstInstruction { + getReference()?.toString() == "Landroid/text/TextPaint;->measureText([CII)F" + } >= 0 + } +) + +internal val rollingNumberMeasureStaticLabelFingerprint = legacyFingerprint( + name = "rollingNumberMeasureStaticLabelFingerprint", + returnType = "F", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Ljava/lang/String;"), + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.RETURN + ) +) + +internal val rollingNumberMeasureTextParentFingerprint = legacyFingerprint( + name = "rollingNumberMeasureTextParentFingerprint", + returnType = "Ljava/lang/String;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf(), + strings = listOf("RollingNumberFontProperties{paint=") +) + +/** + * This fingerprint is compatible with YouTube v18.29.38+ + */ +internal val rollingNumberSetterFingerprint = legacyFingerprint( + name = "rollingNumberSetterFingerprint", + opcodes = listOf(Opcode.CHECK_CAST), + literals = listOf(45427773L), +) + +internal val shortsTextViewFingerprint = legacyFingerprint( + name = "shortsTextViewFingerprint", + returnType = "V", + parameters = listOf("L", "L"), + opcodes = listOf( + Opcode.IF_EQZ, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.GOTO, + Opcode.IGET, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IF_NEZ, + Opcode.SGET_OBJECT, + Opcode.SGET_OBJECT, + Opcode.IF_NE, + Opcode.IGET, + Opcode.AND_INT_LIT8, + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, + Opcode.IF_NEZ, + Opcode.SGET_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.GOTO, + Opcode.IGET, + Opcode.AND_INT_LIT8, + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, + Opcode.IF_NEZ, + Opcode.SGET_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN_VOID + ), + customFingerprint = { _, classDef -> + classDef.methods.count() == 3 + } +) + diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/returnyoutubedislike/ReturnYouTubeDislikePatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/returnyoutubedislike/ReturnYouTubeDislikePatch.kt new file mode 100644 index 0000000000..b73c633def --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/returnyoutubedislike/ReturnYouTubeDislikePatch.kt @@ -0,0 +1,292 @@ +package app.revanced.patches.youtube.utils.returnyoutubedislike + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.shared.dislikeFingerprint +import app.revanced.patches.shared.likeFingerprint +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.patches.shared.removeLikeFingerprint +import app.revanced.patches.shared.textcomponent.hookSpannableString +import app.revanced.patches.shared.textcomponent.hookTextComponent +import app.revanced.patches.shared.textcomponent.textComponentPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.youtube.utils.extension.Constants.UTILS_PATH +import app.revanced.patches.youtube.utils.patch.PatchList.RETURN_YOUTUBE_DISLIKE +import app.revanced.patches.youtube.utils.playservice.is_18_34_or_greater +import app.revanced.patches.youtube.utils.playservice.is_18_49_or_greater +import app.revanced.patches.youtube.utils.playservice.versionCheckPatch +import app.revanced.patches.youtube.utils.rollingNumberTextViewAnimationUpdateFingerprint +import app.revanced.patches.youtube.utils.rollingNumberTextViewFingerprint +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.patches.youtube.video.information.hookShortsVideoInformation +import app.revanced.patches.youtube.video.information.videoInformationPatch +import app.revanced.patches.youtube.video.videoid.hookPlayerResponseVideoId +import app.revanced.patches.youtube.video.videoid.hookVideoId +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private const val EXTENSION_RYD_CLASS_DESCRIPTOR = + "$UTILS_PATH/ReturnYouTubeDislikePatch;" + +private val returnYouTubeDislikeRollingNumberPatch = bytecodePatch( + description = "returnYouTubeDislikeRollingNumberPatch" +) { + dependsOn(versionCheckPatch) + + execute { + if (!is_18_49_or_greater) { + return@execute + } + + rollingNumberSetterFingerprint.matchOrThrow().let { + it.method.apply { + val rollingNumberClassIndex = it.patternMatch!!.startIndex + val rollingNumberClassReference = + getInstruction(rollingNumberClassIndex).reference.toString() + val rollingNumberConstructorMethod = + findMethodOrThrow(rollingNumberClassReference) + val charSequenceFieldReference = with(rollingNumberConstructorMethod) { + getInstruction( + indexOfFirstInstructionOrThrow(Opcode.IPUT_OBJECT) + ).reference + } + + val insertIndex = rollingNumberClassIndex + 1 + val charSequenceInstanceRegister = + getInstruction(rollingNumberClassIndex).registerA + val registerCount = implementation!!.registerCount + + // This register is being overwritten, so it is free to use. + val freeRegister = registerCount - 1 + val conversionContextRegister = registerCount - parameters.size + 1 + + addInstructions( + insertIndex, """ + iget-object v$freeRegister, v$charSequenceInstanceRegister, $charSequenceFieldReference + invoke-static {v$conversionContextRegister, v$freeRegister}, $EXTENSION_RYD_CLASS_DESCRIPTOR->onRollingNumberLoaded(Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/String; + move-result-object v$freeRegister + iput-object v$freeRegister, v$charSequenceInstanceRegister, $charSequenceFieldReference + """ + ) + } + } + + // Rolling Number text views use the measured width of the raw string for layout. + // Modify the measure text calculation to include the left drawable separator if needed. + rollingNumberMeasureAnimatedTextFingerprint.matchOrThrow().let { + it.method.apply { + val endIndex = it.patternMatch!!.endIndex + val measuredTextWidthIndex = endIndex - 2 + val measuredTextWidthRegister = + getInstruction(measuredTextWidthIndex).registerA + + addInstructions( + endIndex + 1, """ + invoke-static {p1, v$measuredTextWidthRegister}, $EXTENSION_RYD_CLASS_DESCRIPTOR->onRollingNumberMeasured(Ljava/lang/String;F)F + move-result v$measuredTextWidthRegister + """ + ) + + val ifGeIndex = indexOfFirstInstructionOrThrow(Opcode.IF_GE) + val ifGeInstruction = getInstruction(ifGeIndex) + + removeInstruction(ifGeIndex) + addInstructionsWithLabels( + ifGeIndex, """ + if-ge v${ifGeInstruction.registerA}, v${ifGeInstruction.registerB}, :jump + """, ExternalLabel("jump", getInstruction(endIndex)) + ) + } + } + + rollingNumberMeasureStaticLabelFingerprint.matchOrThrow( + rollingNumberMeasureTextParentFingerprint + ).let { + it.method.apply { + val measureTextIndex = it.patternMatch!!.startIndex + 1 + val freeRegister = getInstruction(0).registerA + + addInstructions( + measureTextIndex + 1, """ + move-result v$freeRegister + invoke-static {p1, v$freeRegister}, $EXTENSION_RYD_CLASS_DESCRIPTOR->onRollingNumberMeasured(Ljava/lang/String;F)F + """ + ) + } + } + + // The rolling number Span is missing styling since it's initially set as a String. + // Modify the UI text view and use the styled like/dislike Span. + arrayOf( + // Initial TextView is set in this method. + rollingNumberTextViewFingerprint + .methodOrThrow(), + + // Video less than 24 hours after uploaded, like counts will be updated in real time. + // Whenever like counts are updated, TextView is set in this method. + rollingNumberTextViewAnimationUpdateFingerprint + .methodOrThrow(rollingNumberTextViewFingerprint) + ).forEach { method -> + method.apply { + val setTextIndex = indexOfFirstInstructionOrThrow { + getReference()?.name == "setText" + } + val textViewRegister = + getInstruction(setTextIndex).registerC + val textSpanRegister = + getInstruction(setTextIndex).registerD + + addInstructions( + setTextIndex, """ + invoke-static {v$textViewRegister, v$textSpanRegister}, $EXTENSION_RYD_CLASS_DESCRIPTOR->updateRollingNumber(Landroid/widget/TextView;Ljava/lang/CharSequence;)Ljava/lang/CharSequence; + move-result-object v$textSpanRegister + """ + ) + } + } + } +} + +private val returnYouTubeDislikeShortsPatch = bytecodePatch( + description = "returnYouTubeDislikeShortsPatch" +) { + dependsOn( + textComponentPatch, + versionCheckPatch + ) + + execute { + shortsTextViewFingerprint.matchOrThrow().let { + it.method.apply { + val startIndex = it.patternMatch!!.startIndex + + val isDisLikesBooleanIndex = + indexOfFirstInstructionReversedOrThrow(startIndex, Opcode.IGET_BOOLEAN) + val textViewFieldIndex = + indexOfFirstInstructionReversedOrThrow(startIndex, Opcode.IGET_OBJECT) + + // If the field is true, the TextView is for a dislike button. + val isDisLikesBooleanReference = + getInstruction(isDisLikesBooleanIndex).reference + + val textViewFieldReference = // Like/Dislike button TextView field + getInstruction(textViewFieldIndex).reference + + // Check if the hooked TextView object is that of the dislike button. + // If RYD is disabled, or the TextView object is not that of the dislike button, the execution flow is not interrupted. + // Otherwise, the TextView object is modified, and the execution flow is interrupted to prevent it from being changed afterward. + val insertIndex = indexOfFirstInstructionOrThrow(Opcode.CHECK_CAST) + 1 + + addInstructionsWithLabels( + insertIndex, """ + # Check, if the TextView is for a dislike button + iget-boolean v0, p0, $isDisLikesBooleanReference + if-eqz v0, :ryd_disabled + + # Hook the TextView, if it is for the dislike button + iget-object v0, p0, $textViewFieldReference + invoke-static {v0}, $EXTENSION_RYD_CLASS_DESCRIPTOR->setShortsDislikes(Landroid/view/View;)Z + move-result v0 + if-eqz v0, :ryd_disabled + return-void + """, ExternalLabel("ryd_disabled", getInstruction(insertIndex)) + ) + } + } + + if (is_18_34_or_greater) { + hookSpannableString( + EXTENSION_RYD_CLASS_DESCRIPTOR, + "onCharSequenceLoaded" + ) + } + } +} + +private const val FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/ReturnYouTubeDislikeFilterPatch;" + +@Suppress("unused") +val returnYouTubeDislikePatch = bytecodePatch( + RETURN_YOUTUBE_DISLIKE.title, + RETURN_YOUTUBE_DISLIKE.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + returnYouTubeDislikeRollingNumberPatch, + returnYouTubeDislikeShortsPatch, + lithoFilterPatch, + settingsPatch, + videoInformationPatch, + ) + + execute { + mapOf( + likeFingerprint to Vote.LIKE, + dislikeFingerprint to Vote.DISLIKE, + removeLikeFingerprint to Vote.REMOVE_LIKE, + ).forEach { (fingerprint, vote) -> + fingerprint.methodOrThrow().addInstructions( + 0, + """ + const/4 v0, ${vote.value} + invoke-static {v0}, $EXTENSION_RYD_CLASS_DESCRIPTOR->sendVote(I)V + """, + ) + } + + hookTextComponent(EXTENSION_RYD_CLASS_DESCRIPTOR) + + // region Inject newVideoLoaded event handler to update dislikes when a new video is loaded. + hookVideoId("$EXTENSION_RYD_CLASS_DESCRIPTOR->newVideoLoaded(Ljava/lang/String;)V") + + // Hook the player response video id, to start loading RYD sooner in the background. + hookPlayerResponseVideoId("$EXTENSION_RYD_CLASS_DESCRIPTOR->preloadVideoId(Ljava/lang/String;Z)V") + + // endregion + + // Player response video id is needed to search for the video ids in Shorts litho components. + if (is_18_34_or_greater) { + addLithoFilter(FILTER_CLASS_DESCRIPTOR) + hookPlayerResponseVideoId("$FILTER_CLASS_DESCRIPTOR->newPlayerResponseVideoId(Ljava/lang/String;Z)V") + hookShortsVideoInformation("$FILTER_CLASS_DESCRIPTOR->newShortsVideoStarted(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V") + } + + // endregion + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: RETURN_YOUTUBE_DISLIKE" + ), + RETURN_YOUTUBE_DISLIKE + ) + + // endregion + } +} + +enum class Vote(val value: Int) { + LIKE(1), + DISLIKE(-1), + REMOVE_LIKE(0), +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/returnyoutubeusername/ReturnYouTubeUsernamePatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/returnyoutubeusername/ReturnYouTubeUsernamePatch.kt new file mode 100644 index 0000000000..04c6cb02b7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/returnyoutubeusername/ReturnYouTubeUsernamePatch.kt @@ -0,0 +1,36 @@ +package app.revanced.patches.youtube.utils.returnyoutubeusername + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.shared.returnyoutubeusername.baseReturnYouTubeUsernamePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.patch.PatchList.RETURN_YOUTUBE_USERNAME +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch + +@Suppress("unused") +val returnYouTubeUsernamePatch = bytecodePatch( + RETURN_YOUTUBE_USERNAME.title, + RETURN_YOUTUBE_USERNAME.summary, + false, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + baseReturnYouTubeUsernamePatch, + settingsPatch, + ) + + execute { + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: RETURN_YOUTUBE_USERNAME" + ), + RETURN_YOUTUBE_USERNAME + ) + + // endregion + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/settings/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/settings/Fingerprints.kt new file mode 100644 index 0000000000..79bdce50a6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/settings/Fingerprints.kt @@ -0,0 +1,12 @@ +package app.revanced.patches.youtube.utils.settings + +import app.revanced.patches.youtube.utils.resourceid.appearance +import app.revanced.util.fingerprint.legacyFingerprint +import com.android.tools.smali.dexlib2.Opcode + +internal val themeSetterSystemFingerprint = legacyFingerprint( + name = "themeSetterSystemFingerprint", + returnType = "L", + opcodes = listOf(Opcode.RETURN_OBJECT), + literals = listOf(appearance), +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/settings/ResourceUtils.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/settings/ResourceUtils.kt new file mode 100644 index 0000000000..7b84b5f93a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/settings/ResourceUtils.kt @@ -0,0 +1,162 @@ +package app.revanced.patches.youtube.utils.settings + +import app.revanced.patcher.patch.ResourcePatchContext +import app.revanced.patches.music.utils.compatibility.Constants.YOUTUBE_MUSIC_PACKAGE_NAME +import app.revanced.patches.youtube.utils.compatibility.Constants.YOUTUBE_PACKAGE_NAME +import app.revanced.patches.youtube.utils.patch.PatchList +import app.revanced.util.doRecursively +import app.revanced.util.insertNode +import org.w3c.dom.Element +import java.io.File + +internal object ResourceUtils { + private lateinit var context: ResourcePatchContext + private lateinit var youtubeSettingFile: File + private lateinit var rvxSettingFile: File + + fun setContext(context: ResourcePatchContext) { + this.context = context + this.youtubeSettingFile = context[YOUTUBE_SETTINGS_PATH] + this.rvxSettingFile = context[RVX_PREFERENCE_PATH] + } + + fun getContext() = context + + const val RVX_PREFERENCE_PATH = "res/xml/revanced_prefs.xml" + const val YOUTUBE_SETTINGS_PATH = "res/xml/settings_fragment.xml" + + var youtubeMusicPackageName = YOUTUBE_MUSIC_PACKAGE_NAME + var youtubePackageName = YOUTUBE_PACKAGE_NAME + + private var iconType = "default" + fun getIconType() = iconType + + fun updatePackageName( + fromPackageName: String, + toPackageName: String, + musicPackageName: String + ) { + youtubeMusicPackageName = musicPackageName + youtubePackageName = toPackageName + + youtubeSettingFile.writeText( + youtubeSettingFile.readText() + .replace( + "android:targetPackage=\"$fromPackageName", + "android:targetPackage=\"$toPackageName" + ) + ) + } + + fun updateGmsCorePackageName( + fromPackageName: String, + toPackageName: String + ) { + rvxSettingFile.writeText( + rvxSettingFile.readText() + .replace( + "android:targetPackage=\"$fromPackageName", + "android:targetPackage=\"$toPackageName" + ) + ) + } + + fun addPreference(patch: PatchList) { + patch.included = true + updatePatchStatus(patch.title.replace(" for YouTube", "")) + } + + fun addPreference(settingArray: Array, patch: PatchList) { + settingArray.forEach preferenceLoop@{ preference -> + rvxSettingFile.writeText( + rvxSettingFile.readText() + .replace("", "") + ) + } + + addPreference(patch) + } + + fun updatePatchStatus(patchTitle: String) { + updatePatchStatusSettings(patchTitle, "@string/revanced_patches_included") + } + + fun updatePatchStatusIcon(iconName: String) { + iconType = iconName + updatePatchStatusSettings("Icon", "@string/revanced_icon_$iconName") + } + + fun updatePatchStatusLabel(appName: String) = + updatePatchStatusSettings("Label", appName) + + fun updatePatchStatusTheme(themeName: String) = + updatePatchStatusSettings("Theme", themeName) + + fun updatePatchStatusSettings( + patchTitle: String, + updateText: String + ) = context.apply { + document(RVX_PREFERENCE_PATH).use { document -> + document.doRecursively loop@{ + if (it !is Element) return@loop + + it.getAttributeNode("android:title")?.let { attribute -> + if (attribute.textContent == patchTitle) { + it.getAttributeNode("android:summary").textContent = updateText + } + } + } + } + } + + fun addPreferenceFragment(key: String, insertKey: String) = context.apply { + val targetClass = + "com.google.android.apps.youtube.app.settings.videoquality.VideoQualitySettingsActivity" + + document(YOUTUBE_SETTINGS_PATH).use { document -> + with(document) { + val processedKeys = mutableSetOf() // To track processed keys + + doRecursively loop@{ node -> + if (node !is Element) return@loop // Skip if not an element + + val attributeNode = node.getAttributeNode("android:key") + ?: return@loop // Skip if no key attribute + val currentKey = attributeNode.textContent + + // Check if the current key has already been processed + if (processedKeys.contains(currentKey)) { + return@loop // Skip if already processed + } else { + processedKeys.add(currentKey) // Add the current key to processedKeys + } + + when (currentKey) { + insertKey -> { + node.insertNode("Preference", node) { + setAttribute("android:key", "${key}_key") + setAttribute("android:title", "@string/${key}_title") + this.appendChild( + ownerDocument.createElement("intent").also { intentNode -> + intentNode.setAttribute( + "android:targetPackage", + youtubePackageName + ) + intentNode.setAttribute("android:data", key + "_intent") + intentNode.setAttribute("android:targetClass", targetClass) + } + ) + } + node.setAttribute("app:iconSpaceReserved", "true") + } + + "true" -> { + attributeNode.textContent = "false" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/settings/SettingsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/settings/SettingsPatch.kt new file mode 100644 index 0000000000..14f371e818 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/settings/SettingsPatch.kt @@ -0,0 +1,268 @@ +package app.revanced.patches.youtube.utils.settings + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patches.shared.extension.Constants.EXTENSION_UTILS_CLASS_DESCRIPTOR +import app.revanced.patches.shared.extension.Constants.EXTENSION_UTILS_PATH +import app.revanced.patches.shared.mainactivity.injectConstructorMethodCall +import app.revanced.patches.shared.mainactivity.injectOnCreateMethodCall +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.UTILS_PATH +import app.revanced.patches.youtube.utils.extension.sharedExtensionPatch +import app.revanced.patches.youtube.utils.fix.cairo.cairoSettingsPatch +import app.revanced.patches.youtube.utils.mainactivity.mainActivityResolvePatch +import app.revanced.patches.youtube.utils.patch.PatchList.SETTINGS_FOR_YOUTUBE +import app.revanced.patches.youtube.utils.playservice.versionCheckPatch +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.util.ResourceGroup +import app.revanced.util.copyResources +import app.revanced.util.copyXmlNode +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.removeStringsElements +import app.revanced.util.valueOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import org.w3c.dom.Element +import java.util.jar.Manifest + +private const val EXTENSION_INITIALIZATION_CLASS_DESCRIPTOR = + "$UTILS_PATH/InitializationPatch;" + +private const val EXTENSION_THEME_METHOD_DESCRIPTOR = + "$EXTENSION_UTILS_PATH/BaseThemeUtils;->setTheme(Ljava/lang/Enum;)V" + +private val settingsBytecodePatch = bytecodePatch( + description = "settingsBytecodePatch" +) { + dependsOn( + sharedExtensionPatch, + sharedResourceIdPatch, + mainActivityResolvePatch, + versionCheckPatch, + ) + + execute { + fun MutableMethod.injectCall(index: Int) { + val register = getInstruction(index).registerA + + addInstructions( + index + 1, """ + invoke-static {v$register}, $EXTENSION_THEME_METHOD_DESCRIPTOR + return-object v$register + """ + ) + removeInstruction(index) + } + + // apply the current theme of the settings page + themeSetterSystemFingerprint.matchOrThrow().let { + it.method.apply { + injectCall(implementation!!.instructions.size - 1) + injectCall(it.patternMatch!!.startIndex) + } + } + + injectOnCreateMethodCall( + EXTENSION_INITIALIZATION_CLASS_DESCRIPTOR, + "setExtendedUtils" + ) + injectOnCreateMethodCall( + EXTENSION_INITIALIZATION_CLASS_DESCRIPTOR, + "onCreate" + ) + injectConstructorMethodCall( + EXTENSION_UTILS_CLASS_DESCRIPTOR, + "setActivity" + ) + } +} + +private const val DEFAULT_ELEMENT = "@string/about_key" +private const val DEFAULT_LABEL = "ReVanced Extended" + +private val SETTINGS_ELEMENTS_MAP = mapOf( + "Parent settings" to "@string/parent_tools_key", + "General" to "@string/general_key", + "Account" to "@string/account_switcher_key", + "Data saving" to "@string/data_saving_settings_key", + "Autoplay" to "@string/auto_play_key", + "Video quality preferences" to "@string/video_quality_settings_key", + "Background" to "@string/offline_key", + "Watch on TV" to "@string/pair_with_tv_key", + "Manage all history" to "@string/history_key", + "Your data in YouTube" to "@string/your_data_key", + "Privacy" to "@string/privacy_key", + "History & privacy" to "@string/privacy_key", + "Try experimental new features" to "@string/premium_early_access_browse_page_key", + "Purchases and memberships" to "@string/subscription_product_setting_key", + "Billing & payments" to "@string/billing_and_payment_key", + "Billing and payments" to "@string/billing_and_payment_key", + "Notifications" to "@string/notification_key", + "Connected apps" to "@string/connected_accounts_browse_page_key", + "Live chat" to "@string/live_chat_key", + "Captions" to "@string/captions_key", + "Accessibility" to "@string/accessibility_settings_key", + "About" to DEFAULT_ELEMENT +) + +private lateinit var customName: String + +val settingsPatch = resourcePatch( + SETTINGS_FOR_YOUTUBE.title, + SETTINGS_FOR_YOUTUBE.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + settingsBytecodePatch, + cairoSettingsPatch, + ) + + val insertPosition = stringOption( + key = "insertPosition", + default = DEFAULT_ELEMENT, + values = SETTINGS_ELEMENTS_MAP, + title = "Insert position", + description = "The settings menu name that the RVX settings menu should be above.", + required = true, + ) + + val settingsLabel = stringOption( + key = "settingsLabel", + default = DEFAULT_LABEL, + title = "RVX settings label", + description = "The name of the RVX settings menu.", + required = true, + ) + + execute { + /** + * check patch options + */ + customName = settingsLabel + .valueOrThrow() + + val insertKey = insertPosition + .valueOrThrow() + + ResourceUtils.setContext(this) + + /** + * remove strings duplicated with RVX resources + * + * YouTube does not provide translations for these strings. + * That's why it's been added to RVX resources. + * This string also exists in RVX resources, so it must be removed to avoid being duplicated. + */ + removeStringsElements( + arrayOf("values"), + arrayOf( + "accessibility_settings_edu_opt_in_text", + "accessibility_settings_edu_opt_out_text" + ) + ) + + /** + * copy arrays, strings and preference + */ + arrayOf( + "arrays.xml", + "dimens.xml", + "strings.xml", + "styles.xml" + ).forEach { xmlFile -> + copyXmlNode("youtube/settings/host", "values/$xmlFile", "resources") + } + + arrayOf( + ResourceGroup( + "drawable", + "revanced_cursor.xml", + ), + ResourceGroup( + "layout", + "revanced_settings_preferences_category.xml", + "revanced_settings_with_toolbar.xml", + ), + ResourceGroup( + "xml", + "revanced_prefs.xml", + ) + ).forEach { resourceGroup -> + copyResources("youtube/settings", resourceGroup) + } + + /** + * initialize ReVanced Extended Settings + */ + ResourceUtils.addPreferenceFragment( + "revanced_extended_settings", + insertKey + ) + + /** + * remove ReVanced Extended Settings divider + */ + arrayOf("Theme.YouTube.Settings", "Theme.YouTube.Settings.Dark").forEach { themeName -> + document("res/values/styles.xml").use { document -> + with(document) { + val resourcesNode = getElementsByTagName("resources").item(0) as Element + + val newElement: Element = createElement("item") + newElement.setAttribute("name", "android:listDivider") + + for (i in 0 until resourcesNode.childNodes.length) { + val node = resourcesNode.childNodes.item(i) as? Element ?: continue + + if (node.getAttribute("name") == themeName) { + newElement.appendChild(createTextNode("@null")) + + node.appendChild(newElement) + } + } + } + } + } + + /** + * set revanced-patches version + */ + val patchManifest = object {}.javaClass.classLoader.getResources("META-INF/MANIFEST.MF") + while (patchManifest.hasMoreElements()) + ResourceUtils.updatePatchStatusSettings( + "ReVanced Patches", + Manifest(patchManifest.nextElement().openStream()) + .mainAttributes + .getValue("Version") + "" + ) + } + + finalize { + /** + * change RVX settings menu name + * since it must be invoked after the Translations patch, it must be the last in the order. + */ + if (customName != DEFAULT_LABEL) { + removeStringsElements( + arrayOf("revanced_extended_settings_title") + ) + document("res/values/strings.xml").use { document -> + mapOf( + "revanced_extended_settings_title" to customName + ).forEach { (k, v) -> + val stringElement = document.createElement("string") + + stringElement.setAttribute("name", k) + stringElement.textContent = v + + document.getElementsByTagName("resources").item(0) + .appendChild(stringElement) + } + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/Fingerprints.kt new file mode 100644 index 0000000000..4a741bf40e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/Fingerprints.kt @@ -0,0 +1,37 @@ +package app.revanced.patches.youtube.utils.sponsorblock + +import app.revanced.patches.youtube.utils.extension.Constants.EXTENSION_PATH +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionReversed +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val rectangleFieldInvalidatorFingerprint = legacyFingerprint( + name = "rectangleFieldInvalidatorFingerprint", + returnType = "V", + parameters = emptyList(), + customFingerprint = { method, _ -> + indexOfInvalidateInstruction(method) >= 0 + } +) + +internal val segmentPlaybackControllerFingerprint = legacyFingerprint( + name = "segmentPlaybackControllerFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("Ljava/lang/Object;"), + opcodes = listOf(Opcode.CONST_STRING), + customFingerprint = { method, _ -> + method.definingClass == "$EXTENSION_PATH/sponsorblock/SegmentPlaybackController;" + && method.name == "setSponsorBarRect" + } +) + +internal fun indexOfInvalidateInstruction(method: Method) = + method.indexOfFirstInstructionReversed { + getReference()?.name == "invalidate" + } diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/SponsorBlockPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/SponsorBlockPatch.kt new file mode 100644 index 0000000000..2c1692bb39 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/SponsorBlockPatch.kt @@ -0,0 +1,282 @@ +package app.revanced.patches.youtube.utils.sponsorblock + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.booleanOption +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.EXTENSION_PATH +import app.revanced.patches.youtube.utils.extension.Constants.PATCH_STATUS_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.patch.PatchList.SPONSORBLOCK +import app.revanced.patches.youtube.utils.playercontrols.addTopControl +import app.revanced.patches.youtube.utils.playercontrols.hookTopControlButton +import app.revanced.patches.youtube.utils.playercontrols.playerControlsPatch +import app.revanced.patches.youtube.utils.resourceid.insetOverlayViewLayout +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.seekbarFingerprint +import app.revanced.patches.youtube.utils.seekbarOnDrawFingerprint +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.patches.youtube.utils.totalTimeFingerprint +import app.revanced.patches.youtube.utils.youtubeControlsOverlayFingerprint +import app.revanced.patches.youtube.video.information.hookVideoInformation +import app.revanced.patches.youtube.video.information.onCreateHook +import app.revanced.patches.youtube.video.information.videoEndMethod +import app.revanced.patches.youtube.video.information.videoInformationPatch +import app.revanced.patches.youtube.video.information.videoTimeHook +import app.revanced.util.ResourceGroup +import app.revanced.util.copyResources +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import app.revanced.util.updatePatchStatus +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private const val EXTENSION_SPONSOR_BLOCK_PATH = + "$EXTENSION_PATH/sponsorblock" + +private const val EXTENSION_SPONSOR_BLOCK_UI_PATH = + "$EXTENSION_SPONSOR_BLOCK_PATH/ui" + +private const val EXTENSION_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR = + "$EXTENSION_SPONSOR_BLOCK_PATH/SegmentPlaybackController;" + +private const val EXTENSION_SPONSOR_BLOCK_VIEW_CONTROLLER_CLASS_DESCRIPTOR = + "$EXTENSION_SPONSOR_BLOCK_UI_PATH/SponsorBlockViewController;" + +val sponsorBlockBytecodePatch = bytecodePatch( + description = "sponsorBlockBytecodePatch" +) { + dependsOn( + sharedResourceIdPatch, + videoInformationPatch, + ) + + execute { + // Hook the video time method + videoTimeHook( + EXTENSION_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR, + "setVideoTime" + ) + // Initialize the player controller + onCreateHook( + EXTENSION_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR, + "initialize" + ) + + + seekbarOnDrawFingerprint.methodOrThrow(seekbarFingerprint).apply { + // Get left and right of seekbar rectangle + val moveObjectIndex = indexOfFirstInstructionOrThrow(Opcode.MOVE_OBJECT_FROM16) + + addInstruction( + moveObjectIndex + 1, + "invoke-static/range {p0 .. p0}, " + + "$EXTENSION_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR->setSponsorBarRect(Ljava/lang/Object;)V" + ) + + // Set seekbar thickness + val roundIndex = indexOfFirstInstructionOrThrow { + getReference()?.name == "round" + } + 1 + val roundRegister = getInstruction(roundIndex).registerA + + addInstruction( + roundIndex + 1, + "invoke-static {v$roundRegister}, " + + "$EXTENSION_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR->setSponsorBarThickness(I)V" + ) + + // Draw segment + val drawCircleIndex = indexOfFirstInstructionReversedOrThrow { + getReference()?.name == "drawCircle" + } + val drawCircleInstruction = getInstruction(drawCircleIndex) + addInstruction( + drawCircleIndex, + "invoke-static {v${drawCircleInstruction.registerC}, v${drawCircleInstruction.registerE}}, " + + "$EXTENSION_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR->drawSponsorTimeBars(Landroid/graphics/Canvas;F)V" + ) + } + + // Voting & Shield button + setOf("CreateSegmentButtonController;", "VotingButtonController;").forEach { className -> + hookTopControlButton("$EXTENSION_SPONSOR_BLOCK_UI_PATH/$className") + } + + // Append timestamp + totalTimeFingerprint.methodOrThrow().apply { + val targetIndex = indexOfFirstInstructionOrThrow { + getReference()?.name == "getString" + } + 1 + val targetRegister = getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, """ + invoke-static {v$targetRegister}, $EXTENSION_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR->appendTimeWithoutSegments(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$targetRegister + """ + ) + } + + // Initialize the SponsorBlock view + youtubeControlsOverlayFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = + indexOfFirstLiteralInstructionOrThrow(insetOverlayViewLayout) + val checkCastIndex = indexOfFirstInstructionOrThrow(targetIndex, Opcode.CHECK_CAST) + val targetRegister = + getInstruction(checkCastIndex).registerA + + addInstruction( + checkCastIndex + 1, + "invoke-static {v$targetRegister}, $EXTENSION_SPONSOR_BLOCK_VIEW_CONTROLLER_CLASS_DESCRIPTOR->initialize(Landroid/view/ViewGroup;)V" + ) + } + } + + // Replace strings + rectangleFieldInvalidatorFingerprint.methodOrThrow(seekbarFingerprint).apply { + val invalidateIndex = indexOfInvalidateInstruction(this) + val rectangleIndex = indexOfFirstInstructionReversedOrThrow(invalidateIndex + 1) { + getReference()?.type == "Landroid/graphics/Rect;" + } + val rectangleFieldName = + (getInstruction(rectangleIndex).reference as FieldReference).name + + segmentPlaybackControllerFingerprint.matchOrThrow().let { + it.method.apply { + val replaceIndex = it.patternMatch!!.startIndex + val replaceRegister = + getInstruction(replaceIndex).registerA + + replaceInstruction( + replaceIndex, + "const-string v$replaceRegister, \"$rectangleFieldName\"" + ) + } + } + } + + // The vote and create segment buttons automatically change their visibility when appropriate, + // but if buttons are showing when the end of the video is reached then they will not automatically hide. + // Add a hook to forcefully hide when the end of the video is reached. + videoEndMethod.addInstruction( + 0, + "invoke-static {}, $EXTENSION_SPONSOR_BLOCK_VIEW_CONTROLLER_CLASS_DESCRIPTOR->endOfVideoReached()V" + ) + + // Set current video id + hookVideoInformation("$EXTENSION_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR->newVideoStarted(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V") + + updatePatchStatus(PATCH_STATUS_CLASS_DESCRIPTOR, "SponsorBlock") + } +} + +@Suppress("unused") +val sponsorBlockPatch = resourcePatch( + SPONSORBLOCK.title, + SPONSORBLOCK.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + playerControlsPatch, + sponsorBlockBytecodePatch, + settingsPatch + ) + + val outlineIcon by booleanOption( + key = "outlineIcon", + default = false, + title = "Outline icons", + description = "Apply the outline icon.", + required = true + ) + + execute { + /** + * merge SponsorBlock drawables to main drawables + */ + arrayOf( + ResourceGroup( + "layout", + "revanced_sb_inline_sponsor_overlay.xml", + "revanced_sb_skip_sponsor_button.xml" + ), + ResourceGroup( + "drawable", + "revanced_sb_new_segment_background.xml", + "revanced_sb_skip_sponsor_button_background.xml" + ) + ).forEach { resourceGroup -> + copyResources("youtube/sponsorblock/shared", resourceGroup) + } + + if (outlineIcon == true) { + arrayOf( + ResourceGroup( + "layout", + "revanced_sb_new_segment.xml" + ), + ResourceGroup( + "drawable", + "revanced_sb_adjust.xml", + "revanced_sb_backward.xml", + "revanced_sb_compare.xml", + "revanced_sb_edit.xml", + "revanced_sb_forward.xml", + "revanced_sb_logo.xml", + "revanced_sb_publish.xml", + "revanced_sb_voting.xml" + ) + ).forEach { resourceGroup -> + copyResources("youtube/sponsorblock/outline", resourceGroup) + } + } else { + arrayOf( + ResourceGroup( + "layout", + "revanced_sb_new_segment.xml" + ), + ResourceGroup( + "drawable", + "revanced_sb_adjust.xml", + "revanced_sb_compare.xml", + "revanced_sb_edit.xml", + "revanced_sb_logo.xml", + "revanced_sb_publish.xml", + "revanced_sb_voting.xml" + ) + ).forEach { resourceGroup -> + copyResources("youtube/sponsorblock/default", resourceGroup) + } + } + + /** + * merge xml nodes from the host to their real xml files + */ + addTopControl("youtube/sponsorblock") + + /** + * Add settings + */ + addPreference( + arrayOf( + "PREFERENCE_SCREEN: SPONSOR_BLOCK" + ), + SPONSORBLOCK + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/toolbar/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/toolbar/Fingerprints.kt new file mode 100644 index 0000000000..dfaaf14a01 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/toolbar/Fingerprints.kt @@ -0,0 +1,33 @@ +package app.revanced.patches.youtube.utils.toolbar + +import app.revanced.patches.youtube.utils.extension.Constants.UTILS_PATH +import app.revanced.patches.youtube.utils.resourceid.menuItemView +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val toolBarButtonFingerprint = legacyFingerprint( + name = "toolBarButtonFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Landroid/view/MenuItem;"), + opcodes = listOf( + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT, + Opcode.IGET_OBJECT, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL + ), + literals = listOf(menuItemView), +) +internal val toolBarPatchFingerprint = legacyFingerprint( + name = "toolBarPatchFingerprint", + accessFlags = AccessFlags.PRIVATE or AccessFlags.STATIC, + customFingerprint = { method, _ -> + method.definingClass == "$UTILS_PATH/ToolBarPatch;" + && method.name == "hookToolBar" + } +) + + diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/toolbar/ToolBarHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/toolbar/ToolBarHookPatch.kt new file mode 100644 index 0000000000..250bd839f6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/toolbar/ToolBarHookPatch.kt @@ -0,0 +1,61 @@ +package app.revanced.patches.youtube.utils.toolbar + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patches.youtube.utils.extension.Constants.UTILS_PATH +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$UTILS_PATH/ToolBarPatch;" + +private lateinit var toolbarMethod: MutableMethod + +val toolBarHookPatch = bytecodePatch( + description = "toolBarHookPatch" +) { + dependsOn(sharedResourceIdPatch) + + execute { + toolBarButtonFingerprint.matchOrThrow().let { + it.method.apply { + val replaceIndex = it.patternMatch!!.startIndex + val freeIndex = it.patternMatch!!.endIndex - 1 + + val replaceReference = getInstruction(replaceIndex).reference + val replaceRegister = + getInstruction(replaceIndex).registerC + val enumRegister = getInstruction(replaceIndex).registerD + val freeRegister = getInstruction(freeIndex).registerA + + val imageViewIndex = replaceIndex + 2 + val imageViewReference = + getInstruction(imageViewIndex).reference + + addInstructions( + replaceIndex + 1, """ + iget-object v$freeRegister, p0, $imageViewReference + invoke-static {v$enumRegister, v$freeRegister}, $EXTENSION_CLASS_DESCRIPTOR->hookToolBar(Ljava/lang/Enum;Landroid/widget/ImageView;)V + invoke-interface {v$replaceRegister, v$enumRegister}, $replaceReference + """ + ) + removeInstruction(replaceIndex) + } + } + + toolbarMethod = toolBarPatchFingerprint.methodOrThrow() + } +} + +internal fun hookToolBar(descriptor: String) = + toolbarMethod.addInstructions( + 0, + "invoke-static {p0, p1}, $descriptor(Ljava/lang/String;Landroid/view/View;)V" + ) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/trackingurlhook/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/trackingurlhook/Fingerprints.kt new file mode 100644 index 0000000000..1da109cbef --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/trackingurlhook/Fingerprints.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.youtube.utils.trackingurlhook + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val trackingUrlModelFingerprint = legacyFingerprint( + name = "trackingUrlModelFingerprint", + returnType = "Landroid/net/Uri;", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + ), + customFingerprint = { method, _ -> + method.definingClass == "Lcom/google/android/libraries/youtube/innertube/model/player/TrackingUrlModel;" + } +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/trackingurlhook/TrackingUrlHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/trackingurlhook/TrackingUrlHookPatch.kt new file mode 100644 index 0000000000..cd9761c7be --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/trackingurlhook/TrackingUrlHookPatch.kt @@ -0,0 +1,46 @@ +package app.revanced.patches.youtube.utils.trackingurlhook + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private lateinit var trackingUrlMethod: MutableMethod + +val trackingUrlHookPatch = bytecodePatch( + description = "trackingUrlHookPatch" +) { + execute { + trackingUrlMethod = trackingUrlModelFingerprint.methodOrThrow() + } +} + +internal fun hookTrackingUrl( + descriptor: String +) = trackingUrlMethod.apply { + val targetIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_STATIC && + getReference()?.name == "parse" + } + 1 + val targetRegister = getInstruction(targetIndex).registerA + + var smaliInstruction = "invoke-static {v$targetRegister}, $descriptor" + + if (!descriptor.endsWith("V")) { + smaliInstruction += """ + move-result-object v$targetRegister + + """.trimIndent() + } + + addInstructions( + targetIndex + 1, + smaliInstruction + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/information/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/information/Fingerprints.kt new file mode 100644 index 0000000000..9c2e47803e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/information/Fingerprints.kt @@ -0,0 +1,189 @@ +package app.revanced.patches.youtube.video.information + +import app.revanced.patches.youtube.utils.PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.resourceid.notificationBigPictureIconWidth +import app.revanced.patches.youtube.utils.resourceid.qualityAuto +import app.revanced.util.containsLiteralInstruction +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val channelIdFingerprint = legacyFingerprint( + name = "channelIdFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("Ljava/lang/Object;"), + strings = listOf("com.google.android.apps.youtube.mdx.watch.LAST_MEALBAR_PROMOTED_LIVE_FEED_CHANNELS") +) + +internal val channelNameFingerprint = legacyFingerprint( + name = "channelNameFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + strings = listOf( + "setMetadata may only be called once", + "Person", + ) +) + +internal val onPlaybackSpeedItemClickFingerprint = legacyFingerprint( + name = "onPlaybackSpeedItemClickFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + returnType = "V", + parameters = listOf("Landroid/widget/AdapterView;", "Landroid/view/View;", "I", "J"), + customFingerprint = { method, _ -> + method.name == "onItemClick" && + method.indexOfFirstInstruction { + opcode == Opcode.IGET_OBJECT && + getReference()?.type == PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR + } >= 0 + } +) + +internal val playbackInitializationFingerprint = legacyFingerprint( + name = "playbackInitializationFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + strings = listOf("play() called when the player wasn\'t loaded."), + customFingerprint = { method, _ -> + indexOfPlayerResponseModelDirectInstruction(method) >= 0 + } +) + +internal fun indexOfPlayerResponseModelDirectInstruction(method: Method) = + method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_DIRECT && + getReference()?.returnType == PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR + } + +internal val playbackSpeedClassFingerprint = legacyFingerprint( + name = "playbackSpeedClassFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + parameters = listOf("L"), + opcodes = listOf(Opcode.RETURN_OBJECT), + strings = listOf("PLAYBACK_RATE_MENU_BOTTOM_SHEET_FRAGMENT") +) + +internal val playerControllerSetTimeReferenceFingerprint = legacyFingerprint( + name = "playerControllerSetTimeReferenceFingerprint", + opcodes = listOf( + Opcode.INVOKE_DIRECT_RANGE, + Opcode.IGET_OBJECT + ), + strings = listOf("Media progress reported outside media playback: ") +) + +internal val seekRelativeFingerprint = legacyFingerprint( + name = "seekRelativeFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + // returnType = "Z", ~ YouTube 19.39.39 + // returnType = "V", YouTube 19.40.xx ~ + parameters = listOf("J", "L"), + opcodes = listOf( + Opcode.ADD_LONG_2ADDR, + Opcode.INVOKE_VIRTUAL, + ) +) + +internal val videoIdFingerprint = legacyFingerprint( + name = "videoIdFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + strings = listOf("Failed to download video (IllegalStateException): %s") +) + +/** + * Renamed from VideoIdWithoutShortsFingerprint + */ +internal val videoIdFingerprintBackgroundPlay = legacyFingerprint( + name = "videoIdFingerprintBackgroundPlay", + returnType = "V", + parameters = listOf("L"), + opcodes = listOf( + Opcode.IF_EQZ, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IPUT_OBJECT, + Opcode.MONITOR_EXIT, + Opcode.RETURN_VOID, + Opcode.MONITOR_EXIT, + Opcode.RETURN_VOID + ), + customFingerprint = { method, classDef -> + method.name == "l" && + classDef.methods.count() == 17 && + method.implementation != null && + indexOfPlayerResponseModelInterfaceInstruction(method) >= 0 + } +) + +fun indexOfPlayerResponseModelInterfaceInstruction(methodDef: Method) = + methodDef.indexOfFirstInstruction { + opcode == Opcode.INVOKE_INTERFACE && + getReference()?.definingClass == PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR + } + +/** + * This fingerprint is compatible with all versions of YouTube starting from v18.29.38 to supported versions. + * This method is invoked only in Shorts. + * Accurate video information is invoked even when the user moves Shorts upward or downward. + */ +internal val videoIdFingerprintShorts = legacyFingerprint( + name = "videoIdFingerprintShorts", + returnType = "V", + parameters = listOf(PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR), + opcodes = listOf( + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT + ), + customFingerprint = custom@{ method, _ -> + if (method.containsLiteralInstruction(45365621L)) + return@custom true + + method.indexOfFirstInstruction { + getReference()?.name == "reelWatchEndpoint" + } >= 0 + } +) + +internal val videoQualityListFingerprint = legacyFingerprint( + name = "videoQualityListFingerprint", + returnType = "V", + parameters = listOf("L"), + opcodes = listOf( + Opcode.INVOKE_INTERFACE, + Opcode.RETURN_VOID + ), + literals = listOf(qualityAuto), +) + +internal val videoQualityTextFingerprint = legacyFingerprint( + name = "videoQualityTextFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("[L", "I", "Z"), + opcodes = listOf( + Opcode.IF_GE, + Opcode.AGET_OBJECT, + Opcode.IGET_OBJECT + ), + strings = listOf("menu_item_video_quality") +) + +internal val videoTitleFingerprint = legacyFingerprint( + name = "videoTitleFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = emptyList(), + literals = listOf(notificationBigPictureIconWidth), +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/information/VideoInformationPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/information/VideoInformationPatch.kt new file mode 100644 index 0000000000..e149cc6098 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/information/VideoInformationPatch.kt @@ -0,0 +1,647 @@ +package app.revanced.patches.youtube.video.information + +import app.revanced.patcher.Fingerprint +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableClass +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable +import app.revanced.patcher.util.smali.toInstructions +import app.revanced.patches.shared.mdxPlayerDirectorSetVideoStageFingerprint +import app.revanced.patches.shared.videoLengthFingerprint +import app.revanced.patches.youtube.utils.PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.extension.Constants.SHARED_PATH +import app.revanced.patches.youtube.utils.playertype.playerTypeHookPatch +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.videoEndFingerprint +import app.revanced.patches.youtube.video.playerresponse.Hook +import app.revanced.patches.youtube.video.playerresponse.addPlayerResponseMethodHook +import app.revanced.patches.youtube.video.playerresponse.playerResponseMethodHookPatch +import app.revanced.patches.youtube.video.videoid.hookPlayerResponseVideoId +import app.revanced.patches.youtube.video.videoid.hookVideoId +import app.revanced.patches.youtube.video.videoid.videoIdPatch +import app.revanced.util.addStaticFieldToExtension +import app.revanced.util.cloneMutable +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.mutableClassOrThrow +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import app.revanced.util.indexOfFirstLiteralInstructionOrThrow +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.immutable.ImmutableMethod +import com.android.tools.smali.dexlib2.immutable.ImmutableMethodImplementation +import com.android.tools.smali.dexlib2.immutable.ImmutableMethodParameter + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$SHARED_PATH/VideoInformation;" + +private const val REGISTER_PLAYER_RESPONSE_MODEL = 8 + +private const val REGISTER_CHANNEL_ID = 0 +private const val REGISTER_CHANNEL_NAME = 1 +private const val REGISTER_VIDEO_ID = 2 +private const val REGISTER_VIDEO_TITLE = 3 +private const val REGISTER_VIDEO_LENGTH = 4 + +@Suppress("unused") +private const val REGISTER_VIDEO_LENGTH_DUMMY = 5 +private const val REGISTER_VIDEO_IS_LIVE = 6 + +private lateinit var channelIdMethodCall: String +private lateinit var channelNameMethodCall: String +private lateinit var videoIdMethodCall: String +private lateinit var videoTitleMethodCall: String +private lateinit var videoLengthMethodCall: String +private lateinit var videoIsLiveMethodCall: String + +private lateinit var videoInformationMethod: MutableMethod +private lateinit var backgroundVideoInformationMethod: MutableMethod +private lateinit var shortsVideoInformationMethod: MutableMethod + +/** + * Used in [videoEndFingerprint] and [mdxPlayerDirectorSetVideoStageFingerprint]. + * Since both classes are inherited from the same class, + * [videoEndFingerprint] and [mdxPlayerDirectorSetVideoStageFingerprint] always have the same [seekSourceEnumType] and [seekSourceMethodName]. + */ +private var seekSourceEnumType = "" +private var seekSourceMethodName = "" +private var seekRelativeSourceMethodName = "" +private var cloneSeekRelativeSourceMethod = false + +private lateinit var playerConstructorMethod: MutableMethod +private var playerConstructorInsertIndex = -1 + +private lateinit var mdxConstructorMethod: MutableMethod +private var mdxConstructorInsertIndex = -1 + +private lateinit var videoTimeConstructorMethod: MutableMethod +private var videoTimeConstructorInsertIndex = 2 + +// Used by other patches. +internal lateinit var speedSelectionInsertMethod: MutableMethod +internal lateinit var videoEndMethod: MutableMethod + +val videoInformationPatch = bytecodePatch( + description = "videoInformationPatch", +) { + dependsOn( + playerResponseMethodHookPatch, + playerTypeHookPatch, + sharedResourceIdPatch, + videoIdPatch + ) + + execute { + fun cloneSeekRelativeSourceMethod(mutableClass: MutableClass) { + if (!cloneSeekRelativeSourceMethod) return + + val methods = mutableClass.methods + + methods.find { method -> + method.name == seekRelativeSourceMethodName + }?.apply { + methods.add( + cloneMutable( + returnType = "Z" + ).apply { + val lastIndex = implementation!!.instructions.lastIndex + + removeInstruction(lastIndex) + addInstructions( + lastIndex, """ + move-result p1 + return p1 + """ + ) + } + ) + } + } + + fun addSeekInterfaceMethods( + targetClass: MutableClass, + targetMethod: MutableMethod, + seekMethodName: String, + methodName: String, + fieldMethodName: String, + fieldName: String + ) { + targetMethod.apply { + targetClass.methods.add( + ImmutableMethod( + definingClass, + fieldMethodName, + listOf(ImmutableMethodParameter("J", annotations, "time")), + "Z", + AccessFlags.PUBLIC or AccessFlags.FINAL, + annotations, + null, + ImmutableMethodImplementation( + 4, """ + # first enum (field a) is SEEK_SOURCE_UNKNOWN + sget-object v0, $seekSourceEnumType->a:$seekSourceEnumType + invoke-virtual {p0, p1, p2, v0}, $definingClass->$seekMethodName(J$seekSourceEnumType)Z + move-result p1 + return p1 + """.toInstructions(), + null, + null + ) + ).toMutable() + ) + + val smaliInstructions = + """ + if-eqz v0, :ignore + invoke-virtual {v0, p0, p1}, $definingClass->$fieldMethodName(J)Z + move-result v0 + return v0 + :ignore + const/4 v0, 0x0 + return v0 + """ + + addStaticFieldToExtension( + EXTENSION_CLASS_DESCRIPTOR, + methodName, + fieldName, + definingClass, + smaliInstructions + ) + } + } + + fun Pair.getPlayerResponseInstruction(returnType: String): String { + methodOrThrow().apply { + val targetReference = getInstruction( + indexOfFirstInstructionOrThrow { + val reference = getReference() + (opcode == Opcode.INVOKE_INTERFACE_RANGE || opcode == Opcode.INVOKE_INTERFACE) && + reference?.definingClass == PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR && + reference.returnType == returnType + } + ).reference + + return "invoke-interface {v$REGISTER_PLAYER_RESPONSE_MODEL}, $targetReference" + } + } + + videoEndFingerprint.methodOrThrow().apply { + findMethodOrThrow(definingClass).let { + playerConstructorMethod = it + playerConstructorInsertIndex = it.indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_DIRECT && getReference()?.name == "" + } + 1 + } + + // hook the player controller for use through extension + onCreateHook(EXTENSION_CLASS_DESCRIPTOR, "initialize") + + seekSourceEnumType = parameterTypes[1].toString() + seekSourceMethodName = name + + seekRelativeFingerprint.methodOrThrow(videoEndFingerprint).also { method -> + seekRelativeSourceMethodName = method.name + cloneSeekRelativeSourceMethod = method.returnType == "V" + } + + cloneSeekRelativeSourceMethod(videoEndFingerprint.mutableClassOrThrow()) + + // Create extension interface methods. + addSeekInterfaceMethods( + videoEndFingerprint.mutableClassOrThrow(), + this, + seekSourceMethodName, + "overrideVideoTime", + "seekTo", + "videoInformationClass" + ) + addSeekInterfaceMethods( + seekRelativeFingerprint.mutableClassOrThrow(), + this, + seekRelativeSourceMethodName, + "overrideVideoTimeRelative", + "seekToRelative", + "videoInformationClass" + ) + + val literalIndex = indexOfFirstLiteralInstructionOrThrow(45368273L) + val walkerIndex = + indexOfFirstInstructionReversedOrThrow( + literalIndex, + Opcode.INVOKE_VIRTUAL_RANGE + ) + + videoEndMethod = getWalkerMethod(walkerIndex) + } + + mdxPlayerDirectorSetVideoStageFingerprint.methodOrThrow().apply { + findMethodOrThrow(definingClass).let { + mdxConstructorMethod = it + mdxConstructorInsertIndex = it.indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_DIRECT && getReference()?.name == "" + } + 1 + } + + // hook the MDX director for use through extension + onCreateHookMdx(EXTENSION_CLASS_DESCRIPTOR, "initializeMdx") + + cloneSeekRelativeSourceMethod(mdxPlayerDirectorSetVideoStageFingerprint.mutableClassOrThrow()) + + // Create extension interface methods. + addSeekInterfaceMethods( + mdxPlayerDirectorSetVideoStageFingerprint.mutableClassOrThrow(), + this, + seekSourceMethodName, + "overrideMDXVideoTime", + "seekTo", + "videoInformationMDXClass" + ) + addSeekInterfaceMethods( + mdxPlayerDirectorSetVideoStageFingerprint.mutableClassOrThrow(), + this, + seekRelativeSourceMethodName, + "overrideMDXVideoTimeRelative", + "seekToRelative", + "videoInformationMDXClass" + ) + } + + /** + * Set current video information + */ + channelIdMethodCall = + channelIdFingerprint.getPlayerResponseInstruction("Ljava/lang/String;") + channelNameMethodCall = + channelNameFingerprint.getPlayerResponseInstruction("Ljava/lang/String;") + videoIdMethodCall = videoIdFingerprint.getPlayerResponseInstruction("Ljava/lang/String;") + videoTitleMethodCall = + videoTitleFingerprint.getPlayerResponseInstruction("Ljava/lang/String;") + videoLengthMethodCall = videoLengthFingerprint.getPlayerResponseInstruction("J") + videoIsLiveMethodCall = channelIdFingerprint.getPlayerResponseInstruction("Z") + + playbackInitializationFingerprint.matchOrThrow().let { + it.method.apply { + val targetIndex = indexOfPlayerResponseModelDirectInstruction(this) + 1 + val targetRegister = getInstruction(targetIndex).registerA + + addInstruction( + targetIndex + 1, + "invoke-direct {p0, v$targetRegister}, $definingClass->setVideoInformation($PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR)V" + ) + + videoInformationMethod = getVideoInformationMethod() + it.classDef.methods.add(videoInformationMethod) + + hookVideoInformation("$EXTENSION_CLASS_DESCRIPTOR->setVideoInformation(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V") + } + } + + videoIdFingerprintBackgroundPlay.matchOrThrow().let { + it.method.apply { + val targetIndex = indexOfPlayerResponseModelInterfaceInstruction(this) + val targetRegister = getInstruction(targetIndex).registerC + + addInstruction( + targetIndex, + "invoke-direct {p0, v$targetRegister}, $definingClass->setVideoInformation($PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR)V" + ) + + backgroundVideoInformationMethod = getVideoInformationMethod() + it.classDef.methods.add(backgroundVideoInformationMethod) + } + } + + videoIdFingerprintShorts.matchOrThrow().let { + it.method.apply { + val targetIndex = indexOfPlayerResponseModelInterfaceInstruction(this) + val targetRegister = getInstruction(targetIndex).registerC + + addInstruction( + targetIndex, + "invoke-direct {p0, v$targetRegister}, $definingClass->setVideoInformation($PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR)V" + ) + + shortsVideoInformationMethod = getVideoInformationMethod() + it.classDef.methods.add(shortsVideoInformationMethod) + } + } + + /** + * Set current video time method + */ + playerControllerSetTimeReferenceFingerprint.matchOrThrow().let { + videoTimeConstructorMethod = + it.getWalkerMethod(it.patternMatch!!.startIndex) + } + + /** + * Set current video time + */ + videoTimeHook(EXTENSION_CLASS_DESCRIPTOR, "setVideoTime") + + /** + * Set current video id + */ + hookVideoId("$EXTENSION_CLASS_DESCRIPTOR->setVideoId(Ljava/lang/String;)V") + hookPlayerResponseVideoId( + "$EXTENSION_CLASS_DESCRIPTOR->setPlayerResponseVideoId(Ljava/lang/String;Z)V" + ) + // Call before any other video id hooks, + // so they can use VideoInformation and check if the video id is for a Short. + addPlayerResponseMethodHook( + Hook.PlayerParameterBeforeVideoId( + "$EXTENSION_CLASS_DESCRIPTOR->newPlayerResponseParameter(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)Ljava/lang/String;" + ) + ) + + /** + * Hook current playback speed + */ + onPlaybackSpeedItemClickFingerprint.matchOrThrow().let { + it.method.apply { + speedSelectionInsertMethod = this + val speedSelectionValueInstructionIndex = + indexOfFirstInstructionOrThrow(Opcode.IGET) + + val setPlaybackSpeedContainerClassFieldIndex = + indexOfFirstInstructionReversedOrThrow( + speedSelectionValueInstructionIndex, + Opcode.IGET_OBJECT + ) + val setPlaybackSpeedContainerClassFieldReference = + getInstruction(setPlaybackSpeedContainerClassFieldIndex).reference.toString() + + val setPlaybackSpeedClassFieldReference = + getInstruction(speedSelectionValueInstructionIndex + 1).reference.toString() + val setPlaybackSpeedMethodReference = + getInstruction(speedSelectionValueInstructionIndex + 2).reference.toString() + + // add override playback speed method + it.classDef.methods.add( + ImmutableMethod( + definingClass, + "overridePlaybackSpeed", + listOf(ImmutableMethodParameter("F", annotations, null)), + "V", + AccessFlags.PUBLIC or AccessFlags.PUBLIC, + annotations, + null, + ImmutableMethodImplementation( + 4, """ + const/4 v0, 0x0 + cmpg-float v0, v3, v0 + if-lez v0, :ignore + + # Get the container class field. + iget-object v0, v2, $setPlaybackSpeedContainerClassFieldReference + + # Get the field from its class. + iget-object v1, v0, $setPlaybackSpeedClassFieldReference + + # Invoke setPlaybackSpeed on that class. + invoke-virtual {v1, v3}, $setPlaybackSpeedMethodReference + + :ignore + return-void + """.toInstructions(), null, null + ) + ).toMutable() + ) + + // set current playback speed + val walkerMethod = getWalkerMethod(speedSelectionValueInstructionIndex + 2) + walkerMethod.apply { + addInstruction( + this.implementation!!.instructions.size - 1, + "invoke-static { p1 }, $EXTENSION_CLASS_DESCRIPTOR->setPlaybackSpeed(F)V" + ) + } + } + } + + playbackSpeedClassFingerprint.matchOrThrow().let { result -> + result.method.apply { + val index = result.patternMatch!!.endIndex + val register = getInstruction(index).registerA + val playbackSpeedClass = this.returnType + + // set playback speed class + replaceInstruction( + index, + "sput-object v$register, $EXTENSION_CLASS_DESCRIPTOR->playbackSpeedClass:$playbackSpeedClass" + ) + addInstruction( + index + 1, + "return-object v$register" + ) + + val smaliInstructions = + """ + if-eqz v0, :ignore + invoke-virtual {v0, p0}, $playbackSpeedClass->overridePlaybackSpeed(F)V + :ignore + return-void + """ + + addStaticFieldToExtension( + EXTENSION_CLASS_DESCRIPTOR, + "overridePlaybackSpeed", + "playbackSpeedClass", + playbackSpeedClass, + smaliInstructions, + false + ) + } + } + + /** + * Hook current video quality + */ + videoQualityListFingerprint.matchOrThrow().let { + val overrideMethod = + it.classDef.methods.find { method -> method.parameterTypes.first() == "I" } + + val videoQualityClass = it.method.definingClass + val videoQualityMethodName = overrideMethod?.name + ?: throw PatchException("Failed to find hook method") + + // set video quality array + it.method.apply { + val listIndex = it.patternMatch!!.startIndex + val listRegister = getInstruction(listIndex).registerD + + addInstruction( + listIndex, + "invoke-static {v$listRegister}, $EXTENSION_CLASS_DESCRIPTOR->setVideoQualityList([Ljava/lang/Object;)V" + ) + } + + val smaliInstructions = + """ + if-eqz v0, :ignore + invoke-virtual {v0, p0}, $videoQualityClass->$videoQualityMethodName(I)V + :ignore + return-void + """ + + addStaticFieldToExtension( + EXTENSION_CLASS_DESCRIPTOR, + "overrideVideoQuality", + "videoQualityClass", + videoQualityClass, + smaliInstructions + ) + } + + // set current video quality + videoQualityTextFingerprint.matchOrThrow().let { + it.method.apply { + val textIndex = it.patternMatch!!.endIndex + val textRegister = getInstruction(textIndex).registerA + + addInstruction( + textIndex + 1, + "invoke-static {v$textRegister}, $EXTENSION_CLASS_DESCRIPTOR->setVideoQuality(Ljava/lang/String;)V" + ) + } + } + } +} + +private fun MutableMethod.getVideoInformationMethod(): MutableMethod = + ImmutableMethod( + definingClass, + "setVideoInformation", + listOf( + ImmutableMethodParameter( + PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR, + annotations, + null + ) + ), + "V", + AccessFlags.PRIVATE or AccessFlags.FINAL, + annotations, + null, + ImmutableMethodImplementation( + REGISTER_PLAYER_RESPONSE_MODEL + 1, """ + $channelIdMethodCall + move-result-object v$REGISTER_CHANNEL_ID + $channelNameMethodCall + move-result-object v$REGISTER_CHANNEL_NAME + $videoIdMethodCall + move-result-object v$REGISTER_VIDEO_ID + $videoTitleMethodCall + move-result-object v$REGISTER_VIDEO_TITLE + $videoLengthMethodCall + move-result-wide v$REGISTER_VIDEO_LENGTH + $videoIsLiveMethodCall + move-result v$REGISTER_VIDEO_IS_LIVE + return-void + """.toInstructions(), + null, + null + ) + ).toMutable() + +private fun MutableMethod.insert(insertIndex: Int, register: String, descriptor: String) = + addInstruction(insertIndex, "invoke-static/range { $register }, $descriptor") + +/** + * Hook the player controller. Called when a video is opened or the current video is changed. + * + * Note: This hook is called very early and is called before the video id, video time, video length, + * and many other data fields are set. + * + * @param targetMethodClass The descriptor for the class to invoke when the player controller is created. + * @param targetMethodName The name of the static method to invoke when the player controller is created. + */ +internal fun onCreateHook(targetMethodClass: String, targetMethodName: String) = + playerConstructorMethod.addInstruction( + playerConstructorInsertIndex++, + "invoke-static { }, $targetMethodClass->$targetMethodName()V" + ) + +/** + * Hook the MDX player director. Called when playing videos while casting to a big screen device. + * + * @param targetMethodClass The descriptor for the class to invoke when the player controller is created. + * @param targetMethodName The name of the static method to invoke when the player controller is created. + */ +internal fun onCreateHookMdx(targetMethodClass: String, targetMethodName: String) = + mdxConstructorMethod.addInstruction( + mdxConstructorInsertIndex++, + "invoke-static { }, $targetMethodClass->$targetMethodName()V" + ) + +/** + * Hook the video time. + * The hook is usually called once per second. + * + * @param targetMethodClass The descriptor for the static method to invoke when the player controller is created. + * @param targetMethodName The name of the static method to invoke when the player controller is created. + */ +internal fun videoTimeHook(targetMethodClass: String, targetMethodName: String) = + videoTimeConstructorMethod.addInstruction( + videoTimeConstructorInsertIndex++, + "invoke-static { p1, p2 }, $targetMethodClass->$targetMethodName(J)V" + ) + +/** + * This method is invoked on both regular videos and Shorts. + */ +internal fun hookVideoInformation(descriptor: String) = + videoInformationMethod.apply { + val index = implementation!!.instructions.lastIndex + + insert( + index, + "v$REGISTER_CHANNEL_ID .. v$REGISTER_VIDEO_IS_LIVE", + descriptor + ) + } + +/** + * This method is invoked only in regular videos. + */ +internal fun hookBackgroundPlayVideoInformation(descriptor: String) = + backgroundVideoInformationMethod.apply { + val index = implementation!!.instructions.lastIndex + + insert( + index, + "v$REGISTER_CHANNEL_ID .. v$REGISTER_VIDEO_IS_LIVE", + descriptor + ) + } + +/** + * This method is invoked only in shorts videos. + */ +internal fun hookShortsVideoInformation(descriptor: String) = + shortsVideoInformationMethod.apply { + val index = implementation!!.instructions.lastIndex + + insert( + index, + "v$REGISTER_CHANNEL_ID .. v$REGISTER_VIDEO_IS_LIVE", + descriptor + ) + } \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/playback/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/playback/Fingerprints.kt new file mode 100644 index 0000000000..28ede6d2b0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/playback/Fingerprints.kt @@ -0,0 +1,130 @@ +package app.revanced.patches.youtube.video.playback + +import app.revanced.util.containsLiteralInstruction +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val av1CodecFingerprint = legacyFingerprint( + name = "av1CodecFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, + returnType = "L", + strings = listOf("AtomParsers", "video/av01"), + customFingerprint = { method, _ -> + method.returnType != "Ljava/util/List;" && + method.containsLiteralInstruction(1987076931L) + } +) + +internal val byteBufferArrayFingerprint = legacyFingerprint( + name = "byteBufferArrayFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + returnType = "I", + parameters = emptyList(), + opcodes = listOf( + Opcode.SHL_INT_LIT8, + Opcode.SHL_INT_LIT8, + Opcode.OR_INT_2ADDR, + Opcode.SHL_INT_LIT8, + Opcode.OR_INT_2ADDR, + Opcode.OR_INT_2ADDR, + Opcode.RETURN + ) +) + +internal val byteBufferArrayParentFingerprint = legacyFingerprint( + name = "byteBufferArrayParentFingerprint", + accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, + returnType = "C", + parameters = listOf("Ljava/nio/charset/Charset;", "[C") +) + +internal val deviceDimensionsModelToStringFingerprint = legacyFingerprint( + name = "deviceDimensionsModelToStringFingerprint", + returnType = "L", + strings = listOf("minh.", ";maxh.") +) + +internal val hdrCapabilityFingerprint = legacyFingerprint( + name = "hdrCapabilityFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + strings = listOf( + "av1_profile_main_10_hdr_10_plus_supported", + "video/av01" + ) +) + +internal val playbackSpeedChangedFromRecyclerViewFingerprint = legacyFingerprint( + name = "playbackSpeedChangedFromRecyclerViewFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + opcodes = listOf( + Opcode.IGET_OBJECT, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IGET, + Opcode.INVOKE_VIRTUAL + ) +) + +internal val playbackSpeedInitializeFingerprint = legacyFingerprint( + name = "playbackSpeedInitializeFingerprint", + returnType = "F", + accessFlags = AccessFlags.PRIVATE or AccessFlags.STATIC, + parameters = listOf("L"), + opcodes = listOf( + Opcode.IGET, + Opcode.RETURN + ) +) + +internal val qualityChangedFromRecyclerViewFingerprint = legacyFingerprint( + name = "qualityChangedFromRecyclerViewFingerprint", + returnType = "L", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + opcodes = listOf( + Opcode.IGET, // Video resolution (human readable). + Opcode.IGET_OBJECT, + Opcode.IGET_BOOLEAN, + Opcode.IGET_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_DIRECT, + Opcode.IGET_OBJECT, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.GOTO, + Opcode.CONST_4, + Opcode.IF_NE, + Opcode.IGET_OBJECT, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IGET, + ) +) + +internal val qualitySetterFingerprint = legacyFingerprint( + name = "qualitySetterFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + strings = listOf("VIDEO_QUALITIES_MENU_BOTTOM_SHEET_FRAGMENT") +) + +internal val vp9CapabilityFingerprint = legacyFingerprint( + name = "vp9CapabilityFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + returnType = "Z", + strings = listOf( + "vp9_supported", + "video/x-vnd.on2.vp9" + ) +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/playback/VideoPlaybackPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/playback/VideoPlaybackPatch.kt new file mode 100644 index 0000000000..d081d3bde0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/playback/VideoPlaybackPatch.kt @@ -0,0 +1,337 @@ +package app.revanced.patches.youtube.video.playback + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.shared.customspeed.customPlaybackSpeedPatch +import app.revanced.patches.shared.litho.addLithoFilter +import app.revanced.patches.shared.litho.lithoFilterPatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.COMPONENTS_PATH +import app.revanced.patches.youtube.utils.extension.Constants.PATCH_STATUS_CLASS_DESCRIPTOR +import app.revanced.patches.youtube.utils.extension.Constants.VIDEO_PATH +import app.revanced.patches.youtube.utils.fix.shortsplayback.shortsPlaybackPatch +import app.revanced.patches.youtube.utils.flyoutmenu.flyoutMenuHookPatch +import app.revanced.patches.youtube.utils.patch.PatchList.VIDEO_PLAYBACK +import app.revanced.patches.youtube.utils.playertype.playerTypeHookPatch +import app.revanced.patches.youtube.utils.qualityMenuViewInflateFingerprint +import app.revanced.patches.youtube.utils.recyclerview.bottomSheetRecyclerViewHook +import app.revanced.patches.youtube.utils.recyclerview.bottomSheetRecyclerViewPatch +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.patches.youtube.utils.videoEndFingerprint +import app.revanced.patches.youtube.video.information.hookBackgroundPlayVideoInformation +import app.revanced.patches.youtube.video.information.hookVideoInformation +import app.revanced.patches.youtube.video.information.onCreateHook +import app.revanced.patches.youtube.video.information.speedSelectionInsertMethod +import app.revanced.patches.youtube.video.information.videoInformationPatch +import app.revanced.patches.youtube.video.videoid.hookPlayerResponseVideoId +import app.revanced.patches.youtube.video.videoid.videoIdPatch +import app.revanced.util.findMethodOrThrow +import app.revanced.util.fingerprint.definingClassOrThrow +import app.revanced.util.fingerprint.matchOrThrow +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.fingerprint.resolvable +import app.revanced.util.getReference +import app.revanced.util.getWalkerMethod +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstStringInstructionOrThrow +import app.revanced.util.updatePatchStatus +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private const val PLAYBACK_SPEED_MENU_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/PlaybackSpeedMenuFilter;" +private const val VIDEO_QUALITY_MENU_FILTER_CLASS_DESCRIPTOR = + "$COMPONENTS_PATH/VideoQualityMenuFilter;" +private const val EXTENSION_AV1_CODEC_CLASS_DESCRIPTOR = + "$VIDEO_PATH/AV1CodecPatch;" +private const val EXTENSION_VP9_CODEC_CLASS_DESCRIPTOR = + "$VIDEO_PATH/VP9CodecPatch;" +private const val EXTENSION_CUSTOM_PLAYBACK_SPEED_CLASS_DESCRIPTOR = + "$VIDEO_PATH/CustomPlaybackSpeedPatch;" +private const val EXTENSION_HDR_VIDEO_CLASS_DESCRIPTOR = + "$VIDEO_PATH/HDRVideoPatch;" +private const val EXTENSION_PLAYBACK_SPEED_CLASS_DESCRIPTOR = + "$VIDEO_PATH/PlaybackSpeedPatch;" +private const val EXTENSION_RELOAD_VIDEO_CLASS_DESCRIPTOR = + "$VIDEO_PATH/ReloadVideoPatch;" +private const val EXTENSION_RESTORE_OLD_VIDEO_QUALITY_MENU_CLASS_DESCRIPTOR = + "$VIDEO_PATH/RestoreOldVideoQualityMenuPatch;" +private const val EXTENSION_SPOOF_DEVICE_DIMENSIONS_CLASS_DESCRIPTOR = + "$VIDEO_PATH/SpoofDeviceDimensionsPatch;" +private const val EXTENSION_VIDEO_QUALITY_CLASS_DESCRIPTOR = + "$VIDEO_PATH/VideoQualityPatch;" + +@Suppress("unused") +val videoPlaybackPatch = bytecodePatch( + VIDEO_PLAYBACK.title, + VIDEO_PLAYBACK.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + bottomSheetRecyclerViewPatch, + customPlaybackSpeedPatch( + "$VIDEO_PATH/CustomPlaybackSpeedPatch;", + 8.0f + ), + flyoutMenuHookPatch, + lithoFilterPatch, + playerTypeHookPatch, + settingsPatch, + shortsPlaybackPatch, + videoIdPatch, + videoInformationPatch, + sharedResourceIdPatch, + ) + + execute { + + var settingArray = arrayOf( + "PREFERENCE_SCREEN: VIDEO" + ) + + // region patch for custom playback speed + + bottomSheetRecyclerViewHook("$EXTENSION_CUSTOM_PLAYBACK_SPEED_CLASS_DESCRIPTOR->onFlyoutMenuCreate(Landroid/support/v7/widget/RecyclerView;)V") + addLithoFilter(PLAYBACK_SPEED_MENU_FILTER_CLASS_DESCRIPTOR) + + // endregion + + // region patch for disable HDR video + + hdrCapabilityFingerprint.methodOrThrow().apply { + val stringIndex = + indexOfFirstStringInstructionOrThrow("av1_profile_main_10_hdr_10_plus_supported") + val walkerIndex = indexOfFirstInstructionOrThrow(stringIndex) { + val reference = getReference() + reference?.parameterTypes == listOf("I", "Landroid/view/Display;") && + reference.returnType == "Z" + } + + val walkerMethod = getWalkerMethod(walkerIndex) + walkerMethod.apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $EXTENSION_HDR_VIDEO_CLASS_DESCRIPTOR->disableHDRVideo()Z + move-result v0 + if-nez v0, :default + return v0 + """, ExternalLabel("default", getInstruction(0)) + ) + } + } + + // endregion + + // region patch for default playback speed + + val newMethod = + playbackSpeedChangedFromRecyclerViewFingerprint.methodOrThrow( + qualityChangedFromRecyclerViewFingerprint + ) + + arrayOf( + newMethod, + speedSelectionInsertMethod + ).forEach { + it.apply { + val speedSelectionValueInstructionIndex = + indexOfFirstInstructionOrThrow(Opcode.IGET) + val speedSelectionValueRegister = + getInstruction(speedSelectionValueInstructionIndex).registerA + + addInstruction( + speedSelectionValueInstructionIndex + 1, + "invoke-static {v$speedSelectionValueRegister}, " + + "$EXTENSION_PLAYBACK_SPEED_CLASS_DESCRIPTOR->userSelectedPlaybackSpeed(F)V" + ) + } + } + + playbackSpeedInitializeFingerprint.matchOrThrow(videoEndFingerprint).let { + it.method.apply { + val insertIndex = it.patternMatch!!.endIndex + val insertRegister = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $EXTENSION_PLAYBACK_SPEED_CLASS_DESCRIPTOR->getPlaybackSpeedInShorts(F)F + move-result v$insertRegister + """ + ) + } + } + + hookBackgroundPlayVideoInformation("$EXTENSION_PLAYBACK_SPEED_CLASS_DESCRIPTOR->newVideoStarted(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V") + hookPlayerResponseVideoId("$EXTENSION_PLAYBACK_SPEED_CLASS_DESCRIPTOR->fetchPlaylistData(Ljava/lang/String;Z)V") + + updatePatchStatus(PATCH_STATUS_CLASS_DESCRIPTOR, "RememberPlaybackSpeed") + + // endregion + + // region patch for default video quality + + qualityChangedFromRecyclerViewFingerprint.matchOrThrow().let { + it.method.apply { + val index = it.patternMatch!!.startIndex + + addInstruction( + index + 1, + "invoke-static {}, $EXTENSION_VIDEO_QUALITY_CLASS_DESCRIPTOR->userSelectedVideoQuality()V" + ) + + } + } + + qualitySetterFingerprint.matchOrThrow().let { + val onItemClickMethod = + it.classDef.methods.find { method -> method.name == "onItemClick" } + + onItemClickMethod?.apply { + addInstruction( + 0, + "invoke-static {}, $EXTENSION_VIDEO_QUALITY_CLASS_DESCRIPTOR->userSelectedVideoQuality()V" + ) + } ?: throw PatchException("Failed to find onItemClick method") + } + + hookBackgroundPlayVideoInformation("$EXTENSION_RELOAD_VIDEO_CLASS_DESCRIPTOR->newVideoStarted(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V") + hookVideoInformation("$EXTENSION_VIDEO_QUALITY_CLASS_DESCRIPTOR->newVideoStarted(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V") + onCreateHook( + EXTENSION_VIDEO_QUALITY_CLASS_DESCRIPTOR, + "newVideoStarted" + ) + + // endregion + + // region patch for restore old video quality menu + + qualityMenuViewInflateFingerprint.matchOrThrow().let { + it.method.apply { + val insertIndex = indexOfFirstInstructionOrThrow(Opcode.CHECK_CAST) + val insertRegister = getInstruction(insertIndex).registerA + + addInstruction( + insertIndex + 1, + "invoke-static { v$insertRegister }, " + + "$EXTENSION_RESTORE_OLD_VIDEO_QUALITY_MENU_CLASS_DESCRIPTOR->restoreOldVideoQualityMenu(Landroid/widget/ListView;)V" + ) + } + val onItemClickMethod = + it.classDef.methods.find { method -> method.name == "onItemClick" } + + onItemClickMethod?.apply { + val insertIndex = indexOfFirstInstructionOrThrow(Opcode.IGET_OBJECT) + val insertRegister = getInstruction(insertIndex).registerA + + val jumpIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.IGET_OBJECT + && this.getReference()?.type == qualitySetterFingerprint.definingClassOrThrow() + } + + addInstructionsWithLabels( + insertIndex, """ + invoke-static {}, $EXTENSION_RESTORE_OLD_VIDEO_QUALITY_MENU_CLASS_DESCRIPTOR->restoreOldVideoQualityMenu()Z + move-result v$insertRegister + if-nez v$insertRegister, :show + """, ExternalLabel("show", getInstruction(jumpIndex)) + ) + } ?: throw PatchException("Failed to find onItemClick method") + } + + bottomSheetRecyclerViewHook("$EXTENSION_RESTORE_OLD_VIDEO_QUALITY_MENU_CLASS_DESCRIPTOR->onFlyoutMenuCreate(Landroid/support/v7/widget/RecyclerView;)V") + addLithoFilter(VIDEO_QUALITY_MENU_FILTER_CLASS_DESCRIPTOR) + + // endregion + + // region patch for spoof device dimensions + + findMethodOrThrow( + deviceDimensionsModelToStringFingerprint.definingClassOrThrow() + ).addInstructions( + 1, // Add after super call. + mapOf( + 1 to "MinHeightOrWidth", // p1 = min height + 2 to "MaxHeightOrWidth", // p2 = max height + 3 to "MinHeightOrWidth", // p3 = min width + 4 to "MaxHeightOrWidth" // p4 = max width + ).map { (parameter, method) -> + """ + invoke-static { p$parameter }, $EXTENSION_SPOOF_DEVICE_DIMENSIONS_CLASS_DESCRIPTOR->get$method(I)I + move-result p$parameter + """ + }.joinToString("\n") { it } + ) + + // endregion + + // region patch for disable AV1 codec + + // replace av1 codec + + if (av1CodecFingerprint.resolvable()) { + av1CodecFingerprint.methodOrThrow().apply { + val insertIndex = indexOfFirstStringInstructionOrThrow("video/av01") + val insertRegister = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex + 1, """ + invoke-static/range {v$insertRegister .. v$insertRegister}, $EXTENSION_AV1_CODEC_CLASS_DESCRIPTOR->replaceCodec(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$insertRegister + """ + ) + } + settingArray += "SETTINGS: REPLACE_AV1_CODEC" + } + + // reject av1 codec response + + byteBufferArrayFingerprint.matchOrThrow(byteBufferArrayParentFingerprint).let { + it.method.apply { + val insertIndex = it.patternMatch!!.endIndex + val insertRegister = + getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, """ + invoke-static {v$insertRegister}, $EXTENSION_AV1_CODEC_CLASS_DESCRIPTOR->rejectResponse(I)I + move-result v$insertRegister + """ + ) + } + } + + // endregion + + // region patch for disable VP9 codec + + vp9CapabilityFingerprint.methodOrThrow().apply { + addInstructionsWithLabels( + 0, """ + invoke-static {}, $EXTENSION_VP9_CODEC_CLASS_DESCRIPTOR->disableVP9Codec()Z + move-result v0 + if-nez v0, :default + return v0 + """, ExternalLabel("default", getInstruction(0)) + ) + } + + // endregion + + // region add settings + + addPreference(settingArray, VIDEO_PLAYBACK) + + // endregion + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/playerresponse/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/playerresponse/Fingerprints.kt new file mode 100644 index 0000000000..02e4b048f2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/playerresponse/Fingerprints.kt @@ -0,0 +1,79 @@ +package app.revanced.patches.youtube.video.playerresponse + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import app.revanced.util.parametersEqual +import com.android.tools.smali.dexlib2.AccessFlags + +private val PLAYER_PARAMETER_STARTS_WITH_PARAMETER_LIST = listOf( + "Ljava/lang/String;", // VideoId. + "[B", + "Ljava/lang/String;", // Player parameters proto buffer. + "Ljava/lang/String;", // PlaylistId. + "I", + "I" +) +private val PLAYER_PARAMETER_ENDS_WITH_PARAMETER_LIST = listOf( + "Ljava/util/Set;", + "Ljava/lang/String;", + "Ljava/lang/String;", + "L", + "Z", // Appears to indicate if the video id is being opened or is currently playing. + "Z", + "Z" +) + +internal val playerParameterBuilderFingerprint = legacyFingerprint( + name = "playerParameterBuilderFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + returnType = "L", + // 19.22 and earlier parameters are: + // "Ljava/lang/String;", // VideoId. + // "[B", + // "Ljava/lang/String;", // Player parameters proto buffer. + // "Ljava/lang/String;", // PlaylistId. + // "I", + // "I", + // "Ljava/util/Set;", + // "Ljava/lang/String;", + // "Ljava/lang/String;", + // "L", + // "Z", // Appears to indicate if the video id is being opened or is currently playing. + // "Z", + // "Z" + + // 19.23+ parameters are: + // "Ljava/lang/String;", // VideoId. + // "[B", + // "Ljava/lang/String;", // Player parameters proto buffer. + // "Ljava/lang/String;", // PlaylistId. + // "I", + // "I", + // "L", + // "Ljava/util/Set;", + // "Ljava/lang/String;", + // "Ljava/lang/String;", + // "L", + // "Z", // Appears to indicate if the video id is being opened or is currently playing. + // "Z", + // "Z" + customFingerprint = custom@{ method, _ -> + val parameterTypes = method.parameterTypes + val parameterSize = parameterTypes.size + if (parameterSize != 13 && parameterSize != 14) { + return@custom false + } + + val startsWithMethodParameterList = parameterTypes.slice(0..5) + val endsWithMethodParameterList = parameterTypes.slice(parameterSize - 7..() + +fun addPlayerResponseMethodHook(hook: Hook) { + hooks += hook +} + +// Parameter numbers of the patched method. +private var parameterVideoId = 1 +private var parameterPlayerParameter = 3 +private var parameterPlaylistId = 4 +private var parameterIsShortAndOpeningOrPlaying by Delegates.notNull() + +// Registers used to pass the parameters to the extension. +private var playerResponseMethodCopyRegisters = false +private lateinit var registerVideoId: String +private lateinit var registerPlayerParameter: String +private lateinit var registerPlaylistId: String +private lateinit var registerIsShortAndOpeningOrPlaying: String + +private lateinit var playerResponseMethod: MutableMethod +private var numberOfInstructionsAdded = 0 + +val playerResponseMethodHookPatch = bytecodePatch( + description = "playerResponseMethodHookPatch" +) { + execute { + playerParameterBuilderFingerprint.methodOrThrow().apply { + playerResponseMethod = this + parameterIsShortAndOpeningOrPlaying = parameters.size - 2 + // On some app targets the method has too many registers pushing the parameters past v15. + // If needed, move the parameters to 4-bit registers so they can be passed to extension. + playerResponseMethodCopyRegisters = implementation!!.registerCount - + parameterTypes.size + parameterIsShortAndOpeningOrPlaying > 15 + } + + if (playerResponseMethodCopyRegisters) { + registerVideoId = "v0" + registerPlayerParameter = "v1" + registerPlaylistId = "v2" + registerIsShortAndOpeningOrPlaying = "v3" + } else { + registerVideoId = "p$parameterVideoId" + registerPlayerParameter = "p$parameterPlayerParameter" + registerPlaylistId = "p$parameterPlaylistId" + registerIsShortAndOpeningOrPlaying = "p$parameterIsShortAndOpeningOrPlaying" + } + } + + finalize { + fun hookVideoId(hook: Hook) { + playerResponseMethod.addInstruction( + 0, + "invoke-static {$registerVideoId, $registerIsShortAndOpeningOrPlaying}, $hook", + ) + numberOfInstructionsAdded++ + } + + fun hookPlayerParameter(hook: Hook) { + playerResponseMethod.addInstructions( + 0, + """ + invoke-static {$registerVideoId, $registerPlayerParameter, $registerPlaylistId, $registerIsShortAndOpeningOrPlaying}, $hook + move-result-object $registerPlayerParameter + """, + ) + numberOfInstructionsAdded += 2 + } + + // Reverse the order in order to preserve insertion order of the hooks. + val beforeVideoIdHooks = + hooks.filterIsInstance().asReversed() + val videoIdHooks = hooks.filterIsInstance().asReversed() + val afterVideoIdHooks = hooks.filterIsInstance().asReversed() + + // Add the hooks in this specific order as they insert instructions at the beginning of the method. + afterVideoIdHooks.forEach(::hookPlayerParameter) + videoIdHooks.forEach(::hookVideoId) + beforeVideoIdHooks.forEach(::hookPlayerParameter) + + if (playerResponseMethodCopyRegisters) { + playerResponseMethod.apply { + addInstructions( + 0, + """ + move-object/from16 $registerVideoId, p$parameterVideoId + move-object/from16 $registerPlayerParameter, p$parameterPlayerParameter + move-object/from16 $registerPlaylistId, p$parameterPlaylistId + move/from16 $registerIsShortAndOpeningOrPlaying, p$parameterIsShortAndOpeningOrPlaying + """, + ) + + numberOfInstructionsAdded += 4 + + // Move the modified register back. + addInstruction( + numberOfInstructionsAdded, + "move-object/from16 p$parameterPlayerParameter, $registerPlayerParameter" + ) + } + } + } +} + +sealed class Hook(private val methodDescriptor: String) { + class VideoId(methodDescriptor: String) : Hook(methodDescriptor) + + class PlayerParameter(methodDescriptor: String) : Hook(methodDescriptor) + class PlayerParameterBeforeVideoId(methodDescriptor: String) : Hook(methodDescriptor) + + override fun toString() = methodDescriptor +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/videoid/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/videoid/Fingerprints.kt new file mode 100644 index 0000000000..3567bf0fd0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/videoid/Fingerprints.kt @@ -0,0 +1,50 @@ +package app.revanced.patches.youtube.video.videoid + +import app.revanced.patches.youtube.utils.PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val videoIdFingerprint = legacyFingerprint( + name = "videoIdFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L"), + opcodes = listOf( + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT + ), + customFingerprint = custom@{ method, classDef -> + if (!classDef.fields.any { it.type == "Lcom/google/android/libraries/youtube/player/subtitles/model/SubtitleTrack;" }) { + return@custom false + } + val implementation = method.implementation + ?: return@custom false + val instructions = implementation.instructions + val instructionCount = instructions.count() + if (instructionCount < 30) { + return@custom false + } + + val reference = + (instructions.elementAt(instructionCount - 2) as? ReferenceInstruction)?.reference.toString() + if (reference != "Ljava/util/Map;->put(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;") { + return@custom false + } + + method.indexOfFirstInstruction { + val methodReference = getReference() + opcode == Opcode.INVOKE_INTERFACE && + methodReference?.returnType == "Ljava/lang/String;" && + methodReference.parameterTypes.isEmpty() && + methodReference.definingClass == PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR + } >= 0 + }, +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/videoid/VideoIdPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/videoid/VideoIdPatch.kt new file mode 100644 index 0000000000..57034e60dc --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/videoid/VideoIdPatch.kt @@ -0,0 +1,94 @@ +package app.revanced.patches.youtube.video.videoid + +import app.revanced.patcher.Fingerprint +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patches.youtube.utils.playertype.playerTypeHookPatch +import app.revanced.patches.youtube.video.playerresponse.Hook +import app.revanced.patches.youtube.video.playerresponse.addPlayerResponseMethodHook +import app.revanced.patches.youtube.video.playerresponse.playerResponseMethodHookPatch +import app.revanced.util.fingerprint.matchOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +private var videoIdRegister = 0 +private var videoIdInsertIndex = 0 +private lateinit var videoIdMethod: MutableMethod + +val videoIdPatch = bytecodePatch( + description = "videoIdPatch", +) { + dependsOn(playerResponseMethodHookPatch) + + execute { + /** + * Supplies the method and register index of the video id register. + * + * @param consumer Consumer that receives the method, insert index and video id register index. + */ + fun Pair.setFields(consumer: (MutableMethod, Int, Int) -> Unit) = + matchOrThrow().let { result -> + val videoIdRegisterIndex = result.patternMatch!!.endIndex + + result.method.let { + val videoIdRegister = + it.getInstruction(videoIdRegisterIndex).registerA + val insertIndex = videoIdRegisterIndex + 1 + consumer(it, insertIndex, videoIdRegister) + } + } + + videoIdFingerprint.setFields { method, index, register -> + videoIdMethod = method + videoIdInsertIndex = index + videoIdRegister = register + } + } +} + +/** + * Hooks the new video id when the video changes. + * + * Supports all videos (regular videos and Shorts). + * + * _Does not function if playing in the background with no video visible_. + * + * Be aware, this can be called multiple times for the same video id. + * + * @param methodDescriptor which method to call. Params have to be `Ljava/lang/String;` + */ +internal fun hookVideoId( + methodDescriptor: String +) = videoIdMethod.addInstruction( + videoIdInsertIndex++, + "invoke-static {v$videoIdRegister}, $methodDescriptor" +) + +/** + * Hooks the video id of every video when loaded. + * Supports all videos and functions in all situations. + * + * First parameter is the video id. + * Second parameter is if the video is a Short AND it is being opened or is currently playing. + * + * Hook is always called off the main thread. + * + * This hook is called as soon as the player response is parsed, + * and called before many other hooks are updated such as [playerTypeHookPatch]. + * + * Note: The video id returned here may not be the current video that's being played. + * It's common for multiple Shorts to load at once in preparation + * for the user swiping to the next Short. + * + * For most use cases, you probably want to use [hookVideoId] instead. + * + * Be aware, this can be called multiple times for the same video id. + * + * @param methodDescriptor which method to call. Params must be `Ljava/lang/String;Z` + */ +internal fun hookPlayerResponseVideoId(methodDescriptor: String) = addPlayerResponseMethodHook( + Hook.VideoId( + methodDescriptor, + ), +) diff --git a/patches/src/main/kotlin/app/revanced/util/BytecodeUtils.kt b/patches/src/main/kotlin/app/revanced/util/BytecodeUtils.kt new file mode 100644 index 0000000000..ad3f5468f3 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/util/BytecodeUtils.kt @@ -0,0 +1,726 @@ +@file:Suppress("CONTEXT_RECEIVERS_DEPRECATED") + +package app.revanced.util + +import app.revanced.patcher.FingerprintBuilder +import app.revanced.patcher.Match +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.instructions +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.BytecodePatchContext +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.util.proxy.mutableTypes.MutableClass +import app.revanced.patcher.util.proxy.mutableTypes.MutableField.Companion.toMutable +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable +import app.revanced.patches.shared.mapping.get +import app.revanced.patches.shared.mapping.resourceMappingPatch +import app.revanced.patches.shared.mapping.resourceMappings +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.MethodParameter +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.Instruction +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.WideLiteralInstruction +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction31i +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.iface.reference.Reference +import com.android.tools.smali.dexlib2.iface.reference.StringReference +import com.android.tools.smali.dexlib2.immutable.ImmutableField +import com.android.tools.smali.dexlib2.immutable.ImmutableMethod +import com.android.tools.smali.dexlib2.immutable.ImmutableMethodImplementation +import com.android.tools.smali.dexlib2.util.MethodUtil + +const val REGISTER_TEMPLATE_REPLACEMENT: String = "REGISTER_INDEX" + +fun parametersEqual( + parameters1: Iterable, + parameters2: Iterable +): Boolean { + if (parameters1.count() != parameters2.count()) return false + val iterator1 = parameters1.iterator() + parameters2.forEach { + if (!it.startsWith(iterator1.next())) return false + } + return true +} + +/** + * Find the [MutableMethod] from a given [Method] in a [MutableClass]. + * + * @param method The [Method] to find. + * @return The [MutableMethod]. + */ +fun MutableClass.findMutableMethodOf(method: Method) = this.methods.first { + MethodUtil.methodSignaturesMatch(it, method) +} + +/** + * Apply a transform to all methods of the class. + * + * @param transform The transformation function. Accepts a [MutableMethod] and returns a transformed [MutableMethod]. + */ +fun MutableClass.transformMethods(transform: MutableMethod.() -> MutableMethod) { + val transformedMethods = methods.map { it.transform() } + methods.clear() + methods.addAll(transformedMethods) +} + +/** + * Inject a call to a method that hides a view. + * + * @param insertIndex The index to insert the call at. + * @param viewRegister The register of the view to hide. + * @param classDescriptor The descriptor of the class that contains the method. + * @param targetMethod The name of the method to call. + */ +fun MutableMethod.injectHideViewCall( + insertIndex: Int, + viewRegister: Int, + classDescriptor: String, + targetMethod: String, +) = addInstruction( + insertIndex, + "invoke-static { v$viewRegister }, $classDescriptor->$targetMethod(Landroid/view/View;)V", +) + +/** + * Inserts instructions at a given index, using the existing control flow label at that index. + * Inserted instructions can have it's own control flow labels as well. + * + * Effectively this changes the code from: + * :label + * (original code) + * + * Into: + * :label + * (patch code) + * (original code) + */ +internal fun MutableMethod.addInstructionsAtControlFlowLabel( + insertIndex: Int, + instructions: String, +) { + // Duplicate original instruction and add to +1 index. + addInstruction(insertIndex + 1, getInstruction(insertIndex)) + + // Add patch code at same index as duplicated instruction, + // so it uses the original instruction control flow label. + addInstructionsWithLabels(insertIndex + 1, instructions) + + // Remove original non duplicated instruction. + removeInstruction(insertIndex) + + // Original instruction is now after the inserted patch instructions, + // and the original control flow label is on the first instruction of the patch code. +} + +/** + * Get the index of the first instruction with the id of the given resource id name. + * + * Requires [resourceMappingPatch] as a dependency. + * + * @param resourceName the name of the resource to find the id for. + * @return the index of the first instruction with the id of the given resource name, or -1 if not found. + * @throws PatchException if the resource cannot be found. + * @see [indexOfFirstResourceIdOrThrow], [indexOfFirstLiteralInstructionReversed] + */ +fun Method.indexOfFirstResourceId(resourceName: String): Int { + val resourceId = resourceMappings["id", resourceName] + if (resourceId == -1L) { + println("WARNING: Could not find resource type: id name: $name") + return -1 + } + return indexOfFirstLiteralInstruction(resourceId) +} + +/** + * Get the index of the first instruction with the id of the given resource name or throw a [PatchException]. + * + * Requires [resourceMappingPatch] as a dependency. + * + * @throws [PatchException] if the resource is not found, or the method does not contain the resource id literal value. + * @see [indexOfFirstResourceId], [indexOfFirstLiteralInstructionReversedOrThrow] + */ +fun Method.indexOfFirstResourceIdOrThrow(resourceName: String): Int { + val index = indexOfFirstResourceId(resourceName) + if (index < 0) { + throw PatchException("Found resource id for: '$resourceName' but method does not contain the id: $this") + } + + return index +} + +/** + * Find the index of the first literal instruction with the given value. + * + * @return the first literal instruction with the value, or -1 if not found. + * @see indexOfFirstLiteralInstructionOrThrow + */ +fun Method.indexOfFirstLiteralInstruction(literal: Long) = implementation?.let { + it.instructions.indexOfFirst { instruction -> + (instruction as? WideLiteralInstruction)?.wideLiteral == literal + } +} ?: -1 + +/** + * Find the index of the first literal instruction with the given value, + * or throw an exception if not found. + * + * @return the first literal instruction with the value, or throws [PatchException] if not found. + */ +fun Method.indexOfFirstLiteralInstructionOrThrow(literal: Long): Int { + val index = indexOfFirstLiteralInstruction(literal) + if (index < 0) throw PatchException("Could not find literal value: $literal") + return index +} + +/** + * Find the index of the last literal instruction with the given value. + * + * @return the last literal instruction with the value, or -1 if not found. + * @see indexOfFirstLiteralInstructionOrThrow + */ +fun Method.indexOfFirstLiteralInstructionReversed(literal: Long) = implementation?.let { + it.instructions.indexOfLast { instruction -> + (instruction as? WideLiteralInstruction)?.wideLiteral == literal + } +} ?: -1 + +/** + * Find the index of the last wide literal instruction with the given value, + * or throw an exception if not found. + * + * @return the last literal instruction with the value, or throws [PatchException] if not found. + */ +fun Method.indexOfFirstLiteralInstructionReversedOrThrow(literal: Long): Int { + val index = indexOfFirstLiteralInstructionReversed(literal) + if (index < 0) throw PatchException("Could not find literal value: $literal") + return index +} + +fun Method.indexOfFirstStringInstruction(str: String) = + indexOfFirstInstruction { + opcode == Opcode.CONST_STRING && + getReference()?.string == str + } + +fun Method.indexOfFirstStringInstructionOrThrow(str: String): Int { + val index = indexOfFirstStringInstruction(str) + if (index < 0) { + throw PatchException("Found string value for: '$str' but method does not contain the id: $this") + } + + return index +} + +/** + * Check if the method contains a literal with the given value. + * + * @return if the method contains a literal with the given value. + */ +fun Method.containsLiteralInstruction(literal: Long) = + indexOfFirstLiteralInstruction(literal) >= 0 + +/** + * Traverse the class hierarchy starting from the given root class. + * + * @param targetClass the class to start traversing the class hierarchy from. + * @param callback function that is called for every class in the hierarchy. + */ +fun BytecodePatchContext.traverseClassHierarchy( + targetClass: MutableClass, + callback: MutableClass.() -> Unit +) { + callback(targetClass) + + targetClass.superclass ?: return + + classBy { targetClass.superclass == it.type }?.mutableClass?.let { + traverseClassHierarchy(it, callback) + } +} + +fun MutableMethod.injectLiteralInstructionViewCall( + literal: Long, + smaliInstruction: String +) { + val literalIndex = indexOfFirstLiteralInstructionOrThrow(literal) + val targetIndex = indexOfFirstInstructionOrThrow(literalIndex, Opcode.MOVE_RESULT_OBJECT) + val targetRegister = getInstruction(targetIndex).registerA.toString() + + addInstructions( + targetIndex + 1, + smaliInstruction.replace(REGISTER_TEMPLATE_REPLACEMENT, targetRegister) + ) +} + +fun BytecodePatchContext.replaceLiteralInstructionCall( + literal: Long, + smaliInstruction: String +) { + classes.forEach { classDef -> + classDef.methods.forEach { method -> + method.implementation.apply { + this?.instructions?.forEachIndexed { _, instruction -> + if (instruction.opcode != Opcode.CONST) + return@forEachIndexed + if ((instruction as Instruction31i).wideLiteral != literal) + return@forEachIndexed + + proxy(classDef) + .mutableClass + .findMutableMethodOf(method).apply { + val index = indexOfFirstLiteralInstructionOrThrow(literal) + val register = + (instruction as OneRegisterInstruction).registerA.toString() + + addInstructions( + index + 1, + smaliInstruction.replace(REGISTER_TEMPLATE_REPLACEMENT, register) + ) + } + } + } + } + } +} + +/** + * Get the [Reference] of an [Instruction] as [T]. + * + * @param T The type of [Reference] to cast to. + * @return The [Reference] as [T] or null + * if the [Instruction] is not a [ReferenceInstruction] or the [Reference] is not of type [T]. + * @see ReferenceInstruction + */ +inline fun Instruction.getReference() = + (this as? ReferenceInstruction)?.reference as? T + +/** + * @return The index of the first opcode specified, or -1 if not found. + * @see indexOfFirstInstructionOrThrow + */ +fun Method.indexOfFirstInstruction(targetOpcode: Opcode): Int = + indexOfFirstInstruction(0, targetOpcode) + +/** + * @param startIndex Optional starting index to start searching from. + * @return The index of the first opcode specified, or -1 if not found. + * @see indexOfFirstInstructionOrThrow + */ +fun Method.indexOfFirstInstruction(startIndex: Int = 0, targetOpcode: Opcode): Int = + indexOfFirstInstruction(startIndex) { + opcode == targetOpcode + } + +/** + * Get the index of the first [Instruction] that matches the predicate, starting from [startIndex]. + * + * @param startIndex Optional starting index to start searching from. + * @return -1 if the instruction is not found. + * @see indexOfFirstInstructionOrThrow + */ +fun Method.indexOfFirstInstruction(startIndex: Int = 0, filter: Instruction.() -> Boolean): Int { + if (implementation == null) { + return -1 + } + var instructions = this.implementation!!.instructions + if (startIndex != 0) { + instructions = instructions.drop(startIndex) + } + val index = instructions.indexOfFirst(filter) + + return if (index >= 0) { + startIndex + index + } else { + -1 + } +} + +/** + * @return The index of the first opcode specified + * @throws PatchException + * @see indexOfFirstInstruction + */ +fun Method.indexOfFirstInstructionOrThrow(targetOpcode: Opcode): Int = + indexOfFirstInstructionOrThrow(0, targetOpcode) + +/** + * @return The index of the first opcode specified, starting from the index specified. + * @throws PatchException + * @see indexOfFirstInstruction + */ +fun Method.indexOfFirstInstructionOrThrow(startIndex: Int = 0, targetOpcode: Opcode): Int = + indexOfFirstInstructionOrThrow(startIndex) { + opcode == targetOpcode + } + +/** + * Get the index of the first [Instruction] that matches the predicate, starting from [startIndex]. + * + * @return the index of the instruction + * @throws PatchException + * @see indexOfFirstInstruction + */ +fun Method.indexOfFirstInstructionOrThrow( + startIndex: Int = 0, + filter: Instruction.() -> Boolean +): Int { + val index = indexOfFirstInstruction(startIndex, filter) + if (index < 0) { + throw PatchException("Could not find instruction index") + } + + return index +} + +/** + * Get the index of matching instruction, + * starting from and [startIndex] and searching down. + * + * @param startIndex Optional starting index to search down from. Searching includes the start index. + * @return -1 if the instruction is not found. + * @see indexOfFirstInstructionReversedOrThrow + */ +fun Method.indexOfFirstInstructionReversed(startIndex: Int? = null, targetOpcode: Opcode): Int = + indexOfFirstInstructionReversed(startIndex) { + opcode == targetOpcode + } + +/** + * Get the index of matching instruction, + * starting from and [startIndex] and searching down. + * + * @param startIndex Optional starting index to search down from. Searching includes the start index. + * @return -1 if the instruction is not found. + * @see indexOfFirstInstructionReversedOrThrow + */ +fun Method.indexOfFirstInstructionReversed( + startIndex: Int? = null, + filter: Instruction.() -> Boolean +): Int { + if (implementation == null) { + return -1 + } + var instructions = this.implementation!!.instructions + if (startIndex != null) { + instructions = instructions.take(startIndex + 1) + } + + return instructions.indexOfLast(filter) +} + +fun Method.indexOfFirstInstructionReversedOrThrow(opcode: Opcode): Int = + indexOfFirstInstructionReversedOrThrow(null, opcode) + +/** + * Get the index of matching instruction, + * starting from and [startIndex] and searching down. + * + * @param startIndex Optional starting index to search down from. Searching includes the start index. + * @return -1 if the instruction is not found. + * @see indexOfFirstInstructionReversed + */ +fun Method.indexOfFirstInstructionReversedOrThrow( + startIndex: Int? = null, + targetOpcode: Opcode +): Int = + indexOfFirstInstructionReversedOrThrow(startIndex) { + opcode == targetOpcode + } + +/** + * Get the index of matching instruction, + * starting from and [startIndex] and searching down. + * + * @param startIndex Optional starting index to search down from. Searching includes the start index. + * @return -1 if the instruction is not found. + * @see indexOfFirstInstructionReversed + */ +fun Method.indexOfFirstInstructionReversedOrThrow( + startIndex: Int? = null, + filter: Instruction.() -> Boolean +): Int { + val index = indexOfFirstInstructionReversed(startIndex, filter) + + if (index < 0) { + throw PatchException("Could not find instruction index") + } + + return index +} + +/** + * @return An immutable list of indices of the instructions in reverse order. + * _Returns an empty list if no indices are found_ + * @see findInstructionIndicesReversedOrThrow + */ +fun Method.findInstructionIndicesReversed(filter: Instruction.() -> Boolean): List = + instructions + .withIndex() + .filter { (_, instruction) -> filter(instruction) } + .map { (index, _) -> index } + .asReversed() + +/** + * @return An immutable list of indices of the instructions in reverse order. + * @throws PatchException if no matching indices are found. + */ +fun Method.findInstructionIndicesReversedOrThrow(filter: Instruction.() -> Boolean): List { + val indexes = findInstructionIndicesReversed(filter) + if (indexes.isEmpty()) throw PatchException("No matching instructions found in: $this") + + return indexes +} + +/** + * @return An immutable list of indices of the opcode in reverse order. + * _Returns an empty list if no indices are found_ + * @see findInstructionIndicesReversedOrThrow + */ +fun Method.findInstructionIndicesReversed(opcode: Opcode): List = + findInstructionIndicesReversed { this.opcode == opcode } + +/** + * @return An immutable list of indices of the opcode in reverse order. + * @throws PatchException if no matching indices are found. + */ +fun Method.findInstructionIndicesReversedOrThrow(opcode: Opcode): List { + val instructions = findInstructionIndicesReversed(opcode) + if (instructions.isEmpty()) throw PatchException("Could not find opcode: $opcode in: $this") + + return instructions +} + +/** + * Called for _all_ instructions with the given literal value. + */ +fun BytecodePatchContext.forEachLiteralValueInstruction( + literal: Long, + block: MutableMethod.(literalInstructionIndex: Int) -> Unit, +) { + classes.forEach { classDef -> + classDef.methods.forEach { method -> + method.implementation?.instructions?.forEachIndexed { index, instruction -> + if (instruction.opcode == Opcode.CONST && + (instruction as WideLiteralInstruction).wideLiteral == literal + ) { + val mutableMethod = proxy(classDef).mutableClass.findMutableMethodOf(method) + block.invoke(mutableMethod, index) + } + } + } + } +} + +context(BytecodePatchContext) +fun Match.getWalkerMethod(offset: Int) = + method.getWalkerMethod(offset) + +context(BytecodePatchContext) +fun MutableMethod.getWalkerMethod(offset: Int): MutableMethod { + val newMethod = getInstruction(offset).reference as MethodReference + return findMethodOrThrow(newMethod.definingClass) { + MethodUtil.methodSignaturesMatch(this, newMethod) + } +} + +/** + * Taken from BiliRoamingX: + * https://github.com/BiliRoamingX/BiliRoamingX/blob/ae58109f3acdd53ec2d2b3fb439c2a2ef1886221/patches/src/main/kotlin/app/revanced/patches/bilibili/utils/Extenstions.kt#L151 + */ +fun MutableMethod.getFiveRegisters(index: Int) = + with(getInstruction(index)) { + arrayOf(registerC, registerD, registerE, registerF, registerG) + .take(registerCount).joinToString(",") { "v$it" } + } + +context(BytecodePatchContext) +fun addStaticFieldToExtension( + className: String, + methodName: String, + fieldName: String, + objectClass: String, + smaliInstructions: String, + shouldAddConstructor: Boolean = true +) { + val classDef = classes.find { classDef -> classDef.type == className } + ?: throw PatchException("No matching methods found in: $className") + val mutableClass = proxy(classDef).mutableClass + + val objectCall = "$mutableClass->$fieldName:$objectClass" + + mutableClass.apply { + methods.first { method -> method.name == methodName }.apply { + staticFields.add( + ImmutableField( + definingClass, + fieldName, + objectClass, + AccessFlags.PUBLIC or AccessFlags.STATIC, + null, + annotations, + null + ).toMutable() + ) + + addInstructionsWithLabels( + 0, + """ + sget-object v0, $objectCall + """ + smaliInstructions + ) + } + } + + if (!shouldAddConstructor) return + + findMethodsOrThrow(objectClass) + .filter { method -> MethodUtil.isConstructor(method) } + .forEach { mutableMethod -> + mutableMethod.apply { + val initializeIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_DIRECT && + getReference()?.name == "" + } + val insertIndex = if (initializeIndex == -1) + 1 + else + initializeIndex + 1 + + val initializeRegister = if (initializeIndex == -1) + "p0" + else + "v${getInstruction(initializeIndex).registerC}" + + addInstruction( + insertIndex, + "sput-object $initializeRegister, $objectCall" + ) + } + } +} + +context(BytecodePatchContext) +fun findMethodOrThrow( + reference: String, + methodPredicate: Method.() -> Boolean = { MethodUtil.isConstructor(this) } +) = findMethodsOrThrow(reference).first(methodPredicate) + +context(BytecodePatchContext) +fun findMethodsOrThrow(reference: String): MutableSet { + val classDef = classes.find { classDef -> classDef.type == reference } + ?: throw PatchException("No matching methods found in: $reference") + return proxy(classDef) + .mutableClass + .methods +} + +context(BytecodePatchContext) +fun updatePatchStatus( + className: String, + methodName: String +) = findMethodOrThrow(className) { name == methodName } + .replaceInstruction( + 0, + "const/4 v0, 0x1" + ) + +/** + * Taken from BiliRoamingX: + * https://github.com/BiliRoamingX/BiliRoamingX/blob/ae58109f3acdd53ec2d2b3fb439c2a2ef1886221/patches/src/main/kotlin/app/revanced/patches/bilibili/utils/Extenstions.kt#L51 + */ +fun Method.cloneMutable( + registerCount: Int = implementation?.registerCount ?: 0, + clearImplementation: Boolean = false, + name: String = this.name, + accessFlags: Int = this.accessFlags, + parameters: List = this.parameters, + returnType: String = this.returnType +): MutableMethod { + val clonedImplementation = implementation?.let { + ImmutableMethodImplementation( + registerCount, + if (clearImplementation) emptyList() else it.instructions, + if (clearImplementation) emptyList() else it.tryBlocks, + if (clearImplementation) emptyList() else it.debugItems, + ) + } + return ImmutableMethod( + definingClass, + name, + parameters, + returnType, + accessFlags, + annotations, + hiddenApiRestrictions, + clonedImplementation + ).toMutable() +} + +/** + * Return the method early. + */ +fun MutableMethod.returnEarly(bool: Boolean = false) { + val const = if (bool) "0x1" else "0x0" + + val stringInstructions = when (returnType.first()) { + 'L' -> + """ + const/4 v0, $const + return-object v0 + """ + + 'V' -> "return-void" + 'I', 'Z' -> + """ + const/4 v0, $const + return v0 + """ + + else -> throw Exception("This case should never happen.") + } + + addInstructions(0, stringInstructions) +} + +/** + * Set the custom condition for this fingerprint to check for a literal value. + * + * @param literalSupplier The supplier for the literal value to check for. + */ +// TODO: add a way for subclasses to also use their own custom fingerprint. +fun FingerprintBuilder.literal(literalSupplier: () -> Long) { + custom { method, _ -> + method.containsLiteralInstruction(literalSupplier()) + } +} + +/** + * Perform a bitwise OR operation between an [AccessFlags] and an [Int]. + * + * @param other The [Int] to perform the operation with. + */ +infix fun Int.or(other: AccessFlags) = this or other.value + +/** + * Perform a bitwise OR operation between two [AccessFlags]. + * + * @param other The other [AccessFlags] to perform the operation with. + */ +infix fun AccessFlags.or(other: AccessFlags) = value or other.value + +/** + * Perform a bitwise OR operation between an [Int] and an [AccessFlags]. + * + * @param other The [AccessFlags] to perform the operation with. + */ +infix fun AccessFlags.or(other: Int) = value or other \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/util/ResourceUtils.kt b/patches/src/main/kotlin/app/revanced/util/ResourceUtils.kt new file mode 100644 index 0000000000..25d6257b66 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/util/ResourceUtils.kt @@ -0,0 +1,394 @@ +package app.revanced.util + +import app.revanced.patcher.patch.Option +import app.revanced.patcher.patch.Patch +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.ResourcePatchContext +import app.revanced.patcher.util.Document +import org.w3c.dom.Attr +import org.w3c.dom.Element +import org.w3c.dom.Node +import org.w3c.dom.NodeList +import java.io.File +import java.io.InputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +private val classLoader = object {}.javaClass.classLoader + +@Suppress("UNCHECKED_CAST") +fun Patch<*>.getStringOptionValue(key: String) = + options[key] as Option + +fun Option.valueOrThrow() = value + ?: throw PatchException("Invalid patch option: $title.") + +fun Option.valueOrThrow() = value + ?: throw PatchException("Invalid patch option: $title.") + +fun Option.lowerCaseOrThrow() = valueOrThrow() + .lowercase() + +fun Option.underBarOrThrow() = lowerCaseOrThrow() + .replace(" ", "_") + +fun Node.adoptChild(tagName: String, block: Element.() -> Unit) { + val child = ownerDocument.createElement(tagName) + child.block() + appendChild(child) +} + +fun Node.cloneNodes(parent: Node) { + val node = cloneNode(true) + parent.appendChild(node) + parent.removeChild(this) +} + +/** + * Returns a sequence for all child nodes. + */ +fun NodeList.asSequence() = (0 until this.length).asSequence().map { this.item(it) } + +/** + * Returns a sequence for all child nodes. + */ +@Suppress("UNCHECKED_CAST") +fun Node.childElementsSequence() = + this.childNodes.asSequence().filter { it.nodeType == Node.ELEMENT_NODE } as Sequence + +/** + * Performs the given [action] on each child element. + */ +inline fun Node.forEachChildElement(action: (Element) -> Unit) = + childElementsSequence().forEach { + action(it) + } + +/** + * Recursively traverse the DOM tree starting from the given root node. + * + * @param action function that is called for every node in the tree. + */ +fun Node.doRecursively(action: (Node) -> Unit) { + action(this) + for (i in 0 until this.childNodes.length) this.childNodes.item(i).doRecursively(action) +} + +fun String.startsWithAny(vararg prefixes: String): Boolean { + for (prefix in prefixes) + if (this.startsWith(prefix)) + return true + + return false +} + +fun List.getResourceGroup(fileNames: Array) = map { directory -> + ResourceGroup( + directory, *fileNames + ) +} + +fun ResourcePatchContext.appendAppVersion(appVersion: String) { + addEntryValues( + "revanced_spoof_app_version_target_entries", + "@string/revanced_spoof_app_version_target_entry_" + appVersion.replace(".", "_"), + prepend = false + ) + addEntryValues( + "revanced_spoof_app_version_target_entry_values", + appVersion, + prepend = false + ) +} + +fun ResourcePatchContext.addEntryValues( + attributeName: String, + attributeValue: String, + path: String = "res/values/arrays.xml", + prepend: Boolean = true, +) { + document(path).use { document -> + with(document) { + val resourcesNode = getElementsByTagName("resources").item(0) as Element + val newElement: Element = createElement("item") + for (i in 0 until resourcesNode.childNodes.length) { + val node = resourcesNode.childNodes.item(i) as? Element ?: continue + + if (node.getAttribute("name") == attributeName) { + newElement.appendChild(createTextNode(attributeValue)) + + if (prepend) { + node.appendChild(newElement) + } else { + node.insertBefore(newElement, node.firstChild) + } + } + } + } + } +} + +fun ResourcePatchContext.copyFile( + resourceGroup: List, + path: String, + warning: String +): Boolean { + resourceGroup.let { resourceGroups -> + try { + val filePath = File(path) + val resourceDirectory = get("res") + + resourceGroups.forEach { group -> + val fromDirectory = filePath.resolve(group.resourceDirectoryName) + val toDirectory = resourceDirectory.resolve(group.resourceDirectoryName) + + group.resources.forEach { iconFileName -> + Files.write( + toDirectory.resolve(iconFileName).toPath(), + fromDirectory.resolve(iconFileName).readBytes() + ) + } + } + + return true + } catch (_: Exception) { + println(warning) + } + } + return false +} + +fun ResourcePatchContext.removeOverlayBackground( + files: Array, + targetId: Array, +) { + files.forEach { file -> + val resourceDirectory = get("res") + val targetXmlPath = resourceDirectory.resolve("layout").resolve(file) + + if (targetXmlPath.exists()) { + targetId.forEach { identifier -> + document("res/layout/$file").use { document -> + document.doRecursively { + arrayOf("height", "width").forEach replacement@{ replacement -> + if (it !is Element) return@replacement + + if (it.attributes.getNamedItem("android:id")?.nodeValue?.endsWith( + identifier + ) == true + ) { + it.getAttributeNode("android:layout_$replacement") + ?.let { attribute -> + attribute.textContent = "0.0dip" + } + } + } + } + } + } + } + } +} + +fun ResourcePatchContext.removeStringsElements( + replacements: Array +) { + var languageList = emptyArray() + val resourceDirectory = get("res") + val dir = resourceDirectory.listFiles() + for (file in dir!!) { + val path = file.name + if (path.startsWith("values")) { + val targetXml = resourceDirectory.resolve(path).resolve("strings.xml") + if (targetXml.exists()) languageList += path + } + } + removeStringsElements(languageList, replacements) +} + +fun ResourcePatchContext.removeStringsElements( + paths: Array, + replacements: Array +) { + paths.forEach { path -> + val resourceDirectory = get("res") + val targetXmlPath = resourceDirectory.resolve(path).resolve("strings.xml") + + if (targetXmlPath.exists()) { + val targetXml = get("res/$path/strings.xml") + + replacements.forEach replacementsLoop@{ replacement -> + targetXml.writeText( + targetXml.readText() + .replaceFirst(""" {4} Unit) { + val child = ownerDocument.createElement(tagName) + child.block() + parentNode.insertBefore(child, targetNode) +} + +/** + * Copy resources from the current class loader to the resource directory. + * + * @param sourceResourceDirectory The source resource directory name. + * @param resources The resources to copy. + */ +fun ResourcePatchContext.copyResources( + sourceResourceDirectory: String, + vararg resources: ResourceGroup, + createDirectoryIfNotExist: Boolean = false, +) { + val resourceDirectory = get("res") + + for (resourceGroup in resources) { + resourceGroup.resources.forEach { resource -> + val resourceDirectoryName = resourceGroup.resourceDirectoryName + if (createDirectoryIfNotExist) { + val targetDirectory = resourceDirectory.resolve(resourceDirectoryName) + if (!targetDirectory.isDirectory) Files.createDirectories(targetDirectory.toPath()) + } + val resourceFile = "$resourceDirectoryName/$resource" + inputStreamFromBundledResource( + sourceResourceDirectory, + resourceFile + )?.let { inputStream -> + Files.copy( + inputStream, + resourceDirectory.resolve(resourceFile).toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + } + } + } +} + +internal fun inputStreamFromBundledResourceOrThrow( + sourceResourceDirectory: String, + resourceFile: String, +) = classLoader.getResourceAsStream("$sourceResourceDirectory/$resourceFile") + ?: throw PatchException("Could not find $resourceFile") + +internal fun inputStreamFromBundledResource( + sourceResourceDirectory: String, + resourceFile: String, +): InputStream? = classLoader.getResourceAsStream("$sourceResourceDirectory/$resourceFile") + +/** + * Resource names mapped to their corresponding resource data. + * @param resourceDirectoryName The name of the directory of the resource. + * @param resources A list of resource names. + */ +class ResourceGroup(val resourceDirectoryName: String, vararg val resources: String) + +/** + * Iterate through the children of a node by its tag. + * @param resource The xml resource. + * @param targetTag The target xml node. + * @param callback The callback to call when iterating over the nodes. + */ +fun ResourcePatchContext.iterateXmlNodeChildren( + resource: String, + targetTag: String, + callback: (node: Node) -> Unit, +) = document(classLoader.getResourceAsStream(resource)!!).use { document -> + val stringsNode = document.getElementsByTagName(targetTag).item(0).childNodes + for (i in 1 until stringsNode.length - 1) callback(stringsNode.item(i)) +} + +/** + * Copy resources from the current class loader to the resource directory. + * @param resourceDirectory The directory of the resource. + * @param targetResource The target resource. + * @param elementTag The element to copy. + */ +fun ResourcePatchContext.copyXmlNode( + resourceDirectory: String, + targetResource: String, + elementTag: String +) = inputStreamFromBundledResource( + resourceDirectory, + targetResource +)?.let { inputStream -> + // Copy nodes from the resources node to the real resource node + elementTag.copyXmlNode( + document(inputStream), + document("res/$targetResource"), + ).close() +} + +/** + * Copies the specified node of the source [Document] to the target [Document]. + * @param source the source [Document]. + * @param target the target [Document]- + * @return AutoCloseable that closes the [Document]s. + */ +fun String.copyXmlNode( + source: Document, + target: Document, +): AutoCloseable { + val hostNodes = source.getElementsByTagName(this).item(0).childNodes + + val destinationNode = target.getElementsByTagName(this).item(0) + + for (index in 0 until hostNodes.length) { + val node = hostNodes.item(index).cloneNode(true) + target.adoptNode(node) + destinationNode.appendChild(node) + } + + return AutoCloseable { + source.close() + target.close() + } +} + +internal fun org.w3c.dom.Document.getNode(tagName: String) = + this.getElementsByTagName(tagName).item(0) + +internal fun NodeList.findElementByAttributeValue(attributeName: String, value: String): Element? { + for (i in 0 until length) { + val node = item(i) + if (node.nodeType == Node.ELEMENT_NODE) { + val element = node as Element + + if (element.getAttribute(attributeName) == value) { + return element + } + + // Recursively search. + val found = element.childNodes.findElementByAttributeValue(attributeName, value) + if (found != null) { + return found + } + } + } + + return null +} + +internal fun NodeList.findElementByAttributeValueOrThrow(attributeName: String, value: String) = + findElementByAttributeValue(attributeName, value) + ?: throw PatchException("Could not find: $attributeName $value") + +internal fun Element.copyAttributesFrom(oldContainer: Element) { + // Copy attributes from the old element to the new element + val attributes = oldContainer.attributes + for (i in 0 until attributes.length) { + val attr = attributes.item(i) as Attr + setAttribute(attr.name, attr.value) + } +} diff --git a/src/main/kotlin/app/revanced/util/Utils.kt b/patches/src/main/kotlin/app/revanced/util/Utils.kt similarity index 100% rename from src/main/kotlin/app/revanced/util/Utils.kt rename to patches/src/main/kotlin/app/revanced/util/Utils.kt diff --git a/patches/src/main/kotlin/app/revanced/util/fingerprint/LegacyFingerprint.kt b/patches/src/main/kotlin/app/revanced/util/fingerprint/LegacyFingerprint.kt new file mode 100644 index 0000000000..9e73e62579 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/util/fingerprint/LegacyFingerprint.kt @@ -0,0 +1,164 @@ +@file:Suppress("CONTEXT_RECEIVERS_DEPRECATED") + +package app.revanced.util.fingerprint + +import app.revanced.patcher.Fingerprint +import app.revanced.patcher.Match +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.fingerprint +import app.revanced.patcher.patch.BytecodePatchContext +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.util.proxy.mutableTypes.MutableClass +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.util.containsLiteralInstruction +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstLiteralInstruction +import app.revanced.util.injectLiteralInstructionViewCall +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.ClassDef +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +private val String.exception + get() = PatchException("Failed to resolve $this") + +context(BytecodePatchContext) +internal fun Pair.resolvable(): Boolean = + second.methodOrNull != null + +context(BytecodePatchContext) +internal fun Pair.definingClassOrThrow(): String = + second.classDefOrNull?.type ?: throw first.exception + +context(BytecodePatchContext) +internal fun Pair.matchOrThrow(): Match = + second.match(mutableClassOrThrow()) + +context(BytecodePatchContext) +internal fun Pair.matchOrThrow(parentFingerprint: Pair): Match { + val parentClassDef = parentFingerprint.second.classDefOrNull + ?: throw parentFingerprint.first.exception + return second.matchOrNull(parentClassDef) + ?: throw first.exception +} + +context(BytecodePatchContext) +internal fun Pair.matchOrNull(): Match? = + second.classDefOrNull?.let { + second.matchOrNull(it) + } + +context(BytecodePatchContext) +internal fun Pair.matchOrNull(parentFingerprint: Pair): Match? = + parentFingerprint.second.classDefOrNull?.let { parentClassDef -> + second.matchOrNull(parentClassDef) + } + +context(BytecodePatchContext) +internal fun Pair.methodOrThrow(): MutableMethod = + second.methodOrNull ?: throw first.exception + +context(BytecodePatchContext) +internal fun Pair.methodOrThrow(parentFingerprint: Pair): MutableMethod = + matchOrThrow(parentFingerprint).method + +context(BytecodePatchContext) +internal fun Pair.mutableClassOrThrow(): MutableClass = + second.classDefOrNull ?: throw first.exception + +context(BytecodePatchContext) +internal fun Pair.methodCall() = + methodOrThrow().methodCall() + +context(BytecodePatchContext) +internal fun MutableMethod.methodCall(): String { + var methodCall = "$definingClass->$name(" + for (i in 0 until parameters.size) { + methodCall += parameterTypes[i] + } + methodCall += ")$returnType" + return methodCall +} + +context(BytecodePatchContext) +fun Pair.injectLiteralInstructionBooleanCall( + literal: Long, + descriptor: String +) { + methodOrThrow().apply { + val literalIndex = indexOfFirstLiteralInstruction(literal) + val targetIndex = indexOfFirstInstructionOrThrow(literalIndex, Opcode.MOVE_RESULT) + val targetRegister = getInstruction(targetIndex).registerA + + val smaliInstruction = + if (descriptor.startsWith("0x")) """ + const/16 v$targetRegister, $descriptor + """ + else if (descriptor.endsWith("(Z)Z")) """ + invoke-static {v$targetRegister}, $descriptor + move-result v$targetRegister + """ + else """ + invoke-static {}, $descriptor + move-result v$targetRegister + """ + + addInstructions( + targetIndex + 1, + smaliInstruction + ) + } +} + +context(BytecodePatchContext) +fun Pair.injectLiteralInstructionViewCall( + literal: Long, + smaliInstruction: String +) { + val method = methodOrThrow() + method.injectLiteralInstructionViewCall(literal, smaliInstruction) +} + +internal fun legacyFingerprint( + name: String, + accessFlags: Int? = null, + returnType: String? = null, + parameters: List? = null, + opcodes: List? = null, + strings: List? = null, + literals: List? = null, + customFingerprint: ((methodDef: Method, classDef: ClassDef) -> Boolean)? = null +) = Pair( + name, + fingerprint { + if (accessFlags != null) { + accessFlags(accessFlags) + } + if (returnType != null) { + returns(returnType) + } + if (parameters != null) { + parameters(*parameters.toTypedArray()) + } + if (opcodes != null) { + opcodes(*opcodes.toTypedArray()) + } + if (strings != null) { + strings(*strings.toTypedArray()) + } + custom { method, classDef -> + if (literals != null) { + for (literal in literals) + if (!method.containsLiteralInstruction(literal)) + return@custom false + } + if (customFingerprint != null && !customFingerprint(method, classDef)) { + return@custom false + } + + return@custom true + } + } +) + diff --git a/src/main/resources/music/branding/afn_blue/header/drawable-hdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/afn_blue/header/drawable-hdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/header/drawable-hdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/afn_blue/header/drawable-hdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/afn_blue/header/drawable-hdpi/logo_music.png b/patches/src/main/resources/music/branding/afn_blue/header/drawable-hdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/header/drawable-hdpi/logo_music.png rename to patches/src/main/resources/music/branding/afn_blue/header/drawable-hdpi/logo_music.png diff --git a/src/main/resources/music/branding/afn_blue/header/drawable-hdpi/ytm_logo.png b/patches/src/main/resources/music/branding/afn_blue/header/drawable-hdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/header/drawable-hdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/afn_blue/header/drawable-hdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/afn_blue/header/drawable-mdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/afn_blue/header/drawable-mdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/header/drawable-mdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/afn_blue/header/drawable-mdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/afn_blue/header/drawable-mdpi/logo_music.png b/patches/src/main/resources/music/branding/afn_blue/header/drawable-mdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/header/drawable-mdpi/logo_music.png rename to patches/src/main/resources/music/branding/afn_blue/header/drawable-mdpi/logo_music.png diff --git a/src/main/resources/music/branding/afn_blue/header/drawable-mdpi/ytm_logo.png b/patches/src/main/resources/music/branding/afn_blue/header/drawable-mdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/header/drawable-mdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/afn_blue/header/drawable-mdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/afn_blue/header/drawable-xhdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/afn_blue/header/drawable-xhdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/header/drawable-xhdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/afn_blue/header/drawable-xhdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/afn_blue/header/drawable-xhdpi/logo_music.png b/patches/src/main/resources/music/branding/afn_blue/header/drawable-xhdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/header/drawable-xhdpi/logo_music.png rename to patches/src/main/resources/music/branding/afn_blue/header/drawable-xhdpi/logo_music.png diff --git a/src/main/resources/music/branding/afn_blue/header/drawable-xhdpi/ytm_logo.png b/patches/src/main/resources/music/branding/afn_blue/header/drawable-xhdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/header/drawable-xhdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/afn_blue/header/drawable-xhdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/afn_blue/header/drawable-xxhdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/afn_blue/header/drawable-xxhdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/header/drawable-xxhdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/afn_blue/header/drawable-xxhdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/afn_blue/header/drawable-xxhdpi/logo_music.png b/patches/src/main/resources/music/branding/afn_blue/header/drawable-xxhdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/header/drawable-xxhdpi/logo_music.png rename to patches/src/main/resources/music/branding/afn_blue/header/drawable-xxhdpi/logo_music.png diff --git a/src/main/resources/music/branding/afn_blue/header/drawable-xxhdpi/ytm_logo.png b/patches/src/main/resources/music/branding/afn_blue/header/drawable-xxhdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/header/drawable-xxhdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/afn_blue/header/drawable-xxhdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/afn_blue/header/drawable-xxxhdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/afn_blue/header/drawable-xxxhdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/header/drawable-xxxhdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/afn_blue/header/drawable-xxxhdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/afn_blue/header/drawable-xxxhdpi/logo_music.png b/patches/src/main/resources/music/branding/afn_blue/header/drawable-xxxhdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/header/drawable-xxxhdpi/logo_music.png rename to patches/src/main/resources/music/branding/afn_blue/header/drawable-xxxhdpi/logo_music.png diff --git a/src/main/resources/music/branding/afn_blue/header/drawable-xxxhdpi/ytm_logo.png b/patches/src/main/resources/music/branding/afn_blue/header/drawable-xxxhdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/header/drawable-xxxhdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/afn_blue/header/drawable-xxxhdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/afn_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/afn_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/afn_blue/launcher/mipmap-hdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-hdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/launcher/mipmap-hdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-hdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/afn_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/afn_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/afn_blue/launcher/mipmap-mdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-mdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/launcher/mipmap-mdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-mdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/afn_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/afn_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/afn_blue/launcher/mipmap-xhdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xhdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/launcher/mipmap-xhdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xhdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxhdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxhdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/launcher/mipmap-xxhdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxhdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxxhdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxxhdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/launcher/mipmap-xxxhdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/afn_blue/launcher/mipmap-xxxhdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/afn_blue/monochrome/drawable/ic_app_icons_themed_youtube_music.xml b/patches/src/main/resources/music/branding/afn_blue/monochrome/drawable/ic_app_icons_themed_youtube_music.xml similarity index 100% rename from src/main/resources/music/branding/afn_blue/monochrome/drawable/ic_app_icons_themed_youtube_music.xml rename to patches/src/main/resources/music/branding/afn_blue/monochrome/drawable/ic_app_icons_themed_youtube_music.xml diff --git a/src/main/resources/music/branding/afn_blue/settings/drawable/revanced_extended_settings_icon.xml b/patches/src/main/resources/music/branding/afn_blue/settings/drawable/revanced_extended_settings_icon.xml similarity index 100% rename from src/main/resources/music/branding/afn_blue/settings/drawable/revanced_extended_settings_icon.xml rename to patches/src/main/resources/music/branding/afn_blue/settings/drawable/revanced_extended_settings_icon.xml diff --git a/src/main/resources/music/branding/afn_blue/splash/drawable-hdpi/action_bar_logo_release.png b/patches/src/main/resources/music/branding/afn_blue/splash/drawable-hdpi/action_bar_logo_release.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/splash/drawable-hdpi/action_bar_logo_release.png rename to patches/src/main/resources/music/branding/afn_blue/splash/drawable-hdpi/action_bar_logo_release.png diff --git a/src/main/resources/music/branding/afn_blue/splash/drawable-hdpi/record.png b/patches/src/main/resources/music/branding/afn_blue/splash/drawable-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/splash/drawable-hdpi/record.png rename to patches/src/main/resources/music/branding/afn_blue/splash/drawable-hdpi/record.png diff --git a/src/main/resources/music/branding/afn_blue/splash/drawable-large-hdpi/record.png b/patches/src/main/resources/music/branding/afn_blue/splash/drawable-large-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/splash/drawable-large-hdpi/record.png rename to patches/src/main/resources/music/branding/afn_blue/splash/drawable-large-hdpi/record.png diff --git a/src/main/resources/music/branding/afn_blue/splash/drawable-large-mdpi/record.png b/patches/src/main/resources/music/branding/afn_blue/splash/drawable-large-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/splash/drawable-large-mdpi/record.png rename to patches/src/main/resources/music/branding/afn_blue/splash/drawable-large-mdpi/record.png diff --git a/src/main/resources/music/branding/afn_blue/splash/drawable-large-xhdpi/record.png b/patches/src/main/resources/music/branding/afn_blue/splash/drawable-large-xhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/splash/drawable-large-xhdpi/record.png rename to patches/src/main/resources/music/branding/afn_blue/splash/drawable-large-xhdpi/record.png diff --git a/src/main/resources/music/branding/afn_blue/splash/drawable-mdpi/record.png b/patches/src/main/resources/music/branding/afn_blue/splash/drawable-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/splash/drawable-mdpi/record.png rename to patches/src/main/resources/music/branding/afn_blue/splash/drawable-mdpi/record.png diff --git a/src/main/resources/music/branding/afn_blue/splash/drawable-xhdpi/record.png b/patches/src/main/resources/music/branding/afn_blue/splash/drawable-xhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/splash/drawable-xhdpi/record.png rename to patches/src/main/resources/music/branding/afn_blue/splash/drawable-xhdpi/record.png diff --git a/src/main/resources/music/branding/afn_blue/splash/drawable-xlarge-hdpi/record.png b/patches/src/main/resources/music/branding/afn_blue/splash/drawable-xlarge-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/splash/drawable-xlarge-hdpi/record.png rename to patches/src/main/resources/music/branding/afn_blue/splash/drawable-xlarge-hdpi/record.png diff --git a/src/main/resources/music/branding/afn_blue/splash/drawable-xlarge-mdpi/record.png b/patches/src/main/resources/music/branding/afn_blue/splash/drawable-xlarge-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/splash/drawable-xlarge-mdpi/record.png rename to patches/src/main/resources/music/branding/afn_blue/splash/drawable-xlarge-mdpi/record.png diff --git a/src/main/resources/music/branding/afn_blue/splash/drawable-xxhdpi/record.png b/patches/src/main/resources/music/branding/afn_blue/splash/drawable-xxhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_blue/splash/drawable-xxhdpi/record.png rename to patches/src/main/resources/music/branding/afn_blue/splash/drawable-xxhdpi/record.png diff --git a/src/main/resources/music/branding/afn_red/header/drawable-hdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/afn_red/header/drawable-hdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_red/header/drawable-hdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/afn_red/header/drawable-hdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/afn_red/header/drawable-hdpi/logo_music.png b/patches/src/main/resources/music/branding/afn_red/header/drawable-hdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/afn_red/header/drawable-hdpi/logo_music.png rename to patches/src/main/resources/music/branding/afn_red/header/drawable-hdpi/logo_music.png diff --git a/src/main/resources/music/branding/afn_red/header/drawable-hdpi/ytm_logo.png b/patches/src/main/resources/music/branding/afn_red/header/drawable-hdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_red/header/drawable-hdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/afn_red/header/drawable-hdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/afn_red/header/drawable-mdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/afn_red/header/drawable-mdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_red/header/drawable-mdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/afn_red/header/drawable-mdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/afn_red/header/drawable-mdpi/logo_music.png b/patches/src/main/resources/music/branding/afn_red/header/drawable-mdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/afn_red/header/drawable-mdpi/logo_music.png rename to patches/src/main/resources/music/branding/afn_red/header/drawable-mdpi/logo_music.png diff --git a/src/main/resources/music/branding/afn_red/header/drawable-mdpi/ytm_logo.png b/patches/src/main/resources/music/branding/afn_red/header/drawable-mdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_red/header/drawable-mdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/afn_red/header/drawable-mdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/afn_red/header/drawable-xhdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/afn_red/header/drawable-xhdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_red/header/drawable-xhdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/afn_red/header/drawable-xhdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/afn_red/header/drawable-xhdpi/logo_music.png b/patches/src/main/resources/music/branding/afn_red/header/drawable-xhdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/afn_red/header/drawable-xhdpi/logo_music.png rename to patches/src/main/resources/music/branding/afn_red/header/drawable-xhdpi/logo_music.png diff --git a/src/main/resources/music/branding/afn_red/header/drawable-xhdpi/ytm_logo.png b/patches/src/main/resources/music/branding/afn_red/header/drawable-xhdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_red/header/drawable-xhdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/afn_red/header/drawable-xhdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/afn_red/header/drawable-xxhdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/afn_red/header/drawable-xxhdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_red/header/drawable-xxhdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/afn_red/header/drawable-xxhdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/afn_red/header/drawable-xxhdpi/logo_music.png b/patches/src/main/resources/music/branding/afn_red/header/drawable-xxhdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/afn_red/header/drawable-xxhdpi/logo_music.png rename to patches/src/main/resources/music/branding/afn_red/header/drawable-xxhdpi/logo_music.png diff --git a/src/main/resources/music/branding/afn_red/header/drawable-xxhdpi/ytm_logo.png b/patches/src/main/resources/music/branding/afn_red/header/drawable-xxhdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_red/header/drawable-xxhdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/afn_red/header/drawable-xxhdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/afn_red/header/drawable-xxxhdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/afn_red/header/drawable-xxxhdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_red/header/drawable-xxxhdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/afn_red/header/drawable-xxxhdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/afn_red/header/drawable-xxxhdpi/logo_music.png b/patches/src/main/resources/music/branding/afn_red/header/drawable-xxxhdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/afn_red/header/drawable-xxxhdpi/logo_music.png rename to patches/src/main/resources/music/branding/afn_red/header/drawable-xxxhdpi/logo_music.png diff --git a/src/main/resources/music/branding/afn_red/header/drawable-xxxhdpi/ytm_logo.png b/patches/src/main/resources/music/branding/afn_red/header/drawable-xxxhdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/afn_red/header/drawable-xxxhdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/afn_red/header/drawable-xxxhdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/afn_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/afn_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/afn_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/afn_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/afn_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/afn_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/afn_red/launcher/mipmap-hdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/afn_red/launcher/mipmap-hdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/afn_red/launcher/mipmap-hdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/afn_red/launcher/mipmap-hdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/afn_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/afn_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/afn_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/afn_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/afn_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/afn_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/afn_red/launcher/mipmap-mdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/afn_red/launcher/mipmap-mdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/afn_red/launcher/mipmap-mdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/afn_red/launcher/mipmap-mdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/afn_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/afn_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/afn_red/launcher/mipmap-xhdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xhdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/afn_red/launcher/mipmap-xhdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xhdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/afn_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/afn_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/afn_red/launcher/mipmap-xxhdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xxhdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/afn_red/launcher/mipmap-xxhdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xxhdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/afn_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/afn_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/afn_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/afn_red/launcher/mipmap-xxxhdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xxxhdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/afn_red/launcher/mipmap-xxxhdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/afn_red/launcher/mipmap-xxxhdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/afn_red/monochrome/drawable/ic_app_icons_themed_youtube_music.xml b/patches/src/main/resources/music/branding/afn_red/monochrome/drawable/ic_app_icons_themed_youtube_music.xml similarity index 100% rename from src/main/resources/music/branding/afn_red/monochrome/drawable/ic_app_icons_themed_youtube_music.xml rename to patches/src/main/resources/music/branding/afn_red/monochrome/drawable/ic_app_icons_themed_youtube_music.xml diff --git a/src/main/resources/music/branding/afn_red/settings/drawable/revanced_extended_settings_icon.xml b/patches/src/main/resources/music/branding/afn_red/settings/drawable/revanced_extended_settings_icon.xml similarity index 100% rename from src/main/resources/music/branding/afn_red/settings/drawable/revanced_extended_settings_icon.xml rename to patches/src/main/resources/music/branding/afn_red/settings/drawable/revanced_extended_settings_icon.xml diff --git a/src/main/resources/music/branding/afn_red/splash/drawable-hdpi/action_bar_logo_release.png b/patches/src/main/resources/music/branding/afn_red/splash/drawable-hdpi/action_bar_logo_release.png similarity index 100% rename from src/main/resources/music/branding/afn_red/splash/drawable-hdpi/action_bar_logo_release.png rename to patches/src/main/resources/music/branding/afn_red/splash/drawable-hdpi/action_bar_logo_release.png diff --git a/src/main/resources/music/branding/afn_red/splash/drawable-hdpi/record.png b/patches/src/main/resources/music/branding/afn_red/splash/drawable-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_red/splash/drawable-hdpi/record.png rename to patches/src/main/resources/music/branding/afn_red/splash/drawable-hdpi/record.png diff --git a/src/main/resources/music/branding/afn_red/splash/drawable-large-hdpi/record.png b/patches/src/main/resources/music/branding/afn_red/splash/drawable-large-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_red/splash/drawable-large-hdpi/record.png rename to patches/src/main/resources/music/branding/afn_red/splash/drawable-large-hdpi/record.png diff --git a/src/main/resources/music/branding/afn_red/splash/drawable-large-mdpi/record.png b/patches/src/main/resources/music/branding/afn_red/splash/drawable-large-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_red/splash/drawable-large-mdpi/record.png rename to patches/src/main/resources/music/branding/afn_red/splash/drawable-large-mdpi/record.png diff --git a/src/main/resources/music/branding/afn_red/splash/drawable-large-xhdpi/record.png b/patches/src/main/resources/music/branding/afn_red/splash/drawable-large-xhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_red/splash/drawable-large-xhdpi/record.png rename to patches/src/main/resources/music/branding/afn_red/splash/drawable-large-xhdpi/record.png diff --git a/src/main/resources/music/branding/afn_red/splash/drawable-mdpi/record.png b/patches/src/main/resources/music/branding/afn_red/splash/drawable-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_red/splash/drawable-mdpi/record.png rename to patches/src/main/resources/music/branding/afn_red/splash/drawable-mdpi/record.png diff --git a/src/main/resources/music/branding/afn_red/splash/drawable-xhdpi/record.png b/patches/src/main/resources/music/branding/afn_red/splash/drawable-xhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_red/splash/drawable-xhdpi/record.png rename to patches/src/main/resources/music/branding/afn_red/splash/drawable-xhdpi/record.png diff --git a/src/main/resources/music/branding/afn_red/splash/drawable-xlarge-hdpi/record.png b/patches/src/main/resources/music/branding/afn_red/splash/drawable-xlarge-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_red/splash/drawable-xlarge-hdpi/record.png rename to patches/src/main/resources/music/branding/afn_red/splash/drawable-xlarge-hdpi/record.png diff --git a/src/main/resources/music/branding/afn_red/splash/drawable-xlarge-mdpi/record.png b/patches/src/main/resources/music/branding/afn_red/splash/drawable-xlarge-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_red/splash/drawable-xlarge-mdpi/record.png rename to patches/src/main/resources/music/branding/afn_red/splash/drawable-xlarge-mdpi/record.png diff --git a/src/main/resources/music/branding/afn_red/splash/drawable-xxhdpi/record.png b/patches/src/main/resources/music/branding/afn_red/splash/drawable-xxhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/afn_red/splash/drawable-xxhdpi/record.png rename to patches/src/main/resources/music/branding/afn_red/splash/drawable-xxhdpi/record.png diff --git a/src/main/resources/music/branding/mmt/header/drawable-hdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/mmt/header/drawable-hdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/mmt/header/drawable-hdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/mmt/header/drawable-hdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/mmt/header/drawable-hdpi/logo_music.png b/patches/src/main/resources/music/branding/mmt/header/drawable-hdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/mmt/header/drawable-hdpi/logo_music.png rename to patches/src/main/resources/music/branding/mmt/header/drawable-hdpi/logo_music.png diff --git a/src/main/resources/music/branding/mmt/header/drawable-hdpi/ytm_logo.png b/patches/src/main/resources/music/branding/mmt/header/drawable-hdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/mmt/header/drawable-hdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/mmt/header/drawable-hdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/mmt/header/drawable-mdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/mmt/header/drawable-mdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/mmt/header/drawable-mdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/mmt/header/drawable-mdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/mmt/header/drawable-mdpi/logo_music.png b/patches/src/main/resources/music/branding/mmt/header/drawable-mdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/mmt/header/drawable-mdpi/logo_music.png rename to patches/src/main/resources/music/branding/mmt/header/drawable-mdpi/logo_music.png diff --git a/src/main/resources/music/branding/mmt/header/drawable-mdpi/ytm_logo.png b/patches/src/main/resources/music/branding/mmt/header/drawable-mdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/mmt/header/drawable-mdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/mmt/header/drawable-mdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/mmt/header/drawable-xhdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/mmt/header/drawable-xhdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/mmt/header/drawable-xhdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/mmt/header/drawable-xhdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/mmt/header/drawable-xhdpi/logo_music.png b/patches/src/main/resources/music/branding/mmt/header/drawable-xhdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/mmt/header/drawable-xhdpi/logo_music.png rename to patches/src/main/resources/music/branding/mmt/header/drawable-xhdpi/logo_music.png diff --git a/src/main/resources/music/branding/mmt/header/drawable-xhdpi/ytm_logo.png b/patches/src/main/resources/music/branding/mmt/header/drawable-xhdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/mmt/header/drawable-xhdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/mmt/header/drawable-xhdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/mmt/header/drawable-xxhdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/mmt/header/drawable-xxhdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/mmt/header/drawable-xxhdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/mmt/header/drawable-xxhdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/mmt/header/drawable-xxhdpi/logo_music.png b/patches/src/main/resources/music/branding/mmt/header/drawable-xxhdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/mmt/header/drawable-xxhdpi/logo_music.png rename to patches/src/main/resources/music/branding/mmt/header/drawable-xxhdpi/logo_music.png diff --git a/src/main/resources/music/branding/mmt/header/drawable-xxhdpi/ytm_logo.png b/patches/src/main/resources/music/branding/mmt/header/drawable-xxhdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/mmt/header/drawable-xxhdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/mmt/header/drawable-xxhdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/mmt/header/drawable-xxxhdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/mmt/header/drawable-xxxhdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/mmt/header/drawable-xxxhdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/mmt/header/drawable-xxxhdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/mmt/header/drawable-xxxhdpi/logo_music.png b/patches/src/main/resources/music/branding/mmt/header/drawable-xxxhdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/mmt/header/drawable-xxxhdpi/logo_music.png rename to patches/src/main/resources/music/branding/mmt/header/drawable-xxxhdpi/logo_music.png diff --git a/src/main/resources/music/branding/mmt/header/drawable-xxxhdpi/ytm_logo.png b/patches/src/main/resources/music/branding/mmt/header/drawable-xxxhdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/mmt/header/drawable-xxxhdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/mmt/header/drawable-xxxhdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/mmt/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/mmt/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/mmt/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/mmt/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/mmt/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/mmt/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/mmt/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/mmt/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/mmt/launcher/mipmap-hdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/mmt/launcher/mipmap-hdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/mmt/launcher/mipmap-hdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/mmt/launcher/mipmap-hdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/mmt/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/mmt/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/mmt/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/mmt/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/mmt/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/mmt/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/mmt/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/mmt/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/mmt/launcher/mipmap-mdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/mmt/launcher/mipmap-mdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/mmt/launcher/mipmap-mdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/mmt/launcher/mipmap-mdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/mmt/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/mmt/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/mmt/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/mmt/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/mmt/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/mmt/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/mmt/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/mmt/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/mmt/launcher/mipmap-xhdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/mmt/launcher/mipmap-xhdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/mmt/launcher/mipmap-xhdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/mmt/launcher/mipmap-xhdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/mmt/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/mmt/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/mmt/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/mmt/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/mmt/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/mmt/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/mmt/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/mmt/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/mmt/launcher/mipmap-xxhdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/mmt/launcher/mipmap-xxhdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/mmt/launcher/mipmap-xxhdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/mmt/launcher/mipmap-xxhdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/mmt/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/mmt/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/mmt/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/mmt/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/mmt/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/mmt/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/mmt/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/mmt/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/mmt/launcher/mipmap-xxxhdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/mmt/launcher/mipmap-xxxhdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/mmt/launcher/mipmap-xxxhdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/mmt/launcher/mipmap-xxxhdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/mmt/monochrome/drawable/ic_app_icons_themed_youtube_music.xml b/patches/src/main/resources/music/branding/mmt/monochrome/drawable/ic_app_icons_themed_youtube_music.xml similarity index 100% rename from src/main/resources/music/branding/mmt/monochrome/drawable/ic_app_icons_themed_youtube_music.xml rename to patches/src/main/resources/music/branding/mmt/monochrome/drawable/ic_app_icons_themed_youtube_music.xml diff --git a/src/main/resources/music/branding/mmt/settings/drawable/revanced_extended_settings_icon.xml b/patches/src/main/resources/music/branding/mmt/settings/drawable/revanced_extended_settings_icon.xml similarity index 100% rename from src/main/resources/music/branding/mmt/settings/drawable/revanced_extended_settings_icon.xml rename to patches/src/main/resources/music/branding/mmt/settings/drawable/revanced_extended_settings_icon.xml diff --git a/src/main/resources/music/branding/mmt/splash/drawable-hdpi/action_bar_logo_release.png b/patches/src/main/resources/music/branding/mmt/splash/drawable-hdpi/action_bar_logo_release.png similarity index 100% rename from src/main/resources/music/branding/mmt/splash/drawable-hdpi/action_bar_logo_release.png rename to patches/src/main/resources/music/branding/mmt/splash/drawable-hdpi/action_bar_logo_release.png diff --git a/src/main/resources/music/branding/mmt/splash/drawable-hdpi/record.png b/patches/src/main/resources/music/branding/mmt/splash/drawable-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/mmt/splash/drawable-hdpi/record.png rename to patches/src/main/resources/music/branding/mmt/splash/drawable-hdpi/record.png diff --git a/src/main/resources/music/branding/mmt/splash/drawable-large-hdpi/record.png b/patches/src/main/resources/music/branding/mmt/splash/drawable-large-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/mmt/splash/drawable-large-hdpi/record.png rename to patches/src/main/resources/music/branding/mmt/splash/drawable-large-hdpi/record.png diff --git a/src/main/resources/music/branding/mmt/splash/drawable-large-mdpi/record.png b/patches/src/main/resources/music/branding/mmt/splash/drawable-large-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/mmt/splash/drawable-large-mdpi/record.png rename to patches/src/main/resources/music/branding/mmt/splash/drawable-large-mdpi/record.png diff --git a/src/main/resources/music/branding/mmt/splash/drawable-large-xhdpi/record.png b/patches/src/main/resources/music/branding/mmt/splash/drawable-large-xhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/mmt/splash/drawable-large-xhdpi/record.png rename to patches/src/main/resources/music/branding/mmt/splash/drawable-large-xhdpi/record.png diff --git a/src/main/resources/music/branding/mmt/splash/drawable-mdpi/record.png b/patches/src/main/resources/music/branding/mmt/splash/drawable-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/mmt/splash/drawable-mdpi/record.png rename to patches/src/main/resources/music/branding/mmt/splash/drawable-mdpi/record.png diff --git a/src/main/resources/music/branding/mmt/splash/drawable-xhdpi/record.png b/patches/src/main/resources/music/branding/mmt/splash/drawable-xhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/mmt/splash/drawable-xhdpi/record.png rename to patches/src/main/resources/music/branding/mmt/splash/drawable-xhdpi/record.png diff --git a/src/main/resources/music/branding/mmt/splash/drawable-xlarge-hdpi/record.png b/patches/src/main/resources/music/branding/mmt/splash/drawable-xlarge-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/mmt/splash/drawable-xlarge-hdpi/record.png rename to patches/src/main/resources/music/branding/mmt/splash/drawable-xlarge-hdpi/record.png diff --git a/src/main/resources/music/branding/mmt/splash/drawable-xlarge-mdpi/record.png b/patches/src/main/resources/music/branding/mmt/splash/drawable-xlarge-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/mmt/splash/drawable-xlarge-mdpi/record.png rename to patches/src/main/resources/music/branding/mmt/splash/drawable-xlarge-mdpi/record.png diff --git a/src/main/resources/music/branding/mmt/splash/drawable-xxhdpi/record.png b/patches/src/main/resources/music/branding/mmt/splash/drawable-xxhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/mmt/splash/drawable-xxhdpi/record.png rename to patches/src/main/resources/music/branding/mmt/splash/drawable-xxhdpi/record.png diff --git a/src/main/resources/music/branding/revancify_blue/header/drawable-hdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/revancify_blue/header/drawable-hdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/header/drawable-hdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/revancify_blue/header/drawable-hdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/revancify_blue/header/drawable-hdpi/logo_music.png b/patches/src/main/resources/music/branding/revancify_blue/header/drawable-hdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/header/drawable-hdpi/logo_music.png rename to patches/src/main/resources/music/branding/revancify_blue/header/drawable-hdpi/logo_music.png diff --git a/src/main/resources/music/branding/revancify_blue/header/drawable-hdpi/ytm_logo.png b/patches/src/main/resources/music/branding/revancify_blue/header/drawable-hdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/header/drawable-hdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/revancify_blue/header/drawable-hdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/revancify_blue/header/drawable-mdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/revancify_blue/header/drawable-mdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/header/drawable-mdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/revancify_blue/header/drawable-mdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/revancify_blue/header/drawable-mdpi/logo_music.png b/patches/src/main/resources/music/branding/revancify_blue/header/drawable-mdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/header/drawable-mdpi/logo_music.png rename to patches/src/main/resources/music/branding/revancify_blue/header/drawable-mdpi/logo_music.png diff --git a/src/main/resources/music/branding/revancify_blue/header/drawable-mdpi/ytm_logo.png b/patches/src/main/resources/music/branding/revancify_blue/header/drawable-mdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/header/drawable-mdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/revancify_blue/header/drawable-mdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/revancify_blue/header/drawable-xhdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/revancify_blue/header/drawable-xhdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/header/drawable-xhdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/revancify_blue/header/drawable-xhdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/revancify_blue/header/drawable-xhdpi/logo_music.png b/patches/src/main/resources/music/branding/revancify_blue/header/drawable-xhdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/header/drawable-xhdpi/logo_music.png rename to patches/src/main/resources/music/branding/revancify_blue/header/drawable-xhdpi/logo_music.png diff --git a/src/main/resources/music/branding/revancify_blue/header/drawable-xhdpi/ytm_logo.png b/patches/src/main/resources/music/branding/revancify_blue/header/drawable-xhdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/header/drawable-xhdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/revancify_blue/header/drawable-xhdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/revancify_blue/header/drawable-xxhdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/revancify_blue/header/drawable-xxhdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/header/drawable-xxhdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/revancify_blue/header/drawable-xxhdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/revancify_blue/header/drawable-xxhdpi/logo_music.png b/patches/src/main/resources/music/branding/revancify_blue/header/drawable-xxhdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/header/drawable-xxhdpi/logo_music.png rename to patches/src/main/resources/music/branding/revancify_blue/header/drawable-xxhdpi/logo_music.png diff --git a/src/main/resources/music/branding/revancify_blue/header/drawable-xxhdpi/ytm_logo.png b/patches/src/main/resources/music/branding/revancify_blue/header/drawable-xxhdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/header/drawable-xxhdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/revancify_blue/header/drawable-xxhdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/revancify_blue/header/drawable-xxxhdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/revancify_blue/header/drawable-xxxhdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/header/drawable-xxxhdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/revancify_blue/header/drawable-xxxhdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/revancify_blue/header/drawable-xxxhdpi/logo_music.png b/patches/src/main/resources/music/branding/revancify_blue/header/drawable-xxxhdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/header/drawable-xxxhdpi/logo_music.png rename to patches/src/main/resources/music/branding/revancify_blue/header/drawable-xxxhdpi/logo_music.png diff --git a/src/main/resources/music/branding/revancify_blue/header/drawable-xxxhdpi/ytm_logo.png b/patches/src/main/resources/music/branding/revancify_blue/header/drawable-xxxhdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/header/drawable-xxxhdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/revancify_blue/header/drawable-xxxhdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/revancify_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/revancify_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/revancify_blue/launcher/mipmap-hdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-hdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/launcher/mipmap-hdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-hdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/revancify_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/revancify_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/revancify_blue/launcher/mipmap-mdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-mdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/launcher/mipmap-mdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-mdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xhdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xhdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/launcher/mipmap-xhdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xhdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxhdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxhdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxhdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxhdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxxhdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxxhdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxxhdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/revancify_blue/launcher/mipmap-xxxhdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/revancify_blue/monochrome/drawable/ic_app_icons_themed_youtube_music.xml b/patches/src/main/resources/music/branding/revancify_blue/monochrome/drawable/ic_app_icons_themed_youtube_music.xml similarity index 100% rename from src/main/resources/music/branding/revancify_blue/monochrome/drawable/ic_app_icons_themed_youtube_music.xml rename to patches/src/main/resources/music/branding/revancify_blue/monochrome/drawable/ic_app_icons_themed_youtube_music.xml diff --git a/src/main/resources/music/branding/revancify_blue/settings/drawable/revanced_extended_settings_icon.xml b/patches/src/main/resources/music/branding/revancify_blue/settings/drawable/revanced_extended_settings_icon.xml similarity index 100% rename from src/main/resources/music/branding/revancify_blue/settings/drawable/revanced_extended_settings_icon.xml rename to patches/src/main/resources/music/branding/revancify_blue/settings/drawable/revanced_extended_settings_icon.xml diff --git a/src/main/resources/music/branding/revancify_blue/splash/drawable-hdpi/action_bar_logo_release.png b/patches/src/main/resources/music/branding/revancify_blue/splash/drawable-hdpi/action_bar_logo_release.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/splash/drawable-hdpi/action_bar_logo_release.png rename to patches/src/main/resources/music/branding/revancify_blue/splash/drawable-hdpi/action_bar_logo_release.png diff --git a/src/main/resources/music/branding/revancify_blue/splash/drawable-hdpi/record.png b/patches/src/main/resources/music/branding/revancify_blue/splash/drawable-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/splash/drawable-hdpi/record.png rename to patches/src/main/resources/music/branding/revancify_blue/splash/drawable-hdpi/record.png diff --git a/src/main/resources/music/branding/revancify_blue/splash/drawable-large-hdpi/record.png b/patches/src/main/resources/music/branding/revancify_blue/splash/drawable-large-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/splash/drawable-large-hdpi/record.png rename to patches/src/main/resources/music/branding/revancify_blue/splash/drawable-large-hdpi/record.png diff --git a/src/main/resources/music/branding/revancify_blue/splash/drawable-large-mdpi/record.png b/patches/src/main/resources/music/branding/revancify_blue/splash/drawable-large-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/splash/drawable-large-mdpi/record.png rename to patches/src/main/resources/music/branding/revancify_blue/splash/drawable-large-mdpi/record.png diff --git a/src/main/resources/music/branding/revancify_blue/splash/drawable-large-xhdpi/record.png b/patches/src/main/resources/music/branding/revancify_blue/splash/drawable-large-xhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/splash/drawable-large-xhdpi/record.png rename to patches/src/main/resources/music/branding/revancify_blue/splash/drawable-large-xhdpi/record.png diff --git a/src/main/resources/music/branding/revancify_blue/splash/drawable-mdpi/record.png b/patches/src/main/resources/music/branding/revancify_blue/splash/drawable-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/splash/drawable-mdpi/record.png rename to patches/src/main/resources/music/branding/revancify_blue/splash/drawable-mdpi/record.png diff --git a/src/main/resources/music/branding/revancify_blue/splash/drawable-xhdpi/record.png b/patches/src/main/resources/music/branding/revancify_blue/splash/drawable-xhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/splash/drawable-xhdpi/record.png rename to patches/src/main/resources/music/branding/revancify_blue/splash/drawable-xhdpi/record.png diff --git a/src/main/resources/music/branding/revancify_blue/splash/drawable-xlarge-hdpi/record.png b/patches/src/main/resources/music/branding/revancify_blue/splash/drawable-xlarge-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/splash/drawable-xlarge-hdpi/record.png rename to patches/src/main/resources/music/branding/revancify_blue/splash/drawable-xlarge-hdpi/record.png diff --git a/src/main/resources/music/branding/revancify_blue/splash/drawable-xlarge-mdpi/record.png b/patches/src/main/resources/music/branding/revancify_blue/splash/drawable-xlarge-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/splash/drawable-xlarge-mdpi/record.png rename to patches/src/main/resources/music/branding/revancify_blue/splash/drawable-xlarge-mdpi/record.png diff --git a/src/main/resources/music/branding/revancify_blue/splash/drawable-xxhdpi/record.png b/patches/src/main/resources/music/branding/revancify_blue/splash/drawable-xxhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_blue/splash/drawable-xxhdpi/record.png rename to patches/src/main/resources/music/branding/revancify_blue/splash/drawable-xxhdpi/record.png diff --git a/src/main/resources/music/branding/revancify_red/header/drawable-hdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/revancify_red/header/drawable-hdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/header/drawable-hdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/revancify_red/header/drawable-hdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/revancify_red/header/drawable-hdpi/logo_music.png b/patches/src/main/resources/music/branding/revancify_red/header/drawable-hdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/header/drawable-hdpi/logo_music.png rename to patches/src/main/resources/music/branding/revancify_red/header/drawable-hdpi/logo_music.png diff --git a/src/main/resources/music/branding/revancify_red/header/drawable-hdpi/ytm_logo.png b/patches/src/main/resources/music/branding/revancify_red/header/drawable-hdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/header/drawable-hdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/revancify_red/header/drawable-hdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/revancify_red/header/drawable-mdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/revancify_red/header/drawable-mdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/header/drawable-mdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/revancify_red/header/drawable-mdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/revancify_red/header/drawable-mdpi/logo_music.png b/patches/src/main/resources/music/branding/revancify_red/header/drawable-mdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/header/drawable-mdpi/logo_music.png rename to patches/src/main/resources/music/branding/revancify_red/header/drawable-mdpi/logo_music.png diff --git a/src/main/resources/music/branding/revancify_red/header/drawable-mdpi/ytm_logo.png b/patches/src/main/resources/music/branding/revancify_red/header/drawable-mdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/header/drawable-mdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/revancify_red/header/drawable-mdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/revancify_red/header/drawable-xhdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/revancify_red/header/drawable-xhdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/header/drawable-xhdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/revancify_red/header/drawable-xhdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/revancify_red/header/drawable-xhdpi/logo_music.png b/patches/src/main/resources/music/branding/revancify_red/header/drawable-xhdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/header/drawable-xhdpi/logo_music.png rename to patches/src/main/resources/music/branding/revancify_red/header/drawable-xhdpi/logo_music.png diff --git a/src/main/resources/music/branding/revancify_red/header/drawable-xhdpi/ytm_logo.png b/patches/src/main/resources/music/branding/revancify_red/header/drawable-xhdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/header/drawable-xhdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/revancify_red/header/drawable-xhdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/revancify_red/header/drawable-xxhdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/revancify_red/header/drawable-xxhdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/header/drawable-xxhdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/revancify_red/header/drawable-xxhdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/revancify_red/header/drawable-xxhdpi/logo_music.png b/patches/src/main/resources/music/branding/revancify_red/header/drawable-xxhdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/header/drawable-xxhdpi/logo_music.png rename to patches/src/main/resources/music/branding/revancify_red/header/drawable-xxhdpi/logo_music.png diff --git a/src/main/resources/music/branding/revancify_red/header/drawable-xxhdpi/ytm_logo.png b/patches/src/main/resources/music/branding/revancify_red/header/drawable-xxhdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/header/drawable-xxhdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/revancify_red/header/drawable-xxhdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/revancify_red/header/drawable-xxxhdpi/action_bar_logo.png b/patches/src/main/resources/music/branding/revancify_red/header/drawable-xxxhdpi/action_bar_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/header/drawable-xxxhdpi/action_bar_logo.png rename to patches/src/main/resources/music/branding/revancify_red/header/drawable-xxxhdpi/action_bar_logo.png diff --git a/src/main/resources/music/branding/revancify_red/header/drawable-xxxhdpi/logo_music.png b/patches/src/main/resources/music/branding/revancify_red/header/drawable-xxxhdpi/logo_music.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/header/drawable-xxxhdpi/logo_music.png rename to patches/src/main/resources/music/branding/revancify_red/header/drawable-xxxhdpi/logo_music.png diff --git a/src/main/resources/music/branding/revancify_red/header/drawable-xxxhdpi/ytm_logo.png b/patches/src/main/resources/music/branding/revancify_red/header/drawable-xxxhdpi/ytm_logo.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/header/drawable-xxxhdpi/ytm_logo.png rename to patches/src/main/resources/music/branding/revancify_red/header/drawable-xxxhdpi/ytm_logo.png diff --git a/src/main/resources/music/branding/revancify_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/revancify_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/revancify_red/launcher/mipmap-hdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-hdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/launcher/mipmap-hdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-hdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/revancify_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/revancify_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/revancify_red/launcher/mipmap-mdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-mdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/launcher/mipmap-mdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-mdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/revancify_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/revancify_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/revancify_red/launcher/mipmap-xhdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xhdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/launcher/mipmap-xhdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xhdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxhdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxhdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/launcher/mipmap-xxhdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxhdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png b/patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png rename to patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_background_color_108.png diff --git a/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png b/patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png rename to patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_music_foreground_color_108.png diff --git a/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxxhdpi/ic_launcher_release.png b/patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxxhdpi/ic_launcher_release.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/launcher/mipmap-xxxhdpi/ic_launcher_release.png rename to patches/src/main/resources/music/branding/revancify_red/launcher/mipmap-xxxhdpi/ic_launcher_release.png diff --git a/src/main/resources/music/branding/revancify_red/monochrome/drawable/ic_app_icons_themed_youtube_music.xml b/patches/src/main/resources/music/branding/revancify_red/monochrome/drawable/ic_app_icons_themed_youtube_music.xml similarity index 100% rename from src/main/resources/music/branding/revancify_red/monochrome/drawable/ic_app_icons_themed_youtube_music.xml rename to patches/src/main/resources/music/branding/revancify_red/monochrome/drawable/ic_app_icons_themed_youtube_music.xml diff --git a/src/main/resources/music/branding/revancify_red/settings/drawable/revanced_extended_settings_icon.xml b/patches/src/main/resources/music/branding/revancify_red/settings/drawable/revanced_extended_settings_icon.xml similarity index 100% rename from src/main/resources/music/branding/revancify_red/settings/drawable/revanced_extended_settings_icon.xml rename to patches/src/main/resources/music/branding/revancify_red/settings/drawable/revanced_extended_settings_icon.xml diff --git a/src/main/resources/music/branding/revancify_red/splash/drawable-hdpi/action_bar_logo_release.png b/patches/src/main/resources/music/branding/revancify_red/splash/drawable-hdpi/action_bar_logo_release.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/splash/drawable-hdpi/action_bar_logo_release.png rename to patches/src/main/resources/music/branding/revancify_red/splash/drawable-hdpi/action_bar_logo_release.png diff --git a/src/main/resources/music/branding/revancify_red/splash/drawable-hdpi/record.png b/patches/src/main/resources/music/branding/revancify_red/splash/drawable-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/splash/drawable-hdpi/record.png rename to patches/src/main/resources/music/branding/revancify_red/splash/drawable-hdpi/record.png diff --git a/src/main/resources/music/branding/revancify_red/splash/drawable-large-hdpi/record.png b/patches/src/main/resources/music/branding/revancify_red/splash/drawable-large-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/splash/drawable-large-hdpi/record.png rename to patches/src/main/resources/music/branding/revancify_red/splash/drawable-large-hdpi/record.png diff --git a/src/main/resources/music/branding/revancify_red/splash/drawable-large-mdpi/record.png b/patches/src/main/resources/music/branding/revancify_red/splash/drawable-large-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/splash/drawable-large-mdpi/record.png rename to patches/src/main/resources/music/branding/revancify_red/splash/drawable-large-mdpi/record.png diff --git a/src/main/resources/music/branding/revancify_red/splash/drawable-large-xhdpi/record.png b/patches/src/main/resources/music/branding/revancify_red/splash/drawable-large-xhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/splash/drawable-large-xhdpi/record.png rename to patches/src/main/resources/music/branding/revancify_red/splash/drawable-large-xhdpi/record.png diff --git a/src/main/resources/music/branding/revancify_red/splash/drawable-mdpi/record.png b/patches/src/main/resources/music/branding/revancify_red/splash/drawable-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/splash/drawable-mdpi/record.png rename to patches/src/main/resources/music/branding/revancify_red/splash/drawable-mdpi/record.png diff --git a/src/main/resources/music/branding/revancify_red/splash/drawable-xhdpi/record.png b/patches/src/main/resources/music/branding/revancify_red/splash/drawable-xhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/splash/drawable-xhdpi/record.png rename to patches/src/main/resources/music/branding/revancify_red/splash/drawable-xhdpi/record.png diff --git a/src/main/resources/music/branding/revancify_red/splash/drawable-xlarge-hdpi/record.png b/patches/src/main/resources/music/branding/revancify_red/splash/drawable-xlarge-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/splash/drawable-xlarge-hdpi/record.png rename to patches/src/main/resources/music/branding/revancify_red/splash/drawable-xlarge-hdpi/record.png diff --git a/src/main/resources/music/branding/revancify_red/splash/drawable-xlarge-mdpi/record.png b/patches/src/main/resources/music/branding/revancify_red/splash/drawable-xlarge-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/splash/drawable-xlarge-mdpi/record.png rename to patches/src/main/resources/music/branding/revancify_red/splash/drawable-xlarge-mdpi/record.png diff --git a/src/main/resources/music/branding/revancify_red/splash/drawable-xxhdpi/record.png b/patches/src/main/resources/music/branding/revancify_red/splash/drawable-xxhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/revancify_red/splash/drawable-xxhdpi/record.png rename to patches/src/main/resources/music/branding/revancify_red/splash/drawable-xxhdpi/record.png diff --git a/src/main/resources/music/branding/youtube_music/settings/drawable/revanced_extended_settings_icon.xml b/patches/src/main/resources/music/branding/youtube_music/settings/drawable/revanced_extended_settings_icon.xml similarity index 100% rename from src/main/resources/music/branding/youtube_music/settings/drawable/revanced_extended_settings_icon.xml rename to patches/src/main/resources/music/branding/youtube_music/settings/drawable/revanced_extended_settings_icon.xml diff --git a/src/main/resources/music/branding/youtube_music/splash/drawable-hdpi/action_bar_logo_release.png b/patches/src/main/resources/music/branding/youtube_music/splash/drawable-hdpi/action_bar_logo_release.png similarity index 100% rename from src/main/resources/music/branding/youtube_music/splash/drawable-hdpi/action_bar_logo_release.png rename to patches/src/main/resources/music/branding/youtube_music/splash/drawable-hdpi/action_bar_logo_release.png diff --git a/src/main/resources/music/branding/youtube_music/splash/drawable-hdpi/record.png b/patches/src/main/resources/music/branding/youtube_music/splash/drawable-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/youtube_music/splash/drawable-hdpi/record.png rename to patches/src/main/resources/music/branding/youtube_music/splash/drawable-hdpi/record.png diff --git a/src/main/resources/music/branding/youtube_music/splash/drawable-large-hdpi/record.png b/patches/src/main/resources/music/branding/youtube_music/splash/drawable-large-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/youtube_music/splash/drawable-large-hdpi/record.png rename to patches/src/main/resources/music/branding/youtube_music/splash/drawable-large-hdpi/record.png diff --git a/src/main/resources/music/branding/youtube_music/splash/drawable-large-mdpi/record.png b/patches/src/main/resources/music/branding/youtube_music/splash/drawable-large-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/youtube_music/splash/drawable-large-mdpi/record.png rename to patches/src/main/resources/music/branding/youtube_music/splash/drawable-large-mdpi/record.png diff --git a/src/main/resources/music/branding/youtube_music/splash/drawable-large-xhdpi/record.png b/patches/src/main/resources/music/branding/youtube_music/splash/drawable-large-xhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/youtube_music/splash/drawable-large-xhdpi/record.png rename to patches/src/main/resources/music/branding/youtube_music/splash/drawable-large-xhdpi/record.png diff --git a/src/main/resources/music/branding/youtube_music/splash/drawable-mdpi/record.png b/patches/src/main/resources/music/branding/youtube_music/splash/drawable-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/youtube_music/splash/drawable-mdpi/record.png rename to patches/src/main/resources/music/branding/youtube_music/splash/drawable-mdpi/record.png diff --git a/src/main/resources/music/branding/youtube_music/splash/drawable-xhdpi/record.png b/patches/src/main/resources/music/branding/youtube_music/splash/drawable-xhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/youtube_music/splash/drawable-xhdpi/record.png rename to patches/src/main/resources/music/branding/youtube_music/splash/drawable-xhdpi/record.png diff --git a/src/main/resources/music/branding/youtube_music/splash/drawable-xlarge-hdpi/record.png b/patches/src/main/resources/music/branding/youtube_music/splash/drawable-xlarge-hdpi/record.png similarity index 100% rename from src/main/resources/music/branding/youtube_music/splash/drawable-xlarge-hdpi/record.png rename to patches/src/main/resources/music/branding/youtube_music/splash/drawable-xlarge-hdpi/record.png diff --git a/src/main/resources/music/branding/youtube_music/splash/drawable-xlarge-mdpi/record.png b/patches/src/main/resources/music/branding/youtube_music/splash/drawable-xlarge-mdpi/record.png similarity index 100% rename from src/main/resources/music/branding/youtube_music/splash/drawable-xlarge-mdpi/record.png rename to patches/src/main/resources/music/branding/youtube_music/splash/drawable-xlarge-mdpi/record.png diff --git a/src/main/resources/music/branding/youtube_music/splash/drawable-xxhdpi/record.png b/patches/src/main/resources/music/branding/youtube_music/splash/drawable-xxhdpi/record.png similarity index 100% rename from src/main/resources/music/branding/youtube_music/splash/drawable-xxhdpi/record.png rename to patches/src/main/resources/music/branding/youtube_music/splash/drawable-xxhdpi/record.png diff --git a/src/main/resources/music/flyout/drawable-hdpi/yt_outline_play_arrow_half_circle_black_24.png b/patches/src/main/resources/music/flyout/drawable-hdpi/yt_outline_play_arrow_half_circle_black_24.png similarity index 100% rename from src/main/resources/music/flyout/drawable-hdpi/yt_outline_play_arrow_half_circle_black_24.png rename to patches/src/main/resources/music/flyout/drawable-hdpi/yt_outline_play_arrow_half_circle_black_24.png diff --git a/src/main/resources/music/flyout/drawable-mdpi/yt_outline_play_arrow_half_circle_black_24.png b/patches/src/main/resources/music/flyout/drawable-mdpi/yt_outline_play_arrow_half_circle_black_24.png similarity index 100% rename from src/main/resources/music/flyout/drawable-mdpi/yt_outline_play_arrow_half_circle_black_24.png rename to patches/src/main/resources/music/flyout/drawable-mdpi/yt_outline_play_arrow_half_circle_black_24.png diff --git a/src/main/resources/music/flyout/drawable-xhdpi/yt_outline_play_arrow_half_circle_black_24.png b/patches/src/main/resources/music/flyout/drawable-xhdpi/yt_outline_play_arrow_half_circle_black_24.png similarity index 100% rename from src/main/resources/music/flyout/drawable-xhdpi/yt_outline_play_arrow_half_circle_black_24.png rename to patches/src/main/resources/music/flyout/drawable-xhdpi/yt_outline_play_arrow_half_circle_black_24.png diff --git a/src/main/resources/music/flyout/drawable-xxhdpi/yt_outline_play_arrow_half_circle_black_24.png b/patches/src/main/resources/music/flyout/drawable-xxhdpi/yt_outline_play_arrow_half_circle_black_24.png similarity index 100% rename from src/main/resources/music/flyout/drawable-xxhdpi/yt_outline_play_arrow_half_circle_black_24.png rename to patches/src/main/resources/music/flyout/drawable-xxhdpi/yt_outline_play_arrow_half_circle_black_24.png diff --git a/src/main/resources/music/flyout/drawable-xxxhdpi/yt_outline_play_arrow_half_circle_black_24.png b/patches/src/main/resources/music/flyout/drawable-xxxhdpi/yt_outline_play_arrow_half_circle_black_24.png similarity index 100% rename from src/main/resources/music/flyout/drawable-xxxhdpi/yt_outline_play_arrow_half_circle_black_24.png rename to patches/src/main/resources/music/flyout/drawable-xxxhdpi/yt_outline_play_arrow_half_circle_black_24.png diff --git a/src/main/resources/music/settings/host/values/arrays.xml b/patches/src/main/resources/music/settings/host/values/arrays.xml similarity index 100% rename from src/main/resources/music/settings/host/values/arrays.xml rename to patches/src/main/resources/music/settings/host/values/arrays.xml diff --git a/src/main/resources/music/settings/host/values/colors.xml b/patches/src/main/resources/music/settings/host/values/colors.xml similarity index 100% rename from src/main/resources/music/settings/host/values/colors.xml rename to patches/src/main/resources/music/settings/host/values/colors.xml diff --git a/src/main/resources/music/settings/host/values/strings.xml b/patches/src/main/resources/music/settings/host/values/strings.xml similarity index 100% rename from src/main/resources/music/settings/host/values/strings.xml rename to patches/src/main/resources/music/settings/host/values/strings.xml diff --git a/src/main/resources/music/settings/icons/drawable-xxhdpi/empty_icon.png b/patches/src/main/resources/music/settings/icons/drawable-xxhdpi/empty_icon.png similarity index 100% rename from src/main/resources/music/settings/icons/drawable-xxhdpi/empty_icon.png rename to patches/src/main/resources/music/settings/icons/drawable-xxhdpi/empty_icon.png diff --git a/src/main/resources/music/settings/icons/drawable/icon.xml b/patches/src/main/resources/music/settings/icons/drawable/icon.xml similarity index 100% rename from src/main/resources/music/settings/icons/drawable/icon.xml rename to patches/src/main/resources/music/settings/icons/drawable/icon.xml diff --git a/src/main/resources/music/settings/icons/extension/drawable/revanced_extended_settings_icon.xml b/patches/src/main/resources/music/settings/icons/extension/drawable/revanced_extended_settings_icon.xml similarity index 100% rename from src/main/resources/music/settings/icons/extension/drawable/revanced_extended_settings_icon.xml rename to patches/src/main/resources/music/settings/icons/extension/drawable/revanced_extended_settings_icon.xml diff --git a/src/main/resources/music/settings/icons/gear/drawable/revanced_extended_settings_icon.xml b/patches/src/main/resources/music/settings/icons/gear/drawable/revanced_extended_settings_icon.xml similarity index 100% rename from src/main/resources/music/settings/icons/gear/drawable/revanced_extended_settings_icon.xml rename to patches/src/main/resources/music/settings/icons/gear/drawable/revanced_extended_settings_icon.xml diff --git a/src/main/resources/music/settings/icons/revanced/drawable/revanced_extended_settings_icon.xml b/patches/src/main/resources/music/settings/icons/revanced/drawable/revanced_extended_settings_icon.xml similarity index 100% rename from src/main/resources/music/settings/icons/revanced/drawable/revanced_extended_settings_icon.xml rename to patches/src/main/resources/music/settings/icons/revanced/drawable/revanced_extended_settings_icon.xml diff --git a/src/main/resources/music/settings/icons/revanced_colored/drawable/revanced_extended_settings_icon.xml b/patches/src/main/resources/music/settings/icons/revanced_colored/drawable/revanced_extended_settings_icon.xml similarity index 100% rename from src/main/resources/music/settings/icons/revanced_colored/drawable/revanced_extended_settings_icon.xml rename to patches/src/main/resources/music/settings/icons/revanced_colored/drawable/revanced_extended_settings_icon.xml diff --git a/src/main/resources/music/translations/bg-rBG/strings.xml b/patches/src/main/resources/music/translations/bg-rBG/strings.xml similarity index 100% rename from src/main/resources/music/translations/bg-rBG/strings.xml rename to patches/src/main/resources/music/translations/bg-rBG/strings.xml diff --git a/src/main/resources/music/translations/bn/strings.xml b/patches/src/main/resources/music/translations/bn/strings.xml similarity index 100% rename from src/main/resources/music/translations/bn/strings.xml rename to patches/src/main/resources/music/translations/bn/strings.xml diff --git a/src/main/resources/music/translations/cs-rCZ/strings.xml b/patches/src/main/resources/music/translations/cs-rCZ/strings.xml similarity index 100% rename from src/main/resources/music/translations/cs-rCZ/strings.xml rename to patches/src/main/resources/music/translations/cs-rCZ/strings.xml diff --git a/src/main/resources/music/translations/el-rGR/strings.xml b/patches/src/main/resources/music/translations/el-rGR/strings.xml similarity index 100% rename from src/main/resources/music/translations/el-rGR/strings.xml rename to patches/src/main/resources/music/translations/el-rGR/strings.xml diff --git a/src/main/resources/music/translations/es-rES/strings.xml b/patches/src/main/resources/music/translations/es-rES/strings.xml similarity index 100% rename from src/main/resources/music/translations/es-rES/strings.xml rename to patches/src/main/resources/music/translations/es-rES/strings.xml diff --git a/src/main/resources/music/translations/fr-rFR/strings.xml b/patches/src/main/resources/music/translations/fr-rFR/strings.xml similarity index 100% rename from src/main/resources/music/translations/fr-rFR/strings.xml rename to patches/src/main/resources/music/translations/fr-rFR/strings.xml diff --git a/src/main/resources/music/translations/hu-rHU/strings.xml b/patches/src/main/resources/music/translations/hu-rHU/strings.xml similarity index 100% rename from src/main/resources/music/translations/hu-rHU/strings.xml rename to patches/src/main/resources/music/translations/hu-rHU/strings.xml diff --git a/src/main/resources/music/translations/id-rID/strings.xml b/patches/src/main/resources/music/translations/id-rID/strings.xml similarity index 100% rename from src/main/resources/music/translations/id-rID/strings.xml rename to patches/src/main/resources/music/translations/id-rID/strings.xml diff --git a/src/main/resources/music/translations/in/strings.xml b/patches/src/main/resources/music/translations/in/strings.xml similarity index 100% rename from src/main/resources/music/translations/in/strings.xml rename to patches/src/main/resources/music/translations/in/strings.xml diff --git a/src/main/resources/music/translations/it-rIT/strings.xml b/patches/src/main/resources/music/translations/it-rIT/strings.xml similarity index 100% rename from src/main/resources/music/translations/it-rIT/strings.xml rename to patches/src/main/resources/music/translations/it-rIT/strings.xml diff --git a/src/main/resources/music/translations/ja-rJP/strings.xml b/patches/src/main/resources/music/translations/ja-rJP/strings.xml similarity index 100% rename from src/main/resources/music/translations/ja-rJP/strings.xml rename to patches/src/main/resources/music/translations/ja-rJP/strings.xml diff --git a/src/main/resources/music/translations/ko-rKR/strings.xml b/patches/src/main/resources/music/translations/ko-rKR/strings.xml similarity index 100% rename from src/main/resources/music/translations/ko-rKR/strings.xml rename to patches/src/main/resources/music/translations/ko-rKR/strings.xml diff --git a/src/main/resources/music/translations/nl-rNL/strings.xml b/patches/src/main/resources/music/translations/nl-rNL/strings.xml similarity index 100% rename from src/main/resources/music/translations/nl-rNL/strings.xml rename to patches/src/main/resources/music/translations/nl-rNL/strings.xml diff --git a/src/main/resources/music/translations/pl-rPL/strings.xml b/patches/src/main/resources/music/translations/pl-rPL/strings.xml similarity index 100% rename from src/main/resources/music/translations/pl-rPL/strings.xml rename to patches/src/main/resources/music/translations/pl-rPL/strings.xml diff --git a/src/main/resources/music/translations/pt-rBR/strings.xml b/patches/src/main/resources/music/translations/pt-rBR/strings.xml similarity index 100% rename from src/main/resources/music/translations/pt-rBR/strings.xml rename to patches/src/main/resources/music/translations/pt-rBR/strings.xml diff --git a/src/main/resources/music/translations/ro-rRO/strings.xml b/patches/src/main/resources/music/translations/ro-rRO/strings.xml similarity index 100% rename from src/main/resources/music/translations/ro-rRO/strings.xml rename to patches/src/main/resources/music/translations/ro-rRO/strings.xml diff --git a/src/main/resources/music/translations/ru-rRU/strings.xml b/patches/src/main/resources/music/translations/ru-rRU/strings.xml similarity index 100% rename from src/main/resources/music/translations/ru-rRU/strings.xml rename to patches/src/main/resources/music/translations/ru-rRU/strings.xml diff --git a/src/main/resources/music/translations/tr-rTR/strings.xml b/patches/src/main/resources/music/translations/tr-rTR/strings.xml similarity index 100% rename from src/main/resources/music/translations/tr-rTR/strings.xml rename to patches/src/main/resources/music/translations/tr-rTR/strings.xml diff --git a/src/main/resources/music/translations/uk-rUA/strings.xml b/patches/src/main/resources/music/translations/uk-rUA/strings.xml similarity index 100% rename from src/main/resources/music/translations/uk-rUA/strings.xml rename to patches/src/main/resources/music/translations/uk-rUA/strings.xml diff --git a/src/main/resources/music/translations/vi-rVN/strings.xml b/patches/src/main/resources/music/translations/vi-rVN/strings.xml similarity index 100% rename from src/main/resources/music/translations/vi-rVN/strings.xml rename to patches/src/main/resources/music/translations/vi-rVN/strings.xml diff --git a/src/main/resources/music/translations/zh-rCN/strings.xml b/patches/src/main/resources/music/translations/zh-rCN/strings.xml similarity index 100% rename from src/main/resources/music/translations/zh-rCN/strings.xml rename to patches/src/main/resources/music/translations/zh-rCN/strings.xml diff --git a/src/main/resources/music/translations/zh-rTW/strings.xml b/patches/src/main/resources/music/translations/zh-rTW/strings.xml similarity index 100% rename from src/main/resources/music/translations/zh-rTW/strings.xml rename to patches/src/main/resources/music/translations/zh-rTW/strings.xml diff --git a/src/main/resources/music/visual/icons/extension/drawable/revanced_extended_settings_icon.xml b/patches/src/main/resources/music/visual/icons/extension/drawable/revanced_extended_settings_icon.xml similarity index 100% rename from src/main/resources/music/visual/icons/extension/drawable/revanced_extended_settings_icon.xml rename to patches/src/main/resources/music/visual/icons/extension/drawable/revanced_extended_settings_icon.xml diff --git a/src/main/resources/music/visual/icons/gear/drawable/revanced_extended_settings_icon.xml b/patches/src/main/resources/music/visual/icons/gear/drawable/revanced_extended_settings_icon.xml similarity index 100% rename from src/main/resources/music/visual/icons/gear/drawable/revanced_extended_settings_icon.xml rename to patches/src/main/resources/music/visual/icons/gear/drawable/revanced_extended_settings_icon.xml diff --git a/src/main/resources/music/visual/icons/revanced/drawable/revanced_extended_settings_icon.xml b/patches/src/main/resources/music/visual/icons/revanced/drawable/revanced_extended_settings_icon.xml similarity index 100% rename from src/main/resources/music/visual/icons/revanced/drawable/revanced_extended_settings_icon.xml rename to patches/src/main/resources/music/visual/icons/revanced/drawable/revanced_extended_settings_icon.xml diff --git a/src/main/resources/music/visual/icons/revanced_colored/drawable/revanced_extended_settings_icon.xml b/patches/src/main/resources/music/visual/icons/revanced_colored/drawable/revanced_extended_settings_icon.xml similarity index 100% rename from src/main/resources/music/visual/icons/revanced_colored/drawable/revanced_extended_settings_icon.xml rename to patches/src/main/resources/music/visual/icons/revanced_colored/drawable/revanced_extended_settings_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/pref_key_parent_tools_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/pref_key_parent_tools_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/pref_key_parent_tools_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/pref_key_parent_tools_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/revanced_extended_settings_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/revanced_extended_settings_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/revanced_extended_settings_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/revanced_extended_settings_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_account_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_account_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/revanced_preference_screen_account_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_account_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_action_bar_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_action_bar_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/revanced_preference_screen_action_bar_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_action_bar_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_ads_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_ads_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/revanced_preference_screen_ads_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_ads_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_flyout_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_flyout_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/revanced_preference_screen_flyout_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_flyout_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_general_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_general_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/revanced_preference_screen_general_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_general_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_misc_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_misc_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/revanced_preference_screen_misc_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_misc_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_navigation_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_navigation_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/revanced_preference_screen_navigation_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_navigation_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_player_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_player_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/revanced_preference_screen_player_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_player_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_return_youtube_username_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_return_youtube_username_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/revanced_preference_screen_return_youtube_username_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_return_youtube_username_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_ryd_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_ryd_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/revanced_preference_screen_ryd_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_ryd_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_sb_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_sb_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/revanced_preference_screen_sb_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_sb_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_settings_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_settings_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/revanced_preference_screen_settings_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_settings_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_video_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_video_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/revanced_preference_screen_video_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/revanced_preference_screen_video_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/settings_header_about_youtube_music_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/settings_header_about_youtube_music_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/settings_header_about_youtube_music_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/settings_header_about_youtube_music_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/settings_header_data_saving_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/settings_header_data_saving_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/settings_header_data_saving_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/settings_header_data_saving_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/settings_header_downloads_and_storage_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/settings_header_downloads_and_storage_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/settings_header_downloads_and_storage_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/settings_header_downloads_and_storage_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/settings_header_general_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/settings_header_general_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/settings_header_general_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/settings_header_general_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/settings_header_notifications_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/settings_header_notifications_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/settings_header_notifications_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/settings_header_notifications_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/settings_header_paid_memberships_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/settings_header_paid_memberships_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/settings_header_paid_memberships_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/settings_header_paid_memberships_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/settings_header_playback_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/settings_header_playback_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/settings_header_playback_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/settings_header_playback_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/settings_header_privacy_and_location_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/settings_header_privacy_and_location_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/settings_header_privacy_and_location_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/settings_header_privacy_and_location_icon.xml diff --git a/src/main/resources/music/visual/shared/drawable/settings_header_recommendations_icon.xml b/patches/src/main/resources/music/visual/shared/drawable/settings_header_recommendations_icon.xml similarity index 100% rename from src/main/resources/music/visual/shared/drawable/settings_header_recommendations_icon.xml rename to patches/src/main/resources/music/visual/shared/drawable/settings_header_recommendations_icon.xml diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-hdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-hdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-hdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-hdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-hdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-hdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-hdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-hdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-hdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-hdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-hdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-hdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-hdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-hdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-hdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-hdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-mdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-mdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-mdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-mdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-mdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-mdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-mdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-mdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-mdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-mdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-mdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-mdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-mdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-mdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-mdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-mdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-xhdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xhdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-xhdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xhdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-xhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-xhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-xhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-xhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-xxhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xxhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-xxhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xxhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-xxhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xxhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-xxhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xxhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-xxxhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xxxhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-xxxhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xxxhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_blue/header/drawable-xxxhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xxxhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/header/drawable-xxxhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_blue/header/drawable-xxxhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-hdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-hdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-hdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-hdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-hdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-hdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-hdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-mdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-mdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-mdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-mdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-mdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-mdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-mdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xhdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xhdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xhdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xhdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xhdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xhdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxhdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxhdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxhdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxhdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxhdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxhdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxxhdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxxhdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxxhdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxxhdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxxhdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/afn_blue/launcher/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/afn_blue/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml b/patches/src/main/resources/youtube/branding/afn_blue/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml rename to patches/src/main/resources/youtube/branding/afn_blue/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml diff --git a/src/main/resources/youtube/branding/afn_blue/settings/drawable/revanced_extended_settings_key_icon.xml b/patches/src/main/resources/youtube/branding/afn_blue/settings/drawable/revanced_extended_settings_key_icon.xml similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/settings/drawable/revanced_extended_settings_key_icon.xml rename to patches/src/main/resources/youtube/branding/afn_blue/settings/drawable/revanced_extended_settings_key_icon.xml diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-hdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-hdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-hdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-hdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-hdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-hdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-hdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-hdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-hdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-hdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-hdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-hdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-hdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-hdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-hdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-hdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-mdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-mdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-mdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-mdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-mdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-mdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-mdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-mdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-mdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-mdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-mdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-mdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-mdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-mdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-mdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-mdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-xhdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xhdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-xhdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xhdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-xhdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xhdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-xhdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xhdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-xhdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xhdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-xhdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xhdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-xhdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xhdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-xhdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xhdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxhdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxhdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-xxhdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxhdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxhdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxhdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-xxhdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxhdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxhdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxhdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-xxhdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxhdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxhdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxhdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-xxhdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxhdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/afn_blue/splash/drawable/avd_anim.xml b/patches/src/main/resources/youtube/branding/afn_blue/splash/drawable/avd_anim.xml similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/drawable/avd_anim.xml rename to patches/src/main/resources/youtube/branding/afn_blue/splash/drawable/avd_anim.xml diff --git a/src/main/resources/youtube/branding/afn_blue/splash/values-v31/styles.xml b/patches/src/main/resources/youtube/branding/afn_blue/splash/values-v31/styles.xml similarity index 100% rename from src/main/resources/youtube/branding/afn_blue/splash/values-v31/styles.xml rename to patches/src/main/resources/youtube/branding/afn_blue/splash/values-v31/styles.xml diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-hdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-hdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-hdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-hdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-hdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-hdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-hdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-hdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-hdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-hdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-hdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-hdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-hdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-hdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-hdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-hdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-mdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-mdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-mdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-mdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-mdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-mdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-mdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-mdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-mdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-mdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-mdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-mdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-mdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-mdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-mdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-mdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-xhdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-xhdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-xhdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-xhdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-xhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-xhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-xhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-xhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-xhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-xhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-xhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-xhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-xxhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-xxhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-xxhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-xxhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-xxhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-xxhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-xxhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-xxhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-xxxhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-xxxhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-xxxhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-xxxhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/afn_red/header/drawable-xxxhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/afn_red/header/drawable-xxxhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/header/drawable-xxxhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/afn_red/header/drawable-xxxhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-hdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-hdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-hdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-hdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-hdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-hdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-hdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-mdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-mdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-mdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-mdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-mdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-mdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-mdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xhdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-xhdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xhdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xhdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-xhdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xhdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxhdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxhdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxhdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxhdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxhdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxhdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxxhdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxxhdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxxhdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxxhdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxxhdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/afn_red/launcher/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/afn_red/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml b/patches/src/main/resources/youtube/branding/afn_red/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml similarity index 100% rename from src/main/resources/youtube/branding/afn_red/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml rename to patches/src/main/resources/youtube/branding/afn_red/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml diff --git a/src/main/resources/youtube/branding/afn_red/settings/drawable/revanced_extended_settings_key_icon.xml b/patches/src/main/resources/youtube/branding/afn_red/settings/drawable/revanced_extended_settings_key_icon.xml similarity index 100% rename from src/main/resources/youtube/branding/afn_red/settings/drawable/revanced_extended_settings_key_icon.xml rename to patches/src/main/resources/youtube/branding/afn_red/settings/drawable/revanced_extended_settings_key_icon.xml diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-hdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-hdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-hdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-hdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-hdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-hdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-hdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-hdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-hdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-hdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-hdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-hdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-hdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-hdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-hdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-hdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-mdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-mdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-mdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-mdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-mdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-mdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-mdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-mdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-mdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-mdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-mdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-mdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-mdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-mdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-mdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-mdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-xhdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xhdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-xhdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xhdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-xhdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xhdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-xhdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xhdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-xhdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xhdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-xhdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xhdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-xhdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xhdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-xhdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xhdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-xxhdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xxhdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-xxhdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xxhdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-xxhdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xxhdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-xxhdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xxhdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-xxhdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xxhdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-xxhdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xxhdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-xxhdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xxhdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-xxhdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xxhdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/afn_red/splash/drawable/avd_anim.xml b/patches/src/main/resources/youtube/branding/afn_red/splash/drawable/avd_anim.xml similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/drawable/avd_anim.xml rename to patches/src/main/resources/youtube/branding/afn_red/splash/drawable/avd_anim.xml diff --git a/src/main/resources/youtube/branding/afn_red/splash/values-v31/styles.xml b/patches/src/main/resources/youtube/branding/afn_red/splash/values-v31/styles.xml similarity index 100% rename from src/main/resources/youtube/branding/afn_red/splash/values-v31/styles.xml rename to patches/src/main/resources/youtube/branding/afn_red/splash/values-v31/styles.xml diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-hdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-hdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-hdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-hdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-hdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-hdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-hdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-hdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-hdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-hdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-hdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-hdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-hdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-hdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-hdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-hdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-mdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-mdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-mdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-mdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-mdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-mdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-mdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-mdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-mdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-mdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-mdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-mdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-mdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-mdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-mdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-mdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-xhdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-xhdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-xhdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-xhdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-xhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-xhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-xhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-xhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-xhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-xhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-xhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-xhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-xxhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-xxhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-xxhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-xxhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-xxhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-xxhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-xxhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-xxhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-xxxhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-xxxhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-xxxhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-xxxhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/mmt/header/drawable-xxxhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/mmt/header/drawable-xxxhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/header/drawable-xxxhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/mmt/header/drawable-xxxhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-hdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-hdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-hdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-hdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-hdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-hdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-hdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-mdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-mdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-mdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-mdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-mdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-mdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-mdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-xhdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-xhdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xhdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-xhdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-xhdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xhdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxhdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-xxhdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxhdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxhdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-xxhdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxhdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxxhdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-xxxhdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxxhdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxxhdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/launcher/mipmap-xxxhdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/mmt/launcher/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/mmt/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml b/patches/src/main/resources/youtube/branding/mmt/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml similarity index 100% rename from src/main/resources/youtube/branding/mmt/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml rename to patches/src/main/resources/youtube/branding/mmt/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml diff --git a/src/main/resources/youtube/branding/mmt/settings/drawable/revanced_extended_settings_key_icon.xml b/patches/src/main/resources/youtube/branding/mmt/settings/drawable/revanced_extended_settings_key_icon.xml similarity index 100% rename from src/main/resources/youtube/branding/mmt/settings/drawable/revanced_extended_settings_key_icon.xml rename to patches/src/main/resources/youtube/branding/mmt/settings/drawable/revanced_extended_settings_key_icon.xml diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-hdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-hdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-hdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-hdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-hdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-hdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-hdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-hdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-hdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-hdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-hdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-hdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-hdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-hdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-hdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-hdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-mdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-mdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-mdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-mdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-mdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-mdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-mdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-mdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-mdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-mdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-mdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-mdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-mdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-mdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-mdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-mdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-xhdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-xhdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-xhdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-xhdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-xhdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-xhdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-xhdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-xhdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-xhdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-xhdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-xhdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-xhdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-xhdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-xhdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-xhdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-xhdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-xxhdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-xxhdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-xxhdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-xxhdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-xxhdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-xxhdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-xxhdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-xxhdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-xxhdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-xxhdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-xxhdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-xxhdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-xxhdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-xxhdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-xxhdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-xxhdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/mmt/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__0.xml b/patches/src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__0.xml similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__0.xml rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__0.xml diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__1.xml b/patches/src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__1.xml similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__1.xml rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__1.xml diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__2.xml b/patches/src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__2.xml similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__2.xml rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__2.xml diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__3.xml b/patches/src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__3.xml similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__3.xml rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__3.xml diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__4.xml b/patches/src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__4.xml similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__4.xml rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable/$avd_anim__4.xml diff --git a/src/main/resources/youtube/branding/mmt/splash/drawable/avd_anim.xml b/patches/src/main/resources/youtube/branding/mmt/splash/drawable/avd_anim.xml similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/drawable/avd_anim.xml rename to patches/src/main/resources/youtube/branding/mmt/splash/drawable/avd_anim.xml diff --git a/src/main/resources/youtube/branding/mmt/splash/values-v31/styles.xml b/patches/src/main/resources/youtube/branding/mmt/splash/values-v31/styles.xml similarity index 100% rename from src/main/resources/youtube/branding/mmt/splash/values-v31/styles.xml rename to patches/src/main/resources/youtube/branding/mmt/splash/values-v31/styles.xml diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-hdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-hdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-hdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-hdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-hdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-hdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-hdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-hdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-hdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-hdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-hdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-hdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-hdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-hdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-hdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-hdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-mdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-mdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-mdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-mdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-mdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-mdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-mdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-mdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-mdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-mdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-mdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-mdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-mdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-mdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-mdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-mdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-xhdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xhdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-xhdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xhdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-xhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-xhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-xhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-xhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-xxhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-xxhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxxhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxxhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-xxxhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxxhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxxhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxxhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/header/drawable-xxxhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_blue/header/drawable-xxxhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-hdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-hdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-hdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-hdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-hdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-hdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-hdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-mdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-mdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-mdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-mdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-mdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-mdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-mdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xhdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xhdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xhdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xhdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xhdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xhdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxhdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxhdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxhdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxhdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxhdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxhdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxxhdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxxhdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxxhdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxxhdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxxhdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/revancify_blue/launcher/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/revancify_blue/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml b/patches/src/main/resources/youtube/branding/revancify_blue/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml rename to patches/src/main/resources/youtube/branding/revancify_blue/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml diff --git a/src/main/resources/youtube/branding/revancify_blue/settings/drawable/revanced_extended_settings_key_icon.xml b/patches/src/main/resources/youtube/branding/revancify_blue/settings/drawable/revanced_extended_settings_key_icon.xml similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/settings/drawable/revanced_extended_settings_key_icon.xml rename to patches/src/main/resources/youtube/branding/revancify_blue/settings/drawable/revanced_extended_settings_key_icon.xml diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-hdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-hdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-hdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-hdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-hdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-hdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-hdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-hdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-hdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-hdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-hdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-hdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-hdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-hdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-hdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-hdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-mdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-mdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-mdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-mdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-mdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-mdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-mdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-mdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-mdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-mdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-mdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-mdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-mdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-mdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-mdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-mdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xhdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xhdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-xhdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xhdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xhdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xhdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-xhdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xhdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xhdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xhdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-xhdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xhdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xhdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xhdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-xhdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xhdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxhdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxhdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxhdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxhdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxhdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxhdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxhdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxhdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxhdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxhdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxhdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxhdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxhdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxhdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxhdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxhdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/drawable/avd_anim.xml b/patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable/avd_anim.xml similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/drawable/avd_anim.xml rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/drawable/avd_anim.xml diff --git a/src/main/resources/youtube/branding/revancify_blue/splash/values-v31/styles.xml b/patches/src/main/resources/youtube/branding/revancify_blue/splash/values-v31/styles.xml similarity index 100% rename from src/main/resources/youtube/branding/revancify_blue/splash/values-v31/styles.xml rename to patches/src/main/resources/youtube/branding/revancify_blue/splash/values-v31/styles.xml diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-hdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-hdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-hdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-hdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-hdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-hdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-hdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-hdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-hdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-hdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-hdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-hdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-hdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-hdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-hdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-hdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-mdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-mdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-mdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-mdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-mdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-mdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-mdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-mdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-mdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-mdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-mdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-mdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-mdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-mdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-mdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-mdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xhdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-xhdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xhdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-xhdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xhdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-xhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-xhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-xhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-xhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xxhdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xxhdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-xxhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xxhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-xxhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xxhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-xxhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xxhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-xxhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xxhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xxxhdpi/yt_premium_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xxxhdpi/yt_premium_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-xxxhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xxxhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-xxxhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xxxhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/youtube/branding/revancify_red/header/drawable-xxxhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xxxhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/header/drawable-xxxhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/youtube/branding/revancify_red/header/drawable-xxxhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-hdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-hdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-hdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-hdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-hdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-hdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-hdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-mdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-mdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-mdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-mdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-mdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-mdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-mdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xhdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xhdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xhdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xhdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xhdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xhdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxhdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxhdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxhdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxhdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxhdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxhdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxxhdpi/ic_launcher.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxxhdpi/ic_launcher.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxxhdpi/ic_launcher.png diff --git a/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxxhdpi/ic_launcher_round.png b/patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxxhdpi/ic_launcher_round.png rename to patches/src/main/resources/youtube/branding/revancify_red/launcher/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/src/main/resources/youtube/branding/revancify_red/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml b/patches/src/main/resources/youtube/branding/revancify_red/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml rename to patches/src/main/resources/youtube/branding/revancify_red/monochrome/drawable/adaptive_monochrome_ic_youtube_launcher.xml diff --git a/src/main/resources/youtube/branding/revancify_red/settings/drawable/revanced_extended_settings_key_icon.xml b/patches/src/main/resources/youtube/branding/revancify_red/settings/drawable/revanced_extended_settings_key_icon.xml similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/settings/drawable/revanced_extended_settings_key_icon.xml rename to patches/src/main/resources/youtube/branding/revancify_red/settings/drawable/revanced_extended_settings_key_icon.xml diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-hdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-hdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-hdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-hdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-hdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-hdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-hdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-hdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-hdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-hdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-hdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-hdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-hdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-hdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-hdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-hdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-mdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-mdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-mdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-mdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-mdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-mdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-mdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-mdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-mdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-mdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-mdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-mdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-mdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-mdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-mdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-mdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-xhdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xhdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-xhdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xhdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-xhdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xhdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-xhdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xhdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-xhdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xhdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-xhdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xhdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-xhdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xhdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-xhdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xhdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxhdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxhdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-xxhdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxhdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxhdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxhdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-xxhdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxhdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxhdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxhdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-xxhdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxhdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxhdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxhdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-xxhdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxhdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxxhdpi/product_logo_youtube_color_144.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxxhdpi/product_logo_youtube_color_192.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxxhdpi/product_logo_youtube_color_24.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable-xxxhdpi/product_logo_youtube_color_36.png diff --git a/src/main/resources/youtube/branding/revancify_red/splash/drawable/avd_anim.xml b/patches/src/main/resources/youtube/branding/revancify_red/splash/drawable/avd_anim.xml similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/drawable/avd_anim.xml rename to patches/src/main/resources/youtube/branding/revancify_red/splash/drawable/avd_anim.xml diff --git a/src/main/resources/youtube/branding/revancify_red/splash/values-v31/styles.xml b/patches/src/main/resources/youtube/branding/revancify_red/splash/values-v31/styles.xml similarity index 100% rename from src/main/resources/youtube/branding/revancify_red/splash/values-v31/styles.xml rename to patches/src/main/resources/youtube/branding/revancify_red/splash/values-v31/styles.xml diff --git a/src/main/resources/youtube/branding/youtube/settings/drawable/revanced_extended_settings_key_icon.xml b/patches/src/main/resources/youtube/branding/youtube/settings/drawable/revanced_extended_settings_key_icon.xml similarity index 100% rename from src/main/resources/youtube/branding/youtube/settings/drawable/revanced_extended_settings_key_icon.xml rename to patches/src/main/resources/youtube/branding/youtube/settings/drawable/revanced_extended_settings_key_icon.xml diff --git a/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__1__0.xml b/patches/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__1__0.xml similarity index 100% rename from src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__1__0.xml rename to patches/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__1__0.xml diff --git a/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__1__1.xml b/patches/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__1__1.xml similarity index 100% rename from src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__1__1.xml rename to patches/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__1__1.xml diff --git a/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__2__0.xml b/patches/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__2__0.xml similarity index 100% rename from src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__2__0.xml rename to patches/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__2__0.xml diff --git a/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__2__1.xml b/patches/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__2__1.xml similarity index 100% rename from src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__2__1.xml rename to patches/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__2__1.xml diff --git a/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__3__0.xml b/patches/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__3__0.xml similarity index 100% rename from src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__3__0.xml rename to patches/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__3__0.xml diff --git a/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__3__1.xml b/patches/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__3__1.xml similarity index 100% rename from src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__3__1.xml rename to patches/src/main/resources/youtube/branding/youtube/splash/drawable/$$avd_anim__3__1.xml diff --git a/src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__0.xml b/patches/src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__0.xml similarity index 100% rename from src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__0.xml rename to patches/src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__0.xml diff --git a/src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__1.xml b/patches/src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__1.xml similarity index 100% rename from src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__1.xml rename to patches/src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__1.xml diff --git a/src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__2.xml b/patches/src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__2.xml similarity index 100% rename from src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__2.xml rename to patches/src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__2.xml diff --git a/src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__3.xml b/patches/src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__3.xml similarity index 100% rename from src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__3.xml rename to patches/src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__3.xml diff --git a/src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__4.xml b/patches/src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__4.xml similarity index 100% rename from src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__4.xml rename to patches/src/main/resources/youtube/branding/youtube/splash/drawable/$avd_anim__4.xml diff --git a/src/main/resources/youtube/branding/youtube/splash/drawable/avd_anim.xml b/patches/src/main/resources/youtube/branding/youtube/splash/drawable/avd_anim.xml similarity index 100% rename from src/main/resources/youtube/branding/youtube/splash/drawable/avd_anim.xml rename to patches/src/main/resources/youtube/branding/youtube/splash/drawable/avd_anim.xml diff --git a/src/main/resources/youtube/branding/youtube/splash/values-v31/styles.xml b/patches/src/main/resources/youtube/branding/youtube/splash/values-v31/styles.xml similarity index 100% rename from src/main/resources/youtube/branding/youtube/splash/values-v31/styles.xml rename to patches/src/main/resources/youtube/branding/youtube/splash/values-v31/styles.xml diff --git a/src/main/resources/youtube/doubletap/values-v21/arrays.xml b/patches/src/main/resources/youtube/doubletap/values-v21/arrays.xml similarity index 100% rename from src/main/resources/youtube/doubletap/values-v21/arrays.xml rename to patches/src/main/resources/youtube/doubletap/values-v21/arrays.xml diff --git a/src/main/resources/youtube/materialyou/drawable-night-v31/new_content_dot_background.xml b/patches/src/main/resources/youtube/materialyou/drawable-night-v31/new_content_dot_background.xml similarity index 100% rename from src/main/resources/youtube/materialyou/drawable-night-v31/new_content_dot_background.xml rename to patches/src/main/resources/youtube/materialyou/drawable-night-v31/new_content_dot_background.xml diff --git a/src/main/resources/youtube/materialyou/drawable-v31/new_content_count_background.xml b/patches/src/main/resources/youtube/materialyou/drawable-v31/new_content_count_background.xml similarity index 100% rename from src/main/resources/youtube/materialyou/drawable-v31/new_content_count_background.xml rename to patches/src/main/resources/youtube/materialyou/drawable-v31/new_content_count_background.xml diff --git a/src/main/resources/youtube/materialyou/drawable-v31/new_content_dot_background.xml b/patches/src/main/resources/youtube/materialyou/drawable-v31/new_content_dot_background.xml similarity index 100% rename from src/main/resources/youtube/materialyou/drawable-v31/new_content_dot_background.xml rename to patches/src/main/resources/youtube/materialyou/drawable-v31/new_content_dot_background.xml diff --git a/src/main/resources/youtube/materialyou/host/values-v31/colors.xml b/patches/src/main/resources/youtube/materialyou/host/values-v31/colors.xml similarity index 100% rename from src/main/resources/youtube/materialyou/host/values-v31/colors.xml rename to patches/src/main/resources/youtube/materialyou/host/values-v31/colors.xml diff --git a/src/main/resources/youtube/materialyou/layout-v31/new_content_count.xml b/patches/src/main/resources/youtube/materialyou/layout-v31/new_content_count.xml similarity index 100% rename from src/main/resources/youtube/materialyou/layout-v31/new_content_count.xml rename to patches/src/main/resources/youtube/materialyou/layout-v31/new_content_count.xml diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/ic_vr.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/ic_vr.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/ic_vr.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/ic_vr.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/quantum_ic_fullscreen_exit_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/quantum_ic_fullscreen_exit_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/quantum_ic_fullscreen_exit_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/quantum_ic_fullscreen_exit_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/quantum_ic_fullscreen_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/quantum_ic_fullscreen_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/quantum_ic_fullscreen_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/quantum_ic_fullscreen_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/quantum_ic_fullscreen_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/quantum_ic_fullscreen_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/quantum_ic_fullscreen_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/quantum_ic_fullscreen_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/quantum_ic_fullscreen_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/quantum_ic_fullscreen_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/quantum_ic_fullscreen_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/quantum_ic_fullscreen_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_copy_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_copy_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_copy_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_copy_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_copy_timestamp_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_copy_timestamp_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_copy_timestamp_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_copy_timestamp_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_download_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_download_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_download_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_download_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_play_all_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_play_all_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_play_all_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_play_all_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_speed_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_speed_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_speed_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_speed_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_volume_muted_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_volume_muted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_volume_muted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_volume_muted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_volume_unmuted_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_volume_unmuted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_volume_unmuted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_volume_unmuted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_whitelist_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_whitelist_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_whitelist_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/revanced_whitelist_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_fill_arrow_repeat_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_fill_arrow_repeat_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_fill_arrow_repeat_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_fill_arrow_repeat_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_arrow_repeat_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_arrow_repeat_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_arrow_repeat_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_arrow_repeat_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_arrow_shuffle_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_arrow_shuffle_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_arrow_shuffle_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_arrow_shuffle_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_chevron_down_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_chevron_down_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_chevron_down_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_chevron_down_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_gear_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_gear_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_gear_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_gear_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_screen_full_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_screen_full_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_screen_full_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_screen_full_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_screen_full_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_screen_full_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_screen_full_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_screen_full_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_screen_full_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_screen_full_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_screen_full_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_screen_full_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_screen_vertical_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_screen_vertical_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_screen_vertical_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-hdpi/yt_outline_screen_vertical_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/ic_vr.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/ic_vr.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/ic_vr.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/ic_vr.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/quantum_ic_fullscreen_exit_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/quantum_ic_fullscreen_exit_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/quantum_ic_fullscreen_exit_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/quantum_ic_fullscreen_exit_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/quantum_ic_fullscreen_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/quantum_ic_fullscreen_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/quantum_ic_fullscreen_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/quantum_ic_fullscreen_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/quantum_ic_fullscreen_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/quantum_ic_fullscreen_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/quantum_ic_fullscreen_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/quantum_ic_fullscreen_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/quantum_ic_fullscreen_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/quantum_ic_fullscreen_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/quantum_ic_fullscreen_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/quantum_ic_fullscreen_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_copy_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_copy_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_copy_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_copy_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_copy_timestamp_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_copy_timestamp_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_copy_timestamp_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_copy_timestamp_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_download_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_download_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_download_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_download_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_play_all_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_play_all_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_play_all_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_play_all_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_speed_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_speed_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_speed_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_speed_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_volume_muted_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_volume_muted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_volume_muted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_volume_muted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_volume_unmuted_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_volume_unmuted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_volume_unmuted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_volume_unmuted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_whitelist_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_whitelist_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_whitelist_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/revanced_whitelist_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_fill_arrow_repeat_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_fill_arrow_repeat_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_fill_arrow_repeat_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_fill_arrow_repeat_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_arrow_repeat_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_arrow_repeat_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_arrow_repeat_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_arrow_repeat_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_arrow_shuffle_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_arrow_shuffle_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_arrow_shuffle_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_arrow_shuffle_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_chevron_down_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_chevron_down_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_chevron_down_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_chevron_down_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_gear_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_gear_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_gear_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_gear_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_screen_full_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_screen_full_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_screen_full_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_screen_full_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_screen_full_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_screen_full_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_screen_full_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_screen_full_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_screen_full_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_screen_full_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_screen_full_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_screen_full_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_screen_vertical_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_screen_vertical_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_screen_vertical_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-mdpi/yt_outline_screen_vertical_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/ic_vr.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/ic_vr.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/ic_vr.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/ic_vr.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/quantum_ic_fullscreen_exit_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/quantum_ic_fullscreen_exit_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/quantum_ic_fullscreen_exit_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/quantum_ic_fullscreen_exit_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/quantum_ic_fullscreen_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/quantum_ic_fullscreen_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/quantum_ic_fullscreen_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/quantum_ic_fullscreen_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/quantum_ic_fullscreen_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/quantum_ic_fullscreen_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/quantum_ic_fullscreen_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/quantum_ic_fullscreen_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/quantum_ic_fullscreen_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/quantum_ic_fullscreen_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/quantum_ic_fullscreen_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/quantum_ic_fullscreen_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_copy_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_copy_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_copy_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_copy_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_copy_timestamp_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_copy_timestamp_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_copy_timestamp_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_copy_timestamp_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_download_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_download_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_download_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_download_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_play_all_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_play_all_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_play_all_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_play_all_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_speed_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_speed_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_speed_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_speed_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_volume_muted_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_volume_muted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_volume_muted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_volume_muted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_volume_unmuted_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_volume_unmuted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_volume_unmuted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_volume_unmuted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_whitelist_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_whitelist_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_whitelist_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/revanced_whitelist_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_fill_arrow_repeat_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_fill_arrow_repeat_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_fill_arrow_repeat_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_fill_arrow_repeat_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_arrow_repeat_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_arrow_repeat_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_arrow_repeat_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_arrow_repeat_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_arrow_shuffle_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_arrow_shuffle_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_arrow_shuffle_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_arrow_shuffle_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_chevron_down_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_chevron_down_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_chevron_down_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_chevron_down_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_gear_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_gear_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_gear_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_gear_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_screen_full_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_screen_full_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_screen_full_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_screen_full_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_screen_full_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_screen_full_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_screen_full_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_screen_full_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_screen_full_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_screen_full_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_screen_full_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_screen_full_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_screen_vertical_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_screen_vertical_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_screen_vertical_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xhdpi/yt_outline_screen_vertical_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/ic_vr.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/ic_vr.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/ic_vr.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/ic_vr.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/quantum_ic_fullscreen_exit_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/quantum_ic_fullscreen_exit_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/quantum_ic_fullscreen_exit_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/quantum_ic_fullscreen_exit_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/quantum_ic_fullscreen_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/quantum_ic_fullscreen_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/quantum_ic_fullscreen_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/quantum_ic_fullscreen_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/quantum_ic_fullscreen_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/quantum_ic_fullscreen_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/quantum_ic_fullscreen_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/quantum_ic_fullscreen_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/quantum_ic_fullscreen_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/quantum_ic_fullscreen_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/quantum_ic_fullscreen_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/quantum_ic_fullscreen_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_copy_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_copy_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_copy_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_copy_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_copy_timestamp_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_copy_timestamp_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_copy_timestamp_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_copy_timestamp_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_download_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_download_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_download_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_download_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_play_all_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_play_all_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_play_all_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_play_all_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_speed_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_speed_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_speed_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_speed_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_volume_muted_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_volume_muted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_volume_muted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_volume_muted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_volume_unmuted_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_volume_unmuted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_volume_unmuted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_volume_unmuted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_whitelist_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_whitelist_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_whitelist_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/revanced_whitelist_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_fill_arrow_repeat_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_fill_arrow_repeat_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_fill_arrow_repeat_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_fill_arrow_repeat_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_arrow_repeat_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_arrow_repeat_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_arrow_repeat_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_arrow_repeat_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_arrow_shuffle_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_arrow_shuffle_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_arrow_shuffle_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_arrow_shuffle_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_chevron_down_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_chevron_down_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_chevron_down_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_chevron_down_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_gear_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_gear_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_gear_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_gear_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_screen_full_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_screen_full_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_screen_full_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_screen_full_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_screen_full_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_screen_full_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_screen_full_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_screen_full_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_screen_full_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_screen_full_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_screen_full_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_screen_full_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_screen_vertical_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_screen_vertical_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_screen_vertical_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxhdpi/yt_outline_screen_vertical_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/ic_vr.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/ic_vr.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/ic_vr.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/ic_vr.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/quantum_ic_fullscreen_exit_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/quantum_ic_fullscreen_exit_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/quantum_ic_fullscreen_exit_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/quantum_ic_fullscreen_exit_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/quantum_ic_fullscreen_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/quantum_ic_fullscreen_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/quantum_ic_fullscreen_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/quantum_ic_fullscreen_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/quantum_ic_fullscreen_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/quantum_ic_fullscreen_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/quantum_ic_fullscreen_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/quantum_ic_fullscreen_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/quantum_ic_fullscreen_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/quantum_ic_fullscreen_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/quantum_ic_fullscreen_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/quantum_ic_fullscreen_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_copy_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_copy_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_copy_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_copy_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_copy_timestamp_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_copy_timestamp_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_copy_timestamp_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_copy_timestamp_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_download_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_download_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_download_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_download_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_play_all_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_play_all_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_play_all_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_play_all_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_speed_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_speed_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_speed_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_speed_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_volume_muted_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_volume_muted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_volume_muted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_volume_muted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_volume_unmuted_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_volume_unmuted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_volume_unmuted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_volume_unmuted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_whitelist_button.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_whitelist_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_whitelist_button.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/revanced_whitelist_button.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_fill_arrow_repeat_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_fill_arrow_repeat_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_fill_arrow_repeat_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_fill_arrow_repeat_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_arrow_repeat_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_arrow_repeat_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_arrow_repeat_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_arrow_repeat_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_arrow_shuffle_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_arrow_shuffle_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_arrow_shuffle_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_arrow_shuffle_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_chevron_down_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_chevron_down_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_chevron_down_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_chevron_down_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_gear_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_gear_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_gear_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_gear_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_screen_full_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_screen_full_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_screen_full_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_screen_full_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_screen_full_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_screen_full_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_screen_full_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_screen_full_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_screen_full_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_screen_full_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_screen_full_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_screen_full_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_screen_vertical_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_screen_vertical_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_screen_vertical_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/bold/drawable-xxxhdpi/yt_outline_screen_vertical_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/ic_vr.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/ic_vr.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/ic_vr.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/ic_vr.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/quantum_ic_fullscreen_exit_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/quantum_ic_fullscreen_exit_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/quantum_ic_fullscreen_exit_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/quantum_ic_fullscreen_exit_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/quantum_ic_fullscreen_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/quantum_ic_fullscreen_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/quantum_ic_fullscreen_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/quantum_ic_fullscreen_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/quantum_ic_fullscreen_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/quantum_ic_fullscreen_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/quantum_ic_fullscreen_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/quantum_ic_fullscreen_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/quantum_ic_fullscreen_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/quantum_ic_fullscreen_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/quantum_ic_fullscreen_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/quantum_ic_fullscreen_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_copy_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_copy_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_copy_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_copy_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_copy_timestamp_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_copy_timestamp_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_copy_timestamp_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_copy_timestamp_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_download_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_download_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_download_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_download_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_play_all_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_play_all_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_play_all_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_play_all_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_speed_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_speed_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_speed_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_speed_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_volume_muted_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_volume_muted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_volume_muted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_volume_muted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_volume_unmuted_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_volume_unmuted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_volume_unmuted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_volume_unmuted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_whitelist_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_whitelist_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_whitelist_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/revanced_whitelist_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_fill_arrow_repeat_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_fill_arrow_repeat_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_fill_arrow_repeat_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_fill_arrow_repeat_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_arrow_repeat_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_arrow_repeat_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_arrow_repeat_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_arrow_repeat_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_arrow_shuffle_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_arrow_shuffle_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_arrow_shuffle_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_arrow_shuffle_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_chevron_down_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_chevron_down_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_chevron_down_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_chevron_down_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_gear_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_gear_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_gear_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_gear_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_screen_full_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_screen_full_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_screen_full_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_screen_full_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_screen_full_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_screen_full_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_screen_full_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_screen_full_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_screen_full_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_screen_full_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_screen_full_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_screen_full_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_screen_vertical_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_screen_vertical_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_screen_vertical_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-hdpi/yt_outline_screen_vertical_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/ic_vr.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/ic_vr.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/ic_vr.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/ic_vr.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/quantum_ic_fullscreen_exit_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/quantum_ic_fullscreen_exit_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/quantum_ic_fullscreen_exit_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/quantum_ic_fullscreen_exit_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/quantum_ic_fullscreen_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/quantum_ic_fullscreen_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/quantum_ic_fullscreen_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/quantum_ic_fullscreen_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/quantum_ic_fullscreen_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/quantum_ic_fullscreen_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/quantum_ic_fullscreen_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/quantum_ic_fullscreen_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/quantum_ic_fullscreen_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/quantum_ic_fullscreen_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/quantum_ic_fullscreen_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/quantum_ic_fullscreen_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_copy_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_copy_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_copy_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_copy_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_copy_timestamp_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_copy_timestamp_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_copy_timestamp_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_copy_timestamp_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_download_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_download_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_download_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_download_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_play_all_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_play_all_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_play_all_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_play_all_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_speed_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_speed_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_speed_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_speed_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_volume_muted_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_volume_muted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_volume_muted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_volume_muted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_volume_unmuted_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_volume_unmuted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_volume_unmuted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_volume_unmuted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_whitelist_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_whitelist_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_whitelist_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/revanced_whitelist_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_fill_arrow_repeat_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_fill_arrow_repeat_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_fill_arrow_repeat_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_fill_arrow_repeat_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_arrow_repeat_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_arrow_repeat_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_arrow_repeat_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_arrow_repeat_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_arrow_shuffle_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_arrow_shuffle_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_arrow_shuffle_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_arrow_shuffle_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_chevron_down_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_chevron_down_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_chevron_down_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_chevron_down_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_gear_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_gear_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_gear_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_gear_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_screen_full_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_screen_full_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_screen_full_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_screen_full_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_screen_full_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_screen_full_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_screen_full_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_screen_full_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_screen_full_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_screen_full_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_screen_full_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_screen_full_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_screen_vertical_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_screen_vertical_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_screen_vertical_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-mdpi/yt_outline_screen_vertical_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/ic_vr.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/ic_vr.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/ic_vr.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/ic_vr.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/quantum_ic_fullscreen_exit_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/quantum_ic_fullscreen_exit_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/quantum_ic_fullscreen_exit_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/quantum_ic_fullscreen_exit_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/quantum_ic_fullscreen_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/quantum_ic_fullscreen_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/quantum_ic_fullscreen_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/quantum_ic_fullscreen_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/quantum_ic_fullscreen_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/quantum_ic_fullscreen_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/quantum_ic_fullscreen_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/quantum_ic_fullscreen_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/quantum_ic_fullscreen_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/quantum_ic_fullscreen_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/quantum_ic_fullscreen_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/quantum_ic_fullscreen_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_copy_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_copy_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_copy_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_copy_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_copy_timestamp_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_copy_timestamp_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_copy_timestamp_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_copy_timestamp_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_download_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_download_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_download_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_download_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_play_all_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_play_all_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_play_all_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_play_all_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_speed_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_speed_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_speed_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_speed_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_volume_muted_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_volume_muted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_volume_muted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_volume_muted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_volume_unmuted_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_volume_unmuted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_volume_unmuted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_volume_unmuted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_whitelist_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_whitelist_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_whitelist_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/revanced_whitelist_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_fill_arrow_repeat_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_fill_arrow_repeat_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_fill_arrow_repeat_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_fill_arrow_repeat_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_arrow_repeat_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_arrow_repeat_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_arrow_repeat_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_arrow_repeat_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_arrow_shuffle_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_arrow_shuffle_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_arrow_shuffle_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_arrow_shuffle_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_chevron_down_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_chevron_down_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_chevron_down_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_chevron_down_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_gear_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_gear_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_gear_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_gear_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_screen_full_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_screen_full_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_screen_full_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_screen_full_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_screen_full_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_screen_full_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_screen_full_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_screen_full_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_screen_full_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_screen_full_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_screen_full_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_screen_full_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_screen_vertical_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_screen_vertical_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_screen_vertical_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xhdpi/yt_outline_screen_vertical_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/ic_vr.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/ic_vr.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/ic_vr.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/ic_vr.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/quantum_ic_fullscreen_exit_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/quantum_ic_fullscreen_exit_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/quantum_ic_fullscreen_exit_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/quantum_ic_fullscreen_exit_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/quantum_ic_fullscreen_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/quantum_ic_fullscreen_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/quantum_ic_fullscreen_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/quantum_ic_fullscreen_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/quantum_ic_fullscreen_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/quantum_ic_fullscreen_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/quantum_ic_fullscreen_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/quantum_ic_fullscreen_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/quantum_ic_fullscreen_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/quantum_ic_fullscreen_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/quantum_ic_fullscreen_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/quantum_ic_fullscreen_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_copy_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_copy_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_copy_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_copy_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_copy_timestamp_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_copy_timestamp_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_copy_timestamp_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_copy_timestamp_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_download_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_download_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_download_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_download_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_play_all_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_play_all_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_play_all_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_play_all_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_speed_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_speed_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_speed_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_speed_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_volume_muted_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_volume_muted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_volume_muted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_volume_muted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_volume_unmuted_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_volume_unmuted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_volume_unmuted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_volume_unmuted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_whitelist_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_whitelist_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_whitelist_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/revanced_whitelist_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_fill_arrow_repeat_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_fill_arrow_repeat_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_fill_arrow_repeat_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_fill_arrow_repeat_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_arrow_repeat_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_arrow_repeat_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_arrow_repeat_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_arrow_repeat_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_arrow_shuffle_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_arrow_shuffle_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_arrow_shuffle_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_arrow_shuffle_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_chevron_down_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_chevron_down_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_chevron_down_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_chevron_down_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_gear_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_gear_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_gear_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_gear_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_screen_full_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_screen_full_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_screen_full_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_screen_full_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_screen_full_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_screen_full_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_screen_full_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_screen_full_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_screen_full_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_screen_full_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_screen_full_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_screen_full_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_screen_vertical_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_screen_vertical_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_screen_vertical_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxhdpi/yt_outline_screen_vertical_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/ic_vr.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/ic_vr.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/ic_vr.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/ic_vr.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/quantum_ic_fullscreen_exit_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/quantum_ic_fullscreen_exit_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/quantum_ic_fullscreen_exit_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/quantum_ic_fullscreen_exit_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/quantum_ic_fullscreen_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/quantum_ic_fullscreen_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/quantum_ic_fullscreen_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/quantum_ic_fullscreen_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/quantum_ic_fullscreen_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/quantum_ic_fullscreen_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/quantum_ic_fullscreen_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/quantum_ic_fullscreen_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/quantum_ic_fullscreen_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/quantum_ic_fullscreen_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/quantum_ic_fullscreen_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/quantum_ic_fullscreen_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_copy_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_copy_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_copy_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_copy_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_copy_timestamp_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_copy_timestamp_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_copy_timestamp_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_copy_timestamp_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_download_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_download_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_download_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_download_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_play_all_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_play_all_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_play_all_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_play_all_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_speed_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_speed_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_speed_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_speed_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_volume_muted_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_volume_muted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_volume_muted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_volume_muted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_volume_unmuted_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_volume_unmuted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_volume_unmuted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_volume_unmuted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_whitelist_button.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_whitelist_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_whitelist_button.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/revanced_whitelist_button.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_fill_arrow_repeat_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_fill_arrow_repeat_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_fill_arrow_repeat_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_fill_arrow_repeat_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_arrow_repeat_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_arrow_repeat_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_arrow_repeat_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_arrow_repeat_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_arrow_shuffle_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_arrow_shuffle_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_arrow_shuffle_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_arrow_shuffle_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_chevron_down_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_chevron_down_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_chevron_down_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_chevron_down_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_gear_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_gear_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_gear_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_gear_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_screen_full_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_screen_full_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_screen_full_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_screen_full_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_screen_full_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_screen_full_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_screen_full_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_screen_full_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_screen_full_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_screen_full_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_screen_full_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_screen_full_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_screen_vertical_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_screen_vertical_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_screen_vertical_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/rounded/drawable-xxxhdpi/yt_outline_screen_vertical_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/shared/drawable/playlist_repeat_button.xml b/patches/src/main/resources/youtube/overlaybuttons/shared/drawable/playlist_repeat_button.xml similarity index 100% rename from src/main/resources/youtube/overlaybuttons/shared/drawable/playlist_repeat_button.xml rename to patches/src/main/resources/youtube/overlaybuttons/shared/drawable/playlist_repeat_button.xml diff --git a/src/main/resources/youtube/overlaybuttons/shared/drawable/playlist_shuffle_button.xml b/patches/src/main/resources/youtube/overlaybuttons/shared/drawable/playlist_shuffle_button.xml similarity index 100% rename from src/main/resources/youtube/overlaybuttons/shared/drawable/playlist_shuffle_button.xml rename to patches/src/main/resources/youtube/overlaybuttons/shared/drawable/playlist_shuffle_button.xml diff --git a/src/main/resources/youtube/overlaybuttons/shared/drawable/revanced_mute_volume_button.xml b/patches/src/main/resources/youtube/overlaybuttons/shared/drawable/revanced_mute_volume_button.xml similarity index 100% rename from src/main/resources/youtube/overlaybuttons/shared/drawable/revanced_mute_volume_button.xml rename to patches/src/main/resources/youtube/overlaybuttons/shared/drawable/revanced_mute_volume_button.xml diff --git a/src/main/resources/youtube/overlaybuttons/shared/drawable/revanced_repeat_button.xml b/patches/src/main/resources/youtube/overlaybuttons/shared/drawable/revanced_repeat_button.xml similarity index 100% rename from src/main/resources/youtube/overlaybuttons/shared/drawable/revanced_repeat_button.xml rename to patches/src/main/resources/youtube/overlaybuttons/shared/drawable/revanced_repeat_button.xml diff --git a/src/main/resources/youtube/overlaybuttons/shared/host/layout/youtube_controls_bottom_ui_container.xml b/patches/src/main/resources/youtube/overlaybuttons/shared/host/layout/youtube_controls_bottom_ui_container.xml similarity index 100% rename from src/main/resources/youtube/overlaybuttons/shared/host/layout/youtube_controls_bottom_ui_container.xml rename to patches/src/main/resources/youtube/overlaybuttons/shared/host/layout/youtube_controls_bottom_ui_container.xml diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/ic_vr.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/ic_vr.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/ic_vr.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/ic_vr.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_closed_caption_off_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_closed_caption_off_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_closed_caption_off_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_closed_caption_off_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_closed_caption_off_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_closed_caption_off_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_closed_caption_off_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_closed_caption_off_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_closed_caption_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_closed_caption_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_closed_caption_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_closed_caption_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_fullscreen_exit_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_fullscreen_exit_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_fullscreen_exit_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_fullscreen_exit_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_fullscreen_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_fullscreen_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_fullscreen_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_fullscreen_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_fullscreen_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_fullscreen_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_fullscreen_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_fullscreen_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_fullscreen_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_fullscreen_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_fullscreen_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/quantum_ic_fullscreen_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_copy_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_copy_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_copy_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_copy_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_copy_timestamp_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_copy_timestamp_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_copy_timestamp_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_copy_timestamp_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_download_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_download_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_download_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_download_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_play_all_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_play_all_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_play_all_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_play_all_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_speed_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_speed_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_speed_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_speed_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_volume_muted_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_volume_muted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_volume_muted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_volume_muted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_volume_unmuted_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_volume_unmuted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_volume_unmuted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_volume_unmuted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_whitelist_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_whitelist_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_whitelist_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/revanced_whitelist_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_fill_arrow_repeat_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_fill_arrow_repeat_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_fill_arrow_repeat_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_fill_arrow_repeat_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_arrow_repeat_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_arrow_repeat_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_arrow_repeat_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_arrow_repeat_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_arrow_shuffle_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_arrow_shuffle_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_arrow_shuffle_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_arrow_shuffle_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_gear_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_gear_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_gear_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_gear_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_screen_full_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_screen_full_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_screen_full_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_screen_full_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_screen_full_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_screen_full_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_screen_full_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_screen_full_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_screen_full_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_screen_full_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_screen_full_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-hdpi/yt_outline_screen_full_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/ic_vr.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/ic_vr.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/ic_vr.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/ic_vr.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_closed_caption_off_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_closed_caption_off_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_closed_caption_off_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_closed_caption_off_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_closed_caption_off_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_closed_caption_off_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_closed_caption_off_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_closed_caption_off_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_closed_caption_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_closed_caption_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_closed_caption_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_closed_caption_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_fullscreen_exit_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_fullscreen_exit_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_fullscreen_exit_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_fullscreen_exit_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_fullscreen_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_fullscreen_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_fullscreen_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_fullscreen_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_fullscreen_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_fullscreen_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_fullscreen_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_fullscreen_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_fullscreen_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_fullscreen_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_fullscreen_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/quantum_ic_fullscreen_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_copy_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_copy_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_copy_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_copy_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_copy_timestamp_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_copy_timestamp_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_copy_timestamp_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_copy_timestamp_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_download_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_download_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_download_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_download_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_play_all_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_play_all_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_play_all_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_play_all_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_speed_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_speed_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_speed_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_speed_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_volume_muted_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_volume_muted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_volume_muted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_volume_muted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_volume_unmuted_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_volume_unmuted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_volume_unmuted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_volume_unmuted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_whitelist_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_whitelist_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_whitelist_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/revanced_whitelist_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_fill_arrow_repeat_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_fill_arrow_repeat_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_fill_arrow_repeat_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_fill_arrow_repeat_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_arrow_repeat_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_arrow_repeat_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_arrow_repeat_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_arrow_repeat_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_arrow_shuffle_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_arrow_shuffle_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_arrow_shuffle_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_arrow_shuffle_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_gear_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_gear_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_gear_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_gear_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_screen_full_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_screen_full_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_screen_full_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_screen_full_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_screen_full_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_screen_full_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_screen_full_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_screen_full_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_screen_full_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_screen_full_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_screen_full_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-mdpi/yt_outline_screen_full_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/ic_vr.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/ic_vr.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/ic_vr.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/ic_vr.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_closed_caption_off_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_closed_caption_off_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_closed_caption_off_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_closed_caption_off_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_closed_caption_off_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_closed_caption_off_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_closed_caption_off_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_closed_caption_off_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_closed_caption_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_closed_caption_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_closed_caption_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_closed_caption_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_fullscreen_exit_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_fullscreen_exit_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_fullscreen_exit_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_fullscreen_exit_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_fullscreen_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_fullscreen_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_fullscreen_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_fullscreen_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_fullscreen_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_fullscreen_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_fullscreen_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_fullscreen_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_fullscreen_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_fullscreen_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_fullscreen_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/quantum_ic_fullscreen_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_copy_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_copy_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_copy_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_copy_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_copy_timestamp_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_copy_timestamp_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_copy_timestamp_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_copy_timestamp_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_download_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_download_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_download_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_download_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_play_all_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_play_all_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_play_all_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_play_all_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_speed_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_speed_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_speed_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_speed_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_volume_muted_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_volume_muted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_volume_muted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_volume_muted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_volume_unmuted_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_volume_unmuted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_volume_unmuted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_volume_unmuted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_whitelist_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_whitelist_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_whitelist_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/revanced_whitelist_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_fill_arrow_repeat_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_fill_arrow_repeat_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_fill_arrow_repeat_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_fill_arrow_repeat_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_arrow_repeat_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_arrow_repeat_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_arrow_repeat_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_arrow_repeat_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_arrow_shuffle_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_arrow_shuffle_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_arrow_shuffle_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_arrow_shuffle_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_gear_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_gear_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_gear_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_gear_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_screen_full_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_screen_full_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_screen_full_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_screen_full_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_screen_full_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_screen_full_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_screen_full_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_screen_full_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_screen_full_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_screen_full_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_screen_full_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xhdpi/yt_outline_screen_full_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/ic_vr.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/ic_vr.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/ic_vr.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/ic_vr.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_closed_caption_off_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_closed_caption_off_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_closed_caption_off_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_closed_caption_off_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_closed_caption_off_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_closed_caption_off_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_closed_caption_off_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_closed_caption_off_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_closed_caption_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_closed_caption_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_closed_caption_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_closed_caption_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_fullscreen_exit_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_fullscreen_exit_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_fullscreen_exit_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_fullscreen_exit_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_fullscreen_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_fullscreen_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_fullscreen_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_fullscreen_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_fullscreen_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_fullscreen_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_fullscreen_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_fullscreen_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_fullscreen_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_fullscreen_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_fullscreen_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/quantum_ic_fullscreen_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_copy_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_copy_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_copy_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_copy_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_copy_timestamp_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_copy_timestamp_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_copy_timestamp_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_copy_timestamp_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_download_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_download_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_download_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_download_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_play_all_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_play_all_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_play_all_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_play_all_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_speed_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_speed_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_speed_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_speed_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_volume_muted_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_volume_muted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_volume_muted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_volume_muted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_volume_unmuted_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_volume_unmuted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_volume_unmuted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_volume_unmuted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_whitelist_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_whitelist_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_whitelist_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/revanced_whitelist_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_fill_arrow_repeat_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_fill_arrow_repeat_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_fill_arrow_repeat_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_fill_arrow_repeat_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_arrow_repeat_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_arrow_repeat_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_arrow_repeat_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_arrow_repeat_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_arrow_shuffle_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_arrow_shuffle_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_arrow_shuffle_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_arrow_shuffle_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_screen_full_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_screen_full_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_screen_full_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_screen_full_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_screen_full_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_screen_full_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_screen_full_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_screen_full_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_screen_full_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_screen_full_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_screen_full_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxhdpi/yt_outline_screen_full_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/ic_vr.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/ic_vr.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/ic_vr.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/ic_vr.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_closed_caption_off_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_closed_caption_off_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_closed_caption_off_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_closed_caption_off_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_closed_caption_off_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_closed_caption_off_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_closed_caption_off_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_closed_caption_off_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_closed_caption_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_closed_caption_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_closed_caption_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_closed_caption_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_fullscreen_exit_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_fullscreen_exit_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_fullscreen_exit_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_fullscreen_exit_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_fullscreen_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_fullscreen_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_fullscreen_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_fullscreen_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_fullscreen_grey600_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_fullscreen_grey600_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_fullscreen_grey600_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_fullscreen_grey600_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_fullscreen_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_fullscreen_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_fullscreen_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/quantum_ic_fullscreen_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_copy_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_copy_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_copy_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_copy_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_copy_timestamp_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_copy_timestamp_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_copy_timestamp_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_copy_timestamp_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_download_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_download_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_download_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_download_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_play_all_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_play_all_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_play_all_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_play_all_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_speed_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_speed_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_speed_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_speed_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_volume_muted_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_volume_muted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_volume_muted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_volume_muted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_volume_unmuted_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_volume_unmuted_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_volume_unmuted_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_volume_unmuted_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_whitelist_button.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_whitelist_button.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_whitelist_button.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/revanced_whitelist_button.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_fill_arrow_repeat_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_fill_arrow_repeat_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_fill_arrow_repeat_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_fill_arrow_repeat_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_arrow_repeat_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_arrow_repeat_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_arrow_repeat_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_arrow_repeat_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_arrow_shuffle_1_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_arrow_shuffle_1_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_arrow_shuffle_1_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_arrow_shuffle_1_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_screen_full_exit_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_screen_full_exit_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_screen_full_exit_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_screen_full_exit_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_screen_full_vd_theme_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_screen_full_vd_theme_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_screen_full_vd_theme_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_screen_full_vd_theme_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_screen_full_white_24.png b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_screen_full_white_24.png similarity index 100% rename from src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_screen_full_white_24.png rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable-xxxhdpi/yt_outline_screen_full_white_24.png diff --git a/src/main/resources/youtube/overlaybuttons/thin/drawable/yt_outline_screen_vertical_vd_theme_24.xml b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable/yt_outline_screen_vertical_vd_theme_24.xml similarity index 99% rename from src/main/resources/youtube/overlaybuttons/thin/drawable/yt_outline_screen_vertical_vd_theme_24.xml rename to patches/src/main/resources/youtube/overlaybuttons/thin/drawable/yt_outline_screen_vertical_vd_theme_24.xml index 084732f34b..a460f6a950 100644 --- a/src/main/resources/youtube/overlaybuttons/thin/drawable/yt_outline_screen_vertical_vd_theme_24.xml +++ b/patches/src/main/resources/youtube/overlaybuttons/thin/drawable/yt_outline_screen_vertical_vd_theme_24.xml @@ -1,5 +1,5 @@ - - - + + + \ No newline at end of file diff --git a/src/main/resources/youtube/settings/drawable/revanced_cursor.xml b/patches/src/main/resources/youtube/settings/drawable/revanced_cursor.xml similarity index 100% rename from src/main/resources/youtube/settings/drawable/revanced_cursor.xml rename to patches/src/main/resources/youtube/settings/drawable/revanced_cursor.xml diff --git a/src/main/resources/youtube/settings/host/values/arrays.xml b/patches/src/main/resources/youtube/settings/host/values/arrays.xml similarity index 100% rename from src/main/resources/youtube/settings/host/values/arrays.xml rename to patches/src/main/resources/youtube/settings/host/values/arrays.xml diff --git a/src/main/resources/youtube/settings/host/values/dimens.xml b/patches/src/main/resources/youtube/settings/host/values/dimens.xml similarity index 100% rename from src/main/resources/youtube/settings/host/values/dimens.xml rename to patches/src/main/resources/youtube/settings/host/values/dimens.xml diff --git a/src/main/resources/youtube/settings/host/values/strings.xml b/patches/src/main/resources/youtube/settings/host/values/strings.xml similarity index 99% rename from src/main/resources/youtube/settings/host/values/strings.xml rename to patches/src/main/resources/youtube/settings/host/values/strings.xml index 999c74c807..e73453db69 100644 --- a/src/main/resources/youtube/settings/host/values/strings.xml +++ b/patches/src/main/resources/youtube/settings/host/values/strings.xml @@ -1839,8 +1839,7 @@ Tap on the continue button and disable battery optimizations." Android TV Android VR Spoofing side effects - "• Movies or paid videos may not play. -• Livestreams start from the beginning. + "• Livestreams start from the beginning. • Videos may end 1 second early." • Videos may end 1 second early. "• Audio track menu is missing. @@ -1900,4 +1899,4 @@ AVC (H.264) has a maximum resolution of 1080p, and video playback will use more Excluded Included Stock - + \ No newline at end of file diff --git a/src/main/resources/youtube/settings/host/values/styles.xml b/patches/src/main/resources/youtube/settings/host/values/styles.xml similarity index 100% rename from src/main/resources/youtube/settings/host/values/styles.xml rename to patches/src/main/resources/youtube/settings/host/values/styles.xml diff --git a/src/main/resources/youtube/settings/layout/revanced_settings_preferences_category.xml b/patches/src/main/resources/youtube/settings/layout/revanced_settings_preferences_category.xml similarity index 100% rename from src/main/resources/youtube/settings/layout/revanced_settings_preferences_category.xml rename to patches/src/main/resources/youtube/settings/layout/revanced_settings_preferences_category.xml diff --git a/src/main/resources/youtube/settings/layout/revanced_settings_with_toolbar.xml b/patches/src/main/resources/youtube/settings/layout/revanced_settings_with_toolbar.xml similarity index 100% rename from src/main/resources/youtube/settings/layout/revanced_settings_with_toolbar.xml rename to patches/src/main/resources/youtube/settings/layout/revanced_settings_with_toolbar.xml diff --git a/src/main/resources/youtube/settings/xml/revanced_prefs.xml b/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml similarity index 89% rename from src/main/resources/youtube/settings/xml/revanced_prefs.xml rename to patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml index 39d1733896..46657daca5 100644 --- a/src/main/resources/youtube/settings/xml/revanced_prefs.xml +++ b/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml @@ -27,9 +27,9 @@ - + - + SETTINGS: ALTERNATIVE_THUMBNAILS --> @@ -53,7 +53,7 @@ - + @@ -68,7 +68,7 @@ + + SETTINGS: HOOK_DOWNLOAD_ACTIONS --> @@ -173,7 +173,7 @@ - + SETTINGS: MINIPLAYER_TYPE_MODERN --> + + SETTINGS: SPOOF_APP_VERSION --> @@ -386,7 +386,7 @@ - + @@ -395,7 +395,7 @@ + SETTINGS: KEEP_LANDSCAPE_MODE --> @@ -429,11 +429,11 @@ - + - SETTINGS: OVERLAY_BUTTONS --> + SETTINGS: OVERLAY_BUTTONS --> @@ -444,7 +444,7 @@ - + @@ -482,18 +482,18 @@ - + SETTINGS: DESCRIPTION_INTERACTION --> + SETTINGS: SHORTS_TIME_STAMP --> @@ -601,15 +601,15 @@ - - - - - + + + + + - - PREFERENCE_SCREEN: SWIPE_CONTROLS --> + + PREFERENCE_SCREEN: SWIPE_CONTROLS --> @@ -633,7 +633,7 @@ - + @@ -674,9 +674,9 @@ @@ -693,30 +693,30 @@ - - - - - - - - - + + + + + + + + + - + - - + + - + PREFERENCE_SCREEN: SPONSOR_BLOCK --> @@ -727,14 +727,14 @@ - + - + ", "") - ) - } - } - - fun ResourceContext.updatePatchStatus(patchTitle: String) { - updatePatchStatusSettings(patchTitle, "@string/revanced_patches_included") - } - - fun ResourceContext.updatePatchStatusIcon(iconName: String) { - iconType = iconName - updatePatchStatusSettings("Icon", "@string/revanced_icon_$iconName") - } - - fun ResourceContext.updatePatchStatusLabel(appName: String) { - updatePatchStatusSettings("Label", appName) - } - - fun ResourceContext.updatePatchStatusTheme(themeName: String) { - updatePatchStatusSettings("Theme", themeName) - } - - fun ResourceContext.updatePatchStatusSettings( - patchTitle: String, - updateText: String - ) { - this.xmlEditor[TARGET_PREFERENCE_PATH].use { editor -> - editor.file.doRecursively loop@{ - if (it !is Element) return@loop - - it.getAttributeNode("android:title")?.let { attribute -> - if (attribute.textContent == patchTitle) { - it.getAttributeNode("android:summary").textContent = updateText - } - } - } - } - } - - fun ResourceContext.addPreferenceFragment(key: String, insertKey: String) { - val targetClass = - "com.google.android.apps.youtube.app.settings.videoquality.VideoQualitySettingsActivity" - - this.xmlEditor[YOUTUBE_SETTINGS_PATH].use { editor -> - with(editor.file) { - val processedKeys = mutableSetOf() // To track processed keys - - doRecursively loop@{ node -> - if (node !is Element) return@loop // Skip if not an element - - val attributeNode = node.getAttributeNode("android:key") - ?: return@loop // Skip if no key attribute - val currentKey = attributeNode.textContent - - // Check if the current key has already been processed - if (processedKeys.contains(currentKey)) { - return@loop // Skip if already processed - } else { - processedKeys.add(currentKey) // Add the current key to processedKeys - } - - when (currentKey) { - insertKey -> { - node.insertNode("Preference", node) { - setAttribute("android:key", "${key}_key") - setAttribute("android:title", "@string/${key}_title") - this.appendChild( - ownerDocument.createElement("intent").also { intentNode -> - intentNode.setAttribute( - "android:targetPackage", - youtubePackageName - ) - intentNode.setAttribute("android:data", key + "_intent") - intentNode.setAttribute("android:targetClass", targetClass) - } - ) - } - node.setAttribute("app:iconSpaceReserved", "true") - } - - "true" -> { - attributeNode.textContent = "false" - } - } - } - } - } - } -} diff --git a/src/main/kotlin/app/revanced/patches/youtube/utils/settings/SettingsBytecodePatch.kt b/src/main/kotlin/app/revanced/patches/youtube/utils/settings/SettingsBytecodePatch.kt deleted file mode 100644 index e46e4a1236..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/utils/settings/SettingsBytecodePatch.kt +++ /dev/null @@ -1,75 +0,0 @@ -package app.revanced.patches.youtube.utils.settings - -import app.revanced.patcher.data.BytecodeContext -import app.revanced.patcher.extensions.InstructionExtensions.addInstructions -import app.revanced.patcher.extensions.InstructionExtensions.getInstruction -import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction -import app.revanced.patcher.patch.BytecodePatch -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod -import app.revanced.patches.shared.integrations.Constants.INTEGRATIONS_UTILS_CLASS_DESCRIPTOR -import app.revanced.patches.shared.integrations.Constants.INTEGRATIONS_UTILS_PATH -import app.revanced.patches.shared.mapping.ResourceMappingPatch -import app.revanced.patches.youtube.utils.integrations.Constants.UTILS_PATH -import app.revanced.patches.youtube.utils.mainactivity.MainActivityResolvePatch -import app.revanced.patches.youtube.utils.resourceid.SharedResourceIdPatch -import app.revanced.patches.youtube.utils.settings.fingerprints.ThemeSetterSystemFingerprint -import app.revanced.util.resultOrThrow -import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction - -@Patch( - dependencies = [ - MainActivityResolvePatch::class, - ResourceMappingPatch::class, - SharedResourceIdPatch::class - ] -) -object SettingsBytecodePatch : BytecodePatch( - setOf(ThemeSetterSystemFingerprint) -) { - private const val INTEGRATIONS_INITIALIZATION_CLASS_DESCRIPTOR = - "$UTILS_PATH/InitializationPatch;" - - private const val INTEGRATIONS_THEME_METHOD_DESCRIPTOR = - "$INTEGRATIONS_UTILS_PATH/BaseThemeUtils;->setTheme(Ljava/lang/Enum;)V" - - internal lateinit var contexts: BytecodeContext - - override fun execute(context: BytecodeContext) { - contexts = context - - // apply the current theme of the settings page - ThemeSetterSystemFingerprint.resultOrThrow().let { - it.mutableMethod.apply { - injectCall(implementation!!.instructions.size - 1) - injectCall(it.scanResult.patternScanResult!!.startIndex) - } - } - - MainActivityResolvePatch.injectOnCreateMethodCall( - INTEGRATIONS_INITIALIZATION_CLASS_DESCRIPTOR, - "setExtendedUtils" - ) - MainActivityResolvePatch.injectOnCreateMethodCall( - INTEGRATIONS_INITIALIZATION_CLASS_DESCRIPTOR, - "onCreate" - ) - MainActivityResolvePatch.injectConstructorMethodCall( - INTEGRATIONS_UTILS_CLASS_DESCRIPTOR, - "setActivity" - ) - - } - - private fun MutableMethod.injectCall(index: Int) { - val register = getInstruction(index).registerA - - addInstructions( - index + 1, """ - invoke-static {v$register}, $INTEGRATIONS_THEME_METHOD_DESCRIPTOR - return-object v$register - """ - ) - removeInstruction(index) - } -} diff --git a/src/main/kotlin/app/revanced/patches/youtube/utils/settings/SettingsPatch.kt b/src/main/kotlin/app/revanced/patches/youtube/utils/settings/SettingsPatch.kt deleted file mode 100644 index cc57a53c24..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/utils/settings/SettingsPatch.kt +++ /dev/null @@ -1,328 +0,0 @@ -package app.revanced.patches.youtube.utils.settings - -import app.revanced.patcher.data.ResourceContext -import app.revanced.patcher.patch.options.PatchOption.PatchExtensions.stringPatchOption -import app.revanced.patches.shared.elements.StringsElementsUtils.removeStringsElements -import app.revanced.patches.shared.mapping.ResourceMappingPatch -import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE -import app.revanced.patches.youtube.utils.fix.cairo.CairoSettingsPatch -import app.revanced.patches.youtube.utils.integrations.IntegrationsPatch -import app.revanced.patches.youtube.utils.resourceid.SharedResourceIdPatch -import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference -import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreferenceFragment -import app.revanced.patches.youtube.utils.settings.ResourceUtils.updatePatchStatus -import app.revanced.patches.youtube.utils.settings.ResourceUtils.updatePatchStatusSettings -import app.revanced.util.ResourceGroup -import app.revanced.util.classLoader -import app.revanced.util.copyResources -import app.revanced.util.copyXmlNode -import app.revanced.util.patch.BaseBytecodePatch -import app.revanced.util.patch.BaseResourcePatch -import app.revanced.util.valueOrThrow -import org.w3c.dom.Element -import java.io.Closeable -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import java.util.jar.Manifest - -@Suppress("DEPRECATION", "unused") -object SettingsPatch : BaseResourcePatch( - name = "Settings for YouTube", - description = "Applies mandatory patches to implement ReVanced Extended settings into the application.", - dependencies = setOf( - IntegrationsPatch::class, - ResourceMappingPatch::class, - SharedResourceIdPatch::class, - SettingsBytecodePatch::class, - CairoSettingsPatch::class, - ), - compatiblePackages = COMPATIBLE_PACKAGE, - requiresIntegrations = true -), Closeable { - private const val DEFAULT_ELEMENT = "@string/about_key" - private const val DEFAULT_NAME = "ReVanced Extended" - - private val SETTINGS_ELEMENTS_MAP = mapOf( - "Parent settings" to "@string/parent_tools_key", - "General" to "@string/general_key", - "Account" to "@string/account_switcher_key", - "Data saving" to "@string/data_saving_settings_key", - "Autoplay" to "@string/auto_play_key", - "Video quality preferences" to "@string/video_quality_settings_key", - "Background" to "@string/offline_key", - "Watch on TV" to "@string/pair_with_tv_key", - "Manage all history" to "@string/history_key", - "Your data in YouTube" to "@string/your_data_key", - "Privacy" to "@string/privacy_key", - "History & privacy" to "@string/privacy_key", - "Try experimental new features" to "@string/premium_early_access_browse_page_key", - "Purchases and memberships" to "@string/subscription_product_setting_key", - "Billing & payments" to "@string/billing_and_payment_key", - "Billing and payments" to "@string/billing_and_payment_key", - "Notifications" to "@string/notification_key", - "Connected apps" to "@string/connected_accounts_browse_page_key", - "Live chat" to "@string/live_chat_key", - "Captions" to "@string/captions_key", - "Accessibility" to "@string/accessibility_settings_key", - "About" to DEFAULT_ELEMENT - ) - - private val InsertPosition = stringPatchOption( - key = "InsertPosition", - default = DEFAULT_ELEMENT, - values = SETTINGS_ELEMENTS_MAP, - title = "Insert position", - description = "The settings menu name that the RVX settings menu should be above.", - required = true - ) - - private val RVXSettingsMenuName = stringPatchOption( - key = "RVXSettingsMenuName", - default = DEFAULT_NAME, - title = "RVX settings menu name", - description = "The name of the RVX settings menu.", - required = true - ) - - private lateinit var customName: String - - internal lateinit var contexts: ResourceContext - internal var upward1831 = false - internal var upward1834 = false - internal var upward1839 = false - internal var upward1842 = false - internal var upward1849 = false - internal var upward1902 = false - internal var upward1915 = false - internal var upward1923 = false - internal var upward1925 = false - internal var upward1928 = false - - override fun execute(context: ResourceContext) { - - /** - * check patch options - */ - customName = RVXSettingsMenuName - .valueOrThrow() - - val insertKey = InsertPosition - .valueOrThrow() - - /** - * set resource context - */ - contexts = context - - /** - * set version info - */ - setVersionInfo() - - /** - * remove strings duplicated with RVX resources - * - * YouTube does not provide translations for these strings. - * That's why it's been added to RVX resources. - * This string also exists in RVX resources, so it must be removed to avoid being duplicated. - */ - context.removeStringsElements( - arrayOf("values"), - arrayOf( - "accessibility_settings_edu_opt_in_text", - "accessibility_settings_edu_opt_out_text" - ) - ) - - /** - * copy arrays, strings and preference - */ - arrayOf( - "arrays.xml", - "dimens.xml", - "strings.xml", - "styles.xml" - ).forEach { xmlFile -> - context.copyXmlNode("youtube/settings/host", "values/$xmlFile", "resources") - } - - arrayOf( - ResourceGroup( - "drawable", - "revanced_cursor.xml", - ), - ResourceGroup( - "layout", - "revanced_settings_preferences_category.xml", - "revanced_settings_with_toolbar.xml", - ), - ResourceGroup( - "xml", - "revanced_prefs.xml", - ) - ).forEach { resourceGroup -> - context.copyResources("youtube/settings", resourceGroup) - } - - /** - * initialize ReVanced Extended Settings - */ - context.addPreferenceFragment( - "revanced_extended_settings", - insertKey - ) - - /** - * remove ReVanced Extended Settings divider - */ - arrayOf("Theme.YouTube.Settings", "Theme.YouTube.Settings.Dark").forEach { themeName -> - context.xmlEditor["res/values/styles.xml"].use { editor -> - with(editor.file) { - val resourcesNode = getElementsByTagName("resources").item(0) as Element - - val newElement: Element = createElement("item") - newElement.setAttribute("name", "android:listDivider") - - for (i in 0 until resourcesNode.childNodes.length) { - val node = resourcesNode.childNodes.item(i) as? Element ?: continue - - if (node.getAttribute("name") == themeName) { - newElement.appendChild(createTextNode("@null")) - - node.appendChild(newElement) - } - } - } - } - } - - /** - * set revanced-patches version - */ - val jarManifest = classLoader.getResources("META-INF/MANIFEST.MF") - while (jarManifest.hasMoreElements()) - contexts.updatePatchStatusSettings( - "ReVanced Patches", - Manifest(jarManifest.nextElement().openStream()) - .mainAttributes - .getValue("Version") + "" - ) - - /** - * set revanced-integrations version - */ - val versionName = SettingsBytecodePatch.contexts - .findClass { it.sourceFile == "BuildConfig.java" }!! - .mutableClass - .fields - .single { it.name == "VERSION_NAME" } - .initialValue - .toString() - .trim() - .replace("\"", "") - .replace(""", "") - - contexts.updatePatchStatusSettings( - "ReVanced Integrations", - versionName - ) - } - - override fun close() { - /** - * change RVX settings menu name - * since it must be invoked after the Translations patch, it must be the last in the order. - */ - if (customName != DEFAULT_NAME) { - contexts.removeStringsElements( - arrayOf("revanced_extended_settings_title") - ) - contexts.xmlEditor["res/values/strings.xml"].use { editor -> - val document = editor.file - - mapOf( - "revanced_extended_settings_title" to customName - ).forEach { (k, v) -> - val stringElement = document.createElement("string") - - stringElement.setAttribute("name", k) - stringElement.textContent = v - - document.getElementsByTagName("resources").item(0) - .appendChild(stringElement) - } - } - } - } - - private fun setVersionInfo() { - val threadCount = Runtime.getRuntime().availableProcessors() - val threadPoolExecutor = Executors.newFixedThreadPool(threadCount) - - val resourceXmlFile = contexts["res/values/integers.xml"].readBytes() - - for (threadIndex in 0 until threadCount) { - threadPoolExecutor.execute thread@{ - contexts.xmlEditor[resourceXmlFile.inputStream()].use { editor -> - val resources = editor.file.documentElement.childNodes - val resourcesLength = resources.length - val jobSize = resourcesLength / threadCount - - val batchStart = jobSize * threadIndex - val batchEnd = jobSize * (threadIndex + 1) - element@ for (i in batchStart until batchEnd) { - if (i >= resourcesLength) return@thread - - val node = resources.item(i) - if (node !is Element) continue - - if (node.nodeName != "integer" || !node.getAttribute("name") - .startsWith("google_play_services_version") - ) continue - - val playServicesVersion = node.textContent.toInt() - - upward1831 = 233200000 <= playServicesVersion - upward1834 = 233500000 <= playServicesVersion - upward1839 = 234000000 <= playServicesVersion - upward1842 = 234302000 <= playServicesVersion - upward1849 = 235000000 <= playServicesVersion - upward1902 = 240204000 < playServicesVersion - upward1915 = 241602000 <= playServicesVersion - upward1923 = 242402000 <= playServicesVersion - upward1925 = 242599000 <= playServicesVersion - upward1928 = 242905000 <= playServicesVersion - - break - } - } - } - } - - threadPoolExecutor - .also { it.shutdown() } - .awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS) - } - - internal fun addPreference(settingArray: Array) { - contexts.addPreference(settingArray) - } - - internal fun updatePatchStatus(patch: BaseResourcePatch) { - updatePatchStatus(patch.name!!) - } - - internal fun updatePatchStatus(patch: BaseBytecodePatch) { - updatePatchStatus(patch.name!!) - } - - private val patchList = ArrayList() - - internal fun updatePatchStatus(patchName: String) { - patchList.add(patchName) - contexts.updatePatchStatus(patchName) - } - - internal fun containsPatch(patchName: String) = - patchList.contains(patchName) -} diff --git a/src/main/kotlin/app/revanced/patches/youtube/utils/settings/fingerprints/ThemeSetterSystemFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/utils/settings/fingerprints/ThemeSetterSystemFingerprint.kt deleted file mode 100644 index 3b5636d256..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/utils/settings/fingerprints/ThemeSetterSystemFingerprint.kt +++ /dev/null @@ -1,11 +0,0 @@ -package app.revanced.patches.youtube.utils.settings.fingerprints - -import app.revanced.patches.youtube.utils.resourceid.SharedResourceIdPatch.Appearance -import app.revanced.util.fingerprint.LiteralValueFingerprint -import com.android.tools.smali.dexlib2.Opcode - -internal object ThemeSetterSystemFingerprint : LiteralValueFingerprint( - returnType = "L", - opcodes = listOf(Opcode.RETURN_OBJECT), - literalSupplier = { Appearance }, -) \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/SponsorBlockBytecodePatch.kt b/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/SponsorBlockBytecodePatch.kt deleted file mode 100644 index 9bda4f425d..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/SponsorBlockBytecodePatch.kt +++ /dev/null @@ -1,194 +0,0 @@ -package app.revanced.patches.youtube.utils.sponsorblock - -import app.revanced.patcher.data.BytecodeContext -import app.revanced.patcher.extensions.InstructionExtensions.addInstruction -import app.revanced.patcher.extensions.InstructionExtensions.addInstructions -import app.revanced.patcher.extensions.InstructionExtensions.getInstruction -import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction -import app.revanced.patcher.patch.BytecodePatch -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patches.youtube.utils.fingerprints.SeekbarFingerprint -import app.revanced.patches.youtube.utils.fingerprints.SeekbarOnDrawFingerprint -import app.revanced.patches.youtube.utils.fingerprints.TotalTimeFingerprint -import app.revanced.patches.youtube.utils.fingerprints.YouTubeControlsOverlayFingerprint -import app.revanced.patches.youtube.utils.integrations.Constants.INTEGRATIONS_PATH -import app.revanced.patches.youtube.utils.integrations.Constants.PATCH_STATUS_CLASS_DESCRIPTOR -import app.revanced.patches.youtube.utils.playercontrols.PlayerControlsPatch -import app.revanced.patches.youtube.utils.resourceid.SharedResourceIdPatch -import app.revanced.patches.youtube.utils.resourceid.SharedResourceIdPatch.InsetOverlayViewLayout -import app.revanced.patches.youtube.utils.sponsorblock.fingerprints.RectangleFieldInvalidatorFingerprint -import app.revanced.patches.youtube.utils.sponsorblock.fingerprints.SegmentPlaybackControllerFingerprint -import app.revanced.patches.youtube.video.information.VideoInformationPatch -import app.revanced.util.alsoResolve -import app.revanced.util.getReference -import app.revanced.util.indexOfFirstInstructionOrThrow -import app.revanced.util.indexOfFirstInstructionReversedOrThrow -import app.revanced.util.indexOfFirstWideLiteralInstructionValueOrThrow -import app.revanced.util.resultOrThrow -import app.revanced.util.updatePatchStatus -import com.android.tools.smali.dexlib2.Opcode -import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction -import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction -import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction -import com.android.tools.smali.dexlib2.iface.reference.FieldReference -import com.android.tools.smali.dexlib2.iface.reference.MethodReference - -@Patch( - dependencies = [ - PlayerControlsPatch::class, - SharedResourceIdPatch::class, - VideoInformationPatch::class - ] -) -object SponsorBlockBytecodePatch : BytecodePatch( - setOf( - SeekbarFingerprint, - SegmentPlaybackControllerFingerprint, - TotalTimeFingerprint, - YouTubeControlsOverlayFingerprint - ) -) { - private const val INTEGRATIONS_SPONSOR_BLOCK_PATH = - "$INTEGRATIONS_PATH/sponsorblock" - - private const val INTEGRATIONS_SPONSOR_BLOCK_UI_PATH = - "$INTEGRATIONS_SPONSOR_BLOCK_PATH/ui" - - private const val INTEGRATIONS_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR = - "$INTEGRATIONS_SPONSOR_BLOCK_PATH/SegmentPlaybackController;" - - private const val INTEGRATIONS_SPONSOR_BLOCK_VIEW_CONTROLLER_CLASS_DESCRIPTOR = - "$INTEGRATIONS_SPONSOR_BLOCK_UI_PATH/SponsorBlockViewController;" - - override fun execute(context: BytecodeContext) { - - VideoInformationPatch.apply { - // Hook the video time method - videoTimeHook( - INTEGRATIONS_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR, - "setVideoTime" - ) - // Initialize the player controller - onCreateHook( - INTEGRATIONS_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR, - "initialize" - ) - } - - SeekbarOnDrawFingerprint.alsoResolve( - context, SeekbarFingerprint - ).mutableMethod.apply { - // Get left and right of seekbar rectangle - val moveObjectIndex = indexOfFirstInstructionOrThrow(opcode = Opcode.MOVE_OBJECT_FROM16) - - addInstruction( - moveObjectIndex + 1, - "invoke-static/range {p0 .. p0}, " + - "$INTEGRATIONS_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR->setSponsorBarRect(Ljava/lang/Object;)V" - ) - - // Set seekbar thickness - val roundIndex = indexOfFirstInstructionOrThrow { - getReference()?.name == "round" - } + 1 - val roundRegister = getInstruction(roundIndex).registerA - - addInstruction( - roundIndex + 1, - "invoke-static {v$roundRegister}, " + - "$INTEGRATIONS_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR->setSponsorBarThickness(I)V" - ) - - // Draw segment - val drawCircleIndex = indexOfFirstInstructionReversedOrThrow { - getReference()?.name == "drawCircle" - } - val drawCircleInstruction = getInstruction(drawCircleIndex) - addInstruction( - drawCircleIndex, - "invoke-static {v${drawCircleInstruction.registerC}, v${drawCircleInstruction.registerE}}, " + - "$INTEGRATIONS_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR->drawSponsorTimeBars(Landroid/graphics/Canvas;F)V" - ) - } - - // Voting & Shield button - arrayOf("CreateSegmentButtonController;", "VotingButtonController;").forEach { className -> - PlayerControlsPatch.hookTopControlButton("$INTEGRATIONS_SPONSOR_BLOCK_UI_PATH/$className") - } - - // Append timestamp - TotalTimeFingerprint.resultOrThrow().let { - it.mutableMethod.apply { - val targetIndex = indexOfFirstInstructionOrThrow { - getReference()?.name == "getString" - } + 1 - val targetRegister = getInstruction(targetIndex).registerA - - addInstructions( - targetIndex + 1, """ - invoke-static {v$targetRegister}, $INTEGRATIONS_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR->appendTimeWithoutSegments(Ljava/lang/String;)Ljava/lang/String; - move-result-object v$targetRegister - """ - ) - } - } - - // Initialize the SponsorBlock view - YouTubeControlsOverlayFingerprint.resultOrThrow().let { - it.mutableMethod.apply { - val targetIndex = - indexOfFirstWideLiteralInstructionValueOrThrow(InsetOverlayViewLayout) - val checkCastIndex = indexOfFirstInstructionOrThrow(targetIndex, Opcode.CHECK_CAST) - val targetRegister = - getInstruction(checkCastIndex).registerA - - addInstruction( - checkCastIndex + 1, - "invoke-static {v$targetRegister}, $INTEGRATIONS_SPONSOR_BLOCK_VIEW_CONTROLLER_CLASS_DESCRIPTOR->initialize(Landroid/view/ViewGroup;)V" - ) - } - } - - // Replace strings - RectangleFieldInvalidatorFingerprint.alsoResolve( - context, SeekbarFingerprint - ).let { result -> - result.mutableMethod.apply { - val invalidateIndex = - RectangleFieldInvalidatorFingerprint.indexOfInvalidateInstruction(this) - val rectangleIndex = indexOfFirstInstructionReversedOrThrow(invalidateIndex + 1) { - getReference()?.type == "Landroid/graphics/Rect;" - } - val rectangleFieldName = - (getInstruction(rectangleIndex).reference as FieldReference).name - - SegmentPlaybackControllerFingerprint.resultOrThrow().let { - it.mutableMethod.apply { - val replaceIndex = it.scanResult.patternScanResult!!.startIndex - val replaceRegister = - getInstruction(replaceIndex).registerA - - replaceInstruction( - replaceIndex, - "const-string v$replaceRegister, \"$rectangleFieldName\"" - ) - } - } - } - } - - // The vote and create segment buttons automatically change their visibility when appropriate, - // but if buttons are showing when the end of the video is reached then they will not automatically hide. - // Add a hook to forcefully hide when the end of the video is reached. - VideoInformationPatch.videoEndMethod.addInstruction( - 0, - "invoke-static {}, $INTEGRATIONS_SPONSOR_BLOCK_VIEW_CONTROLLER_CLASS_DESCRIPTOR->endOfVideoReached()V" - ) - - // Set current video id - VideoInformationPatch.hook("$INTEGRATIONS_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR->newVideoStarted(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V") - - context.updatePatchStatus(PATCH_STATUS_CLASS_DESCRIPTOR, "SponsorBlock") - - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/SponsorBlockPatch.kt b/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/SponsorBlockPatch.kt deleted file mode 100644 index 9dad5dc886..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/SponsorBlockPatch.kt +++ /dev/null @@ -1,146 +0,0 @@ -package app.revanced.patches.youtube.utils.sponsorblock - -import app.revanced.patcher.data.ResourceContext -import app.revanced.patcher.patch.PatchException -import app.revanced.patcher.patch.options.PatchOption.PatchExtensions.booleanPatchOption -import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE -import app.revanced.patches.youtube.utils.settings.SettingsPatch -import app.revanced.util.ResourceGroup -import app.revanced.util.copyResources -import app.revanced.util.copyXmlNode -import app.revanced.util.inputStreamFromBundledResource -import app.revanced.util.patch.BaseResourcePatch - -@Suppress("DEPRECATION", "unused") -object SponsorBlockPatch : BaseResourcePatch( - name = "SponsorBlock", - description = "Adds options to enable and configure SponsorBlock, which can skip undesired video segments, such as sponsored content.", - dependencies = setOf( - SettingsPatch::class, - SponsorBlockBytecodePatch::class - ), - compatiblePackages = COMPATIBLE_PACKAGE -) { - private val OutlineIcon by booleanPatchOption( - key = "OutlineIcon", - default = false, - title = "Outline icons", - description = "Apply the outline icon.", - required = true - ) - - override fun execute(context: ResourceContext) { - /** - * merge SponsorBlock drawables to main drawables - */ - arrayOf( - ResourceGroup( - "layout", - "revanced_sb_inline_sponsor_overlay.xml", - "revanced_sb_skip_sponsor_button.xml" - ), - ResourceGroup( - "drawable", - "revanced_sb_new_segment_background.xml", - "revanced_sb_skip_sponsor_button_background.xml" - ) - ).forEach { resourceGroup -> - context.copyResources("youtube/sponsorblock/shared", resourceGroup) - } - - if (OutlineIcon == true) { - arrayOf( - ResourceGroup( - "layout", - "revanced_sb_new_segment.xml" - ), - ResourceGroup( - "drawable", - "revanced_sb_adjust.xml", - "revanced_sb_backward.xml", - "revanced_sb_compare.xml", - "revanced_sb_edit.xml", - "revanced_sb_forward.xml", - "revanced_sb_logo.xml", - "revanced_sb_publish.xml", - "revanced_sb_voting.xml" - ) - ).forEach { resourceGroup -> - context.copyResources("youtube/sponsorblock/outline", resourceGroup) - } - } else { - arrayOf( - ResourceGroup( - "layout", - "revanced_sb_new_segment.xml" - ), - ResourceGroup( - "drawable", - "revanced_sb_adjust.xml", - "revanced_sb_compare.xml", - "revanced_sb_edit.xml", - "revanced_sb_logo.xml", - "revanced_sb_publish.xml", - "revanced_sb_voting.xml" - ) - ).forEach { resourceGroup -> - context.copyResources("youtube/sponsorblock/default", resourceGroup) - } - } - - /** - * merge xml nodes from the host to their real xml files - */ - // copy nodes from host resources to their real xml files - var modifiedControlsLayout = false - - inputStreamFromBundledResource( - "youtube/sponsorblock", - "shared/host/layout/youtube_controls_layout.xml", - )?.let { hostingResourceStream -> - val editor = context.xmlEditor["res/layout/youtube_controls_layout.xml"] - - // voting button id from the voting button view from the youtube_controls_layout.xml host file - val votingButtonId = "@+id/revanced_sb_voting_button" - - "RelativeLayout".copyXmlNode( - context.xmlEditor[hostingResourceStream], - editor - ).also { - val document = editor.file - val children = document.getElementsByTagName("RelativeLayout").item(0).childNodes - - // Replace the startOf with the voting button view so that the button does not overlap - for (i in 1 until children.length) { - val view = children.item(i) - - val playerVideoHeading = view.hasAttributes() && - view.attributes.getNamedItem("android:id").nodeValue.endsWith("player_video_heading") - - // Replace the attribute for a specific node only - if (!playerVideoHeading) continue - - view.attributes.getNamedItem("android:layout_toStartOf").nodeValue = - votingButtonId - - modifiedControlsLayout = true - break - } - }.close() - } - - if (!modifiedControlsLayout) throw PatchException("Could not modify controls layout") - - /** - * Add settings - */ - SettingsPatch.addPreference( - arrayOf( - "PREFERENCE_SCREEN: SPONSOR_BLOCK" - ) - ) - - SettingsPatch.updatePatchStatus(this) - - } -} diff --git a/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/fingerprints/RectangleFieldInvalidatorFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/fingerprints/RectangleFieldInvalidatorFingerprint.kt deleted file mode 100644 index 89415c67a3..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/fingerprints/RectangleFieldInvalidatorFingerprint.kt +++ /dev/null @@ -1,21 +0,0 @@ -package app.revanced.patches.youtube.utils.sponsorblock.fingerprints - -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.patches.youtube.utils.sponsorblock.fingerprints.RectangleFieldInvalidatorFingerprint.indexOfInvalidateInstruction -import app.revanced.util.getReference -import app.revanced.util.indexOfFirstInstructionReversed -import com.android.tools.smali.dexlib2.iface.Method -import com.android.tools.smali.dexlib2.iface.reference.MethodReference - -internal object RectangleFieldInvalidatorFingerprint : MethodFingerprint( - returnType = "V", - parameters = emptyList(), - customFingerprint = { methodDef, _ -> - indexOfInvalidateInstruction(methodDef) >= 0 - } -) { - fun indexOfInvalidateInstruction(methodDef: Method) = - methodDef.indexOfFirstInstructionReversed { - getReference()?.name == "invalidate" - } -} diff --git a/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/fingerprints/SegmentPlaybackControllerFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/fingerprints/SegmentPlaybackControllerFingerprint.kt deleted file mode 100644 index fde182634b..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/utils/sponsorblock/fingerprints/SegmentPlaybackControllerFingerprint.kt +++ /dev/null @@ -1,18 +0,0 @@ -package app.revanced.patches.youtube.utils.sponsorblock.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.patches.youtube.utils.integrations.Constants.INTEGRATIONS_PATH -import com.android.tools.smali.dexlib2.AccessFlags -import com.android.tools.smali.dexlib2.Opcode - -internal object SegmentPlaybackControllerFingerprint : MethodFingerprint( - returnType = "V", - accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, - parameters = listOf("Ljava/lang/Object;"), - opcodes = listOf(Opcode.CONST_STRING), - customFingerprint = { methodDef, _ -> - methodDef.definingClass == "$INTEGRATIONS_PATH/sponsorblock/SegmentPlaybackController;" - && methodDef.name == "setSponsorBarRect" - } -) diff --git a/src/main/kotlin/app/revanced/patches/youtube/utils/toolbar/ToolBarHookPatch.kt b/src/main/kotlin/app/revanced/patches/youtube/utils/toolbar/ToolBarHookPatch.kt deleted file mode 100644 index f4e7ef890a..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/utils/toolbar/ToolBarHookPatch.kt +++ /dev/null @@ -1,70 +0,0 @@ -package app.revanced.patches.youtube.utils.toolbar - -import app.revanced.patcher.data.BytecodeContext -import app.revanced.patcher.extensions.InstructionExtensions.addInstructions -import app.revanced.patcher.extensions.InstructionExtensions.getInstruction -import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction -import app.revanced.patcher.patch.BytecodePatch -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod -import app.revanced.patches.youtube.utils.integrations.Constants.UTILS_PATH -import app.revanced.patches.youtube.utils.resourceid.SharedResourceIdPatch -import app.revanced.patches.youtube.utils.toolbar.fingerprints.ToolBarButtonFingerprint -import app.revanced.patches.youtube.utils.toolbar.fingerprints.ToolBarPatchFingerprint -import app.revanced.util.resultOrThrow -import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction -import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction -import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction - -@Patch(dependencies = [SharedResourceIdPatch::class]) -object ToolBarHookPatch : BytecodePatch( - setOf( - ToolBarButtonFingerprint, - ToolBarPatchFingerprint - ) -) { - private const val INTEGRATIONS_CLASS_DESCRIPTOR = - "$UTILS_PATH/ToolBarPatch;" - - private lateinit var toolbarMethod: MutableMethod - - override fun execute(context: BytecodeContext) { - - ToolBarButtonFingerprint.resultOrThrow().let { - it.mutableMethod.apply { - val replaceIndex = it.scanResult.patternScanResult!!.startIndex - val freeIndex = it.scanResult.patternScanResult!!.endIndex - 1 - - val replaceReference = getInstruction(replaceIndex).reference - val replaceRegister = - getInstruction(replaceIndex).registerC - val enumRegister = getInstruction(replaceIndex).registerD - val freeRegister = getInstruction(freeIndex).registerA - - val imageViewIndex = replaceIndex + 2 - val imageViewReference = - getInstruction(imageViewIndex).reference - - addInstructions( - replaceIndex + 1, """ - iget-object v$freeRegister, p0, $imageViewReference - invoke-static {v$enumRegister, v$freeRegister}, $INTEGRATIONS_CLASS_DESCRIPTOR->hookToolBar(Ljava/lang/Enum;Landroid/widget/ImageView;)V - invoke-interface {v$replaceRegister, v$enumRegister}, $replaceReference - """ - ) - removeInstruction(replaceIndex) - } - } - - toolbarMethod = ToolBarPatchFingerprint.resultOrThrow().mutableMethod - } - - internal fun hook( - descriptor: String - ) { - toolbarMethod.addInstructions( - 0, - "invoke-static {p0, p1}, $descriptor(Ljava/lang/String;Landroid/view/View;)V" - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/youtube/utils/toolbar/fingerprints/ToolBarButtonFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/utils/toolbar/fingerprints/ToolBarButtonFingerprint.kt deleted file mode 100644 index a053668c9a..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/utils/toolbar/fingerprints/ToolBarButtonFingerprint.kt +++ /dev/null @@ -1,21 +0,0 @@ -package app.revanced.patches.youtube.utils.toolbar.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patches.youtube.utils.resourceid.SharedResourceIdPatch.MenuItemView -import app.revanced.util.fingerprint.LiteralValueFingerprint -import com.android.tools.smali.dexlib2.AccessFlags -import com.android.tools.smali.dexlib2.Opcode - -internal object ToolBarButtonFingerprint : LiteralValueFingerprint( - returnType = "V", - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - parameters = listOf("Landroid/view/MenuItem;"), - opcodes = listOf( - Opcode.INVOKE_INTERFACE, - Opcode.MOVE_RESULT, - Opcode.IGET_OBJECT, - Opcode.IGET_OBJECT, - Opcode.INVOKE_VIRTUAL - ), - literalSupplier = { MenuItemView }, -) \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/youtube/utils/toolbar/fingerprints/ToolBarPatchFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/utils/toolbar/fingerprints/ToolBarPatchFingerprint.kt deleted file mode 100644 index 9d0fa8d042..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/utils/toolbar/fingerprints/ToolBarPatchFingerprint.kt +++ /dev/null @@ -1,14 +0,0 @@ -package app.revanced.patches.youtube.utils.toolbar.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.patches.youtube.utils.integrations.Constants.UTILS_PATH -import com.android.tools.smali.dexlib2.AccessFlags - -internal object ToolBarPatchFingerprint : MethodFingerprint( - accessFlags = AccessFlags.PRIVATE or AccessFlags.STATIC, - customFingerprint = { methodDef, _ -> - methodDef.definingClass == "$UTILS_PATH/ToolBarPatch;" - && methodDef.name == "hookToolBar" - } -) \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/youtube/utils/trackingurlhook/TrackingUrlHookPatch.kt b/src/main/kotlin/app/revanced/patches/youtube/utils/trackingurlhook/TrackingUrlHookPatch.kt deleted file mode 100644 index 5012f0c09d..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/utils/trackingurlhook/TrackingUrlHookPatch.kt +++ /dev/null @@ -1,48 +0,0 @@ -package app.revanced.patches.youtube.utils.trackingurlhook - -import app.revanced.patcher.data.BytecodeContext -import app.revanced.patcher.extensions.InstructionExtensions.addInstructions -import app.revanced.patcher.extensions.InstructionExtensions.getInstruction -import app.revanced.patcher.patch.BytecodePatch -import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod -import app.revanced.patches.youtube.utils.trackingurlhook.fingerprints.TrackingUrlModelFingerprint -import app.revanced.util.getReference -import app.revanced.util.indexOfFirstInstructionOrThrow -import app.revanced.util.resultOrThrow -import com.android.tools.smali.dexlib2.Opcode -import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction -import com.android.tools.smali.dexlib2.iface.reference.MethodReference - -object TrackingUrlHookPatch : BytecodePatch( - setOf(TrackingUrlModelFingerprint) -) { - private lateinit var trackingUrlMethod: MutableMethod - - override fun execute(context: BytecodeContext) { - trackingUrlMethod = TrackingUrlModelFingerprint.resultOrThrow().mutableMethod - } - - internal fun hookTrackingUrl( - descriptor: String - ) = trackingUrlMethod.apply { - val targetIndex = indexOfFirstInstructionOrThrow { - opcode == Opcode.INVOKE_STATIC && - getReference()?.name == "parse" - } + 1 - val targetRegister = getInstruction(targetIndex).registerA - - var smaliInstruction = "invoke-static {v$targetRegister}, $descriptor" - - if (!descriptor.endsWith("V")) { - smaliInstruction += """ - move-result-object v$targetRegister - - """.trimIndent() - } - - addInstructions( - targetIndex + 1, - smaliInstruction - ) - } -} diff --git a/src/main/kotlin/app/revanced/patches/youtube/utils/trackingurlhook/fingerprints/TrackingUrlModelFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/utils/trackingurlhook/fingerprints/TrackingUrlModelFingerprint.kt deleted file mode 100644 index 37a6897b74..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/utils/trackingurlhook/fingerprints/TrackingUrlModelFingerprint.kt +++ /dev/null @@ -1,20 +0,0 @@ -package app.revanced.patches.youtube.utils.trackingurlhook.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags -import com.android.tools.smali.dexlib2.Opcode - -internal object TrackingUrlModelFingerprint : MethodFingerprint( - returnType = "Landroid/net/Uri;", - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - parameters = emptyList(), - opcodes = listOf( - Opcode.IGET_OBJECT, - Opcode.INVOKE_STATIC, - Opcode.MOVE_RESULT_OBJECT, - ), - customFingerprint = { methodDef, _ -> - methodDef.definingClass == "Lcom/google/android/libraries/youtube/innertube/model/player/TrackingUrlModel;" - } -) \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/information/VideoInformationPatch.kt b/src/main/kotlin/app/revanced/patches/youtube/video/information/VideoInformationPatch.kt deleted file mode 100644 index 8a5f61e0d4..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/information/VideoInformationPatch.kt +++ /dev/null @@ -1,682 +0,0 @@ -package app.revanced.patches.youtube.video.information - -import app.revanced.patcher.data.BytecodeContext -import app.revanced.patcher.extensions.InstructionExtensions.addInstruction -import app.revanced.patcher.extensions.InstructionExtensions.addInstructions -import app.revanced.patcher.extensions.InstructionExtensions.getInstruction -import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction -import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.patcher.fingerprint.MethodFingerprintResult -import app.revanced.patcher.patch.BytecodePatch -import app.revanced.patcher.patch.PatchException -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod -import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable -import app.revanced.patcher.util.smali.toInstructions -import app.revanced.patches.shared.fingerprints.MdxPlayerDirectorSetVideoStageFingerprint -import app.revanced.patches.shared.fingerprints.VideoLengthFingerprint -import app.revanced.patches.youtube.utils.PlayerResponseModelUtils.PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR -import app.revanced.patches.youtube.utils.PlayerResponseModelUtils.indexOfPlayerResponseModelInstruction -import app.revanced.patches.youtube.utils.fingerprints.VideoEndFingerprint -import app.revanced.patches.youtube.utils.integrations.Constants.SHARED_PATH -import app.revanced.patches.youtube.utils.playertype.PlayerTypeHookPatch -import app.revanced.patches.youtube.utils.resourceid.SharedResourceIdPatch -import app.revanced.patches.youtube.video.information.fingerprints.ChannelIdFingerprint -import app.revanced.patches.youtube.video.information.fingerprints.ChannelNameFingerprint -import app.revanced.patches.youtube.video.information.fingerprints.OnPlaybackSpeedItemClickFingerprint -import app.revanced.patches.youtube.video.information.fingerprints.PlaybackInitializationFingerprint -import app.revanced.patches.youtube.video.information.fingerprints.PlaybackSpeedClassFingerprint -import app.revanced.patches.youtube.video.information.fingerprints.PlayerControllerSetTimeReferenceFingerprint -import app.revanced.patches.youtube.video.information.fingerprints.SeekRelativeFingerprint -import app.revanced.patches.youtube.video.information.fingerprints.VideoIdFingerprint -import app.revanced.patches.youtube.video.information.fingerprints.VideoIdFingerprintBackgroundPlay -import app.revanced.patches.youtube.video.information.fingerprints.VideoIdFingerprintShorts -import app.revanced.patches.youtube.video.information.fingerprints.VideoQualityListFingerprint -import app.revanced.patches.youtube.video.information.fingerprints.VideoQualityTextFingerprint -import app.revanced.patches.youtube.video.information.fingerprints.VideoTitleFingerprint -import app.revanced.patches.youtube.video.playerresponse.PlayerResponseMethodHookPatch -import app.revanced.patches.youtube.video.videoid.VideoIdPatch -import app.revanced.util.addStaticFieldToIntegration -import app.revanced.util.alsoResolve -import app.revanced.util.cloneMutable -import app.revanced.util.getReference -import app.revanced.util.getWalkerMethod -import app.revanced.util.indexOfFirstInstructionOrThrow -import app.revanced.util.indexOfFirstInstructionReversedOrThrow -import app.revanced.util.indexOfFirstWideLiteralInstructionValueOrThrow -import app.revanced.util.resultOrThrow -import com.android.tools.smali.dexlib2.AccessFlags -import com.android.tools.smali.dexlib2.Opcode -import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction -import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction -import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction -import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction -import com.android.tools.smali.dexlib2.iface.reference.MethodReference -import com.android.tools.smali.dexlib2.immutable.ImmutableMethod -import com.android.tools.smali.dexlib2.immutable.ImmutableMethodImplementation -import com.android.tools.smali.dexlib2.immutable.ImmutableMethodParameter -import com.android.tools.smali.dexlib2.util.MethodUtil - -@Patch( - description = "Hooks YouTube to get information about the current playing video.", - dependencies = [ - PlayerResponseMethodHookPatch::class, - PlayerTypeHookPatch::class, - SharedResourceIdPatch::class, - VideoIdPatch::class - ] -) -@Suppress("MemberVisibilityCanBePrivate") -object VideoInformationPatch : BytecodePatch( - setOf( - ChannelIdFingerprint, - ChannelNameFingerprint, - MdxPlayerDirectorSetVideoStageFingerprint, - OnPlaybackSpeedItemClickFingerprint, - PlaybackInitializationFingerprint, - PlaybackSpeedClassFingerprint, - PlayerControllerSetTimeReferenceFingerprint, - VideoEndFingerprint, - VideoIdFingerprint, - VideoIdFingerprintBackgroundPlay, - VideoIdFingerprintShorts, - VideoLengthFingerprint, - VideoQualityListFingerprint, - VideoQualityTextFingerprint, - VideoTitleFingerprint, - ) -) { - private const val INTEGRATIONS_CLASS_DESCRIPTOR = - "$SHARED_PATH/VideoInformation;" - - private const val REGISTER_PLAYER_RESPONSE_MODEL = 8 - - private const val REGISTER_CHANNEL_ID = 0 - private const val REGISTER_CHANNEL_NAME = 1 - private const val REGISTER_VIDEO_ID = 2 - private const val REGISTER_VIDEO_TITLE = 3 - private const val REGISTER_VIDEO_LENGTH = 4 - - @Suppress("unused") - private const val REGISTER_VIDEO_LENGTH_DUMMY = 5 - private const val REGISTER_VIDEO_IS_LIVE = 6 - - private lateinit var channelIdMethodCall: String - private lateinit var channelNameMethodCall: String - private lateinit var videoIdMethodCall: String - private lateinit var videoTitleMethodCall: String - private lateinit var videoLengthMethodCall: String - private lateinit var videoIsLiveMethodCall: String - - private lateinit var videoInformationMethod: MutableMethod - private lateinit var backgroundVideoInformationMethod: MutableMethod - private lateinit var shortsVideoInformationMethod: MutableMethod - - /** - * Used in [VideoEndFingerprint] and [MdxPlayerDirectorSetVideoStageFingerprint]. - * Since both classes are inherited from the same class, - * [VideoEndFingerprint] and [MdxPlayerDirectorSetVideoStageFingerprint] always have the same [seekSourceEnumType], [seekSourceMethodName] and [seekRelativeSourceMethodName]. - */ - private var seekSourceEnumType = "" - private var seekSourceMethodName = "" - private var seekRelativeSourceMethodName = "" - private var cloneSeekRelativeSourceMethod = false - - private lateinit var context: BytecodeContext - - private lateinit var playerConstructorMethod: MutableMethod - private var playerConstructorInsertIndex = -1 - - private lateinit var mdxConstructorMethod: MutableMethod - private var mdxConstructorInsertIndex = -1 - - private lateinit var videoTimeConstructorMethod: MutableMethod - private var videoTimeConstructorInsertIndex = 2 - - // Used by other patches. - internal lateinit var speedSelectionInsertMethod: MutableMethod - internal lateinit var videoEndMethod: MutableMethod - - private fun cloneSeekRelativeSourceMethod(fingerprintResult: MethodFingerprintResult) { - if (!cloneSeekRelativeSourceMethod) return - - val methods = fingerprintResult.mutableClass.methods - - methods.find { method -> - method.name == seekRelativeSourceMethodName - }?.apply { - methods.add( - cloneMutable( - returnType = "Z" - ).apply { - val lastIndex = implementation!!.instructions.lastIndex - - removeInstruction(lastIndex) - addInstructions( - lastIndex, """ - move-result p1 - return p1 - """ - ) - } - ) - } - } - - private fun addSeekInterfaceMethods( - result: MethodFingerprintResult, - seekMethodName: String, - methodName: String, - fieldMethodName: String, - fieldName: String - ) { - result.mutableMethod.apply { - result.mutableClass.methods.add( - ImmutableMethod( - definingClass, - fieldMethodName, - listOf(ImmutableMethodParameter("J", annotations, "time")), - "Z", - AccessFlags.PUBLIC or AccessFlags.FINAL, - annotations, - null, - ImmutableMethodImplementation( - 4, """ - # first enum (field a) is SEEK_SOURCE_UNKNOWN - sget-object v0, $seekSourceEnumType->a:$seekSourceEnumType - invoke-virtual {p0, p1, p2, v0}, $definingClass->$seekMethodName(J$seekSourceEnumType)Z - move-result p1 - return p1 - """.toInstructions(), - null, - null - ) - ).toMutable() - ) - - val smaliInstructions = - """ - if-eqz v0, :ignore - invoke-virtual {v0, p0, p1}, $definingClass->$fieldMethodName(J)Z - move-result v0 - return v0 - :ignore - const/4 v0, 0x0 - return v0 - """ - - context.addStaticFieldToIntegration( - INTEGRATIONS_CLASS_DESCRIPTOR, - methodName, - fieldName, - definingClass, - smaliInstructions - ) - } - } - - override fun execute(context: BytecodeContext) { - this.context = context - - VideoEndFingerprint.resultOrThrow().let { - it.mutableMethod.apply { - playerConstructorMethod = - it.mutableClass.methods.first { method -> MethodUtil.isConstructor(method) } - - playerConstructorInsertIndex = - playerConstructorMethod.indexOfFirstInstructionOrThrow { - opcode == Opcode.INVOKE_DIRECT && getReference()?.name == "" - } + 1 - - // hook the player controller for use through integrations - onCreateHook(INTEGRATIONS_CLASS_DESCRIPTOR, "initialize") - - val seekRelativeMethod = SeekRelativeFingerprint.alsoResolve( - context, - VideoEndFingerprint - ).mutableMethod - - seekSourceEnumType = parameterTypes[1].toString() - seekSourceMethodName = name - seekRelativeSourceMethodName = seekRelativeMethod.name - cloneSeekRelativeSourceMethod = seekRelativeMethod.returnType == "V" - cloneSeekRelativeSourceMethod(it) - - // Create integrations interface methods. - addSeekInterfaceMethods( - it, - seekSourceMethodName, - "overrideVideoTime", - "seekTo", - "videoInformationClass" - ) - addSeekInterfaceMethods( - it, - seekRelativeSourceMethodName, - "overrideVideoTimeRelative", - "seekToRelative", - "videoInformationClass" - ) - - val literalIndex = indexOfFirstWideLiteralInstructionValueOrThrow(45368273) - val walkerIndex = - indexOfFirstInstructionReversedOrThrow( - literalIndex, - Opcode.INVOKE_VIRTUAL_RANGE - ) - - videoEndMethod = - getWalkerMethod(context, walkerIndex) - } - } - - MdxPlayerDirectorSetVideoStageFingerprint.resultOrThrow().let { - it.mutableMethod.apply { - mdxConstructorMethod = - it.mutableClass.methods.first { method -> MethodUtil.isConstructor(method) } - - mdxConstructorInsertIndex = mdxConstructorMethod.indexOfFirstInstructionOrThrow { - opcode == Opcode.INVOKE_DIRECT && getReference()?.name == "" - } + 1 - - // hook the MDX director for use through integrations - onCreateHookMdx(INTEGRATIONS_CLASS_DESCRIPTOR, "initializeMdx") - - cloneSeekRelativeSourceMethod(it) - - // Create integrations interface methods. - addSeekInterfaceMethods( - it, - seekSourceMethodName, - "overrideMDXVideoTime", - "seekTo", - "videoInformationMDXClass" - ) - addSeekInterfaceMethods( - it, - seekRelativeSourceMethodName, - "overrideMDXVideoTimeRelative", - "seekToRelative", - "videoInformationMDXClass" - ) - } - } - - /** - * Set current video information - */ - channelIdMethodCall = - ChannelIdFingerprint.getPlayerResponseInstruction("Ljava/lang/String;") - channelNameMethodCall = - ChannelNameFingerprint.getPlayerResponseInstruction("Ljava/lang/String;") - videoIdMethodCall = VideoIdFingerprint.getPlayerResponseInstruction("Ljava/lang/String;") - videoTitleMethodCall = - VideoTitleFingerprint.getPlayerResponseInstruction("Ljava/lang/String;") - videoLengthMethodCall = VideoLengthFingerprint.getPlayerResponseInstruction("J") - videoIsLiveMethodCall = ChannelIdFingerprint.getPlayerResponseInstruction("Z") - - PlaybackInitializationFingerprint.resultOrThrow().let { - it.mutableMethod.apply { - val targetIndex = - PlaybackInitializationFingerprint.indexOfPlayerResponseModelInstruction(this) + 1 - val targetRegister = getInstruction(targetIndex).registerA - - addInstruction( - targetIndex + 1, - "invoke-direct {p0, v$targetRegister}, $definingClass->setVideoInformation($PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR)V" - ) - - videoInformationMethod = getVideoInformationMethod() - it.mutableClass.methods.add(videoInformationMethod) - - hook("$INTEGRATIONS_CLASS_DESCRIPTOR->setVideoInformation(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V") - } - } - - VideoIdFingerprintBackgroundPlay.resultOrThrow().let { - it.mutableMethod.apply { - val targetIndex = indexOfPlayerResponseModelInstruction(this) - val targetRegister = getInstruction(targetIndex).registerC - - addInstruction( - targetIndex, - "invoke-direct {p0, v$targetRegister}, $definingClass->setVideoInformation($PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR)V" - ) - - backgroundVideoInformationMethod = getVideoInformationMethod() - it.mutableClass.methods.add(backgroundVideoInformationMethod) - } - } - - VideoIdFingerprintShorts.resultOrThrow().let { - it.mutableMethod.apply { - val targetIndex = indexOfPlayerResponseModelInstruction(this) - val targetRegister = getInstruction(targetIndex).registerC - - addInstruction( - targetIndex, - "invoke-direct {p0, v$targetRegister}, $definingClass->setVideoInformation($PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR)V" - ) - - shortsVideoInformationMethod = getVideoInformationMethod() - it.mutableClass.methods.add(shortsVideoInformationMethod) - } - } - - /** - * Set current video time method - */ - PlayerControllerSetTimeReferenceFingerprint.resultOrThrow().let { - videoTimeConstructorMethod = - it.getWalkerMethod(context, it.scanResult.patternScanResult!!.startIndex) - } - - /** - * Set current video time - */ - videoTimeHook(INTEGRATIONS_CLASS_DESCRIPTOR, "setVideoTime") - - /** - * Set current video id - */ - VideoIdPatch.hookVideoId("$INTEGRATIONS_CLASS_DESCRIPTOR->setVideoId(Ljava/lang/String;)V") - VideoIdPatch.hookPlayerResponseVideoId( - "$INTEGRATIONS_CLASS_DESCRIPTOR->setPlayerResponseVideoId(Ljava/lang/String;Z)V" - ) - // Call before any other video id hooks, - // so they can use VideoInformation and check if the video id is for a Short. - PlayerResponseMethodHookPatch += PlayerResponseMethodHookPatch.Hook.PlayerParameterBeforeVideoId( - "$INTEGRATIONS_CLASS_DESCRIPTOR->newPlayerResponseParameter(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)Ljava/lang/String;" - ) - - /** - * Hook current playback speed - */ - OnPlaybackSpeedItemClickFingerprint.resultOrThrow().let { - it.mutableMethod.apply { - speedSelectionInsertMethod = this - val speedSelectionValueInstructionIndex = - indexOfFirstInstructionOrThrow(Opcode.IGET) - - val setPlaybackSpeedContainerClassFieldIndex = - indexOfFirstInstructionReversedOrThrow( - speedSelectionValueInstructionIndex, - Opcode.IGET_OBJECT - ) - val setPlaybackSpeedContainerClassFieldReference = - getInstruction(setPlaybackSpeedContainerClassFieldIndex).reference.toString() - - val setPlaybackSpeedClassFieldReference = - getInstruction(speedSelectionValueInstructionIndex + 1).reference.toString() - val setPlaybackSpeedMethodReference = - getInstruction(speedSelectionValueInstructionIndex + 2).reference.toString() - - // add override playback speed method - it.mutableClass.methods.add( - ImmutableMethod( - definingClass, - "overridePlaybackSpeed", - listOf(ImmutableMethodParameter("F", annotations, null)), - "V", - AccessFlags.PUBLIC or AccessFlags.PUBLIC, - annotations, - null, - ImmutableMethodImplementation( - 4, """ - const/4 v0, 0x0 - cmpg-float v0, v3, v0 - if-lez v0, :ignore - - # Get the container class field. - iget-object v0, v2, $setPlaybackSpeedContainerClassFieldReference - - # Get the field from its class. - iget-object v1, v0, $setPlaybackSpeedClassFieldReference - - # Invoke setPlaybackSpeed on that class. - invoke-virtual {v1, v3}, $setPlaybackSpeedMethodReference - - :ignore - return-void - """.toInstructions(), null, null - ) - ).toMutable() - ) - - // set current playback speed - val walkerMethod = getWalkerMethod(context, speedSelectionValueInstructionIndex + 2) - walkerMethod.apply { - addInstruction( - this.implementation!!.instructions.size - 1, - "invoke-static { p1 }, $INTEGRATIONS_CLASS_DESCRIPTOR->setPlaybackSpeed(F)V" - ) - } - } - } - - PlaybackSpeedClassFingerprint.resultOrThrow().let { result -> - result.mutableMethod.apply { - val index = result.scanResult.patternScanResult!!.endIndex - val register = getInstruction(index).registerA - val playbackSpeedClass = this.returnType - - // set playback speed class - replaceInstruction( - index, - "sput-object v$register, $INTEGRATIONS_CLASS_DESCRIPTOR->playbackSpeedClass:$playbackSpeedClass" - ) - addInstruction( - index + 1, - "return-object v$register" - ) - - val smaliInstructions = - """ - if-eqz v0, :ignore - invoke-virtual {v0, p0}, $playbackSpeedClass->overridePlaybackSpeed(F)V - :ignore - return-void - """ - - context.addStaticFieldToIntegration( - INTEGRATIONS_CLASS_DESCRIPTOR, - "overridePlaybackSpeed", - "playbackSpeedClass", - playbackSpeedClass, - smaliInstructions, - false - ) - } - } - - /** - * Hook current video quality - */ - VideoQualityListFingerprint.resultOrThrow().let { - val overrideMethod = - it.mutableClass.methods.find { method -> method.parameterTypes.first() == "I" } - - val videoQualityClass = it.method.definingClass - val videoQualityMethodName = overrideMethod?.name - ?: throw PatchException("Failed to find hook method") - - // set video quality array - it.mutableMethod.apply { - val listIndex = it.scanResult.patternScanResult!!.startIndex - val listRegister = getInstruction(listIndex).registerD - - addInstruction( - listIndex, - "invoke-static {v$listRegister}, $INTEGRATIONS_CLASS_DESCRIPTOR->setVideoQualityList([Ljava/lang/Object;)V" - ) - } - - val smaliInstructions = - """ - if-eqz v0, :ignore - invoke-virtual {v0, p0}, $videoQualityClass->$videoQualityMethodName(I)V - :ignore - return-void - """ - - context.addStaticFieldToIntegration( - INTEGRATIONS_CLASS_DESCRIPTOR, - "overrideVideoQuality", - "videoQualityClass", - videoQualityClass, - smaliInstructions - ) - } - - // set current video quality - VideoQualityTextFingerprint.resultOrThrow().let { - it.mutableMethod.apply { - val textIndex = it.scanResult.patternScanResult!!.endIndex - val textRegister = getInstruction(textIndex).registerA - - addInstruction( - textIndex + 1, - "invoke-static {v$textRegister}, $INTEGRATIONS_CLASS_DESCRIPTOR->setVideoQuality(Ljava/lang/String;)V" - ) - } - } - } - - /** - * Hook the player controller. Called when a video is opened or the current video is changed. - * - * Note: This hook is called very early and is called before the video id, video time, video length, - * and many other data fields are set. - * - * @param targetMethodClass The descriptor for the class to invoke when the player controller is created. - * @param targetMethodName The name of the static method to invoke when the player controller is created. - */ - internal fun onCreateHook(targetMethodClass: String, targetMethodName: String) = - playerConstructorMethod.addInstruction( - playerConstructorInsertIndex++, - "invoke-static { }, $targetMethodClass->$targetMethodName()V" - ) - - /** - * Hook the MDX player director. Called when playing videos while casting to a big screen device. - * - * @param targetMethodClass The descriptor for the class to invoke when the player controller is created. - * @param targetMethodName The name of the static method to invoke when the player controller is created. - */ - internal fun onCreateHookMdx(targetMethodClass: String, targetMethodName: String) = - mdxConstructorMethod.addInstruction( - mdxConstructorInsertIndex++, - "invoke-static { }, $targetMethodClass->$targetMethodName()V" - ) - - /** - * Hook the video time. - * The hook is usually called once per second. - * - * @param targetMethodClass The descriptor for the static method to invoke when the player controller is created. - * @param targetMethodName The name of the static method to invoke when the player controller is created. - */ - internal fun videoTimeHook(targetMethodClass: String, targetMethodName: String) = - videoTimeConstructorMethod.addInstruction( - videoTimeConstructorInsertIndex++, - "invoke-static { p1, p2 }, $targetMethodClass->$targetMethodName(J)V" - ) - - private fun MethodFingerprint.getPlayerResponseInstruction(returnType: String): String { - resultOrThrow().mutableMethod.apply { - val targetReference = getInstruction( - indexOfFirstInstructionOrThrow { - val reference = getReference() - opcode == Opcode.INVOKE_INTERFACE && - reference?.definingClass == PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR && - reference.returnType == returnType - } - ).reference - - return "invoke-interface {v$REGISTER_PLAYER_RESPONSE_MODEL}, $targetReference" - } - } - - private fun MutableMethod.getVideoInformationMethod(): MutableMethod = - ImmutableMethod( - definingClass, - "setVideoInformation", - listOf( - ImmutableMethodParameter( - PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR, - annotations, - null - ) - ), - "V", - AccessFlags.PRIVATE or AccessFlags.FINAL, - annotations, - null, - ImmutableMethodImplementation( - REGISTER_PLAYER_RESPONSE_MODEL + 1, """ - $channelIdMethodCall - move-result-object v$REGISTER_CHANNEL_ID - $channelNameMethodCall - move-result-object v$REGISTER_CHANNEL_NAME - $videoIdMethodCall - move-result-object v$REGISTER_VIDEO_ID - $videoTitleMethodCall - move-result-object v$REGISTER_VIDEO_TITLE - $videoLengthMethodCall - move-result-wide v$REGISTER_VIDEO_LENGTH - $videoIsLiveMethodCall - move-result v$REGISTER_VIDEO_IS_LIVE - return-void - """.toInstructions(), - null, - null - ) - ).toMutable() - - private fun MutableMethod.insert(insertIndex: Int, register: String, descriptor: String) = - addInstruction(insertIndex, "invoke-static/range { $register }, $descriptor") - - /** - * This method is invoked on both regular videos and Shorts. - */ - internal fun hook(descriptor: String) = - videoInformationMethod.apply { - val index = implementation!!.instructions.size - 1 - - insert( - index, - "v$REGISTER_CHANNEL_ID .. v$REGISTER_VIDEO_IS_LIVE", - descriptor - ) - } - - /** - * This method is invoked only in regular videos. - */ - internal fun hookBackgroundPlay(descriptor: String) = - backgroundVideoInformationMethod.apply { - val index = implementation!!.instructions.size - 1 - - insert( - index, - "v$REGISTER_CHANNEL_ID .. v$REGISTER_VIDEO_IS_LIVE", - descriptor - ) - } - - /** - * This method is invoked only in shorts videos. - */ - internal fun hookShorts(descriptor: String) = - shortsVideoInformationMethod.apply { - val index = implementation!!.instructions.size - 1 - - insert( - index, - "v$REGISTER_CHANNEL_ID .. v$REGISTER_VIDEO_IS_LIVE", - descriptor - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/ChannelIdFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/ChannelIdFingerprint.kt deleted file mode 100644 index 928aaf4b32..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/ChannelIdFingerprint.kt +++ /dev/null @@ -1,12 +0,0 @@ -package app.revanced.patches.youtube.video.information.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags - -internal object ChannelIdFingerprint : MethodFingerprint( - returnType = "V", - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - parameters = listOf("Ljava/lang/Object;"), - strings = listOf("com.google.android.apps.youtube.mdx.watch.LAST_MEALBAR_PROMOTED_LIVE_FEED_CHANNELS") -) diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/ChannelNameFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/ChannelNameFingerprint.kt deleted file mode 100644 index 7f2f928cb8..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/ChannelNameFingerprint.kt +++ /dev/null @@ -1,15 +0,0 @@ -package app.revanced.patches.youtube.video.information.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags - -internal object ChannelNameFingerprint : MethodFingerprint( - returnType = "V", - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - parameters = listOf("L"), - strings = listOf( - "setMetadata may only be called once", - "Person", - ) -) diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/OnPlaybackSpeedItemClickFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/OnPlaybackSpeedItemClickFingerprint.kt deleted file mode 100644 index 3655b5282a..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/OnPlaybackSpeedItemClickFingerprint.kt +++ /dev/null @@ -1,23 +0,0 @@ -package app.revanced.patches.youtube.video.information.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.patches.youtube.utils.PlayerResponseModelUtils.PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR -import app.revanced.util.getReference -import app.revanced.util.indexOfFirstInstruction -import com.android.tools.smali.dexlib2.AccessFlags -import com.android.tools.smali.dexlib2.Opcode -import com.android.tools.smali.dexlib2.iface.reference.FieldReference - -internal object OnPlaybackSpeedItemClickFingerprint : MethodFingerprint( - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - returnType = "V", - parameters = listOf("Landroid/widget/AdapterView;", "Landroid/view/View;", "I", "J"), - customFingerprint = { methodDef, _ -> - methodDef.name == "onItemClick" && - methodDef.indexOfFirstInstruction { - opcode == Opcode.IGET_OBJECT && - getReference()?.type == PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR - } >= 0 - } -) \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/PlaybackInitializationFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/PlaybackInitializationFingerprint.kt deleted file mode 100644 index d5bbd3cd67..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/PlaybackInitializationFingerprint.kt +++ /dev/null @@ -1,28 +0,0 @@ -package app.revanced.patches.youtube.video.information.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.patches.youtube.utils.PlayerResponseModelUtils.PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR -import app.revanced.patches.youtube.video.information.fingerprints.PlaybackInitializationFingerprint.indexOfPlayerResponseModelInstruction -import app.revanced.util.getReference -import app.revanced.util.indexOfFirstInstruction -import com.android.tools.smali.dexlib2.AccessFlags -import com.android.tools.smali.dexlib2.Opcode -import com.android.tools.smali.dexlib2.iface.Method -import com.android.tools.smali.dexlib2.iface.reference.MethodReference - -internal object PlaybackInitializationFingerprint : MethodFingerprint( - returnType = "V", - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - parameters = emptyList(), - strings = listOf("play() called when the player wasn\'t loaded."), - customFingerprint = { methodDef, _ -> - indexOfPlayerResponseModelInstruction(methodDef) >= 0 - } -) { - fun indexOfPlayerResponseModelInstruction(methodDef: Method) = - methodDef.indexOfFirstInstruction { - opcode == Opcode.INVOKE_DIRECT && - getReference()?.returnType == PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR - } -} diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/PlaybackSpeedClassFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/PlaybackSpeedClassFingerprint.kt deleted file mode 100644 index 2a72d9ebb9..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/PlaybackSpeedClassFingerprint.kt +++ /dev/null @@ -1,14 +0,0 @@ -package app.revanced.patches.youtube.video.information.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags -import com.android.tools.smali.dexlib2.Opcode - -internal object PlaybackSpeedClassFingerprint : MethodFingerprint( - returnType = "L", - accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, - parameters = listOf("L"), - opcodes = listOf(Opcode.RETURN_OBJECT), - strings = listOf("PLAYBACK_RATE_MENU_BOTTOM_SHEET_FRAGMENT") -) \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/PlayerControllerSetTimeReferenceFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/PlayerControllerSetTimeReferenceFingerprint.kt deleted file mode 100644 index 48d56206cc..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/PlayerControllerSetTimeReferenceFingerprint.kt +++ /dev/null @@ -1,12 +0,0 @@ -package app.revanced.patches.youtube.video.information.fingerprints - -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.Opcode - -object PlayerControllerSetTimeReferenceFingerprint : MethodFingerprint( - opcodes = listOf( - Opcode.INVOKE_DIRECT_RANGE, - Opcode.IGET_OBJECT - ), - strings = listOf("Media progress reported outside media playback: ") -) \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/SeekRelativeFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/SeekRelativeFingerprint.kt deleted file mode 100644 index 90146b3c70..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/SeekRelativeFingerprint.kt +++ /dev/null @@ -1,21 +0,0 @@ -package app.revanced.patches.youtube.video.information.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.patches.youtube.utils.fingerprints.VideoEndFingerprint -import com.android.tools.smali.dexlib2.AccessFlags -import com.android.tools.smali.dexlib2.Opcode - -/** - * Resolves using class found in [VideoEndFingerprint]. - */ -internal object SeekRelativeFingerprint : MethodFingerprint( - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - // returnType = "Z", ~ YouTube 19.39.39 - // returnType = "V", YouTube 19.40.xx ~ - parameters = listOf("J", "L"), - opcodes = listOf( - Opcode.ADD_LONG_2ADDR, - Opcode.INVOKE_VIRTUAL, - ) -) \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoIdFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoIdFingerprint.kt deleted file mode 100644 index c69648f994..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoIdFingerprint.kt +++ /dev/null @@ -1,12 +0,0 @@ -package app.revanced.patches.youtube.video.information.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags - -internal object VideoIdFingerprint : MethodFingerprint( - returnType = "V", - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - parameters = emptyList(), - strings = listOf("Failed to download video (IllegalStateException): %s") -) diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoIdFingerprintBackgroundPlay.kt b/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoIdFingerprintBackgroundPlay.kt deleted file mode 100644 index 750929252a..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoIdFingerprintBackgroundPlay.kt +++ /dev/null @@ -1,29 +0,0 @@ -package app.revanced.patches.youtube.video.information.fingerprints - -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.patches.youtube.utils.PlayerResponseModelUtils.indexOfPlayerResponseModelInstruction -import com.android.tools.smali.dexlib2.Opcode - -/** - * Renamed from VideoIdWithoutShortsFingerprint - */ -internal object VideoIdFingerprintBackgroundPlay : MethodFingerprint( - returnType = "V", - parameters = listOf("L"), - opcodes = listOf( - Opcode.IF_EQZ, - Opcode.INVOKE_INTERFACE, - Opcode.MOVE_RESULT_OBJECT, - Opcode.IPUT_OBJECT, - Opcode.MONITOR_EXIT, - Opcode.RETURN_VOID, - Opcode.MONITOR_EXIT, - Opcode.RETURN_VOID - ), - customFingerprint = { methodDef, classDef -> - methodDef.name == "l" && - classDef.methods.count() == 17 && - methodDef.implementation != null && - indexOfPlayerResponseModelInstruction(methodDef) >= 0 - } -) diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoIdFingerprintShorts.kt b/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoIdFingerprintShorts.kt deleted file mode 100644 index 0e419ab536..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoIdFingerprintShorts.kt +++ /dev/null @@ -1,31 +0,0 @@ -package app.revanced.patches.youtube.video.information.fingerprints - -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.patches.youtube.utils.PlayerResponseModelUtils.PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR -import app.revanced.util.containsWideLiteralInstructionValue -import app.revanced.util.getReference -import app.revanced.util.indexOfFirstInstruction -import com.android.tools.smali.dexlib2.Opcode -import com.android.tools.smali.dexlib2.iface.reference.FieldReference - -/** - * This fingerprint is compatible with all versions of YouTube starting from v18.29.38 to supported versions. - * This method is invoked only in Shorts. - * Accurate video information is invoked even when the user moves Shorts upward or downward. - */ -internal object VideoIdFingerprintShorts : MethodFingerprint( - returnType = "V", - parameters = listOf(PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR), - opcodes = listOf( - Opcode.INVOKE_INTERFACE, - Opcode.MOVE_RESULT_OBJECT - ), - customFingerprint = custom@{ methodDef, _ -> - if (methodDef.containsWideLiteralInstructionValue(45365621)) - return@custom true - - methodDef.indexOfFirstInstruction { - getReference()?.name == "reelWatchEndpoint" - } >= 0 - } -) \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoQualityListFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoQualityListFingerprint.kt deleted file mode 100644 index 21fcd8e6da..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoQualityListFingerprint.kt +++ /dev/null @@ -1,15 +0,0 @@ -package app.revanced.patches.youtube.video.information.fingerprints - -import app.revanced.patches.youtube.utils.resourceid.SharedResourceIdPatch.QualityAuto -import app.revanced.util.fingerprint.LiteralValueFingerprint -import com.android.tools.smali.dexlib2.Opcode - -internal object VideoQualityListFingerprint : LiteralValueFingerprint( - returnType = "V", - parameters = listOf("L"), - opcodes = listOf( - Opcode.INVOKE_INTERFACE, - Opcode.RETURN_VOID - ), - literalSupplier = { QualityAuto }, -) \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoQualityTextFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoQualityTextFingerprint.kt deleted file mode 100644 index 12f65ccf23..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoQualityTextFingerprint.kt +++ /dev/null @@ -1,18 +0,0 @@ -package app.revanced.patches.youtube.video.information.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags -import com.android.tools.smali.dexlib2.Opcode - -internal object VideoQualityTextFingerprint : MethodFingerprint( - returnType = "V", - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - parameters = listOf("[L", "I", "Z"), - opcodes = listOf( - Opcode.IF_GE, - Opcode.AGET_OBJECT, - Opcode.IGET_OBJECT - ), - strings = listOf("menu_item_video_quality") -) \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoTitleFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoTitleFingerprint.kt deleted file mode 100644 index ff8ec7ef47..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/information/fingerprints/VideoTitleFingerprint.kt +++ /dev/null @@ -1,13 +0,0 @@ -package app.revanced.patches.youtube.video.information.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patches.youtube.utils.resourceid.SharedResourceIdPatch.NotificationBigPictureIconWidth -import app.revanced.util.fingerprint.LiteralValueFingerprint -import com.android.tools.smali.dexlib2.AccessFlags - -internal object VideoTitleFingerprint : LiteralValueFingerprint( - returnType = "V", - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - parameters = emptyList(), - literalSupplier = { NotificationBigPictureIconWidth }, -) diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/playback/CustomPlaybackSpeedPatch.kt b/src/main/kotlin/app/revanced/patches/youtube/video/playback/CustomPlaybackSpeedPatch.kt deleted file mode 100644 index c99deb1abc..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/playback/CustomPlaybackSpeedPatch.kt +++ /dev/null @@ -1,9 +0,0 @@ -package app.revanced.patches.youtube.video.playback - -import app.revanced.patches.shared.customspeed.BaseCustomPlaybackSpeedPatch -import app.revanced.patches.youtube.utils.integrations.Constants.VIDEO_PATH - -object CustomPlaybackSpeedPatch : BaseCustomPlaybackSpeedPatch( - "$VIDEO_PATH/CustomPlaybackSpeedPatch;", - 8.0f -) diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/playback/VideoPlaybackPatch.kt b/src/main/kotlin/app/revanced/patches/youtube/video/playback/VideoPlaybackPatch.kt deleted file mode 100644 index 6a17f77c82..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/playback/VideoPlaybackPatch.kt +++ /dev/null @@ -1,360 +0,0 @@ -package app.revanced.patches.youtube.video.playback - -import app.revanced.patcher.data.BytecodeContext -import app.revanced.patcher.extensions.InstructionExtensions.addInstruction -import app.revanced.patcher.extensions.InstructionExtensions.addInstructions -import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels -import app.revanced.patcher.extensions.InstructionExtensions.getInstruction -import app.revanced.patcher.patch.PatchException -import app.revanced.patcher.util.smali.ExternalLabel -import app.revanced.patches.shared.litho.LithoFilterPatch -import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE -import app.revanced.patches.youtube.utils.fingerprints.QualityMenuViewInflateFingerprint -import app.revanced.patches.youtube.utils.fingerprints.VideoEndFingerprint -import app.revanced.patches.youtube.utils.fix.shortsplayback.ShortsPlaybackPatch -import app.revanced.patches.youtube.utils.flyoutmenu.FlyoutMenuHookPatch -import app.revanced.patches.youtube.utils.integrations.Constants.COMPONENTS_PATH -import app.revanced.patches.youtube.utils.integrations.Constants.PATCH_STATUS_CLASS_DESCRIPTOR -import app.revanced.patches.youtube.utils.integrations.Constants.VIDEO_PATH -import app.revanced.patches.youtube.utils.playertype.PlayerTypeHookPatch -import app.revanced.patches.youtube.utils.recyclerview.BottomSheetRecyclerViewPatch -import app.revanced.patches.youtube.utils.settings.SettingsPatch -import app.revanced.patches.youtube.video.information.VideoInformationPatch -import app.revanced.patches.youtube.video.information.VideoInformationPatch.speedSelectionInsertMethod -import app.revanced.patches.youtube.video.playback.fingerprints.AV1CodecFingerprint -import app.revanced.patches.youtube.video.playback.fingerprints.ByteBufferArrayFingerprint -import app.revanced.patches.youtube.video.playback.fingerprints.ByteBufferArrayParentFingerprint -import app.revanced.patches.youtube.video.playback.fingerprints.DeviceDimensionsModelToStringFingerprint -import app.revanced.patches.youtube.video.playback.fingerprints.HDRCapabilityFingerprint -import app.revanced.patches.youtube.video.playback.fingerprints.PlaybackSpeedChangedFromRecyclerViewFingerprint -import app.revanced.patches.youtube.video.playback.fingerprints.PlaybackSpeedInitializeFingerprint -import app.revanced.patches.youtube.video.playback.fingerprints.QualityChangedFromRecyclerViewFingerprint -import app.revanced.patches.youtube.video.playback.fingerprints.QualitySetterFingerprint -import app.revanced.patches.youtube.video.playback.fingerprints.VP9CapabilityFingerprint -import app.revanced.patches.youtube.video.videoid.VideoIdPatch -import app.revanced.util.getReference -import app.revanced.util.getWalkerMethod -import app.revanced.util.indexOfFirstInstructionOrThrow -import app.revanced.util.indexOfFirstStringInstructionOrThrow -import app.revanced.util.patch.BaseBytecodePatch -import app.revanced.util.resultOrThrow -import app.revanced.util.updatePatchStatus -import com.android.tools.smali.dexlib2.Opcode -import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction -import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction -import com.android.tools.smali.dexlib2.iface.reference.FieldReference -import com.android.tools.smali.dexlib2.iface.reference.MethodReference -import com.android.tools.smali.dexlib2.util.MethodUtil - -@Suppress("unused") -object VideoPlaybackPatch : BaseBytecodePatch( - name = "Video playback", - description = "Adds options to customize settings related to video playback, " + - "such as default video quality and playback speed.", - dependencies = setOf( - BottomSheetRecyclerViewPatch::class, - CustomPlaybackSpeedPatch::class, - FlyoutMenuHookPatch::class, - LithoFilterPatch::class, - PlayerTypeHookPatch::class, - SettingsPatch::class, - ShortsPlaybackPatch::class, - VideoIdPatch::class, - VideoInformationPatch::class - ), - compatiblePackages = COMPATIBLE_PACKAGE, - fingerprints = setOf( - AV1CodecFingerprint, - ByteBufferArrayParentFingerprint, - DeviceDimensionsModelToStringFingerprint, - HDRCapabilityFingerprint, - PlaybackSpeedChangedFromRecyclerViewFingerprint, - QualityChangedFromRecyclerViewFingerprint, - QualityMenuViewInflateFingerprint, - QualitySetterFingerprint, - VideoEndFingerprint, - VP9CapabilityFingerprint - ) -) { - private const val PLAYBACK_SPEED_MENU_FILTER_CLASS_DESCRIPTOR = - "$COMPONENTS_PATH/PlaybackSpeedMenuFilter;" - private const val VIDEO_QUALITY_MENU_FILTER_CLASS_DESCRIPTOR = - "$COMPONENTS_PATH/VideoQualityMenuFilter;" - private const val INTEGRATIONS_AV1_CODEC_CLASS_DESCRIPTOR = - "$VIDEO_PATH/AV1CodecPatch;" - private const val INTEGRATIONS_VP9_CODEC_CLASS_DESCRIPTOR = - "$VIDEO_PATH/VP9CodecPatch;" - private const val INTEGRATIONS_CUSTOM_PLAYBACK_SPEED_CLASS_DESCRIPTOR = - "$VIDEO_PATH/CustomPlaybackSpeedPatch;" - private const val INTEGRATIONS_HDR_VIDEO_CLASS_DESCRIPTOR = - "$VIDEO_PATH/HDRVideoPatch;" - private const val INTEGRATIONS_PLAYBACK_SPEED_CLASS_DESCRIPTOR = - "$VIDEO_PATH/PlaybackSpeedPatch;" - private const val INTEGRATIONS_RELOAD_VIDEO_CLASS_DESCRIPTOR = - "$VIDEO_PATH/ReloadVideoPatch;" - private const val INTEGRATIONS_RESTORE_OLD_VIDEO_QUALITY_MENU_CLASS_DESCRIPTOR = - "$VIDEO_PATH/RestoreOldVideoQualityMenuPatch;" - private const val INTEGRATIONS_SPOOF_DEVICE_DIMENSIONS_CLASS_DESCRIPTOR = - "$VIDEO_PATH/SpoofDeviceDimensionsPatch;" - private const val INTEGRATIONS_VIDEO_QUALITY_CLASS_DESCRIPTOR = - "$VIDEO_PATH/VideoQualityPatch;" - - override fun execute(context: BytecodeContext) { - - // region patch for custom playback speed - - BottomSheetRecyclerViewPatch.injectCall("$INTEGRATIONS_CUSTOM_PLAYBACK_SPEED_CLASS_DESCRIPTOR->onFlyoutMenuCreate(Landroid/support/v7/widget/RecyclerView;)V") - LithoFilterPatch.addFilter(PLAYBACK_SPEED_MENU_FILTER_CLASS_DESCRIPTOR) - - // endregion - - // region patch for disable HDR video - - HDRCapabilityFingerprint.resultOrThrow().mutableMethod.apply { - val stringIndex = - indexOfFirstStringInstructionOrThrow("av1_profile_main_10_hdr_10_plus_supported") - val walkerIndex = indexOfFirstInstructionOrThrow(stringIndex) { - val reference = getReference() - reference?.parameterTypes == listOf("I", "Landroid/view/Display;") - && reference.returnType == "Z" - } - - val walkerMethod = getWalkerMethod(context, walkerIndex) - walkerMethod.apply { - addInstructionsWithLabels( - 0, """ - invoke-static {}, $INTEGRATIONS_HDR_VIDEO_CLASS_DESCRIPTOR->disableHDRVideo()Z - move-result v0 - if-nez v0, :default - return v0 - """, ExternalLabel("default", getInstruction(0)) - ) - } - } - - // endregion - - // region patch for default playback speed - - PlaybackSpeedChangedFromRecyclerViewFingerprint.resolve( - context, - QualityChangedFromRecyclerViewFingerprint.resultOrThrow().classDef - ) - - val newMethod = - PlaybackSpeedChangedFromRecyclerViewFingerprint.resultOrThrow().mutableMethod - - arrayOf( - newMethod, - speedSelectionInsertMethod - ).forEach { - it.apply { - val speedSelectionValueInstructionIndex = - indexOfFirstInstructionOrThrow(Opcode.IGET) - val speedSelectionValueRegister = - getInstruction(speedSelectionValueInstructionIndex).registerA - - addInstruction( - speedSelectionValueInstructionIndex + 1, - "invoke-static {v$speedSelectionValueRegister}, " + - "$INTEGRATIONS_PLAYBACK_SPEED_CLASS_DESCRIPTOR->userSelectedPlaybackSpeed(F)V" - ) - } - } - - PlaybackSpeedInitializeFingerprint.resolve( - context, - VideoEndFingerprint.resultOrThrow().classDef - ) - PlaybackSpeedInitializeFingerprint.resultOrThrow().let { - it.mutableMethod.apply { - val insertIndex = it.scanResult.patternScanResult!!.endIndex - val insertRegister = getInstruction(insertIndex).registerA - - addInstructions( - insertIndex, """ - invoke-static {v$insertRegister}, $INTEGRATIONS_PLAYBACK_SPEED_CLASS_DESCRIPTOR->getPlaybackSpeedInShorts(F)F - move-result v$insertRegister - """ - ) - } - } - - VideoInformationPatch.hookBackgroundPlay("$INTEGRATIONS_PLAYBACK_SPEED_CLASS_DESCRIPTOR->newVideoStarted(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V") - VideoIdPatch.hookPlayerResponseVideoId("$INTEGRATIONS_PLAYBACK_SPEED_CLASS_DESCRIPTOR->fetchPlaylistData(Ljava/lang/String;Z)V") - - context.updatePatchStatus(PATCH_STATUS_CLASS_DESCRIPTOR, "RememberPlaybackSpeed") - - // endregion - - // region patch for default video quality - - QualityChangedFromRecyclerViewFingerprint.resultOrThrow().let { - it.mutableMethod.apply { - val index = it.scanResult.patternScanResult!!.startIndex - - addInstruction( - index + 1, - "invoke-static {}, $INTEGRATIONS_VIDEO_QUALITY_CLASS_DESCRIPTOR->userSelectedVideoQuality()V" - ) - - } - } - - QualitySetterFingerprint.resultOrThrow().let { - val onItemClickMethod = - it.mutableClass.methods.find { method -> method.name == "onItemClick" } - - onItemClickMethod?.apply { - addInstruction( - 0, - "invoke-static {}, $INTEGRATIONS_VIDEO_QUALITY_CLASS_DESCRIPTOR->userSelectedVideoQuality()V" - ) - } ?: throw PatchException("Failed to find onItemClick method") - } - - VideoInformationPatch.hookBackgroundPlay("$INTEGRATIONS_RELOAD_VIDEO_CLASS_DESCRIPTOR->newVideoStarted(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V") - VideoInformationPatch.hook("$INTEGRATIONS_VIDEO_QUALITY_CLASS_DESCRIPTOR->newVideoStarted(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V") - VideoInformationPatch.onCreateHook( - INTEGRATIONS_VIDEO_QUALITY_CLASS_DESCRIPTOR, - "newVideoStarted" - ) - - // endregion - - // region patch for restore old video quality menu - - val videoQualityClass = QualitySetterFingerprint.resultOrThrow().mutableMethod.definingClass - - QualityMenuViewInflateFingerprint.resultOrThrow().let { - it.mutableMethod.apply { - val insertIndex = indexOfFirstInstructionOrThrow(Opcode.CHECK_CAST) - val insertRegister = getInstruction(insertIndex).registerA - - addInstruction( - insertIndex + 1, - "invoke-static { v$insertRegister }, " + - "$INTEGRATIONS_RESTORE_OLD_VIDEO_QUALITY_MENU_CLASS_DESCRIPTOR->restoreOldVideoQualityMenu(Landroid/widget/ListView;)V" - ) - } - val onItemClickMethod = - it.mutableClass.methods.find { method -> method.name == "onItemClick" } - - onItemClickMethod?.apply { - val insertIndex = indexOfFirstInstructionOrThrow(Opcode.IGET_OBJECT) - val insertRegister = getInstruction(insertIndex).registerA - - val jumpIndex = indexOfFirstInstructionOrThrow { - opcode == Opcode.IGET_OBJECT - && this.getReference()?.type == videoQualityClass - } - - addInstructionsWithLabels( - insertIndex, """ - invoke-static {}, $INTEGRATIONS_RESTORE_OLD_VIDEO_QUALITY_MENU_CLASS_DESCRIPTOR->restoreOldVideoQualityMenu()Z - move-result v$insertRegister - if-nez v$insertRegister, :show - """, ExternalLabel("show", getInstruction(jumpIndex)) - ) - } ?: throw PatchException("Failed to find onItemClick method") - } - - BottomSheetRecyclerViewPatch.injectCall("$INTEGRATIONS_RESTORE_OLD_VIDEO_QUALITY_MENU_CLASS_DESCRIPTOR->onFlyoutMenuCreate(Landroid/support/v7/widget/RecyclerView;)V") - LithoFilterPatch.addFilter(VIDEO_QUALITY_MENU_FILTER_CLASS_DESCRIPTOR) - - // endregion - - // region patch for spoof device dimensions - - DeviceDimensionsModelToStringFingerprint.resultOrThrow().let { result -> - result.mutableClass.methods.first { method -> MethodUtil.isConstructor(method) } - .addInstructions( - 1, // Add after super call. - mapOf( - 1 to "MinHeightOrWidth", // p1 = min height - 2 to "MaxHeightOrWidth", // p2 = max height - 3 to "MinHeightOrWidth", // p3 = min width - 4 to "MaxHeightOrWidth" // p4 = max width - ).map { (parameter, method) -> - """ - invoke-static { p$parameter }, $INTEGRATIONS_SPOOF_DEVICE_DIMENSIONS_CLASS_DESCRIPTOR->get$method(I)I - move-result p$parameter - """ - }.joinToString("\n") { it } - ) - } - - // endregion - - // region patch for disable AV1 codec - - // replace av1 codec - - AV1CodecFingerprint.result?.let { - it.mutableMethod.apply { - val insertIndex = indexOfFirstStringInstructionOrThrow("video/av01") - val insertRegister = getInstruction(insertIndex).registerA - - addInstructions( - insertIndex + 1, """ - invoke-static/range {v$insertRegister .. v$insertRegister}, $INTEGRATIONS_AV1_CODEC_CLASS_DESCRIPTOR->replaceCodec(Ljava/lang/String;)Ljava/lang/String; - move-result-object v$insertRegister - """ - ) - } - - SettingsPatch.addPreference( - arrayOf( - "SETTINGS: REPLACE_AV1_CODEC" - ) - ) - } // for compatibility with old versions, no exceptions are raised. - - // reject av1 codec response - - ByteBufferArrayParentFingerprint.resultOrThrow().classDef.let { classDef -> - ByteBufferArrayFingerprint.also { it.resolve(context, classDef) }.resultOrThrow().let { - it.mutableMethod.apply { - val insertIndex = it.scanResult.patternScanResult!!.endIndex - val insertRegister = - getInstruction(insertIndex).registerA - - addInstructions( - insertIndex, """ - invoke-static {v$insertRegister}, $INTEGRATIONS_AV1_CODEC_CLASS_DESCRIPTOR->rejectResponse(I)I - move-result v$insertRegister - """ - ) - } - } - } - - // endregion - - // region patch for disable VP9 codec - - VP9CapabilityFingerprint.resultOrThrow().mutableMethod.apply { - addInstructionsWithLabels( - 0, """ - invoke-static {}, $INTEGRATIONS_VP9_CODEC_CLASS_DESCRIPTOR->disableVP9Codec()Z - move-result v0 - if-nez v0, :default - return v0 - """, ExternalLabel("default", getInstruction(0)) - ) - } - - // endregion - - /** - * Add settings - */ - SettingsPatch.addPreference( - arrayOf( - "PREFERENCE_SCREEN: VIDEO" - ) - ) - - SettingsPatch.updatePatchStatus(this) - } -} diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/AV1CodecFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/AV1CodecFingerprint.kt deleted file mode 100644 index 88e28d7fa1..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/AV1CodecFingerprint.kt +++ /dev/null @@ -1,18 +0,0 @@ -package app.revanced.patches.youtube.video.playback.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.util.containsWideLiteralInstructionValue -import com.android.tools.smali.dexlib2.AccessFlags - -internal object AV1CodecFingerprint : MethodFingerprint( - accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, - returnType = "L", - strings = listOf("AtomParsers", "video/av01"), - customFingerprint = handler@{ methodDef, _ -> - if (methodDef.returnType == "Ljava/util/List;") - return@handler false - - methodDef.containsWideLiteralInstructionValue(1987076931) - } -) diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/ByteBufferArrayFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/ByteBufferArrayFingerprint.kt deleted file mode 100644 index 8a7a46c88e..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/ByteBufferArrayFingerprint.kt +++ /dev/null @@ -1,21 +0,0 @@ -package app.revanced.patches.youtube.video.playback.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags -import com.android.tools.smali.dexlib2.Opcode - -internal object ByteBufferArrayFingerprint : MethodFingerprint( - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - returnType = "I", - parameters = emptyList(), - opcodes = listOf( - Opcode.SHL_INT_LIT8, - Opcode.SHL_INT_LIT8, - Opcode.OR_INT_2ADDR, - Opcode.SHL_INT_LIT8, - Opcode.OR_INT_2ADDR, - Opcode.OR_INT_2ADDR, - Opcode.RETURN - ) -) diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/ByteBufferArrayParentFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/ByteBufferArrayParentFingerprint.kt deleted file mode 100644 index d9e7803aff..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/ByteBufferArrayParentFingerprint.kt +++ /dev/null @@ -1,11 +0,0 @@ -package app.revanced.patches.youtube.video.playback.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags - -internal object ByteBufferArrayParentFingerprint : MethodFingerprint( - accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, - returnType = "C", - parameters = listOf("Ljava/nio/charset/Charset;", "[C") -) diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/DeviceDimensionsModelToStringFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/DeviceDimensionsModelToStringFingerprint.kt deleted file mode 100644 index 42270bb2da..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/DeviceDimensionsModelToStringFingerprint.kt +++ /dev/null @@ -1,8 +0,0 @@ -package app.revanced.patches.youtube.video.playback.fingerprints - -import app.revanced.patcher.fingerprint.MethodFingerprint - -internal object DeviceDimensionsModelToStringFingerprint : MethodFingerprint( - returnType = "L", - strings = listOf("minh.", ";maxh.") -) diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/HDRCapabilityFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/HDRCapabilityFingerprint.kt deleted file mode 100644 index 4b3bb4f759..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/HDRCapabilityFingerprint.kt +++ /dev/null @@ -1,13 +0,0 @@ -package app.revanced.patches.youtube.video.playback.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags - -internal object HDRCapabilityFingerprint : MethodFingerprint( - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - strings = listOf( - "av1_profile_main_10_hdr_10_plus_supported", - "video/av01" - ) -) diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/PlaybackSpeedChangedFromRecyclerViewFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/PlaybackSpeedChangedFromRecyclerViewFingerprint.kt deleted file mode 100644 index 4dec570e38..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/PlaybackSpeedChangedFromRecyclerViewFingerprint.kt +++ /dev/null @@ -1,23 +0,0 @@ -package app.revanced.patches.youtube.video.playback.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags -import com.android.tools.smali.dexlib2.Opcode - -internal object PlaybackSpeedChangedFromRecyclerViewFingerprint : MethodFingerprint( - returnType = "L", - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - parameters = listOf("L"), - opcodes = listOf( - Opcode.IGET_OBJECT, - Opcode.INVOKE_INTERFACE, - Opcode.MOVE_RESULT_OBJECT, - Opcode.IF_EQZ, - Opcode.IGET_OBJECT, - Opcode.INVOKE_INTERFACE, - Opcode.MOVE_RESULT_OBJECT, - Opcode.IGET, - Opcode.INVOKE_VIRTUAL - ) -) diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/PlaybackSpeedInitializeFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/PlaybackSpeedInitializeFingerprint.kt deleted file mode 100644 index b750f4bc09..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/PlaybackSpeedInitializeFingerprint.kt +++ /dev/null @@ -1,16 +0,0 @@ -package app.revanced.patches.youtube.video.playback.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags -import com.android.tools.smali.dexlib2.Opcode - -internal object PlaybackSpeedInitializeFingerprint : MethodFingerprint( - returnType = "F", - accessFlags = AccessFlags.PRIVATE or AccessFlags.STATIC, - parameters = listOf("L"), - opcodes = listOf( - Opcode.IGET, - Opcode.RETURN - ) -) diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/QualityChangedFromRecyclerViewFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/QualityChangedFromRecyclerViewFingerprint.kt deleted file mode 100644 index a317ae5037..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/QualityChangedFromRecyclerViewFingerprint.kt +++ /dev/null @@ -1,32 +0,0 @@ -package app.revanced.patches.youtube.video.playback.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags -import com.android.tools.smali.dexlib2.Opcode - -internal object QualityChangedFromRecyclerViewFingerprint : MethodFingerprint( - returnType = "L", - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - parameters = listOf("L"), - opcodes = listOf( - Opcode.IGET, // Video resolution (human readable). - Opcode.IGET_OBJECT, - Opcode.IGET_BOOLEAN, - Opcode.IGET_OBJECT, - Opcode.INVOKE_STATIC, - Opcode.MOVE_RESULT_OBJECT, - Opcode.INVOKE_DIRECT, - Opcode.IGET_OBJECT, - Opcode.INVOKE_INTERFACE, - Opcode.MOVE_RESULT_OBJECT, - Opcode.INVOKE_VIRTUAL, - Opcode.GOTO, - Opcode.CONST_4, - Opcode.IF_NE, - Opcode.IGET_OBJECT, - Opcode.INVOKE_INTERFACE, - Opcode.MOVE_RESULT_OBJECT, - Opcode.IGET, - ) -) diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/QualitySetterFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/QualitySetterFingerprint.kt deleted file mode 100644 index f626f4bb13..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/QualitySetterFingerprint.kt +++ /dev/null @@ -1,12 +0,0 @@ -package app.revanced.patches.youtube.video.playback.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags - -internal object QualitySetterFingerprint : MethodFingerprint( - returnType = "V", - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - parameters = listOf("L"), - strings = listOf("VIDEO_QUALITIES_MENU_BOTTOM_SHEET_FRAGMENT") -) \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/VP9CapabilityFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/VP9CapabilityFingerprint.kt deleted file mode 100644 index e904b93125..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/playback/fingerprints/VP9CapabilityFingerprint.kt +++ /dev/null @@ -1,14 +0,0 @@ -package app.revanced.patches.youtube.video.playback.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags - -internal object VP9CapabilityFingerprint : MethodFingerprint( - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - returnType = "Z", - strings = listOf( - "vp9_supported", - "video/x-vnd.on2.vp9" - ) -) diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/playerresponse/PlayerResponseMethodHookPatch.kt b/src/main/kotlin/app/revanced/patches/youtube/video/playerresponse/PlayerResponseMethodHookPatch.kt deleted file mode 100644 index 68beae36b5..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/playerresponse/PlayerResponseMethodHookPatch.kt +++ /dev/null @@ -1,123 +0,0 @@ -package app.revanced.patches.youtube.video.playerresponse - -import app.revanced.patcher.data.BytecodeContext -import app.revanced.patcher.extensions.InstructionExtensions.addInstruction -import app.revanced.patcher.extensions.InstructionExtensions.addInstructions -import app.revanced.patcher.patch.BytecodePatch -import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod -import app.revanced.patches.youtube.video.playerresponse.fingerprint.PlayerParameterBuilderFingerprint -import app.revanced.util.resultOrThrow -import java.io.Closeable -import kotlin.properties.Delegates - -object PlayerResponseMethodHookPatch : - BytecodePatch(setOf(PlayerParameterBuilderFingerprint)), - Closeable, - MutableSet by mutableSetOf() { - - // Parameter numbers of the patched method. - private var PARAMETER_VIDEO_ID = 1 - private var PARAMETER_PLAYER_PARAMETER = 3 - private var PARAMETER_PLAYLIST_ID = 4 - private var PARAMETER_IS_SHORT_AND_OPENING_OR_PLAYING by Delegates.notNull() - - // Registers used to pass the parameters to integrations. - private var playerResponseMethodCopyRegisters = false - private lateinit var REGISTER_VIDEO_ID: String - private lateinit var REGISTER_PLAYER_PARAMETER: String - private lateinit var REGISTER_PLAYLIST_ID: String - private lateinit var REGISTER_IS_SHORT_AND_OPENING_OR_PLAYING: String - - private lateinit var playerResponseMethod: MutableMethod - private var numberOfInstructionsAdded = 0 - - override fun execute(context: BytecodeContext) { - playerResponseMethod = PlayerParameterBuilderFingerprint - .resultOrThrow() - .mutableMethod - - playerResponseMethod.apply { - PARAMETER_IS_SHORT_AND_OPENING_OR_PLAYING = parameters.size - 2 - - // On some app targets the method has too many registers pushing the parameters past v15. - // If needed, move the parameters to 4-bit registers so they can be passed to integrations. - playerResponseMethodCopyRegisters = implementation!!.registerCount - - parameterTypes.size + PARAMETER_IS_SHORT_AND_OPENING_OR_PLAYING > 15 - } - - if (playerResponseMethodCopyRegisters) { - REGISTER_VIDEO_ID = "v0" - REGISTER_PLAYER_PARAMETER = "v1" - REGISTER_PLAYLIST_ID = "v2" - REGISTER_IS_SHORT_AND_OPENING_OR_PLAYING = "v3" - } else { - REGISTER_VIDEO_ID = "p$PARAMETER_VIDEO_ID" - REGISTER_PLAYER_PARAMETER = "p$PARAMETER_PLAYER_PARAMETER" - REGISTER_PLAYLIST_ID = "p$PARAMETER_PLAYLIST_ID" - REGISTER_IS_SHORT_AND_OPENING_OR_PLAYING = "p$PARAMETER_IS_SHORT_AND_OPENING_OR_PLAYING" - } - } - - override fun close() { - fun hookVideoId(hook: Hook) { - playerResponseMethod.addInstruction( - 0, - "invoke-static {$REGISTER_VIDEO_ID, $REGISTER_IS_SHORT_AND_OPENING_OR_PLAYING}, $hook" - ) - numberOfInstructionsAdded++ - } - - fun hookPlayerParameter(hook: Hook) { - playerResponseMethod.addInstructions( - 0, """ - invoke-static {$REGISTER_VIDEO_ID, $REGISTER_PLAYER_PARAMETER, $REGISTER_PLAYLIST_ID, $REGISTER_IS_SHORT_AND_OPENING_OR_PLAYING}, $hook - move-result-object $REGISTER_PLAYER_PARAMETER - """ - ) - numberOfInstructionsAdded += 2 - } - - // Reverse the order in order to preserve insertion order of the hooks. - val beforeVideoIdHooks = filterIsInstance().asReversed() - val videoIdHooks = filterIsInstance().asReversed() - val afterVideoIdHooks = filterIsInstance().asReversed() - - // Add the hooks in this specific order as they insert instructions at the beginning of the method. - afterVideoIdHooks.forEach(::hookPlayerParameter) - videoIdHooks.forEach(::hookVideoId) - beforeVideoIdHooks.forEach(::hookPlayerParameter) - - if (playerResponseMethodCopyRegisters) { - playerResponseMethod.apply { - addInstructions( - 0, - """ - move-object/from16 $REGISTER_VIDEO_ID, p$PARAMETER_VIDEO_ID - move-object/from16 $REGISTER_PLAYER_PARAMETER, p$PARAMETER_PLAYER_PARAMETER - move-object/from16 $REGISTER_PLAYLIST_ID, p$PARAMETER_PLAYLIST_ID - move/from16 $REGISTER_IS_SHORT_AND_OPENING_OR_PLAYING, p$PARAMETER_IS_SHORT_AND_OPENING_OR_PLAYING - """, - ) - - numberOfInstructionsAdded += 4 - - // Move the modified register back. - addInstruction( - numberOfInstructionsAdded, - "move-object/from16 p$PARAMETER_PLAYER_PARAMETER, $REGISTER_PLAYER_PARAMETER" - ) - } - } - } - - internal abstract class Hook(private val methodDescriptor: String) { - internal class VideoId(methodDescriptor: String) : Hook(methodDescriptor) - - internal class PlayerParameter(methodDescriptor: String) : Hook(methodDescriptor) - internal class PlayerParameterBeforeVideoId(methodDescriptor: String) : - Hook(methodDescriptor) - - override fun toString() = methodDescriptor - } -} - diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/playerresponse/fingerprint/PlayerParameterBuilderFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/playerresponse/fingerprint/PlayerParameterBuilderFingerprint.kt deleted file mode 100644 index 6053c8064b..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/playerresponse/fingerprint/PlayerParameterBuilderFingerprint.kt +++ /dev/null @@ -1,74 +0,0 @@ -package app.revanced.patches.youtube.video.playerresponse.fingerprint - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.patches.youtube.video.playerresponse.fingerprint.PlayerParameterBuilderFingerprint.ENDS_WITH_PARAMETER_LIST -import app.revanced.patches.youtube.video.playerresponse.fingerprint.PlayerParameterBuilderFingerprint.STARTS_WITH_PARAMETER_LIST -import app.revanced.util.parametersEqual -import com.android.tools.smali.dexlib2.AccessFlags - -internal object PlayerParameterBuilderFingerprint : MethodFingerprint( - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - returnType = "L", - // 19.22 and earlier parameters are: - // "Ljava/lang/String;", // VideoId. - // "[B", - // "Ljava/lang/String;", // Player parameters proto buffer. - // "Ljava/lang/String;", // PlaylistId. - // "I", - // "I", - // "Ljava/util/Set;", - // "Ljava/lang/String;", - // "Ljava/lang/String;", - // "L", - // "Z", // Appears to indicate if the video id is being opened or is currently playing. - // "Z", - // "Z" - - // 19.23+ parameters are: - // "Ljava/lang/String;", // VideoId. - // "[B", - // "Ljava/lang/String;", // Player parameters proto buffer. - // "Ljava/lang/String;", // PlaylistId. - // "I", - // "I", - // "L", - // "Ljava/util/Set;", - // "Ljava/lang/String;", - // "Ljava/lang/String;", - // "L", - // "Z", // Appears to indicate if the video id is being opened or is currently playing. - // "Z", - // "Z" - customFingerprint = custom@{ methodDef, _ -> - val parameterTypes = methodDef.parameterTypes - val parameterSize = parameterTypes.size - if (parameterSize != 13 && parameterSize != 14) { - return@custom false - } - - val startsWithMethodParameterList = parameterTypes.slice(0..5) - val endsWithMethodParameterList = parameterTypes.slice(parameterSize - 7.. Unit) = - resultOrThrow().let { result -> - val videoIdRegisterIndex = result.scanResult.patternScanResult!!.endIndex - - result.mutableMethod.let { - val videoIdRegister = - it.getInstruction(videoIdRegisterIndex).registerA - val insertIndex = videoIdRegisterIndex + 1 - consumer(it, insertIndex, videoIdRegister) - } - } - - VideoIdFingerprint.setFields { method, index, register -> - videoIdMethod = method - videoIdInsertIndex = index - videoIdRegister = register - } - } - - /** - * Hooks the new video id when the video changes. - * - * Supports all videos (regular videos and Shorts). - * - * _Does not function if playing in the background with no video visible_. - * - * Be aware, this can be called multiple times for the same video id. - * - * @param methodDescriptor which method to call. Params have to be `Ljava/lang/String;` - */ - fun hookVideoId( - methodDescriptor: String - ) = videoIdMethod.addInstruction( - videoIdInsertIndex++, - "invoke-static {v$videoIdRegister}, $methodDescriptor" - ) - - /** - * Hooks the video id of every video when loaded. - * Supports all videos and functions in all situations. - * - * First parameter is the video id. - * Second parameter is if the video is a Short AND it is being opened or is currently playing. - * - * Hook is always called off the main thread. - * - * This hook is called as soon as the player response is parsed, - * and called before many other hooks are updated such as [PlayerTypeHookPatch]. - * - * Note: The video id returned here may not be the current video that's being played. - * It's common for multiple Shorts to load at once in preparation - * for the user swiping to the next Short. - * - * For most use cases, you probably want to use [hookVideoId] instead. - * - * Be aware, this can be called multiple times for the same video id. - * - * @param methodDescriptor which method to call. Params must be `Ljava/lang/String;Z` - */ - fun hookPlayerResponseVideoId(methodDescriptor: String) { - PlayerResponseMethodHookPatch += PlayerResponseMethodHookPatch.Hook.VideoId( - methodDescriptor - ) - } -} - diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/videoid/fingerprints/VideoIdFingerprint.kt b/src/main/kotlin/app/revanced/patches/youtube/video/videoid/fingerprints/VideoIdFingerprint.kt deleted file mode 100644 index 04ed1527a2..0000000000 --- a/src/main/kotlin/app/revanced/patches/youtube/video/videoid/fingerprints/VideoIdFingerprint.kt +++ /dev/null @@ -1,49 +0,0 @@ -package app.revanced.patches.youtube.video.videoid.fingerprints - -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.patches.youtube.utils.PlayerResponseModelUtils.PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR -import app.revanced.util.getReference -import app.revanced.util.indexOfFirstInstruction -import com.android.tools.smali.dexlib2.AccessFlags -import com.android.tools.smali.dexlib2.Opcode -import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction -import com.android.tools.smali.dexlib2.iface.reference.MethodReference - -internal object VideoIdFingerprint : MethodFingerprint( - returnType = "V", - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, - parameters = listOf("L"), - opcodes = listOf( - Opcode.INVOKE_INTERFACE, - Opcode.MOVE_RESULT_OBJECT, - Opcode.INVOKE_INTERFACE, - Opcode.MOVE_RESULT_OBJECT - ), - customFingerprint = custom@{ methodDef, classDef -> - if (!classDef.fields.any { it.type == "Lcom/google/android/libraries/youtube/player/subtitles/model/SubtitleTrack;" }) { - return@custom false - } - val implementation = methodDef.implementation - ?: return@custom false - val instructions = implementation.instructions - val instructionCount = instructions.count() - if (instructionCount < 30) { - return@custom false - } - - val reference = - (instructions.elementAt(instructionCount - 2) as? ReferenceInstruction)?.reference.toString() - if (reference != "Ljava/util/Map;->put(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;") { - return@custom false - } - - methodDef.indexOfFirstInstruction { - val methodReference = getReference() - opcode == Opcode.INVOKE_INTERFACE && - methodReference?.returnType == "Ljava/lang/String;" && - methodReference.parameterTypes.isEmpty() && - methodReference.definingClass == PLAYER_RESPONSE_MODEL_CLASS_DESCRIPTOR - } >= 0 - }, -) \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/util/BytecodeUtils.kt b/src/main/kotlin/app/revanced/util/BytecodeUtils.kt deleted file mode 100644 index 0f49188622..0000000000 --- a/src/main/kotlin/app/revanced/util/BytecodeUtils.kt +++ /dev/null @@ -1,671 +0,0 @@ -@file:Suppress("unused") - -package app.revanced.util - -import app.revanced.patcher.data.BytecodeContext -import app.revanced.patcher.extensions.InstructionExtensions.addInstruction -import app.revanced.patcher.extensions.InstructionExtensions.addInstructions -import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels -import app.revanced.patcher.extensions.InstructionExtensions.getInstruction -import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.patcher.fingerprint.MethodFingerprintResult -import app.revanced.patcher.patch.PatchException -import app.revanced.patcher.util.proxy.mutableTypes.MutableClass -import app.revanced.patcher.util.proxy.mutableTypes.MutableField -import app.revanced.patcher.util.proxy.mutableTypes.MutableField.Companion.toMutable -import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod -import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable -import app.revanced.util.fingerprint.MultiMethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags -import com.android.tools.smali.dexlib2.Opcode -import com.android.tools.smali.dexlib2.iface.Method -import com.android.tools.smali.dexlib2.iface.MethodParameter -import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction -import com.android.tools.smali.dexlib2.iface.instruction.Instruction -import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction -import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction -import com.android.tools.smali.dexlib2.iface.instruction.WideLiteralInstruction -import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction31i -import com.android.tools.smali.dexlib2.iface.reference.MethodReference -import com.android.tools.smali.dexlib2.iface.reference.Reference -import com.android.tools.smali.dexlib2.iface.reference.StringReference -import com.android.tools.smali.dexlib2.immutable.ImmutableField -import com.android.tools.smali.dexlib2.immutable.ImmutableMethod -import com.android.tools.smali.dexlib2.immutable.ImmutableMethodImplementation -import com.android.tools.smali.dexlib2.util.MethodUtil - -const val REGISTER_TEMPLATE_REPLACEMENT: String = "REGISTER_INDEX" - -fun MethodFingerprint.isDeprecated() = - javaClass.annotations[0].toString().contains("Deprecated") - -fun MethodFingerprint.resultOrThrow() = result ?: throw exception - -fun MultiMethodFingerprint.resultOrThrow() = result.ifEmpty { throw exception } - -fun parametersEqual( - parameters1: Iterable, - parameters2: Iterable -): Boolean { - if (parameters1.count() != parameters2.count()) return false - val iterator1 = parameters1.iterator() - parameters2.forEach { - if (!it.startsWith(iterator1.next())) return false - } - return true -} - -/** - * The [PatchException] of failing to resolve a [MethodFingerprint]. - * - * @return The [PatchException]. - */ -val MethodFingerprint.exception - get() = PatchException("Failed to resolve ${this.javaClass.simpleName}") - -val MultiMethodFingerprint.exception - get() = PatchException("Failed to resolve ${this.javaClass.simpleName}") - -fun MethodFingerprint.alsoResolve(context: BytecodeContext, fingerprint: MethodFingerprint) = - also { resolve(context, fingerprint.resultOrThrow().classDef) }.resultOrThrow() - -fun MethodFingerprint.getMethodCall() = - resultOrThrow().mutableMethod.getMethodCall() - -fun MutableMethod.getMethodCall(): String { - var methodCall = "$definingClass->$name(" - for (i in 0 until parameters.size) { - methodCall += parameterTypes[i] - } - methodCall += ")$returnType" - return methodCall -} - -/** - * Find the [MutableMethod] from a given [Method] in a [MutableClass]. - * - * @param method The [Method] to find. - * @return The [MutableMethod]. - */ -fun MutableClass.findMutableMethodOf(method: Method) = this.methods.first { - MethodUtil.methodSignaturesMatch(it, method) -} - -/** - * Apply a transform to all fields of the class. - * - * @param transform The transformation function. Accepts a [MutableField] and returns a transformed [MutableField]. - */ -fun MutableClass.transformFields(transform: MutableField.() -> MutableField) { - val transformedFields = fields.map { it.transform() } - fields.clear() - fields.addAll(transformedFields) -} - -/** - * Apply a transform to all methods of the class. - * - * @param transform The transformation function. Accepts a [MutableMethod] and returns a transformed [MutableMethod]. - */ -fun MutableClass.transformMethods(transform: MutableMethod.() -> MutableMethod) { - val transformedMethods = methods.map { it.transform() } - methods.removeIf { !MethodUtil.isConstructor(it) } - methods.addAll(transformedMethods) -} - -/** - * Inject a call to a method that hides a view. - * - * @param insertIndex The index to insert the call at. - * @param viewRegister The register of the view to hide. - * @param classDescriptor The descriptor of the class that contains the method. - * @param targetMethod The name of the method to call. - */ -fun MutableMethod.injectHideViewCall( - insertIndex: Int, - viewRegister: Int, - classDescriptor: String, - targetMethod: String -) = addInstruction( - insertIndex, - "invoke-static { v$viewRegister }, $classDescriptor->$targetMethod(Landroid/view/View;)V" -) - -fun MethodFingerprint.injectLiteralInstructionBooleanCall( - literal: Int, - descriptor: String -) = injectLiteralInstructionBooleanCall(literal.toLong(), descriptor) - -fun MethodFingerprint.injectLiteralInstructionBooleanCall( - literal: Long, - descriptor: String -) { - resultOrThrow().mutableMethod.apply { - val literalIndex = indexOfFirstWideLiteralInstructionValueOrThrow(literal) - val targetIndex = indexOfFirstInstructionOrThrow(literalIndex, Opcode.MOVE_RESULT) - val targetRegister = getInstruction(targetIndex).registerA - - val smaliInstruction = - if (descriptor.startsWith("0x")) """ - const/16 v$targetRegister, $descriptor - """ - else if (descriptor.endsWith("(Z)Z")) """ - invoke-static {v$targetRegister}, $descriptor - move-result v$targetRegister - """ - else """ - invoke-static {}, $descriptor - move-result v$targetRegister - """ - - addInstructions( - targetIndex + 1, - smaliInstruction - ) - } -} - -fun MethodFingerprint.injectLiteralInstructionViewCall( - literal: Long, - smaliInstruction: String -) = resultOrThrow().mutableMethod.injectLiteralInstructionViewCall(literal, smaliInstruction) - -fun MutableMethod.injectLiteralInstructionViewCall( - literal: Long, - smaliInstruction: String -) { - val literalIndex = indexOfFirstWideLiteralInstructionValueOrThrow(literal) - val targetIndex = indexOfFirstInstructionOrThrow(literalIndex, Opcode.MOVE_RESULT_OBJECT) - val targetRegister = getInstruction(targetIndex).registerA.toString() - - addInstructions( - targetIndex + 1, - smaliInstruction.replace(REGISTER_TEMPLATE_REPLACEMENT, targetRegister) - ) -} - -fun BytecodeContext.injectLiteralInstructionViewCall( - literal: Long, - smaliInstruction: String -) { - val context = this - context.classes.forEach { classDef -> - classDef.methods.forEach { method -> - method.implementation.apply { - this?.instructions?.forEachIndexed { _, instruction -> - if (instruction.opcode != Opcode.CONST) - return@forEachIndexed - if ((instruction as Instruction31i).wideLiteral != literal) - return@forEachIndexed - - context.proxy(classDef) - .mutableClass - .findMutableMethodOf(method) - .injectLiteralInstructionViewCall(literal, smaliInstruction) - } - } - } - } -} - -fun BytecodeContext.replaceLiteralInstructionCall( - literal: Long, - smaliInstruction: String -) { - val context = this - context.classes.forEach { classDef -> - classDef.methods.forEach { method -> - method.implementation.apply { - this?.instructions?.forEachIndexed { _, instruction -> - if (instruction.opcode != Opcode.CONST) - return@forEachIndexed - if ((instruction as Instruction31i).wideLiteral != literal) - return@forEachIndexed - - context.proxy(classDef) - .mutableClass - .findMutableMethodOf(method).apply { - val index = indexOfFirstWideLiteralInstructionValueOrThrow(literal) - val register = - (instruction as OneRegisterInstruction).registerA.toString() - - addInstructions( - index + 1, - smaliInstruction.replace(REGISTER_TEMPLATE_REPLACEMENT, register) - ) - } - } - } - } - } -} - -/** - * Get the index of the first [Instruction] that matches the predicate, starting from [startIndex]. - * - * @param startIndex Optional starting index to start searching from. - * @return -1 if the instruction is not found. - * @see indexOfFirstInstructionOrThrow - */ -fun Method.indexOfFirstInstruction(startIndex: Int = 0, opcode: Opcode): Int = - indexOfFirstInstruction(startIndex) { - this.opcode == opcode - } - -/** - * Get the index of the first [Instruction] that matches the predicate, starting from [startIndex]. - * - * @param startIndex Optional starting index to start searching from. - * @return -1 if the instruction is not found. - * @see indexOfFirstInstructionOrThrow - */ -fun Method.indexOfFirstInstruction(startIndex: Int = 0, predicate: Instruction.() -> Boolean): Int { - if (implementation == null) { - return -1 - } - var instructions = implementation!!.instructions - if (startIndex != 0) { - instructions = instructions.drop(startIndex) - } - val index = instructions.indexOfFirst(predicate) - - return if (index >= 0) { - startIndex + index - } else { - -1 - } -} - -fun Method.indexOfFirstInstructionOrThrow(opcode: Opcode): Int = - indexOfFirstInstructionOrThrow(0, opcode) - -/** - * Get the index of the first [Instruction] that matches the predicate, starting from [startIndex]. - * - * @return the index of the instruction - * @throws PatchException - * @see indexOfFirstInstruction - */ -fun Method.indexOfFirstInstructionOrThrow(startIndex: Int = 0, opcode: Opcode): Int = - indexOfFirstInstructionOrThrow(startIndex) { - this.opcode == opcode - } - -fun Method.indexOfFirstInstructionReversedOrThrow(opcode: Opcode): Int = - indexOfFirstInstructionReversedOrThrow(null, opcode) - -/** - * Get the index of the first [Instruction] that matches the predicate, starting from [startIndex]. - * - * @return the index of the instruction - * @throws PatchException - * @see indexOfFirstInstruction - */ -fun Method.indexOfFirstInstructionOrThrow( - startIndex: Int = 0, - predicate: Instruction.() -> Boolean -): Int { - val index = indexOfFirstInstruction(startIndex, predicate) - if (index < 0) { - throw PatchException("Could not find instruction index") - } - return index -} - -/** - * Get the index of matching instruction, - * starting from and [startIndex] and searching down. - * - * @param startIndex Optional starting index to search down from. Searching includes the start index. - * @return -1 if the instruction is not found. - * @see indexOfFirstInstructionReversedOrThrow - */ -fun Method.indexOfFirstInstructionReversed(startIndex: Int? = null, opcode: Opcode): Int = - indexOfFirstInstructionReversed(startIndex) { - this.opcode == opcode - } - -/** - * Get the index of matching instruction, - * starting from and [startIndex] and searching down. - * - * @param startIndex Optional starting index to search down from. Searching includes the start index. - * @return -1 if the instruction is not found. - * @see indexOfFirstInstructionReversedOrThrow - */ -fun Method.indexOfFirstInstructionReversed( - startIndex: Int? = null, - predicate: Instruction.() -> Boolean -): Int { - if (implementation == null) { - return -1 - } - var instructions = implementation!!.instructions - if (startIndex != null) { - instructions = instructions.take(startIndex + 1) - } - - return instructions.indexOfLast(predicate) -} - -/** - * Get the index of matching instruction, - * starting from and [startIndex] and searching down. - * - * @param startIndex Optional starting index to search down from. Searching includes the start index. - * @return -1 if the instruction is not found. - * @see indexOfFirstInstructionReversed - */ -fun Method.indexOfFirstInstructionReversedOrThrow( - startIndex: Int? = null, - opcode: Opcode -): Int = - indexOfFirstInstructionReversedOrThrow(startIndex) { - this.opcode == opcode - } - -/** - * Get the index of matching instruction, - * starting from and [startIndex] and searching down. - * - * @param startIndex Optional starting index to search down from. Searching includes the start index. - * @return -1 if the instruction is not found. - * @see indexOfFirstInstructionReversed - */ -fun Method.indexOfFirstInstructionReversedOrThrow( - startIndex: Int? = null, - predicate: Instruction.() -> Boolean -): Int { - val index = indexOfFirstInstructionReversed(startIndex, predicate) - - if (index < 0) { - throw PatchException("Could not find instruction index") - } - - return index -} - -/** - * @return The list of indices of the opcode in reverse order. - */ -fun Method.findOpcodeIndicesReversed(opcode: Opcode): List = - findOpcodeIndicesReversed { this.opcode == opcode } - -/** - * @return The list of indices of the opcode in reverse order. - */ -fun Method.findOpcodeIndicesReversed(filter: Instruction.() -> Boolean): List { - val indexes = implementation!!.instructions - .withIndex() - .filter { (_, instruction) -> filter(instruction) } - .map { (index, _) -> index } - .reversed() - - if (indexes.isEmpty()) throw PatchException("No matching instructions found in: $this") - - return indexes -} - -/** - * Find the index of the first wide literal instruction with the given value. - * - * @return the first literal instruction with the value, or -1 if not found. - * @see indexOfFirstWideLiteralInstructionValueOrThrow - */ -fun Method.indexOfFirstWideLiteralInstructionValue(literal: Long) = implementation?.let { - it.instructions.indexOfFirst { instruction -> - (instruction as? WideLiteralInstruction)?.wideLiteral == literal - } -} ?: -1 - - -/** - * Find the index of the first wide literal instruction with the given value, - * or throw an exception if not found. - * - * @return the first literal instruction with the value, or throws [PatchException] if not found. - */ -fun Method.indexOfFirstWideLiteralInstructionValueOrThrow(literal: Long): Int { - val index = indexOfFirstWideLiteralInstructionValue(literal) - if (index < 0) { - val value = - if (literal >= 2130706432) // 0x7f000000, general resource id - String.format("%#X", literal).lowercase() - else - literal.toString() - - throw PatchException("Found literal value: '$value' but method does not contain the id: $this") - } - - return index -} - -fun Method.indexOfFirstStringInstruction(str: String) = - indexOfFirstInstruction { - opcode == Opcode.CONST_STRING && - getReference()?.string == str - } - - -fun Method.indexOfFirstStringInstructionOrThrow(str: String): Int { - val index = indexOfFirstStringInstruction(str) - if (index < 0) { - throw PatchException("Found string value for: '$str' but method does not contain the id: $this") - } - - return index -} - -/** - * Check if the method contains a literal with the given value. - * - * @return if the method contains a literal with the given value. - */ -fun Method.containsWideLiteralInstructionValue(literal: Long) = - indexOfFirstWideLiteralInstructionValue(literal) >= 0 - -/** - * Traverse the class hierarchy starting from the given root class. - * - * @param targetClass the class to start traversing the class hierarchy from. - * @param callback function that is called for every class in the hierarchy. - */ -fun BytecodeContext.traverseClassHierarchy( - targetClass: MutableClass, - callback: MutableClass.() -> Unit -) { - callback(targetClass) - this.findClass(targetClass.superclass ?: return)?.mutableClass?.let { - traverseClassHierarchy(it, callback) - } -} - -/** - * Get the [Reference] of an [Instruction] as [T]. - * - * @param T The type of [Reference] to cast to. - * @return The [Reference] as [T] or null - * if the [Instruction] is not a [ReferenceInstruction] or the [Reference] is not of type [T]. - * @see ReferenceInstruction - */ -inline fun Instruction.getReference() = - (this as? ReferenceInstruction)?.reference as? T - -fun MethodFingerprintResult.getWalkerMethod(context: BytecodeContext, offset: Int) = - mutableMethod.getWalkerMethod(context, offset) - -/** - * MethodWalker can find the wrong class: - * https://github.com/ReVanced/revanced-patcher/issues/309 - * - * As a workaround, redefine MethodWalker here - */ -fun MutableMethod.getWalkerMethod(context: BytecodeContext, offset: Int): MutableMethod { - val newMethod = getInstruction(offset).reference as MethodReference - return context.findMethodOrThrow(newMethod.definingClass) { - MethodUtil.methodSignaturesMatch(this, newMethod) - } -} - -/** - * Taken from BiliRoamingX: - * https://github.com/BiliRoamingX/BiliRoamingX/blob/ae58109f3acdd53ec2d2b3fb439c2a2ef1886221/patches/src/main/kotlin/app/revanced/patches/bilibili/utils/Extenstions.kt#L151 - */ -fun MutableMethod.getFiveRegisters(index: Int) = - with(getInstruction(index)) { - arrayOf(registerC, registerD, registerE, registerF, registerG) - .take(registerCount).joinToString(",") { "v$it" } - } - -fun BytecodeContext.addStaticFieldToIntegration( - className: String, - methodName: String, - fieldName: String, - objectClass: String, - smaliInstructions: String, - shouldAddConstructor: Boolean = true -) { - val mutableClass = findClass { classDef -> classDef.type == className } - ?.mutableClass - ?: throw PatchException("No matching classes found: $className") - - val objectCall = "$mutableClass->$fieldName:$objectClass" - - mutableClass.apply { - methods.first { method -> method.name == methodName }.apply { - staticFields.add( - ImmutableField( - definingClass, - fieldName, - objectClass, - AccessFlags.PUBLIC or AccessFlags.STATIC, - null, - annotations, - null - ).toMutable() - ) - - addInstructionsWithLabels( - 0, - """ - sget-object v0, $objectCall - """ + smaliInstructions - ) - } - } - - if (!shouldAddConstructor) return - - findMethodsOrThrow(objectClass) - .filter { method -> MethodUtil.isConstructor(method) } - .forEach { mutableMethod -> - mutableMethod.apply { - val initializeIndex = indexOfFirstInstructionOrThrow { - opcode == Opcode.INVOKE_DIRECT && - getReference()?.name == "" - } - val insertIndex = if (initializeIndex == -1) - 1 - else - initializeIndex + 1 - - val initializeRegister = if (initializeIndex == -1) - "p0" - else - "v${getInstruction(initializeIndex).registerC}" - - addInstruction( - insertIndex, - "sput-object $initializeRegister, $objectCall" - ) - } - } -} - -fun BytecodeContext.findMethodOrThrow( - reference: String, - methodPredicate: Method.() -> Boolean = { MethodUtil.isConstructor(this) } -) = findMethodsOrThrow(reference).first(methodPredicate) - -fun BytecodeContext.findMethodsOrThrow(reference: String): MutableSet { - val methods = - findClass { classDef -> classDef.type == reference } - ?.mutableClass - ?.methods - - if (methods != null) { - return methods - } else { - throw PatchException("No matching methods found in: $reference") - } -} - -fun BytecodeContext.updatePatchStatus( - className: String, - methodName: String -) = findMethodOrThrow(className) { name == methodName } - .replaceInstruction( - 0, - "const/4 v0, 0x1" - ) - -/** - * Taken from BiliRoamingX: - * https://github.com/BiliRoamingX/BiliRoamingX/blob/ae58109f3acdd53ec2d2b3fb439c2a2ef1886221/patches/src/main/kotlin/app/revanced/patches/bilibili/utils/Extenstions.kt#L51 - */ -fun Method.cloneMutable( - registerCount: Int = implementation?.registerCount ?: 0, - clearImplementation: Boolean = false, - name: String = this.name, - accessFlags: Int = this.accessFlags, - parameters: List = this.parameters, - returnType: String = this.returnType -): MutableMethod { - val clonedImplementation = implementation?.let { - ImmutableMethodImplementation( - registerCount, - if (clearImplementation) emptyList() else it.instructions, - if (clearImplementation) emptyList() else it.tryBlocks, - if (clearImplementation) emptyList() else it.debugItems, - ) - } - return ImmutableMethod( - definingClass, - name, - parameters, - returnType, - accessFlags, - annotations, - hiddenApiRestrictions, - clonedImplementation - ).toMutable() -} - -/** - * Return the resolved methods of [MethodFingerprint]s early. - */ -fun List.returnEarly(bool: Boolean = false) { - val const = if (bool) "0x1" else "0x0" - this.forEach { fingerprint -> - fingerprint.resultOrThrow().let { result -> - val stringInstructions = when (result.method.returnType.first()) { - 'L' -> """ - const/4 v0, $const - return-object v0 - """ - - 'V' -> "return-void" - 'I', 'Z' -> """ - const/4 v0, $const - return v0 - """ - - else -> throw PatchException("This case should never happen: ${fingerprint.javaClass.simpleName}") - } - - result.mutableMethod.addInstructions(0, stringInstructions) - } - } -} diff --git a/src/main/kotlin/app/revanced/util/ResourceUtils.kt b/src/main/kotlin/app/revanced/util/ResourceUtils.kt deleted file mode 100644 index f82f8c1a52..0000000000 --- a/src/main/kotlin/app/revanced/util/ResourceUtils.kt +++ /dev/null @@ -1,267 +0,0 @@ -@file:Suppress("DEPRECATION", "MemberVisibilityCanBePrivate", "SpellCheckingInspection") - -package app.revanced.util - -import app.revanced.patcher.data.ResourceContext -import app.revanced.patcher.patch.PatchException -import app.revanced.patcher.patch.options.PatchOption -import app.revanced.patcher.util.DomFileEditor -import org.w3c.dom.Element -import org.w3c.dom.Node -import org.w3c.dom.NodeList -import java.io.File -import java.io.InputStream -import java.nio.file.Files -import java.nio.file.StandardCopyOption - -val classLoader: ClassLoader = object {}.javaClass.classLoader - -fun PatchOption.valueOrThrow() = value - ?: throw PatchException("Invalid patch option: $title.") - -fun PatchOption.valueOrThrow() = value - ?: throw PatchException("Invalid patch option: $title.") - -fun PatchOption.lowerCaseOrThrow() = valueOrThrow() - .lowercase() - -fun PatchOption.underBarOrThrow() = lowerCaseOrThrow() - .replace(" ", "_") - -fun Node.adoptChild(tagName: String, block: Element.() -> Unit) { - val child = ownerDocument.createElement(tagName) - child.block() - appendChild(child) -} - -fun Node.cloneNodes(parent: Node) { - val node = cloneNode(true) - parent.appendChild(node) - parent.removeChild(this) -} - -/** - * Recursively traverse the DOM tree starting from the given root node. - * - * @param action function that is called for every node in the tree. - */ -fun Node.doRecursively(action: (Node) -> Unit) { - action(this) - for (i in 0 until this.childNodes.length) this.childNodes.item(i).doRecursively(action) -} - -fun Node.insertNode(tagName: String, targetNode: Node, block: Element.() -> Unit) { - val child = ownerDocument.createElement(tagName) - child.block() - parentNode.insertBefore(child, targetNode) -} - -fun String.startsWithAny(vararg prefixes: String): Boolean { - for (prefix in prefixes) - if (this.startsWith(prefix)) - return true - - return false -} - -fun List.getResourceGroup(fileNames: Array) = map { directory -> - ResourceGroup( - directory, *fileNames - ) -} - -fun ResourceContext.appendAppVersion(appVersion: String) { - addEntryValues( - "revanced_spoof_app_version_target_entries", - "@string/revanced_spoof_app_version_target_entry_" + appVersion.replace(".", "_"), - prepend = false - ) - addEntryValues( - "revanced_spoof_app_version_target_entry_values", - appVersion, - prepend = false - ) -} - -fun ResourceContext.addEntryValues( - attributeName: String, - attributeValue: String, - path: String = "res/values/arrays.xml", - prepend: Boolean = true, -) { - xmlEditor[path].use { - with(it.file) { - val resourcesNode = getElementsByTagName("resources").item(0) as Element - - val newElement: Element = createElement("item") - for (i in 0 until resourcesNode.childNodes.length) { - val node = resourcesNode.childNodes.item(i) as? Element ?: continue - - if (node.getAttribute("name") == attributeName) { - newElement.appendChild(createTextNode(attributeValue)) - - if (prepend) { - node.appendChild(newElement) - } else { - node.insertBefore(newElement, node.firstChild) - } - } - } - } - } -} - -fun ResourceContext.copyFile( - resourceGroup: List, - path: String, - warning: String -): Boolean { - resourceGroup.let { resourceGroups -> - try { - val filePath = File(path) - val resourceDirectory = this["res"] - - resourceGroups.forEach { group -> - val fromDirectory = filePath.resolve(group.resourceDirectoryName) - val toDirectory = resourceDirectory.resolve(group.resourceDirectoryName) - - group.resources.forEach { iconFileName -> - Files.write( - toDirectory.resolve(iconFileName).toPath(), - fromDirectory.resolve(iconFileName).readBytes() - ) - } - } - - return true - } catch (_: Exception) { - println(warning) - } - } - return false -} - -/** - * Copy resources from the current class loader to the resource directory. - * - * @param sourceResourceDirectory The source resource directory name. - * @param resources The resources to copy. - * @param createDirectoryIfNotExist Whether to create a new directory if it does not exist. - */ -fun ResourceContext.copyResources( - sourceResourceDirectory: String, - vararg resources: ResourceGroup, - createDirectoryIfNotExist: Boolean = false, -) { - val targetResourceDirectory = this["res"] - - for (resourceGroup in resources) { - resourceGroup.resources.forEach { resource -> - val resourceDirectoryName = resourceGroup.resourceDirectoryName - - if (createDirectoryIfNotExist) { - val targetDirectory = targetResourceDirectory.resolve(resourceDirectoryName) - if (!targetDirectory.isDirectory) Files.createDirectories(targetDirectory.toPath()) - } - - val resourceFile = "$resourceDirectoryName/$resource" - - inputStreamFromBundledResource( - sourceResourceDirectory, - resourceFile - )?.let { inputStream -> - Files.copy( - inputStream, - targetResourceDirectory.resolve(resourceFile).toPath(), - StandardCopyOption.REPLACE_EXISTING, - ) - } - } - } -} - -internal fun inputStreamFromBundledResource( - sourceResourceDirectory: String, - resourceFile: String, -): InputStream? = classLoader.getResourceAsStream("$sourceResourceDirectory/$resourceFile") - -/** - * Resource names mapped to their corresponding resource data. - * @param resourceDirectoryName The name of the directory of the resource. - * @param resources A list of resource names. - */ -class ResourceGroup(val resourceDirectoryName: String, vararg val resources: String) - -/** - * Copy resources from the current class loader to the resource directory. - * @param resourceDirectory The directory of the resource. - * @param targetResource The target resource. - * @param elementTag The element to copy. - */ -fun ResourceContext.copyXmlNode( - resourceDirectory: String, - targetResource: String, - elementTag: String -) = inputStreamFromBundledResource( - resourceDirectory, - targetResource -)?.let { inputStream -> - // Copy nodes from the resources node to the real resource node - elementTag.copyXmlNode( - this.xmlEditor[inputStream], - this.xmlEditor["res/$targetResource"] - ).close() -} - -/** - * Copies the specified node of the source [DomFileEditor] to the target [DomFileEditor]. - * @param source the source [DomFileEditor]. - * @param target the target [DomFileEditor]- - * @return AutoCloseable that closes the target [DomFileEditor]s. - */ -fun String.copyXmlNode(source: DomFileEditor, target: DomFileEditor): AutoCloseable { - val hostNodes = source.file.getElementsByTagName(this).item(0).childNodes - - val destinationResourceFile = target.file - val destinationNode = destinationResourceFile.getElementsByTagName(this).item(0) - - for (index in 0 until hostNodes.length) { - val node = hostNodes.item(index).cloneNode(true) - destinationResourceFile.adoptNode(node) - destinationNode.appendChild(node) - } - - return AutoCloseable { - source.close() - target.close() - } -} - -internal fun NodeList.findElementByAttributeValue(attributeName: String, value: String): Element? { - for (i in 0 until length) { - val node = item(i) - if (node.nodeType == Node.ELEMENT_NODE) { - val element = node as Element - - if (element.getAttribute(attributeName) == value) { - return element - } - - // Recursively search. - val found = element.childNodes.findElementByAttributeValue(attributeName, value) - if (found != null) { - return found - } - } - } - - return null -} - -internal fun NodeList.findElementByAttributeValueOrThrow( - attributeName: String, - value: String -): Element { - return findElementByAttributeValue(attributeName, value) - ?: throw PatchException("Could not find: $attributeName $value") -} \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/util/fingerprint/LiteralValueFingerprint.kt b/src/main/kotlin/app/revanced/util/fingerprint/LiteralValueFingerprint.kt deleted file mode 100644 index 729f7217fc..0000000000 --- a/src/main/kotlin/app/revanced/util/fingerprint/LiteralValueFingerprint.kt +++ /dev/null @@ -1,34 +0,0 @@ -package app.revanced.util.fingerprint - -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.util.containsWideLiteralInstructionValue -import com.android.tools.smali.dexlib2.Opcode - -/** - * A fingerprint to resolve methods that contain a specific literal value. - * - * @param returnType The method's return type compared using String.startsWith. - * @param accessFlags The method's exact access flags using values of AccessFlags. - * @param parameters The parameters of the method. Partial matches allowed and follow the same rules as returnType. - * @param opcodes An opcode pattern of the method's instructions. Wildcard or unknown opcodes can be specified by null. - * @param strings A list of the method's strings compared each using String.contains. - * @param literalSupplier A supplier for the literal value to check for. - */ -abstract class LiteralValueFingerprint( - returnType: String? = null, - accessFlags: Int? = null, - parameters: Iterable? = null, - opcodes: Iterable? = null, - strings: Iterable? = null, - // Has to be a supplier because the fingerprint is created before patches can set literals. - literalSupplier: () -> Long, -) : MethodFingerprint( - returnType = returnType, - accessFlags = accessFlags, - parameters = parameters, - opcodes = opcodes, - strings = strings, - customFingerprint = { methodDef, _ -> - methodDef.containsWideLiteralInstructionValue(literalSupplier()) - } -) \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/util/fingerprint/MultiMethodFingerprint.kt b/src/main/kotlin/app/revanced/util/fingerprint/MultiMethodFingerprint.kt deleted file mode 100644 index 54927e0347..0000000000 --- a/src/main/kotlin/app/revanced/util/fingerprint/MultiMethodFingerprint.kt +++ /dev/null @@ -1,211 +0,0 @@ -package app.revanced.util.fingerprint - -import app.revanced.patcher.data.BytecodeContext -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.patcher.fingerprint.MethodFingerprintResult -import com.android.tools.smali.dexlib2.Opcode -import com.android.tools.smali.dexlib2.iface.ClassDef -import com.android.tools.smali.dexlib2.iface.Method -import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction -import com.android.tools.smali.dexlib2.iface.reference.StringReference - -private typealias StringMatch = MethodFingerprintResult.MethodFingerprintScanResult.StringsScanResult.StringMatch -private typealias StringsScanResult = MethodFingerprintResult.MethodFingerprintScanResult.StringsScanResult - -/** - * Taken from BiliRoamingX: - * https://github.com/BiliRoamingX/BiliRoamingX/blob/ae58109f3acdd53ec2d2b3fb439c2a2ef1886221/patches/src/main/kotlin/app/revanced/patches/bilibili/patcher/fingerprint/MultiMethodFingerprint.kt - * - * Represents the [MethodFingerprint] for a method. - * @param returnType The return type of the method. - * @param accessFlags The access flags of the method. - * @param parameters The parameters of the method. - * @param opcodes The list of opcodes of the method. - * @param strings A list of strings which a method contains. - * @param customFingerprint A custom condition for this fingerprint. - * A `null` opcode is equals to an unknown opcode. - */ -abstract class MultiMethodFingerprint( - val returnType: String? = null, - val accessFlags: Int? = null, - val parameters: Iterable? = null, - val opcodes: Iterable? = null, - val strings: Iterable? = null, - val customFingerprint: ((methodDef: Method, classDef: ClassDef) -> Boolean)? = null -) { - /** - * The result of the [MethodFingerprint]. - */ - var result = mutableListOf() - private var resolved = false - - companion object { - /** - * Resolve a list of [MethodFingerprint] against a list of [ClassDef]. - * - * @param classes The classes on which to resolve the [MethodFingerprint] in. - * @param context The [BytecodeContext] to host proxies. - * @return True if the resolution was successful, false otherwise. - */ - fun Iterable.resolve( - context: BytecodeContext, - classes: Iterable - ) { - for (fingerprint in this) { // For each fingerprint - if (fingerprint.resolved) continue - for (classDef in classes) // search through all classes for the fingerprint - fingerprint.resolve(context, classDef) - fingerprint.resolved = true - } - } - - /** - * Resolve a [MethodFingerprint] against a [ClassDef]. - * - * @param forClass The class on which to resolve the [MethodFingerprint] in. - * @param context The [BytecodeContext] to host proxies. - * @return True if the resolution was successful, false otherwise. - */ - fun MultiMethodFingerprint.resolve(context: BytecodeContext, forClass: ClassDef): Boolean { - for (method in forClass.methods) - if (this.resolve(context, method, forClass)) - return true - return false - } - - /** - * Resolve a [MethodFingerprint] against a [Method]. - * - * @param method The class on which to resolve the [MethodFingerprint] in. - * @param forClass The class on which to resolve the [MethodFingerprint]. - * @param context The [BytecodeContext] to host proxies. - * @return True if the resolution was successful or if the fingerprint is already resolved, false otherwise. - */ - fun MultiMethodFingerprint.resolve( - context: BytecodeContext, - method: Method, - forClass: ClassDef - ): Boolean { - val methodFingerprint = this - - if (methodFingerprint.returnType != null && !method.returnType.startsWith( - methodFingerprint.returnType - ) - ) - return false - - if (methodFingerprint.accessFlags != null && methodFingerprint.accessFlags != method.accessFlags) - return false - - fun parametersEqual( - parameters1: Iterable, parameters2: Iterable - ): Boolean { - if (parameters1.count() != parameters2.count()) return false - val iterator1 = parameters1.iterator() - parameters2.forEach { - if (!it.startsWith(iterator1.next())) return false - } - return true - } - - if (methodFingerprint.parameters != null && !parametersEqual( - methodFingerprint.parameters, // TODO: parseParameters() - method.parameterTypes - ) - ) return false - - @Suppress("UNNECESSARY_NOT_NULL_ASSERTION") - if (methodFingerprint.customFingerprint != null && !methodFingerprint.customFingerprint!!( - method, - forClass - ) - ) - return false - - val stringsScanResult = if (methodFingerprint.strings != null) { - StringsScanResult( - buildList { - val implementation = method.implementation ?: return false - - val stringsList = methodFingerprint.strings.toMutableList() - - implementation.instructions.forEachIndexed { instructionIndex, instruction -> - if ( - instruction.opcode != Opcode.CONST_STRING && - instruction.opcode != Opcode.CONST_STRING_JUMBO - ) return@forEachIndexed - - val string = - ((instruction as ReferenceInstruction).reference as StringReference).string - val index = stringsList.indexOfFirst(string::contains) - if (index == -1) return@forEachIndexed - - add(StringMatch(string, instructionIndex)) - stringsList.removeAt(index) - } - - if (stringsList.isNotEmpty()) return false - } - ) - } else null - - val patternScanResult = if (methodFingerprint.opcodes != null) { - method.implementation?.instructions ?: return false - - method.patternScan(methodFingerprint) ?: return false - } else null - - methodFingerprint.result.add( - MethodFingerprintResult( - method, - forClass, - MethodFingerprintResult.MethodFingerprintScanResult( - patternScanResult, - stringsScanResult - ), - context - ) - ) - - return true - } - - private fun Method.patternScan( - fingerprint: MultiMethodFingerprint - ): MethodFingerprintResult.MethodFingerprintScanResult.PatternScanResult? { - val instructions = this.implementation!!.instructions - - val pattern = fingerprint.opcodes!! - val instructionLength = instructions.count() - val patternLength = pattern.count() - - for (index in 0 until instructionLength) { - var patternIndex = 0 - - while (index + patternIndex < instructionLength) { - val originalOpcode = instructions.elementAt(index + patternIndex).opcode - val patternOpcode = pattern.elementAt(patternIndex) - - if (patternOpcode != null && patternOpcode.ordinal != originalOpcode.ordinal) { - // reaching maximum threshold (0) means, - // the pattern does not match to the current instructions - break - } - - if (patternIndex < patternLength - 1) { - // if the entire pattern has not been scanned yet - // continue the scan - patternIndex++ - continue - } - return MethodFingerprintResult.MethodFingerprintScanResult.PatternScanResult( - index, - index + patternIndex - ) - } - } - - return null - } - } -} diff --git a/src/main/kotlin/app/revanced/util/patch/BaseBytecodePatch.kt b/src/main/kotlin/app/revanced/util/patch/BaseBytecodePatch.kt deleted file mode 100644 index 4a15c9de77..0000000000 --- a/src/main/kotlin/app/revanced/util/patch/BaseBytecodePatch.kt +++ /dev/null @@ -1,23 +0,0 @@ -package app.revanced.util.patch - -import app.revanced.patcher.PatchClass -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.patcher.patch.BytecodePatch - -abstract class BaseBytecodePatch( - name: String? = null, - description: String? = null, - dependencies: Set? = null, - compatiblePackages: Set? = null, - fingerprints: Set = emptySet(), - requiresIntegrations: Boolean = false, - use: Boolean = true, -) : BytecodePatch( - name = name, - description = description, - dependencies = dependencies, - compatiblePackages = compatiblePackages, - fingerprints = fingerprints, - requiresIntegrations = requiresIntegrations, - use = use -) \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/util/patch/BaseResourcePatch.kt b/src/main/kotlin/app/revanced/util/patch/BaseResourcePatch.kt deleted file mode 100644 index 2f61b3777f..0000000000 --- a/src/main/kotlin/app/revanced/util/patch/BaseResourcePatch.kt +++ /dev/null @@ -1,20 +0,0 @@ -package app.revanced.util.patch - -import app.revanced.patcher.PatchClass -import app.revanced.patcher.patch.ResourcePatch - -abstract class BaseResourcePatch( - name: String? = null, - description: String? = null, - dependencies: Set? = null, - compatiblePackages: Set? = null, - requiresIntegrations: Boolean = false, - use: Boolean = true -) : ResourcePatch( - name = name, - description = description, - dependencies = dependencies, - compatiblePackages = compatiblePackages, - requiresIntegrations = requiresIntegrations, - use = use -) \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/util/patch/MultiMethodBytecodePatch.kt b/src/main/kotlin/app/revanced/util/patch/MultiMethodBytecodePatch.kt deleted file mode 100644 index fc3e5d18d9..0000000000 --- a/src/main/kotlin/app/revanced/util/patch/MultiMethodBytecodePatch.kt +++ /dev/null @@ -1,20 +0,0 @@ -package app.revanced.util.patch - -import app.revanced.patcher.data.BytecodeContext -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.patcher.patch.BytecodePatch -import app.revanced.util.fingerprint.MultiMethodFingerprint -import app.revanced.util.fingerprint.MultiMethodFingerprint.Companion.resolve - -/** - * Taken from BiliRoamingX: - * https://github.com/BiliRoamingX/BiliRoamingX/blob/ae58109f3acdd53ec2d2b3fb439c2a2ef1886221/patches/src/main/kotlin/app/revanced/patches/bilibili/patcher/patch/MultiMethodBytecodePatch.kt - */ -abstract class MultiMethodBytecodePatch( - val fingerprints: Set = setOf(), - val multiFingerprints: Set = setOf() -) : BytecodePatch(fingerprints) { - override fun execute(context: BytecodeContext) { - multiFingerprints.resolve(context, context.classes) - } -}