diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 250871bcc..193a26af0 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -16,6 +16,12 @@ jobs: 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 diff --git a/.github/workflows/open_pull_request.yml b/.github/workflows/open_pull_request.yml index 2afa0596c..33c8a7211 100644 --- a/.github/workflows/open_pull_request.yml +++ b/.github/workflows/open_pull_request.yml @@ -20,13 +20,12 @@ jobs: - name: Open pull request uses: repo-sync/pull-request@v2 with: - destination_branch: "main" - pr_title: "chore: ${{ env.MESSAGE }}" + destination_branch: main + pr_title: 'chore: ${{ env.MESSAGE }}' pr_body: | This pull request will ${{ env.MESSAGE }}. ## Before merging this PR - - [ ] Remember about https://github.com/revanced/revanced-integrations - [ ] Pull translations from Crowdin pr_draft: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b210aad5c..498cca413 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,13 +23,19 @@ jobs: 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 }} - run: ./gradlew generateMeta clean + run: ./gradlew build clean - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.gitignore b/.gitignore index 501541766..62f6eb424 100644 --- a/.gitignore +++ b/.gitignore @@ -122,5 +122,8 @@ gradle-app.setting # Dependency directories node_modules/ -# gradle properties, due to Github token +# Gradle properties, due to Github token ./gradle.properties + +# 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 e086a70c4..f1854e4b5 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -4,5 +4,5 @@ - + \ No newline at end of file diff --git a/.releaserc b/.releaserc index ff81c99ad..b3d61b10b 100644 --- a/.releaserc +++ b/.releaserc @@ -23,7 +23,6 @@ "assets": [ "CHANGELOG.md", "gradle.properties", - "patches.json" ], "message": "chore: Release v${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" } @@ -33,11 +32,8 @@ { "assets": [ { - "path": "build/libs/revanced-patches*" + "path": "patches/build/libs/patches-!(*sources*|*javadoc*).rvp?(.asc)" }, - { - "path": "patches.json" - } ], successComment: false } diff --git a/api/revanced-patches.api b/api/revanced-patches.api deleted file mode 100644 index 0b4976b98..000000000 --- a/api/revanced-patches.api +++ /dev/null @@ -1,2308 +0,0 @@ -public final class app/revanced/generator/MainKt { - public static synthetic fun main ([Ljava/lang/String;)V -} - -public final class app/revanced/patches/all/activity/exportall/ExportAllActivitiesPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/all/activity/exportall/ExportAllActivitiesPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/all/connectivity/wifi/spoof/SpoofWifiPatch : app/revanced/patches/all/misc/transformation/BaseTransformInstructionsPatch { - public static final field INSTANCE Lapp/revanced/patches/all/connectivity/wifi/spoof/SpoofWifiPatch; - public synthetic fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Ljava/lang/Object; - public fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Lkotlin/Triple; - public synthetic fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Ljava/lang/Object;)V - public fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Lkotlin/Triple;)V -} - -public final class app/revanced/patches/all/directory/ChangeDataDirectoryLocationPatch : app/revanced/patches/all/misc/transformation/BaseTransformInstructionsPatch { - public static final field INSTANCE Lapp/revanced/patches/all/directory/ChangeDataDirectoryLocationPatch; - public fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Ljava/lang/Integer; - public synthetic fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Ljava/lang/Object; - public fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;I)V - public synthetic fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Ljava/lang/Object;)V -} - -public final class app/revanced/patches/all/interaction/gestures/PredictiveBackGesturePatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/all/interaction/gestures/PredictiveBackGesturePatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/all/location/hide/HideMockLocationPatch : app/revanced/patches/all/misc/transformation/BaseTransformInstructionsPatch { - public static final field INSTANCE Lapp/revanced/patches/all/location/hide/HideMockLocationPatch; - public synthetic fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Ljava/lang/Object; - public fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Lkotlin/Pair; - public synthetic fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Ljava/lang/Object;)V - public fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Lkotlin/Pair;)V -} - -public abstract class app/revanced/patches/all/misc/build/BaseSpoofBuildInfoPatch : app/revanced/patches/all/misc/transformation/BaseTransformInstructionsPatch { - public fun ()V - public synthetic fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Ljava/lang/Object; - public fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Lkotlin/Pair; - protected fun getBoard ()Ljava/lang/String; - protected fun getBootloader ()Ljava/lang/String; - protected fun getBrand ()Ljava/lang/String; - protected fun getCpuAbi ()Ljava/lang/String; - protected fun getCpuAbi2 ()Ljava/lang/String; - protected fun getDevice ()Ljava/lang/String; - protected fun getDisplay ()Ljava/lang/String; - protected fun getFingerprint ()Ljava/lang/String; - protected fun getHardware ()Ljava/lang/String; - protected fun getHost ()Ljava/lang/String; - protected fun getId ()Ljava/lang/String; - protected fun getManufacturer ()Ljava/lang/String; - protected fun getModel ()Ljava/lang/String; - protected fun getOdmSku ()Ljava/lang/String; - protected fun getProduct ()Ljava/lang/String; - protected fun getRadio ()Ljava/lang/String; - protected fun getSerial ()Ljava/lang/String; - protected fun getSku ()Ljava/lang/String; - protected fun getSocManufacturer ()Ljava/lang/String; - protected fun getSocModel ()Ljava/lang/String; - protected fun getTags ()Ljava/lang/String; - protected fun getTime ()Ljava/lang/Long; - protected fun getType ()Ljava/lang/String; - protected fun getUser ()Ljava/lang/String; - public synthetic fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Ljava/lang/Object;)V - public fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Lkotlin/Pair;)V -} - -public final class app/revanced/patches/all/misc/build/SpoofBuildInfoPatch : app/revanced/patches/all/misc/build/BaseSpoofBuildInfoPatch { - public fun ()V -} - -public final class app/revanced/patches/all/misc/debugging/EnableAndroidDebuggingPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/all/misc/debugging/EnableAndroidDebuggingPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/all/misc/hex/HexPatch : app/revanced/patches/shared/misc/hex/BaseHexPatch { - public fun ()V - public fun getReplacements ()Ljava/util/List; -} - -public final class app/revanced/patches/all/misc/network/OverrideCertificatePinningPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/all/misc/network/OverrideCertificatePinningPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/all/misc/packagename/ChangePackageNamePatch : app/revanced/patcher/patch/ResourcePatch, java/io/Closeable { - public static final field INSTANCE Lapp/revanced/patches/all/misc/packagename/ChangePackageNamePatch; - public fun close ()V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V - public final fun setOrGetFallbackPackageName (Ljava/lang/String;)Ljava/lang/String; -} - -public final class app/revanced/patches/all/misc/resources/AddResourcesPatch : app/revanced/patcher/patch/ResourcePatch, java/io/Closeable, java/util/Map, kotlin/jvm/internal/markers/KMutableMap { - public static final field INSTANCE Lapp/revanced/patches/all/misc/resources/AddResourcesPatch; - public fun clear ()V - public fun close ()V - public final fun containsKey (Ljava/lang/Object;)Z - public fun containsKey (Ljava/lang/String;)Z - public final fun containsValue (Ljava/lang/Object;)Z - public fun containsValue (Ljava/util/Set;)Z - public final fun entrySet ()Ljava/util/Set; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V - public final synthetic fun get (Ljava/lang/Object;)Ljava/lang/Object; - public final fun get (Ljava/lang/Object;)Ljava/util/Set; - public fun get (Ljava/lang/String;)Ljava/util/Set; - public fun getEntries ()Ljava/util/Set; - public fun getKeys ()Ljava/util/Set; - public fun getSize ()I - public fun getValues ()Ljava/util/Collection; - public final fun invoke (Ljava/lang/String;Lapp/revanced/util/resource/BaseResource;)Z - public final fun invoke (Ljava/lang/String;Ljava/lang/Iterable;)Z - public final fun invoke (Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;)Z - public final fun invoke (Ljava/lang/String;Ljava/util/List;)Z - public final fun invoke (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function1;)Z - public static synthetic fun invoke$default (Lapp/revanced/patches/all/misc/resources/AddResourcesPatch;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;ILjava/lang/Object;)Z - public static synthetic fun invoke$default (Lapp/revanced/patches/all/misc/resources/AddResourcesPatch;Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Z - public fun isEmpty ()Z - public final fun keySet ()Ljava/util/Set; - public synthetic fun put (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; - public fun put (Ljava/lang/String;Ljava/util/Set;)Ljava/util/Set; - public fun putAll (Ljava/util/Map;)V - public final synthetic fun remove (Ljava/lang/Object;)Ljava/lang/Object; - public final fun remove (Ljava/lang/Object;)Ljava/util/Set; - public fun remove (Ljava/lang/String;)Ljava/util/Set; - public final fun size ()I - public final fun values ()Ljava/util/Collection; -} - -public abstract class app/revanced/patches/all/misc/transformation/BaseTransformInstructionsPatch : app/revanced/patcher/patch/BytecodePatch { - public fun ()V - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public abstract fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Ljava/lang/Object; - public final fun findPatchIndices (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;)Lkotlin/sequences/Sequence; - public abstract fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Ljava/lang/Object;)V -} - -public abstract interface class app/revanced/patches/all/misc/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 replaceInvokeVirtualWithIntegrations (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/all/misc/transformation/IMethodCall$DefaultImpls { - public static fun replaceInvokeVirtualWithIntegrations (Lapp/revanced/patches/all/misc/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/all/misc/versioncode/ChangeVersionCodePatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/all/misc/versioncode/ChangeVersionCodePatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/all/screencapture/removerestriction/RemoveCaptureRestrictionPatch : app/revanced/patches/all/misc/transformation/BaseTransformInstructionsPatch { - public static final field INSTANCE Lapp/revanced/patches/all/screencapture/removerestriction/RemoveCaptureRestrictionPatch; - public synthetic fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Ljava/lang/Object; - public fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Lkotlin/Triple; - public synthetic fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Ljava/lang/Object;)V - public fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Lkotlin/Triple;)V -} - -public final class app/revanced/patches/all/screencapture/removerestriction/RemoveCaptureRestrictionPatch$MethodCall : java/lang/Enum, app/revanced/patches/all/misc/transformation/IMethodCall { - public static final field SetAllowedCapturePolicyGlobal Lapp/revanced/patches/all/screencapture/removerestriction/RemoveCaptureRestrictionPatch$MethodCall; - public static final field SetAllowedCapturePolicySingle Lapp/revanced/patches/all/screencapture/removerestriction/RemoveCaptureRestrictionPatch$MethodCall; - public fun getDefinedClassName ()Ljava/lang/String; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public fun getMethodName ()Ljava/lang/String; - public fun getMethodParams ()[Ljava/lang/String; - public fun getReturnType ()Ljava/lang/String; - public fun replaceInvokeVirtualWithIntegrations (Ljava/lang/String;Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Lcom/android/tools/smali/dexlib2/iface/instruction/formats/Instruction35c;I)V - public static fun valueOf (Ljava/lang/String;)Lapp/revanced/patches/all/screencapture/removerestriction/RemoveCaptureRestrictionPatch$MethodCall; - public static fun values ()[Lapp/revanced/patches/all/screencapture/removerestriction/RemoveCaptureRestrictionPatch$MethodCall; -} - -public final class app/revanced/patches/all/screenshot/removerestriction/RemoveScreenshotRestrictionPatch : app/revanced/patches/all/misc/transformation/BaseTransformInstructionsPatch { - public static final field INSTANCE Lapp/revanced/patches/all/screenshot/removerestriction/RemoveScreenshotRestrictionPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public synthetic fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Ljava/lang/Object; - public fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Lkotlin/Triple; - public synthetic fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Ljava/lang/Object;)V - public fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Lkotlin/Triple;)V -} - -public final class app/revanced/patches/all/screenshot/removerestriction/RemoveScreenshotRestrictionPatch$MethodCall : java/lang/Enum, app/revanced/patches/all/misc/transformation/IMethodCall { - public static final field AddFlags Lapp/revanced/patches/all/screenshot/removerestriction/RemoveScreenshotRestrictionPatch$MethodCall; - public static final field SetFlags Lapp/revanced/patches/all/screenshot/removerestriction/RemoveScreenshotRestrictionPatch$MethodCall; - public fun getDefinedClassName ()Ljava/lang/String; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public fun getMethodName ()Ljava/lang/String; - public fun getMethodParams ()[Ljava/lang/String; - public fun getReturnType ()Ljava/lang/String; - public fun replaceInvokeVirtualWithIntegrations (Ljava/lang/String;Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Lcom/android/tools/smali/dexlib2/iface/instruction/formats/Instruction35c;I)V - public static fun valueOf (Ljava/lang/String;)Lapp/revanced/patches/all/screenshot/removerestriction/RemoveScreenshotRestrictionPatch$MethodCall; - public static fun values ()[Lapp/revanced/patches/all/screenshot/removerestriction/RemoveScreenshotRestrictionPatch$MethodCall; -} - -public final class app/revanced/patches/all/shortcut/sharetargets/RemoveShareTargetsPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/all/shortcut/sharetargets/RemoveShareTargetsPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/all/telephony/sim/spoof/SpoofSimCountryPatch : app/revanced/patches/all/misc/transformation/BaseTransformInstructionsPatch { - public static final field INSTANCE Lapp/revanced/patches/all/telephony/sim/spoof/SpoofSimCountryPatch; - public synthetic fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Ljava/lang/Object; - public fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Lkotlin/Pair; - public synthetic fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Ljava/lang/Object;)V - public fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Lkotlin/Pair;)V -} - -public final class app/revanced/patches/amazon/deeplinking/DeepLinkingPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/amazon/deeplinking/DeepLinkingPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/backdrops/misc/pro/ProUnlockPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/backdrops/misc/pro/ProUnlockPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/bandcamp/limitations/RemovePlayLimitsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/bandcamp/limitations/RemovePlayLimitsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/candylinkvpn/UnlockProPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/candylinkvpn/UnlockProPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/cieid/restrictions/root/BypassRootChecksPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/cieid/restrictions/root/BypassRootChecksPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/duolingo/ad/DisableAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/duolingo/ad/DisableAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/duolingo/debug/EnableDebugMenuPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/duolingo/debug/EnableDebugMenuPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/facebook/ads/mainfeed/HideSponsoredStoriesPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/facebook/ads/mainfeed/HideSponsoredStoriesPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/facebook/ads/story/HideStoryAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/facebook/ads/story/HideStoryAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/finanzonline/detection/bootloader/BootloaderDetectionPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/finanzonline/detection/bootloader/BootloaderDetectionPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/finanzonline/detection/root/RootDetectionPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/finanzonline/detection/root/RootDetectionPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/googlenews/customtabs/EnableCustomTabs : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/googlenews/customtabs/EnableCustomTabs; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/googlenews/misc/gms/GmsCoreSupportPatch : app/revanced/patches/shared/misc/gms/BaseGmsCoreSupportPatch { - public static final field INSTANCE Lapp/revanced/patches/googlenews/misc/gms/GmsCoreSupportPatch; -} - -public final class app/revanced/patches/googlenews/misc/gms/GmsCoreSupportResourcePatch : app/revanced/patches/shared/misc/gms/BaseGmsCoreSupportResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/googlenews/misc/gms/GmsCoreSupportResourcePatch; -} - -public final class app/revanced/patches/googlenews/misc/integrations/IntegrationsPatch : app/revanced/patches/shared/misc/integrations/BaseIntegrationsPatch { - public static final field INSTANCE Lapp/revanced/patches/googlenews/misc/integrations/IntegrationsPatch; -} - -public final class app/revanced/patches/googlephotos/features/SpoofFeaturesPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/googlephotos/features/SpoofFeaturesPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/googlephotos/features/fingerprints/InitializeFeaturesEnumFingerprint : app/revanced/patcher/fingerprint/MethodFingerprint { - public static final field INSTANCE Lapp/revanced/patches/googlephotos/features/fingerprints/InitializeFeaturesEnumFingerprint; -} - -public final class app/revanced/patches/googlephotos/misc/gms/GmsCoreSupportPatch : app/revanced/patches/shared/misc/gms/BaseGmsCoreSupportPatch { - public static final field INSTANCE Lapp/revanced/patches/googlephotos/misc/gms/GmsCoreSupportPatch; -} - -public final class app/revanced/patches/googlephotos/misc/gms/GmsCoreSupportResourcePatch : app/revanced/patches/shared/misc/gms/BaseGmsCoreSupportResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/googlephotos/misc/gms/GmsCoreSupportResourcePatch; -} - -public final class app/revanced/patches/googlephotos/misc/integrations/IntegrationsPatch : app/revanced/patches/shared/misc/integrations/BaseIntegrationsPatch { - public static final field INSTANCE Lapp/revanced/patches/googlephotos/misc/integrations/IntegrationsPatch; -} - -public final class app/revanced/patches/googlephotos/preferences/RestoreHiddenBackUpWhileChargingTogglePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/googlephotos/preferences/RestoreHiddenBackUpWhileChargingTogglePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/googlerecorder/restrictions/RemoveDeviceRestrictions : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/googlerecorder/restrictions/RemoveDeviceRestrictions; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/hexeditor/ad/DisableAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/hexeditor/ad/DisableAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/iconpackstudio/misc/pro/UnlockProPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/iconpackstudio/misc/pro/UnlockProPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/idaustria/detection/root/RootDetectionPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/idaustria/detection/root/RootDetectionPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/idaustria/detection/signature/SpoofSignaturePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/idaustria/detection/signature/SpoofSignaturePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/inshorts/ad/HideAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/inshorts/ad/HideAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/instagram/patches/ad/HideAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/instagram/patches/ad/HideAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/instagram/patches/ads/timeline/HideTimelineAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/instagram/patches/ads/timeline/HideTimelineAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/irplus/ad/RemoveAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/irplus/ad/RemoveAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/lightroom/misc/login/DisableMandatoryLoginPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/lightroom/misc/login/DisableMandatoryLoginPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/lightroom/misc/premium/UnlockPremiumPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/lightroom/misc/premium/UnlockPremiumPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/memegenerator/detection/license/LicenseValidationPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/memegenerator/detection/license/LicenseValidationPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/memegenerator/detection/signature/SignatureVerificationPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/memegenerator/detection/signature/SignatureVerificationPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/memegenerator/misc/pro/UnlockProVersionPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/memegenerator/misc/pro/UnlockProVersionPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/messenger/inbox/HideInboxAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/messenger/inbox/HideInboxAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/messenger/inbox/HideInboxSubtabsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/messenger/inbox/HideInboxSubtabsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/messenger/inputfield/DisableSwitchingEmojiToStickerPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/messenger/inputfield/DisableSwitchingEmojiToStickerPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/messenger/inputfield/DisableTypingIndicatorPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/messenger/inputfield/DisableTypingIndicatorPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/mifitness/misc/locale/ForceEnglishLocalePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/mifitness/misc/locale/ForceEnglishLocalePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/mifitness/misc/login/FixLoginPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/mifitness/misc/login/FixLoginPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/moneymanager/UnlockProPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/moneymanager/UnlockProPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/music/ad/video/HideMusicVideoAds : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/music/ad/video/HideMusicVideoAds; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/music/ad/video/HideVideoAds : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/music/ad/video/HideVideoAds; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/music/ad/video/MusicVideoAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/music/ad/video/MusicVideoAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/music/audio/codecs/CodecsUnlockPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/music/audio/codecs/CodecsUnlockPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/music/audio/exclusiveaudio/EnableExclusiveAudioPlayback : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/music/audio/exclusiveaudio/EnableExclusiveAudioPlayback; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/music/audio/exclusiveaudio/ExclusiveAudioPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/music/audio/exclusiveaudio/ExclusiveAudioPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/music/interaction/permanentrepeat/PermanentRepeatPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/music/interaction/permanentrepeat/PermanentRepeatPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/music/interaction/permanentshuffle/PermanentShufflePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/music/interaction/permanentshuffle/PermanentShufflePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/music/interaction/permanentshuffle/PermanentShuffleTogglePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/music/interaction/permanentshuffle/PermanentShuffleTogglePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/music/layout/compactheader/CompactHeaderPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/music/layout/compactheader/CompactHeaderPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/music/layout/compactheader/HideCategoryBar : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/music/layout/compactheader/HideCategoryBar; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/music/layout/minimizedplayback/MinimizedPlaybackPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/music/layout/minimizedplayback/MinimizedPlaybackPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/music/layout/premium/HideGetPremiumPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/music/layout/premium/HideGetPremiumPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/music/layout/upgradebutton/RemoveUpgradeButtonPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/music/layout/upgradebutton/RemoveUpgradeButtonPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/music/misc/androidauto/BypassCertificateChecksPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/music/misc/androidauto/BypassCertificateChecksPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/music/misc/backgroundplayback/BackgroundPlaybackPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/music/misc/backgroundplayback/BackgroundPlaybackPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/music/misc/gms/Constants { - public static final field INSTANCE Lapp/revanced/patches/music/misc/gms/Constants; -} - -public final class app/revanced/patches/music/misc/gms/GmsCoreSupportPatch : app/revanced/patches/shared/misc/gms/BaseGmsCoreSupportPatch { - public static final field INSTANCE Lapp/revanced/patches/music/misc/gms/GmsCoreSupportPatch; -} - -public final class app/revanced/patches/music/misc/gms/GmsCoreSupportResourcePatch : app/revanced/patches/shared/misc/gms/BaseGmsCoreSupportResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/music/misc/gms/GmsCoreSupportResourcePatch; -} - -public final class app/revanced/patches/music/misc/integrations/IntegrationsPatch : app/revanced/patches/shared/misc/integrations/BaseIntegrationsPatch { - public static final field INSTANCE Lapp/revanced/patches/music/misc/integrations/IntegrationsPatch; -} - -public final class app/revanced/patches/music/premium/backgroundplay/BackgroundPlayPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/music/premium/backgroundplay/BackgroundPlayPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/myexpenses/misc/pro/UnlockProPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/myexpenses/misc/pro/UnlockProPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/myfitnesspal/ads/HideAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/myfitnesspal/ads/HideAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/myfitnesspal/ads/fingerprints/IsPremiumUseCaseImplFingerprint : app/revanced/patcher/fingerprint/MethodFingerprint { - public static final field INSTANCE Lapp/revanced/patches/myfitnesspal/ads/fingerprints/IsPremiumUseCaseImplFingerprint; -} - -public final class app/revanced/patches/myfitnesspal/ads/fingerprints/MainActivityNavigateToNativePremiumUpsellFingerprint : app/revanced/patcher/fingerprint/MethodFingerprint { - public static final field INSTANCE Lapp/revanced/patches/myfitnesspal/ads/fingerprints/MainActivityNavigateToNativePremiumUpsellFingerprint; -} - -public final class app/revanced/patches/netguard/broadcasts/removerestriction/RemoveBroadcastsRestrictionPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/netguard/broadcasts/removerestriction/RemoveBroadcastsRestrictionPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/nfctoolsse/misc/pro/UnlockProPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/nfctoolsse/misc/pro/UnlockProPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/nyx/misc/pro/UnlockProPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/nyx/misc/pro/UnlockProPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/openinghours/misc/fix/crash/FixCrashPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/openinghours/misc/fix/crash/FixCrashPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/photomath/detection/deviceid/SpoofDeviceIdPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/photomath/detection/deviceid/SpoofDeviceIdPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/photomath/detection/signature/SignatureDetectionPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/photomath/detection/signature/SignatureDetectionPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/photomath/misc/annoyances/HideUpdatePopupPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/photomath/misc/annoyances/HideUpdatePopupPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/photomath/misc/unlock/plus/UnlockPlusPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/photomath/misc/unlock/plus/UnlockPlusPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/piccomafr/misc/SpoofAndroidDeviceIdPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/piccomafr/misc/SpoofAndroidDeviceIdPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/piccomafr/tracking/DisableTrackingPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/piccomafr/tracking/DisableTrackingPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/pixiv/ads/HideAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/pixiv/ads/HideAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/rar/misc/annoyances/purchasereminder/HidePurchaseReminderPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/rar/misc/annoyances/purchasereminder/HidePurchaseReminderPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/rar/misc/annoyances/purchasereminder/fingerprints/ShowReminderFingerprint : app/revanced/patcher/fingerprint/MethodFingerprint { - public static final field INSTANCE Lapp/revanced/patches/rar/misc/annoyances/purchasereminder/fingerprints/ShowReminderFingerprint; -} - -public final class app/revanced/patches/reddit/ad/banner/HideBannerPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/ad/banner/HideBannerPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/reddit/ad/comments/HideCommentAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/ad/comments/HideCommentAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/reddit/ad/general/HideAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/ad/general/HideAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public abstract class app/revanced/patches/reddit/customclients/BaseFixSLinksPatch : app/revanced/patcher/patch/BytecodePatch { - public fun (Lapp/revanced/patcher/fingerprint/MethodFingerprint;Lapp/revanced/patcher/fingerprint/MethodFingerprint;Ljava/util/Set;Ljava/util/Set;)V - public synthetic fun (Lapp/revanced/patcher/fingerprint/MethodFingerprint;Lapp/revanced/patcher/fingerprint/MethodFingerprint;Ljava/util/Set;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - protected abstract fun getIntegrationsClassDescriptor ()Ljava/lang/String; - protected final fun getResolveSLinkMethod ()Ljava/lang/String; - protected final fun getSetAccessTokenMethod ()Ljava/lang/String; - protected abstract fun patchNavigationHandler (Lapp/revanced/patcher/fingerprint/MethodFingerprintResult;Lapp/revanced/patcher/data/BytecodeContext;)V - protected abstract fun patchSetAccessToken (Lapp/revanced/patcher/fingerprint/MethodFingerprintResult;Lapp/revanced/patcher/data/BytecodeContext;)V -} - -public abstract class app/revanced/patches/reddit/customclients/BaseSpoofClientPatch : app/revanced/patcher/patch/BytecodePatch { - public fun (Ljava/lang/String;Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;)V - public synthetic fun (Ljava/lang/String;Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public final fun getClientId ()Ljava/lang/String; - public fun patchClientId (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V - public fun patchMiscellaneous (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V - public fun patchUserAgent (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V - public final fun setClientId (Ljava/lang/String;)V -} - -public final class app/revanced/patches/reddit/customclients/Constants { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/Constants; - public static final field OAUTH_USER_AGENT Ljava/lang/String; -} - -public abstract class app/revanced/patches/reddit/customclients/ads/BaseDisableAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public fun (Ljava/util/Set;Ljava/util/Set;)V - public synthetic fun (Ljava/util/Set;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/reddit/customclients/baconreader/api/SpoofClientPatch : app/revanced/patches/reddit/customclients/BaseSpoofClientPatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/baconreader/api/SpoofClientPatch; - public fun patchClientId (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V -} - -public final class app/revanced/patches/reddit/customclients/boostforreddit/ads/DisableAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/boostforreddit/ads/DisableAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/reddit/customclients/boostforreddit/api/SpoofClientPatch : app/revanced/patches/reddit/customclients/BaseSpoofClientPatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/boostforreddit/api/SpoofClientPatch; - public fun patchClientId (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V - public fun patchUserAgent (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V -} - -public final class app/revanced/patches/reddit/customclients/boostforreddit/fix/downloads/FixAudioMissingInDownloadsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/boostforreddit/fix/downloads/FixAudioMissingInDownloadsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/reddit/customclients/boostforreddit/fix/slink/FixSLinksPatch : app/revanced/patches/reddit/customclients/BaseFixSLinksPatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/boostforreddit/fix/slink/FixSLinksPatch; -} - -public final class app/revanced/patches/reddit/customclients/boostforreddit/misc/integrations/IntegrationsPatch : app/revanced/patches/shared/misc/integrations/BaseIntegrationsPatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/boostforreddit/misc/integrations/IntegrationsPatch; -} - -public final class app/revanced/patches/reddit/customclients/infinityforreddit/api/SpoofClientPatch : app/revanced/patches/reddit/customclients/BaseSpoofClientPatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/infinityforreddit/api/SpoofClientPatch; - public fun patchClientId (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V -} - -public final class app/revanced/patches/reddit/customclients/infinityforreddit/subscription/UnlockSubscriptionPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/infinityforreddit/subscription/UnlockSubscriptionPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/reddit/customclients/joeyforreddit/ads/DisableAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/joeyforreddit/ads/DisableAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/reddit/customclients/joeyforreddit/api/SpoofClientPatch : app/revanced/patches/reddit/customclients/BaseSpoofClientPatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/joeyforreddit/api/SpoofClientPatch; - public fun patchClientId (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V - public fun patchUserAgent (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V -} - -public final class app/revanced/patches/reddit/customclients/joeyforreddit/api/fingerprints/AuthUtilityUserAgent : app/revanced/patcher/fingerprint/MethodFingerprint { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/joeyforreddit/api/fingerprints/AuthUtilityUserAgent; -} - -public final class app/revanced/patches/reddit/customclients/joeyforreddit/detection/piracy/DisablePiracyDetectionPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/joeyforreddit/detection/piracy/DisablePiracyDetectionPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/reddit/customclients/redditisfun/api/SpoofClientPatch : app/revanced/patches/reddit/customclients/BaseSpoofClientPatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/redditisfun/api/SpoofClientPatch; - public fun patchClientId (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V - public fun patchMiscellaneous (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V - public fun patchUserAgent (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V -} - -public final class app/revanced/patches/reddit/customclients/relayforreddit/api/SpoofClientPatch : app/revanced/patches/reddit/customclients/BaseSpoofClientPatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/relayforreddit/api/SpoofClientPatch; - public fun patchClientId (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V - public fun patchMiscellaneous (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V -} - -public final class app/revanced/patches/reddit/customclients/slide/api/SpoofClientPatch : app/revanced/patches/reddit/customclients/BaseSpoofClientPatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/slide/api/SpoofClientPatch; - public fun patchClientId (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V -} - -public final class app/revanced/patches/reddit/customclients/syncforlemmy/ads/DisableAdsPatch : app/revanced/patches/reddit/customclients/ads/BaseDisableAdsPatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/syncforlemmy/ads/DisableAdsPatch; -} - -public final class app/revanced/patches/reddit/customclients/syncforreddit/ads/DisableAdsPatch : app/revanced/patches/reddit/customclients/ads/BaseDisableAdsPatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/syncforreddit/ads/DisableAdsPatch; -} - -public final class app/revanced/patches/reddit/customclients/syncforreddit/annoyances/startup/DisableSyncForLemmyBottomSheetPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/syncforreddit/annoyances/startup/DisableSyncForLemmyBottomSheetPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/reddit/customclients/syncforreddit/api/SpoofClientPatch : app/revanced/patches/reddit/customclients/BaseSpoofClientPatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/syncforreddit/api/SpoofClientPatch; - public fun patchClientId (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V - public fun patchMiscellaneous (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V - public fun patchUserAgent (Ljava/util/Set;Lapp/revanced/patcher/data/BytecodeContext;)V -} - -public final class app/revanced/patches/reddit/customclients/syncforreddit/detection/piracy/DisablePiracyDetectionPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/syncforreddit/detection/piracy/DisablePiracyDetectionPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/reddit/customclients/syncforreddit/fix/slink/FixSLinksPatch : app/revanced/patches/reddit/customclients/BaseFixSLinksPatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/syncforreddit/fix/slink/FixSLinksPatch; -} - -public final class app/revanced/patches/reddit/customclients/syncforreddit/fix/user/UseUserEndpointPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/syncforreddit/fix/user/UseUserEndpointPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/reddit/customclients/syncforreddit/fix/video/FixVideoDownloadsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/syncforreddit/fix/video/FixVideoDownloadsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/reddit/customclients/syncforreddit/misc/integrations/IntegrationsPatch : app/revanced/patches/shared/misc/integrations/BaseIntegrationsPatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/syncforreddit/misc/integrations/IntegrationsPatch; -} - -public final class app/revanced/patches/reddit/layout/disablescreenshotpopup/DisableScreenshotPopupPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/layout/disablescreenshotpopup/DisableScreenshotPopupPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/reddit/layout/premiumicon/UnlockPremiumIconPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/layout/premiumicon/UnlockPremiumIconPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/reddit/misc/tracking/url/SanitizeUrlQueryPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/reddit/misc/tracking/url/SanitizeUrlQueryPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/scbeasy/detection/debugging/RemoveDebuggingDetectionPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/scbeasy/detection/debugging/RemoveDebuggingDetectionPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/serviceportalbund/detection/root/RootDetectionPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/serviceportalbund/detection/root/RootDetectionPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public abstract class app/revanced/patches/shared/misc/checks/BaseCheckEnvironmentPatch : app/revanced/patcher/patch/BytecodePatch { - public fun (Lapp/revanced/patcher/fingerprint/MethodFingerprint;Ljava/util/Set;Lapp/revanced/patches/shared/misc/integrations/BaseIntegrationsPatch;)V - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/shared/misc/fix/verticalscroll/VerticalScrollPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/shared/misc/fix/verticalscroll/VerticalScrollPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public abstract class app/revanced/patches/shared/misc/gms/BaseGmsCoreSupportPatch : app/revanced/patcher/patch/BytecodePatch { - public fun (Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patcher/fingerprint/MethodFingerprint;Ljava/util/Set;Lapp/revanced/patcher/fingerprint/MethodFingerprint;Lkotlin/reflect/KClass;Lapp/revanced/patches/shared/misc/gms/BaseGmsCoreSupportResourcePatch;Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patcher/fingerprint/MethodFingerprint;Ljava/util/Set;Lapp/revanced/patcher/fingerprint/MethodFingerprint;Lkotlin/reflect/KClass;Lapp/revanced/patches/shared/misc/gms/BaseGmsCoreSupportResourcePatch;Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public abstract class app/revanced/patches/shared/misc/gms/BaseGmsCoreSupportResourcePatch : app/revanced/patcher/patch/ResourcePatch { - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V - protected final fun getGmsCoreVendor ()Ljava/lang/String; - protected final fun getGmsCoreVendorGroupId ()Ljava/lang/String; -} - -public abstract class app/revanced/patches/shared/misc/hex/BaseHexPatch : app/revanced/patcher/patch/RawResourcePatch { - public fun ()V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V - public abstract fun getReplacements ()Ljava/util/List; -} - -public final class app/revanced/patches/shared/misc/hex/BaseHexPatch$Replacement { - public static final field Companion Lapp/revanced/patches/shared/misc/hex/BaseHexPatch$Replacement$Companion; - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V - public final fun replacePattern ([B)V -} - -public final class app/revanced/patches/shared/misc/hex/BaseHexPatch$Replacement$Companion { -} - -public abstract class app/revanced/patches/shared/misc/integrations/BaseIntegrationsPatch : app/revanced/patcher/patch/BytecodePatch { - public fun (Ljava/lang/String;Ljava/util/Set;)V - public fun (Ljava/util/Set;)V - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public abstract class app/revanced/patches/shared/misc/integrations/BaseIntegrationsPatch$IntegrationsFingerprint : app/revanced/patcher/fingerprint/MethodFingerprint { - public fun ()V - public fun (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Iterable;Ljava/lang/Iterable;Ljava/lang/Iterable;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Iterable;Ljava/lang/Iterable;Ljava/lang/Iterable;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Iterable;Ljava/lang/Iterable;Ljava/lang/Iterable;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Iterable;Ljava/lang/Iterable;Ljava/lang/Iterable;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun invoke (Ljava/lang/String;)V -} - -public abstract interface class app/revanced/patches/shared/misc/integrations/BaseIntegrationsPatch$IntegrationsFingerprint$IHookInsertIndexResolver : kotlin/jvm/functions/Function1 { - public abstract fun invoke (Lcom/android/tools/smali/dexlib2/iface/Method;)Ljava/lang/Integer; -} - -public final class app/revanced/patches/shared/misc/integrations/BaseIntegrationsPatch$IntegrationsFingerprint$IHookInsertIndexResolver$DefaultImpls { - public static fun invoke (Lapp/revanced/patches/shared/misc/integrations/BaseIntegrationsPatch$IntegrationsFingerprint$IHookInsertIndexResolver;Lcom/android/tools/smali/dexlib2/iface/Method;)Ljava/lang/Integer; -} - -public abstract interface class app/revanced/patches/shared/misc/integrations/BaseIntegrationsPatch$IntegrationsFingerprint$IRegisterResolver : kotlin/jvm/functions/Function1 { - public abstract fun invoke (Lcom/android/tools/smali/dexlib2/iface/Method;)Ljava/lang/Integer; -} - -public final class app/revanced/patches/shared/misc/integrations/BaseIntegrationsPatch$IntegrationsFingerprint$IRegisterResolver$DefaultImpls { - public static fun invoke (Lapp/revanced/patches/shared/misc/integrations/BaseIntegrationsPatch$IntegrationsFingerprint$IRegisterResolver;Lcom/android/tools/smali/dexlib2/iface/Method;)Ljava/lang/Integer; -} - -public final class app/revanced/patches/shared/misc/mapping/ResourceMappingPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/shared/misc/mapping/ResourceMappingPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V - public final fun get (Ljava/lang/String;Ljava/lang/String;)J -} - -public final class app/revanced/patches/shared/misc/mapping/ResourceMappingPatch$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/misc/mapping/ResourceMappingPatch$ResourceElement; - public static synthetic fun copy$default (Lapp/revanced/patches/shared/misc/mapping/ResourceMappingPatch$ResourceElement;Ljava/lang/String;Ljava/lang/String;JILjava/lang/Object;)Lapp/revanced/patches/shared/misc/mapping/ResourceMappingPatch$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 abstract class app/revanced/patches/shared/misc/settings/BaseSettingsResourcePatch : app/revanced/patcher/patch/ResourcePatch, java/io/Closeable, java/util/Set, kotlin/jvm/internal/markers/KMutableSet { - public fun ()V - public fun (Lkotlin/Pair;Ljava/util/Set;)V - public synthetic fun (Lkotlin/Pair;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun add (Lapp/revanced/patches/shared/misc/settings/preference/BasePreference;)Z - public synthetic fun add (Ljava/lang/Object;)Z - public fun addAll (Ljava/util/Collection;)Z - public fun clear ()V - public fun close ()V - public fun contains (Lapp/revanced/patches/shared/misc/settings/preference/BasePreference;)Z - public final fun contains (Ljava/lang/Object;)Z - public fun containsAll (Ljava/util/Collection;)Z - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V - public fun getSize ()I - public fun isEmpty ()Z - public fun iterator ()Ljava/util/Iterator; - public fun remove (Lapp/revanced/patches/shared/misc/settings/preference/BasePreference;)Z - public final fun remove (Ljava/lang/Object;)Z - public fun removeAll (Ljava/util/Collection;)Z - public fun retainAll (Ljava/util/Collection;)Z - public final fun size ()I - public fun toArray ()[Ljava/lang/Object; - public fun toArray ([Ljava/lang/Object;)[Ljava/lang/Object; -} - -public abstract class app/revanced/patches/shared/misc/settings/preference/BasePreference { - public static final field Companion Lapp/revanced/patches/shared/misc/settings/preference/BasePreference$Companion; - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun equals (Ljava/lang/Object;)Z - public final fun getKey ()Ljava/lang/String; - public final fun getSummaryKey ()Ljava/lang/String; - public final fun getTag ()Ljava/lang/String; - public final fun getTitleKey ()Ljava/lang/String; - public fun hashCode ()I - public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; -} - -public final class app/revanced/patches/shared/misc/settings/preference/BasePreference$Companion { - public final fun addSummary (Lorg/w3c/dom/Element;Ljava/lang/String;Lapp/revanced/patches/shared/misc/settings/preference/SummaryType;)V - public static synthetic fun addSummary$default (Lapp/revanced/patches/shared/misc/settings/preference/BasePreference$Companion;Lorg/w3c/dom/Element;Ljava/lang/String;Lapp/revanced/patches/shared/misc/settings/preference/SummaryType;ILjava/lang/Object;)V -} - -public abstract class app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen : java/io/Closeable { - public fun ()V - public fun (Ljava/util/Set;)V - public synthetic fun (Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun close ()V - public abstract fun commit (Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreen;)V -} - -public abstract class app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$BasePreferenceCollection { - public fun ()V - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getKey ()Ljava/lang/String; - public final fun getPreferences ()Ljava/util/Set; - public final fun getTitleKey ()Ljava/lang/String; - public abstract fun transform ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreference; -} - -public class app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen : app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$BasePreferenceCollection { - public fun (Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;Ljava/util/Set;Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreen$Sorting;)V - public synthetic fun (Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;Ljava/util/Set;Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreen$Sorting;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun addPreferences ([Lapp/revanced/patches/shared/misc/settings/preference/BasePreference;)V - public final fun getCategories ()Ljava/util/Set; - public synthetic fun transform ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreference; - public fun transform ()Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreen; -} - -public class app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen$Category : app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$BasePreferenceCollection { - public fun (Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;)V - public synthetic fun (Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun addPreferences ([Lapp/revanced/patches/shared/misc/settings/preference/BasePreference;)V - public synthetic fun transform ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreference; - public fun transform ()Lapp/revanced/patches/shared/misc/settings/preference/PreferenceCategory; -} - -public final class app/revanced/patches/shared/misc/settings/preference/InputType : java/lang/Enum { - public static final field NUMBER Lapp/revanced/patches/shared/misc/settings/preference/InputType; - public static final field TEXT Lapp/revanced/patches/shared/misc/settings/preference/InputType; - public static final field TEXT_CAP_CHARACTERS Lapp/revanced/patches/shared/misc/settings/preference/InputType; - public static final field TEXT_MULTI_LINE Lapp/revanced/patches/shared/misc/settings/preference/InputType; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public final fun getType ()Ljava/lang/String; - public static fun valueOf (Ljava/lang/String;)Lapp/revanced/patches/shared/misc/settings/preference/InputType; - public static fun values ()[Lapp/revanced/patches/shared/misc/settings/preference/InputType; -} - -public final class app/revanced/patches/shared/misc/settings/preference/IntentPreference : app/revanced/patches/shared/misc/settings/preference/BasePreference { - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patches/shared/misc/settings/preference/IntentPreference$Intent;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patches/shared/misc/settings/preference/IntentPreference$Intent;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun equals (Ljava/lang/Object;)Z - public final fun getIntent ()Lapp/revanced/patches/shared/misc/settings/preference/IntentPreference$Intent; - public fun hashCode ()I - public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; -} - -public final class app/revanced/patches/shared/misc/settings/preference/IntentPreference$Intent { - public fun (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V - public final fun copy (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)Lapp/revanced/patches/shared/misc/settings/preference/IntentPreference$Intent; - public static synthetic fun copy$default (Lapp/revanced/patches/shared/misc/settings/preference/IntentPreference$Intent;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lapp/revanced/patches/shared/misc/settings/preference/IntentPreference$Intent; - public fun equals (Ljava/lang/Object;)Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class app/revanced/patches/shared/misc/settings/preference/ListPreference : app/revanced/patches/shared/misc/settings/preference/BasePreference { - public fun ()V - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/util/resource/ArrayResource;Lapp/revanced/util/resource/ArrayResource;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/util/resource/ArrayResource;Lapp/revanced/util/resource/ArrayResource;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getEntries ()Lapp/revanced/util/resource/ArrayResource; - public final fun getEntriesKey ()Ljava/lang/String; - public final fun getEntryValues ()Lapp/revanced/util/resource/ArrayResource; - public final fun getEntryValuesKey ()Ljava/lang/String; - public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; -} - -public final class app/revanced/patches/shared/misc/settings/preference/NonInteractivePreference : app/revanced/patches/shared/misc/settings/preference/BasePreference { - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getSelectable ()Z - public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; -} - -public class app/revanced/patches/shared/misc/settings/preference/PreferenceCategory : app/revanced/patches/shared/misc/settings/preference/BasePreference { - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getPreferences ()Ljava/util/Set; - public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; -} - -public class app/revanced/patches/shared/misc/settings/preference/PreferenceScreen : app/revanced/patches/shared/misc/settings/preference/BasePreference { - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreen$Sorting;Ljava/lang/String;Ljava/util/Set;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreen$Sorting;Ljava/lang/String;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getPreferences ()Ljava/util/Set; - public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; -} - -public final class app/revanced/patches/shared/misc/settings/preference/PreferenceScreen$Sorting : java/lang/Enum { - public static final field BY_KEY Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreen$Sorting; - public static final field BY_TITLE Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreen$Sorting; - public static final field UNSORTED Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreen$Sorting; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public final fun getKeySuffix ()Ljava/lang/String; - public static fun valueOf (Ljava/lang/String;)Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreen$Sorting; - public static fun values ()[Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreen$Sorting; -} - -public final class app/revanced/patches/shared/misc/settings/preference/SummaryType : java/lang/Enum { - public static final field DEFAULT Lapp/revanced/patches/shared/misc/settings/preference/SummaryType; - public static final field OFF Lapp/revanced/patches/shared/misc/settings/preference/SummaryType; - public static final field ON Lapp/revanced/patches/shared/misc/settings/preference/SummaryType; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public final fun getType ()Ljava/lang/String; - public static fun valueOf (Ljava/lang/String;)Lapp/revanced/patches/shared/misc/settings/preference/SummaryType; - public static fun values ()[Lapp/revanced/patches/shared/misc/settings/preference/SummaryType; -} - -public final class app/revanced/patches/shared/misc/settings/preference/SwitchPreference : app/revanced/patches/shared/misc/settings/preference/BasePreference { - public fun ()V - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getSummaryOffKey ()Ljava/lang/String; - public final fun getSummaryOnKey ()Ljava/lang/String; - public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; -} - -public final class app/revanced/patches/shared/misc/settings/preference/TextPreference : app/revanced/patches/shared/misc/settings/preference/BasePreference { - public fun ()V - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patches/shared/misc/settings/preference/InputType;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patches/shared/misc/settings/preference/InputType;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getInputType ()Lapp/revanced/patches/shared/misc/settings/preference/InputType; - public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; -} - -public final class app/revanced/patches/solidexplorer2/functionality/filesize/RemoveFileSizeLimitPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/solidexplorer2/functionality/filesize/RemoveFileSizeLimitPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/songpal/badge/BadgeTabPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field ACTIVITY_TAB_DESCRIPTOR Ljava/lang/String; - public static final field INSTANCE Lapp/revanced/patches/songpal/badge/BadgeTabPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/songpal/badge/RemoveNotificationBadgePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/songpal/badge/RemoveNotificationBadgePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/soundcloud/ad/HideAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/soundcloud/ad/HideAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/soundcloud/analytics/DisableTelemetryPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/soundcloud/analytics/DisableTelemetryPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/soundcloud/offlinesync/EnableOfflineSyncPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/soundcloud/offlinesync/EnableOfflineSyncPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/spotify/layout/theme/CustomThemePatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/spotify/layout/theme/CustomThemePatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/spotify/lite/ondemand/OnDemandPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/spotify/lite/ondemand/OnDemandPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/spotify/navbar/PremiumNavbarTabPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/spotify/navbar/PremiumNavbarTabPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/spotify/navbar/PremiumNavbarTabResourcePatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/spotify/navbar/PremiumNavbarTabResourcePatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/stocard/layout/HideOffersTabPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/stocard/layout/HideOffersTabPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/stocard/layout/HideStoryBubblesPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/stocard/layout/HideStoryBubblesPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/strava/subscription/UnlockSubscriptionPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/strava/subscription/UnlockSubscriptionPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/strava/upselling/DisableSubscriptionSuggestionsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/strava/upselling/DisableSubscriptionSuggestionsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/swissid/integritycheck/RemoveGooglePlayIntegrityCheck : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/swissid/integritycheck/RemoveGooglePlayIntegrityCheck; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/ticktick/misc/themeunlock/UnlockProPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/ticktick/misc/themeunlock/UnlockProPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tiktok/feedfilter/FeedFilterPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tiktok/feedfilter/FeedFilterPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tiktok/interaction/cleardisplay/RememberClearDisplayPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tiktok/interaction/cleardisplay/RememberClearDisplayPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tiktok/interaction/downloads/DownloadsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tiktok/interaction/downloads/DownloadsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tiktok/interaction/seekbar/ShowSeekbarPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tiktok/interaction/seekbar/ShowSeekbarPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tiktok/interaction/speed/PlaybackSpeedPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tiktok/interaction/speed/PlaybackSpeedPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tiktok/misc/integrations/IntegrationsPatch : app/revanced/patches/shared/misc/integrations/BaseIntegrationsPatch { - public static final field INSTANCE Lapp/revanced/patches/tiktok/misc/integrations/IntegrationsPatch; -} - -public final class app/revanced/patches/tiktok/misc/login/disablerequirement/DisableLoginRequirementPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tiktok/misc/login/disablerequirement/DisableLoginRequirementPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tiktok/misc/login/fixgoogle/FixGoogleLoginPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tiktok/misc/login/fixgoogle/FixGoogleLoginPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tiktok/misc/settings/SettingsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tiktok/misc/settings/SettingsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tiktok/misc/spoof/sim/SpoofSimPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tiktok/misc/spoof/sim/SpoofSimPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/trakt/UnlockProPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/trakt/UnlockProPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tudortmund/lockscreen/patch/ShowOnLockscreenPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tudortmund/lockscreen/patch/ShowOnLockscreenPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tumblr/ads/DisableDashboardAds : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tumblr/ads/DisableDashboardAds; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tumblr/annoyances/adfree/DisableAdFreeBannerPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tumblr/annoyances/adfree/DisableAdFreeBannerPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tumblr/annoyances/inappupdate/DisableInAppUpdatePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tumblr/annoyances/inappupdate/DisableInAppUpdatePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tumblr/annoyances/notifications/DisableBlogNotificationReminderPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tumblr/annoyances/notifications/DisableBlogNotificationReminderPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tumblr/annoyances/popups/DisableGiftMessagePopupPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tumblr/annoyances/popups/DisableGiftMessagePopupPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tumblr/featureflags/OverrideFeatureFlagsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tumblr/featureflags/OverrideFeatureFlagsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tumblr/fixes/FixOldVersionsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tumblr/fixes/FixOldVersionsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tumblr/live/DisableTumblrLivePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tumblr/live/DisableTumblrLivePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/tumblr/timelinefilter/TimelineFilterPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/tumblr/timelinefilter/TimelineFilterPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/twitch/ad/audio/AudioAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/twitch/ad/audio/AudioAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/twitch/ad/embedded/EmbeddedAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/twitch/ad/embedded/EmbeddedAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public abstract class app/revanced/patches/twitch/ad/shared/util/BaseAdPatch : app/revanced/patcher/patch/BytecodePatch { - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - protected final fun blockMethods (Lapp/revanced/patcher/data/BytecodeContext;Ljava/lang/String;[Ljava/lang/String;Lapp/revanced/patches/twitch/ad/shared/util/BaseAdPatch$ReturnMethod;)Z - public static synthetic fun blockMethods$default (Lapp/revanced/patches/twitch/ad/shared/util/BaseAdPatch;Lapp/revanced/patcher/data/BytecodeContext;Ljava/lang/String;[Ljava/lang/String;Lapp/revanced/patches/twitch/ad/shared/util/BaseAdPatch$ReturnMethod;ILjava/lang/Object;)Z - protected final fun createConditionInstructions (Ljava/lang/String;)Ljava/lang/String; - public static synthetic fun createConditionInstructions$default (Lapp/revanced/patches/twitch/ad/shared/util/BaseAdPatch;Ljava/lang/String;ILjava/lang/Object;)Ljava/lang/String; - public final fun getConditionCall ()Ljava/lang/String; - public final fun getSkipLabelName ()Ljava/lang/String; -} - -protected final class app/revanced/patches/twitch/ad/shared/util/BaseAdPatch$ReturnMethod { - public fun ()V - public fun (CLjava/lang/String;)V - public synthetic fun (CLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()C - public final fun component2 ()Ljava/lang/String; - public final fun copy (CLjava/lang/String;)Lapp/revanced/patches/twitch/ad/shared/util/BaseAdPatch$ReturnMethod; - public static synthetic fun copy$default (Lapp/revanced/patches/twitch/ad/shared/util/BaseAdPatch$ReturnMethod;CLjava/lang/String;ILjava/lang/Object;)Lapp/revanced/patches/twitch/ad/shared/util/BaseAdPatch$ReturnMethod; - public fun equals (Ljava/lang/Object;)Z - public final fun getReturnType ()C - public final fun getValue ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class app/revanced/patches/twitch/ad/video/VideoAdsPatch : app/revanced/patches/twitch/ad/shared/util/BaseAdPatch { - public static final field INSTANCE Lapp/revanced/patches/twitch/ad/video/VideoAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/twitch/chat/antidelete/ShowDeletedMessagesPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/twitch/chat/antidelete/ShowDeletedMessagesPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/twitch/chat/autoclaim/AutoClaimChannelPointsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/twitch/chat/autoclaim/AutoClaimChannelPointsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/twitch/debug/DebugModePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/twitch/debug/DebugModePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/twitch/misc/integrations/IntegrationsPatch : app/revanced/patches/shared/misc/integrations/BaseIntegrationsPatch { - public static final field INSTANCE Lapp/revanced/patches/twitch/misc/integrations/IntegrationsPatch; -} - -public final class app/revanced/patches/twitch/misc/settings/SettingsPatch : app/revanced/patcher/patch/BytecodePatch, java/io/Closeable { - public static final field INSTANCE Lapp/revanced/patches/twitch/misc/settings/SettingsPatch; - public fun close ()V - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/twitch/misc/settings/SettingsResourcePatch : app/revanced/patches/shared/misc/settings/BaseSettingsResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/twitch/misc/settings/SettingsResourcePatch; -} - -public final class app/revanced/patches/twitter/interaction/downloads/UnlockDownloadsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/twitter/interaction/downloads/UnlockDownloadsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/twitter/layout/viewcount/HideViewCountPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/twitter/layout/viewcount/HideViewCountPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/twitter/misc/dynamiccolor/DynamicColorPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/twitter/misc/dynamiccolor/DynamicColorPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/twitter/misc/hook/json/JsonHookPatch : app/revanced/patcher/patch/BytecodePatch, java/io/Closeable { - public static final field INSTANCE Lapp/revanced/patches/twitter/misc/hook/json/JsonHookPatch; - public fun close ()V - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public abstract class app/revanced/patches/twitter/misc/hook/patch/BaseHookPatch : app/revanced/patcher/patch/BytecodePatch { - public fun (Ljava/lang/String;)V - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/twitter/misc/hook/patch/ads/HideAdsHookPatch : app/revanced/patches/twitter/misc/hook/patch/BaseHookPatch { - public static final field INSTANCE Lapp/revanced/patches/twitter/misc/hook/patch/ads/HideAdsHookPatch; -} - -public final class app/revanced/patches/twitter/misc/hook/patch/recommendation/HideRecommendedUsersPatch : app/revanced/patches/twitter/misc/hook/patch/BaseHookPatch { - public static final field INSTANCE Lapp/revanced/patches/twitter/misc/hook/patch/recommendation/HideRecommendedUsersPatch; -} - -public final class app/revanced/patches/twitter/misc/links/ChangeLinkSharingDomainPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/twitter/misc/links/ChangeLinkSharingDomainPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/twitter/misc/links/OpenLinksWithAppChooserPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/twitter/misc/links/OpenLinksWithAppChooserPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/twitter/misc/links/SanitizeSharingLinksPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/twitter/misc/links/SanitizeSharingLinksPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/vsco/misc/pro/UnlockProPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/vsco/misc/pro/UnlockProPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/warnwetter/misc/firebasegetcert/FirebaseGetCertPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/warnwetter/misc/firebasegetcert/FirebaseGetCertPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/warnwetter/misc/promocode/PromoCodeUnlockPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/warnwetter/misc/promocode/PromoCodeUnlockPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/willhaben/ads/HideAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/willhaben/ads/HideAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/windyapp/misc/unlockpro/UnlockProPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/windyapp/misc/unlockpro/UnlockProPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/ad/general/HideAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/ad/general/HideAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/ad/general/HideAdsResourcePatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/ad/general/HideAdsResourcePatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/youtube/ad/getpremium/HideGetPremiumPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/ad/getpremium/HideGetPremiumPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/ad/video/VideoAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/ad/video/VideoAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/interaction/copyvideourl/CopyVideoUrlBytecodePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/interaction/copyvideourl/CopyVideoUrlBytecodePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/interaction/dialog/RemoveViewerDiscretionDialogPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/interaction/dialog/RemoveViewerDiscretionDialogPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/interaction/downloads/DownloadsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/interaction/downloads/DownloadsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/interaction/seekbar/DisablePreciseSeekingGesturePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/interaction/seekbar/DisablePreciseSeekingGesturePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/interaction/seekbar/EnableSeekbarTappingPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/interaction/seekbar/EnableSeekbarTappingPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/interaction/seekbar/EnableSlideToSeekPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/interaction/seekbar/EnableSlideToSeekPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/interaction/swipecontrols/SwipeControlsBytecodePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/interaction/swipecontrols/SwipeControlsBytecodePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/autocaptions/AutoCaptionsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/autocaptions/AutoCaptionsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/branding/CustomBrandingPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/branding/CustomBrandingPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/youtube/layout/branding/header/ChangeHeaderPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/branding/header/ChangeHeaderPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/youtube/layout/branding/header/PremiumHeadingPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/branding/header/PremiumHeadingPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/youtube/layout/buttons/action/HideButtonsPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/buttons/action/HideButtonsPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/youtube/layout/buttons/autoplay/HideAutoplayButtonPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/buttons/autoplay/HideAutoplayButtonPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/buttons/captions/HideCaptionsButtonPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/buttons/captions/HideCaptionsButtonPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/buttons/cast/HideCastButtonPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/buttons/cast/HideCastButtonPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/buttons/navigation/NavigationButtonsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/buttons/navigation/NavigationButtonsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/buttons/overlay/HidePlayerOverlayButtonsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/buttons/overlay/HidePlayerOverlayButtonsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/buttons/player/hide/HidePlayerButtonsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/buttons/player/hide/HidePlayerButtonsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/hide/albumcards/AlbumCardsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/albumcards/AlbumCardsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/hide/breakingnews/BreakingNewsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/breakingnews/BreakingNewsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/hide/comments/CommentsPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/comments/CommentsPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/youtube/layout/hide/crowdfundingbox/CrowdfundingBoxPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/crowdfundingbox/CrowdfundingBoxPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/hide/endscreencards/HideEndscreenCardsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/endscreencards/HideEndscreenCardsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/hide/filterbar/HideFilterBarPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/filterbar/HideFilterBarPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/hide/floatingmicrophone/HideFloatingMicrophoneButtonPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/floatingmicrophone/HideFloatingMicrophoneButtonPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/hide/fullscreenambientmode/DisableFullscreenAmbientModePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/fullscreenambientmode/DisableFullscreenAmbientModePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/hide/general/HideLayoutComponentsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/general/HideLayoutComponentsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/hide/infocards/HideInfoCardsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/infocards/HideInfoCardsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/hide/infocards/HideInfocardsResourcePatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/infocards/HideInfocardsResourcePatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/youtube/layout/hide/loadmorebutton/HideLoadMoreButtonPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/loadmorebutton/HideLoadMoreButtonPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/hide/player/flyoutmenupanel/HidePlayerFlyoutMenuPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/player/flyoutmenupanel/HidePlayerFlyoutMenuPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/youtube/layout/hide/rollingnumber/DisableRollingNumberAnimationPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/rollingnumber/DisableRollingNumberAnimationPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/hide/seekbar/HideSeekbarPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/seekbar/HideSeekbarPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/hide/shorts/HideShortsComponentsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/shorts/HideShortsComponentsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/hide/shorts/HideShortsComponentsResourcePatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/shorts/HideShortsComponentsResourcePatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/youtube/layout/hide/suggestedvideoendscreen/DisableSuggestedVideoEndScreenPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/suggestedvideoendscreen/DisableSuggestedVideoEndScreenPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/hide/time/HideTimestampPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/hide/time/HideTimestampPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/miniplayer/MiniplayerPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/miniplayer/MiniplayerPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/panels/popup/PlayerPopupPanelsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/panels/popup/PlayerPopupPanelsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/player/background/PlayerControlsBackgroundPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/player/background/PlayerControlsBackgroundPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/youtube/layout/player/overlay/CustomPlayerOverlayOpacityPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/player/overlay/CustomPlayerOverlayOpacityPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/returnyoutubedislike/ReturnYouTubeDislikePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/returnyoutubedislike/ReturnYouTubeDislikePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/searchbar/WideSearchbarPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/searchbar/WideSearchbarPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/seekbar/RestoreOldSeekbarThumbnailsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/seekbar/RestoreOldSeekbarThumbnailsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/shortsautoplay/ShortsAutoplayPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/shortsautoplay/ShortsAutoplayPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/sponsorblock/SponsorBlockBytecodePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/sponsorblock/SponsorBlockBytecodePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/spoofappversion/SpoofAppVersionPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/spoofappversion/SpoofAppVersionPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/startpage/ChangeStartPagePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/startpage/ChangeStartPagePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/startupshortsreset/DisableResumingShortsOnStartupPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/startupshortsreset/DisableResumingShortsOnStartupPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/startupshortsreset/fingerprints/UserWasInShortsFingerprint : app/revanced/patcher/fingerprint/MethodFingerprint { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/startupshortsreset/fingerprints/UserWasInShortsFingerprint; -} - -public final class app/revanced/patches/youtube/layout/tablet/EnableTabletLayoutPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/tablet/EnableTabletLayoutPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/tabletminiplayer/TabletMiniPlayerPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/tabletminiplayer/TabletMiniPlayerPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/theme/ThemeBytecodePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/theme/ThemeBytecodePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/thumbnails/AlternativeThumbnailsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/thumbnails/AlternativeThumbnailsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/layout/thumbnails/BypassImageRegionRestrictions : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/layout/thumbnails/BypassImageRegionRestrictions; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/misc/announcements/AnnouncementsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/announcements/AnnouncementsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/misc/autorepeat/AutoRepeatPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/autorepeat/AutoRepeatPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/misc/backgroundplayback/BackgroundPlaybackPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/backgroundplayback/BackgroundPlaybackPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/misc/check/CheckEnvironmentPatch : app/revanced/patches/shared/misc/checks/BaseCheckEnvironmentPatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/check/CheckEnvironmentPatch; -} - -public final class app/revanced/patches/youtube/misc/debugging/DebuggingPatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/debugging/DebuggingPatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/youtube/misc/dimensions/spoof/SpoofDeviceDimensionsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/dimensions/spoof/SpoofDeviceDimensionsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/misc/fix/playback/SpoofClientPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/fix/playback/SpoofClientPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/misc/fix/playback/SpoofSignaturePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/fix/playback/SpoofSignaturePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/misc/fix/playback/SpoofSignatureResourcePatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/fix/playback/SpoofSignatureResourcePatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/youtube/misc/fix/playback/SpoofVideoStreamsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/fix/playback/SpoofVideoStreamsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/misc/fix/playback/UserAgentClientSpoofPatch : app/revanced/patches/all/misc/transformation/BaseTransformInstructionsPatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/fix/playback/UserAgentClientSpoofPatch; - public synthetic fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Ljava/lang/Object; - public fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Lkotlin/Triple; - public synthetic fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Ljava/lang/Object;)V - public fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Lkotlin/Triple;)V -} - -public final class app/revanced/patches/youtube/misc/gms/GmsCoreSupportPatch : app/revanced/patches/shared/misc/gms/BaseGmsCoreSupportPatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/gms/GmsCoreSupportPatch; -} - -public final class app/revanced/patches/youtube/misc/gms/GmsCoreSupportResourcePatch : app/revanced/patches/shared/misc/gms/BaseGmsCoreSupportResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/gms/GmsCoreSupportResourcePatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/youtube/misc/imageurlhook/CronetImageUrlHook : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/imageurlhook/CronetImageUrlHook; - public final fun addImageUrlErrorCallbackHook (Ljava/lang/String;)V - public final fun addImageUrlHook (Ljava/lang/String;Z)V - public static synthetic fun addImageUrlHook$default (Lapp/revanced/patches/youtube/misc/imageurlhook/CronetImageUrlHook;Ljava/lang/String;ZILjava/lang/Object;)V - public final fun addImageUrlSuccessCallbackHook (Ljava/lang/String;)V - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/misc/integrations/IntegrationsPatch : app/revanced/patches/shared/misc/integrations/BaseIntegrationsPatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/integrations/IntegrationsPatch; -} - -public final class app/revanced/patches/youtube/misc/links/BypassURLRedirectsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/links/BypassURLRedirectsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/misc/links/OpenLinksExternallyPatch : app/revanced/patches/all/misc/transformation/BaseTransformInstructionsPatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/links/OpenLinksExternallyPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public synthetic fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Ljava/lang/Object; - public fun filterMap (Lcom/android/tools/smali/dexlib2/iface/ClassDef;Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/iface/instruction/Instruction;I)Lkotlin/Pair; - public synthetic fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Ljava/lang/Object;)V - public fun transform (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;Lkotlin/Pair;)V -} - -public final class app/revanced/patches/youtube/misc/litho/filter/LithoFilterPatch : app/revanced/patcher/patch/BytecodePatch, java/io/Closeable { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/litho/filter/LithoFilterPatch; - public fun close ()V - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/misc/minimizedplayback/MinimizedPlaybackPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/minimizedplayback/MinimizedPlaybackPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/misc/navigation/NavigationBarHookPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/navigation/NavigationBarHookPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public final fun getHookNavigationButtonCreated ()Lkotlin/jvm/functions/Function1; -} - -public final class app/revanced/patches/youtube/misc/playercontrols/BottomControlsResourcePatch : app/revanced/patcher/patch/ResourcePatch, java/io/Closeable { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/playercontrols/BottomControlsResourcePatch; - public final fun addControls (Ljava/lang/String;)V - public fun close ()V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/youtube/misc/playercontrols/PlayerControlsBytecodePatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/playercontrols/PlayerControlsBytecodePatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public final fun initializeBottomControl (Ljava/lang/String;)V - public final fun initializeControl (Ljava/lang/String;)V - public final fun injectVisibilityCheckCall (Ljava/lang/String;)V -} - -public final class app/revanced/patches/youtube/misc/playercontrols/PlayerControlsResourcePatch : app/revanced/patcher/patch/ResourcePatch, java/io/Closeable { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/playercontrols/PlayerControlsResourcePatch; - public final fun addBottomControls (Ljava/lang/String;)V - public fun close ()V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/youtube/misc/playeroverlay/PlayerOverlaysHookPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/playeroverlay/PlayerOverlaysHookPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/misc/playertype/PlayerTypeHookPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/playertype/PlayerTypeHookPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/misc/privacy/RemoveTrackingQueryParameterPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/privacy/RemoveTrackingQueryParameterPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/misc/settings/SettingsPatch : app/revanced/patcher/patch/BytecodePatch, java/io/Closeable { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/settings/SettingsPatch; - public fun close ()V - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public final fun newIntent (Ljava/lang/String;)Lapp/revanced/patches/shared/misc/settings/preference/IntentPreference$Intent; -} - -public final class app/revanced/patches/youtube/misc/settings/SettingsPatch$PreferenceScreen : app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/settings/SettingsPatch$PreferenceScreen; - public fun commit (Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreen;)V - public final fun getADS ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; - public final fun getALTERNATIVE_THUMBNAILS ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; - public final fun getFEED ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; - public final fun getGENERAL_LAYOUT ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; - public final fun getMISC ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; - public final fun getPLAYER ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; - public final fun getSEEKBAR ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; - public final fun getSHORTS ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; - public final fun getSWIPE_CONTROLS ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; - public final fun getVIDEO ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; -} - -public final class app/revanced/patches/youtube/misc/settings/SettingsResourcePatch : app/revanced/patches/shared/misc/settings/BaseSettingsResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/settings/SettingsResourcePatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/youtube/misc/zoomhaptics/ZoomHapticsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/misc/zoomhaptics/ZoomHapticsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/video/hdrbrightness/HDRBrightnessPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/video/hdrbrightness/HDRBrightnessPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/video/information/VideoInformationPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/video/information/VideoInformationPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/video/playerresponse/PlayerResponseMethodHookPatch : app/revanced/patcher/patch/BytecodePatch, java/io/Closeable, java/util/Set, kotlin/jvm/internal/markers/KMutableSet { - public static final field INSTANCE Lapp/revanced/patches/youtube/video/playerresponse/PlayerResponseMethodHookPatch; - public fun add (Lapp/revanced/patches/youtube/video/playerresponse/PlayerResponseMethodHookPatch$Hook;)Z - public synthetic fun add (Ljava/lang/Object;)Z - public fun addAll (Ljava/util/Collection;)Z - public fun clear ()V - public fun close ()V - public fun contains (Lapp/revanced/patches/youtube/video/playerresponse/PlayerResponseMethodHookPatch$Hook;)Z - public final fun contains (Ljava/lang/Object;)Z - public fun containsAll (Ljava/util/Collection;)Z - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun getSize ()I - public fun isEmpty ()Z - public fun iterator ()Ljava/util/Iterator; - public fun remove (Lapp/revanced/patches/youtube/video/playerresponse/PlayerResponseMethodHookPatch$Hook;)Z - public final fun remove (Ljava/lang/Object;)Z - public fun removeAll (Ljava/util/Collection;)Z - public fun retainAll (Ljava/util/Collection;)Z - public final fun size ()I - public fun toArray ()[Ljava/lang/Object; - public fun toArray ([Ljava/lang/Object;)[Ljava/lang/Object; -} - -public final class app/revanced/patches/youtube/video/quality/RememberVideoQualityPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/video/quality/RememberVideoQualityPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/video/speed/PlaybackSpeedPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/video/speed/PlaybackSpeedPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/video/speed/button/PlaybackSpeedButtonPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/video/speed/button/PlaybackSpeedButtonPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/video/speed/custom/CustomPlaybackSpeedPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/video/speed/custom/CustomPlaybackSpeedPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/video/speed/remember/RememberPlaybackSpeedPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/video/speed/remember/RememberPlaybackSpeedPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/video/videoid/VideoIdPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/video/videoid/VideoIdPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public final fun hookBackgroundPlayVideoId (Ljava/lang/String;)V - public final fun hookPlayerResponseVideoId (Ljava/lang/String;)V - public final fun hookVideoId (Ljava/lang/String;)V -} - -public final class app/revanced/patches/youtube/video/videoqualitymenu/RestoreOldVideoQualityMenuPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/video/videoqualitymenu/RestoreOldVideoQualityMenuPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/youtube/video/videoqualitymenu/RestoreOldVideoQualityMenuResourcePatch : app/revanced/patcher/patch/ResourcePatch { - public static final field INSTANCE Lapp/revanced/patches/youtube/video/videoqualitymenu/RestoreOldVideoQualityMenuResourcePatch; - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V - public fun execute (Lapp/revanced/patcher/data/ResourceContext;)V -} - -public final class app/revanced/patches/youtubevanced/ad/general/HideAdsPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/youtubevanced/ad/general/HideAdsPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/patches/yuka/misc/unlockpremium/UnlockPremiumPatch : app/revanced/patcher/patch/BytecodePatch { - public static final field INSTANCE Lapp/revanced/patches/yuka/misc/unlockpremium/UnlockPremiumPatch; - public fun execute (Lapp/revanced/patcher/data/BytecodeContext;)V - public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V -} - -public final class app/revanced/util/BytecodeUtilsKt { - public static final fun alsoResolve (Lapp/revanced/patcher/fingerprint/MethodFingerprint;Lapp/revanced/patcher/data/BytecodeContext;Lapp/revanced/patcher/fingerprint/MethodFingerprint;)Lapp/revanced/patcher/fingerprint/MethodFingerprintResult; - public static final fun containsWideLiteralInstructionValue (Lcom/android/tools/smali/dexlib2/iface/Method;J)Z - 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 findOpcodeIndicesReversed (Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/Opcode;)Ljava/util/List; - public static final fun findOpcodeIndicesReversed (Lcom/android/tools/smali/dexlib2/iface/Method;Lkotlin/jvm/functions/Function1;)Ljava/util/List; - public static final fun forEachLiteralValueInstruction (Lapp/revanced/patcher/data/BytecodeContext;JLkotlin/jvm/functions/Function2;)V - public static final fun getException (Lapp/revanced/patcher/fingerprint/MethodFingerprint;)Lapp/revanced/patcher/patch/PatchException; - 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 final fun indexOfFirstInstruction (Lcom/android/tools/smali/dexlib2/iface/Method;Lkotlin/jvm/functions/Function1;)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;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 indexOfFirstWideLiteralInstructionValue (Lcom/android/tools/smali/dexlib2/iface/Method;J)I - public static final fun indexOfFirstWideLiteralInstructionValueOrThrow (Lcom/android/tools/smali/dexlib2/iface/Method;J)I - public static final fun indexOfFirstWideLiteralInstructionValueReversed (Lcom/android/tools/smali/dexlib2/iface/Method;J)I - public static final fun indexOfFirstWideLiteralInstructionValueReversedOrThrow (Lcom/android/tools/smali/dexlib2/iface/Method;J)I - public static final fun indexOfIdResource (Lcom/android/tools/smali/dexlib2/iface/Method;Ljava/lang/String;)I - public static final fun indexOfIdResourceOrThrow (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 resultOrThrow (Lapp/revanced/patcher/fingerprint/MethodFingerprint;)Lapp/revanced/patcher/fingerprint/MethodFingerprintResult; - public static final fun returnEarly (Lapp/revanced/patcher/fingerprint/MethodFingerprint;Z)V - public static final fun returnEarly (Ljava/lang/Iterable;Z)V - public static final fun returnEarly (Ljava/util/List;Z)V - public static synthetic fun returnEarly$default (Lapp/revanced/patcher/fingerprint/MethodFingerprint;ZILjava/lang/Object;)V - public static synthetic fun returnEarly$default (Ljava/lang/Iterable;ZILjava/lang/Object;)V - public static synthetic fun returnEarly$default (Ljava/util/List;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/data/BytecodeContext;Lapp/revanced/patcher/util/proxy/mutableTypes/MutableClass;Lkotlin/jvm/functions/Function1;)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 asSequence (Lorg/w3c/dom/NodeList;)Lkotlin/sequences/Sequence; - public static final fun childElementsSequence (Lorg/w3c/dom/Node;)Lkotlin/sequences/Sequence; - public static final fun copyResources (Lapp/revanced/patcher/data/ResourceContext;Ljava/lang/String;[Lapp/revanced/util/ResourceGroup;)V - public static final fun copyXmlNode (Ljava/lang/String;Lapp/revanced/patcher/util/DomFileEditor;Lapp/revanced/patcher/util/DomFileEditor;)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 insertFirst (Lorg/w3c/dom/Node;Lorg/w3c/dom/Node;)V - public static final fun iterateXmlNodeChildren (Lapp/revanced/patcher/data/ResourceContext;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V -} - -public abstract class app/revanced/util/patch/LiteralValueFingerprint : app/revanced/patcher/fingerprint/MethodFingerprint { - public fun (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Iterable;Ljava/lang/Iterable;Ljava/lang/Iterable;Lkotlin/jvm/functions/Function0;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Iterable;Ljava/lang/Iterable;Ljava/lang/Iterable;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V -} - -public final class app/revanced/util/resource/ArrayResource : app/revanced/util/resource/BaseResource { - public static final field Companion Lapp/revanced/util/resource/ArrayResource$Companion; - public fun (Ljava/lang/String;Ljava/util/List;)V - public final fun getItems ()Ljava/util/List; - public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; -} - -public final class app/revanced/util/resource/ArrayResource$Companion { - public final fun fromNode (Lorg/w3c/dom/Node;)Lapp/revanced/util/resource/ArrayResource; -} - -public abstract class app/revanced/util/resource/BaseResource { - public fun (Ljava/lang/String;Ljava/lang/String;)V - public fun equals (Ljava/lang/Object;)Z - public final fun getName ()Ljava/lang/String; - public final fun getTag ()Ljava/lang/String; - public fun hashCode ()I - public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; - public static synthetic fun serialize$default (Lapp/revanced/util/resource/BaseResource;Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/w3c/dom/Element; -} - -public final class app/revanced/util/resource/StringResource : app/revanced/util/resource/BaseResource { - public static final field Companion Lapp/revanced/util/resource/StringResource$Companion; - public fun (Ljava/lang/String;Ljava/lang/String;Z)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getFormatted ()Z - public final fun getValue ()Ljava/lang/String; - public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; -} - -public final class app/revanced/util/resource/StringResource$Companion { - public final fun fromNode (Lorg/w3c/dom/Node;)Lapp/revanced/util/resource/StringResource; -} - diff --git a/build.gradle.kts b/build.gradle.kts deleted file mode 100644 index 9ed141678..000000000 --- a/build.gradle.kts +++ /dev/null @@ -1,155 +0,0 @@ -import org.gradle.kotlin.dsl.support.listFilesOrdered -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - alias(libs.plugins.kotlin) - alias(libs.plugins.binary.compatibility.validator) - `maven-publish` - signing -} - -group = "app.revanced" - -repositories { - mavenCentral() - mavenLocal() - google() - maven { - // A repository must be specified for some reason. "registry" is a dummy. - url = uri("https://maven.pkg.github.com/revanced/registry") - 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) - // TODO: Required because build fails without it. Find a way to remove this dependency. - implementation(libs.guava) - // Used in JsonGenerator. - implementation(libs.gson) - // Android API stubs defined here. - compileOnly(project(":stub")) -} - -kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } -} - -java { - targetCompatibility = JavaVersion.VERSION_11 -} - -tasks { - withType(Jar::class) { - exclude("app/revanced/meta") - - 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/revanced/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/crowdin.yml b/crowdin.yml index 4ac3cb98b..148f321cd 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -3,6 +3,6 @@ api_token_env: "CROWDIN_PERSONAL_TOKEN" preserve_hierarchy: false files: - - source: src/main/resources/addresources/values/strings.xml - translation: src/main/resources/addresources/values-%android_code%/strings.xml + - source: patches/src/main/resources/addresources/values/strings.xml + translation: patches/src/main/resources/addresources/values-%android_code%/strings.xml skip_untranslated_strings: true diff --git a/extensions/remove-screen-capture-restriction/build.gradle.kts b/extensions/remove-screen-capture-restriction/build.gradle.kts new file mode 100644 index 000000000..46f94dac8 --- /dev/null +++ b/extensions/remove-screen-capture-restriction/build.gradle.kts @@ -0,0 +1,11 @@ +extension { + name = "extensions/all/screencapture/remove-screen-capture-restriction.rve" +} + +android { + namespace = "app.revanced.extension" +} + +dependencies { + compileOnly(libs.annotation) +} diff --git a/extensions/remove-screen-capture-restriction/src/main/AndroidManifest.xml b/extensions/remove-screen-capture-restriction/src/main/AndroidManifest.xml new file mode 100644 index 000000000..15e7c2ae6 --- /dev/null +++ b/extensions/remove-screen-capture-restriction/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/extensions/remove-screen-capture-restriction/src/main/java/app/revanced/extension/all/screencapture/removerestriction/RemoveScreencaptureRestrictionPatch.java b/extensions/remove-screen-capture-restriction/src/main/java/app/revanced/extension/all/screencapture/removerestriction/RemoveScreencaptureRestrictionPatch.java new file mode 100644 index 000000000..1dac34144 --- /dev/null +++ b/extensions/remove-screen-capture-restriction/src/main/java/app/revanced/extension/all/screencapture/removerestriction/RemoveScreencaptureRestrictionPatch.java @@ -0,0 +1,21 @@ +package app.revanced.extension.all.screencapture.removerestriction; + +import android.media.AudioAttributes; +import android.os.Build; + +import androidx.annotation.RequiresApi; + +public final class RemoveScreencaptureRestrictionPatch { + // Member of AudioAttributes.Builder + @RequiresApi(api = Build.VERSION_CODES.Q) + public static AudioAttributes.Builder setAllowedCapturePolicy(final AudioAttributes.Builder builder, final int capturePolicy) { + builder.setAllowedCapturePolicy(AudioAttributes.ALLOW_CAPTURE_BY_ALL); + + return builder; + } + + // Member of AudioManager static class + public static void setAllowedCapturePolicy(final int capturePolicy) { + // Ignore request + } +} diff --git a/extensions/remove-screenshot-restriction/build.gradle.kts b/extensions/remove-screenshot-restriction/build.gradle.kts new file mode 100644 index 000000000..cdbad5e1e --- /dev/null +++ b/extensions/remove-screenshot-restriction/build.gradle.kts @@ -0,0 +1,7 @@ +extension { + name = "extensions/all/screenshot/remove-screenshot-restriction.rve" +} + +android { + namespace = "app.revanced.extension" +} diff --git a/extensions/remove-screenshot-restriction/src/main/AndroidManifest.xml b/extensions/remove-screenshot-restriction/src/main/AndroidManifest.xml new file mode 100644 index 000000000..15e7c2ae6 --- /dev/null +++ b/extensions/remove-screenshot-restriction/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/extensions/remove-screenshot-restriction/src/main/java/app/revanced/extension/all/screenshot/removerestriction/RemoveScreenshotRestrictionPatch.java b/extensions/remove-screenshot-restriction/src/main/java/app/revanced/extension/all/screenshot/removerestriction/RemoveScreenshotRestrictionPatch.java new file mode 100644 index 000000000..fd5c427d3 --- /dev/null +++ b/extensions/remove-screenshot-restriction/src/main/java/app/revanced/extension/all/screenshot/removerestriction/RemoveScreenshotRestrictionPatch.java @@ -0,0 +1,15 @@ +package app.revanced.extension.all.screenshot.removerestriction; + +import android.view.Window; +import android.view.WindowManager; + +public class RemoveScreenshotRestrictionPatch { + + public static void addFlags(Window window, int flags) { + window.addFlags(flags & ~WindowManager.LayoutParams.FLAG_SECURE); + } + + public static void setFlags(Window window, int flags, int mask) { + window.setFlags(flags & ~WindowManager.LayoutParams.FLAG_SECURE, mask & ~WindowManager.LayoutParams.FLAG_SECURE); + } +} diff --git a/extensions/shared/build.gradle.kts b/extensions/shared/build.gradle.kts new file mode 100644 index 000000000..5abaf83ab --- /dev/null +++ b/extensions/shared/build.gradle.kts @@ -0,0 +1,22 @@ +extension { + name = "extensions/shared.rve" +} + +android { + namespace = "app.revanced.extension" + + buildTypes { + release { + isMinifyEnabled = true + } + } +} + +dependencies { + compileOnly(libs.appcompat) + compileOnly(libs.annotation) + compileOnly(libs.okhttp) + compileOnly(libs.retrofit) + + compileOnly(project(":extensions:shared:stub")) +} diff --git a/extensions/shared/proguard-rules.pro b/extensions/shared/proguard-rules.pro new file mode 100644 index 000000000..8f804140d --- /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 000000000..e960b0003 --- /dev/null +++ b/extensions/shared/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/extensions/shared/src/main/java/app/revanced/extension/boostforreddit/FixSLinksPatch.java b/extensions/shared/src/main/java/app/revanced/extension/boostforreddit/FixSLinksPatch.java new file mode 100644 index 000000000..b7a150fb1 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/boostforreddit/FixSLinksPatch.java @@ -0,0 +1,24 @@ +package app.revanced.extension.boostforreddit; + +import com.rubenmayayo.reddit.ui.activities.WebViewActivity; + +import app.revanced.extension.shared.fixes.slink.BaseFixSLinksPatch; + +/** @noinspection unused*/ +public class FixSLinksPatch extends BaseFixSLinksPatch { + static { + INSTANCE = new FixSLinksPatch(); + } + + private FixSLinksPatch() { + webViewActivityClass = WebViewActivity.class; + } + + public static boolean patchResolveSLink(String link) { + return INSTANCE.resolveSLink(link); + } + + public static void patchSetAccessToken(String accessToken) { + INSTANCE.setAccessToken(accessToken); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/FilterPromotedLinksPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/FilterPromotedLinksPatch.java new file mode 100644 index 000000000..7534d6928 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/FilterPromotedLinksPatch.java @@ -0,0 +1,23 @@ +package app.revanced.extension.reddit.patches; + +import com.reddit.domain.model.ILink; + +import java.util.ArrayList; +import java.util.List; + +public final class FilterPromotedLinksPatch { + /** + * Filters list from promoted links. + **/ + public static List filterChildren(final Iterable links) { + final List filteredList = new ArrayList<>(); + + for (Object item : links) { + if (item instanceof ILink && ((ILink) item).getPromoted()) continue; + + filteredList.add(item); + } + + return filteredList; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/GmsCoreSupport.java b/extensions/shared/src/main/java/app/revanced/extension/shared/GmsCoreSupport.java new file mode 100644 index 000000000..1e2586b2b --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/GmsCoreSupport.java @@ -0,0 +1,158 @@ +package app.revanced.extension.shared; + +import static app.revanced.extension.shared.StringRef.str; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.SearchManager; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.PowerManager; +import android.provider.Settings; + +import androidx.annotation.RequiresApi; + +import java.net.MalformedURLException; +import java.net.URL; + +/** + * @noinspection unused + */ +public class GmsCoreSupport { + public static final String ORIGINAL_UNPATCHED_PACKAGE_NAME = "com.google.android.youtube"; + 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 void open(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); + Utils.getContext().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) { + // Do not set cancelable to false, to allow using back button to skip the action, + // just in case the check can never be satisfied. + var dialog = new AlertDialog.Builder(context) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setTitle(str("gms_core_dialog_title")) + .setMessage(str(dialogMessageRef)) + .setPositiveButton(str(positiveButtonStringRef), onPositiveClickListener) + .create(); + Utils.showDialog(context, dialog); + } + + /** + * Injection point. + */ + @RequiresApi(api = Build.VERSION_CODES.N) + public static void checkGmsCore(Activity context) { + 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 (context.getPackageName().equals(ORIGINAL_UNPATCHED_PACKAGE_NAME)) { + 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 = context.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(getGmsCoreDownload()); + return; + } + + // Check if GmsCore is running in the background. + try (var client = context.getContentResolver().acquireContentProviderClient(GMS_CORE_PROVIDER)) { + if (client == null) { + Logger.printInfo(() -> "GmsCore is not running in the background"); + + showBatteryOptimizationDialog(context, + "gms_core_dialog_not_whitelisted_not_allowed_in_background_message", + "gms_core_dialog_open_website_text", + (dialog, id) -> open(DONT_KILL_MY_APP_LINK)); + return; + } + } + + // Check if GmsCore is whitelisted from battery optimizations. + if (batteryOptimizationsEnabled(context)) { + Logger.printInfo(() -> "GmsCore is not whitelisted from battery optimizations"); + showBatteryOptimizationDialog(context, + "gms_core_dialog_not_whitelisted_using_battery_optimizations_message", + "gms_core_dialog_continue_text", + (dialog, id) -> openGmsCoreDisableBatteryOptimizationsIntent(context)); + } + } catch (Exception ex) { + Logger.printException(() -> "checkGmsCore failure", ex); + } + } + + @SuppressLint("BatteryLife") // Permission is part of GmsCore + private static void openGmsCoreDisableBatteryOptimizationsIntent(Activity activity) { + Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); + intent.setData(Uri.fromParts("package", GMS_CORE_PACKAGE_NAME, null)); + activity.startActivityForResult(intent, 0); + } + + /** + * @return If GmsCore is not whitelisted from battery optimizations. + */ + private static boolean batteryOptimizationsEnabled(Context context) { + var powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + return !powerManager.isIgnoringBatteryOptimizations(GMS_CORE_PACKAGE_NAME); + } + + private static String getGmsCoreDownload() { + final var vendorGroupId = getGmsCoreVendorGroupId(); + //noinspection SwitchStatementWithTooFewBranches + switch (vendorGroupId) { + case "app.revanced": + return "https://github.com/revanced/gmscore/releases/latest"; + default: + return vendorGroupId + ".android.gms"; + } + } + + // Modified by a patch. Do not touch. + private static String getGmsCoreVendorGroupId() { + return "app.revanced"; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/Logger.java b/extensions/shared/src/main/java/app/revanced/extension/shared/Logger.java new file mode 100644 index 000000000..3ac7438b9 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/Logger.java @@ -0,0 +1,168 @@ +package app.revanced.extension.shared; + +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import app.revanced.extension.shared.settings.BaseSettings; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import static app.revanced.extension.shared.settings.BaseSettings.*; + +public class Logger { + + /** + * Log messages using lambdas. + */ + @FunctionalInterface + 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 + * + */ + private String findOuterClassSimpleName() { + var 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 = "revanced: "; + + /** + * 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#DEBUG} is enabled. + */ + public static void printDebug(@NonNull LogMessage message) { + printDebug(message, null); + } + + /** + * 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#DEBUG} is enabled. + */ + public static void printDebug(@NonNull LogMessage message, @Nullable Exception ex) { + if (DEBUG.get()) { + String logMessage = message.buildMessageString(); + String logTag = REVANCED_LOG_PREFIX + message.findOuterClassSimpleName(); + + if (DEBUG_STACKTRACE.get()) { + var builder = new StringBuilder(logMessage); + var sw = new StringWriter(); + new Throwable().printStackTrace(new PrintWriter(sw)); + + builder.append('\n').append(sw); + logMessage = builder.toString(); + } + + if (ex == null) { + Log.d(logTag, logMessage); + } else { + Log.d(logTag, logMessage, ex); + } + } + } + + /** + * 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, null); + } + + /** + * Logs exceptions under the outer class name of the code calling this method. + */ + public static void printException(@NonNull LogMessage message, @Nullable Throwable ex) { + printException(message, ex, 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) + * @param userToastMessage user specific toast message to show instead of the log message (optional) + */ + public static void printException(@NonNull LogMessage message, @Nullable Throwable ex, + @Nullable String userToastMessage) { + 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); + } + if (DEBUG_TOAST_ON_ERROR.get()) { + String toastMessageToDisplay = (userToastMessage != null) + ? userToastMessage + : outerClassSimpleName + ": " + messageString; + Utils.showToastLong(toastMessageToDisplay); + } + } + + /** + * Logging to use if {@link BaseSettings#DEBUG} 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#DEBUG} 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/StringRef.java b/extensions/shared/src/main/java/app/revanced/extension/shared/StringRef.java new file mode 100644 index 000000000..4390137de --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/StringRef.java @@ -0,0 +1,122 @@ +package app.revanced.extension.shared; + +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; + +public class StringRef { + private static Resources resources; + private static String packageName; + + // 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 + */ + @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) { + if (resources == null || packageName == null) { + Context context = Utils.getContext(); + resources = context.getResources(); + packageName = context.getPackageName(); + } + resolved = true; + if (resources != null) { + final int identifier = resources.getIdentifier(value, "string", packageName); + if (identifier == 0) + Logger.printException(() -> "Resource not found: " + value); + else + value = resources.getString(identifier); + } else { + Logger.printException(() -> "Could not resolve resources!"); + } + } + return value; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/Utils.java b/extensions/shared/src/main/java/app/revanced/extension/shared/Utils.java new file mode 100644 index 000000000..45cf5616e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/Utils.java @@ -0,0 +1,741 @@ +package app.revanced.extension.shared; + +import android.annotation.SuppressLint; +import android.app.*; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.net.ConnectivityManager; +import android.os.Build; +import android.os.Bundle; +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.view.animation.Animation; +import android.view.animation.AnimationUtils; +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.text.Bidi; +import java.util.*; +import java.util.regex.Pattern; +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 app.revanced.extension.shared.settings.preference.ReVancedAboutPreference; + +public class Utils { + + @SuppressLint("StaticFieldLeak") + private static Context context; + + private static String versionName; + + private Utils() { + } // utility class + + /** + * Injection point. + * + * @return The manifest 'Version' entry of the patches.jar used during patching. + */ + @SuppressWarnings("SameReturnValue") + public static String getPatchesReleaseVersion() { + return ""; // Value is replaced during patching. + } + + /** + * @return The version name of the app, such as 19.11.43 + */ + public static String getAppVersionName() { + if (versionName == null) { + try { + final var packageName = Objects.requireNonNull(getContext()).getPackageName(); + + PackageManager packageManager = context.getPackageManager(); + PackageInfo packageInfo; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageInfo = packageManager.getPackageInfo( + packageName, + PackageManager.PackageInfoFlags.of(0) + ); + } else { + packageInfo = packageManager.getPackageInfo( + packageName, + 0 + ); + } + versionName = packageInfo.versionName; + } catch (Exception ex) { + Logger.printException(() -> "Failed to get package info", ex); + versionName = "Unknown"; + } + } + + return versionName; + } + + + /** + * 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) { + if (hideViewBy0dpUnderCondition(condition.get(), view)) { + Logger.printDebug(() -> "View hidden by setting: " + condition); + } + } + + /** + * Hide a view by setting its layout height and width to 0dp. + * + * @param condition The setting to check for hiding the view. + * @param view The view to hide. + */ + public static boolean hideViewBy0dpUnderCondition(boolean condition, View view) { + if (condition) { + hideViewByLayoutParams(view); + return true; + } + + return false; + } + + /** + * 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) { + if (hideViewUnderCondition(condition.get(), view)) { + Logger.printDebug(() -> "View hidden by setting: " + condition); + } + } + + /** + * 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 boolean hideViewUnderCondition(boolean condition, View view) { + if (condition) { + view.setVisibility(View.GONE); + return true; + } + + return false; + } + + public static void hideViewByRemovingFromParentUnderCondition(BooleanSetting condition, View view) { + if (hideViewByRemovingFromParentUnderCondition(condition.get(), view)) { + Logger.printDebug(() -> "View hidden by setting: " + condition); + } + } + + public static boolean hideViewByRemovingFromParentUnderCondition(boolean setting, View view) { + if (setting) { + ViewParent parent = view.getParent(); + if (parent instanceof ViewGroup) { + ((ViewGroup) parent).removeView(view); + return true; + } + } + + return false; + } + + /** + * 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); + } + + /** + * Simulates a delay by doing meaningless calculations. + * Used for debugging to verify UI timeout logic. + */ + @SuppressWarnings("UnusedReturnValue") + public static long doNothingForDuration(long amountOfTimeToWaste) { + final long timeCalculationStarted = System.currentTimeMillis(); + Logger.printDebug(() -> "Artificially creating delay of: " + amountOfTimeToWaste + "ms"); + + long meaninglessValue = 0; + while (System.currentTimeMillis() - timeCalculationStarted < amountOfTimeToWaste) { + // could do a thread sleep, but that will trigger an exception if the thread is interrupted + meaninglessValue += Long.numberOfLeadingZeros((long) Math.exp(Math.random())); + } + // return the value, otherwise the compiler or VM might optimize and remove the meaningless time wasting work, + // leaving an empty loop that hammers on the System.currentTimeMillis native call + return meaninglessValue; + } + + + 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; + } + + /** + * @return zero, if the resource is not found + */ + @SuppressLint("DiscouragedApi") + public static int getResourceIdentifier(@NonNull Context context, @NonNull String resourceIdentifierName, @NonNull String type) { + return context.getResources().getIdentifier(resourceIdentifierName, type, context.getPackageName()); + } + + /** + * @return zero, if the resource is not found + */ + public static int getResourceIdentifier(@NonNull String resourceIdentifierName, @NonNull String type) { + return getResourceIdentifier(getContext(), resourceIdentifierName, type); + } + + public static int getResourceInteger(@NonNull String resourceIdentifierName) throws Resources.NotFoundException { + return getContext().getResources().getInteger(getResourceIdentifier(resourceIdentifierName, "integer")); + } + + @NonNull + public static Animation getResourceAnimation(@NonNull String resourceIdentifierName) throws Resources.NotFoundException { + return AnimationUtils.loadAnimation(getContext(), getResourceIdentifier(resourceIdentifierName, "anim")); + } + + public static int getResourceColor(@NonNull String resourceIdentifierName) throws Resources.NotFoundException { + //noinspection deprecation + return getContext().getResources().getColor(getResourceIdentifier(resourceIdentifierName, "color")); + } + + public static int getResourceDimensionPixelSize(@NonNull String resourceIdentifierName) throws Resources.NotFoundException { + return getContext().getResources().getDimensionPixelSize(getResourceIdentifier(resourceIdentifierName, "dimen")); + } + + public static float getResourceDimension(@NonNull String resourceIdentifierName) throws Resources.NotFoundException { + return getContext().getResources().getDimension(getResourceIdentifier(resourceIdentifierName, "dimen")); + } + + public interface MatchFilter { + boolean matches(T object); + } + + /** + * Includes sub children. + * + * @noinspection unchecked + */ + public static R getChildViewByResourceName(@NonNull View view, @NonNull String str) { + var child = view.findViewById(Utils.getResourceIdentifier(str, "id")); + if (child != null) { + return (R) child; + } + + throw new IllegalArgumentException("View with resource 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; + } + + @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 currentDepthLog = currentDepth; + Logger.printDebug(() -> "Could not find parent view of depth: " + nthParent + + " and instead found at: " + currentDepthLog + " view: " + view); + return null; + } + + public static void restartApp(@NonNull Context context) { + String packageName = context.getPackageName(); + Intent intent = context.getPackageManager().getLaunchIntentForPackage(packageName); + 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); + context.startActivity(mainIntent); + System.exit(0); + } + + public static Context getContext() { + if (context == null) { + Logger.initializationException(Utils.class, "Context is null, returning null!", null); + } + return context; + } + + public static void setContext(Context appContext) { + 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) { + android.content.ClipboardManager clipboard = (android.content.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + android.content.ClipData clip = android.content.ClipData.newPlainText("ReVanced", text); + clipboard.setPrimaryClip(clip); + } + + public static boolean isTablet() { + return context.getResources().getConfiguration().smallestScreenWidthDp >= 600; + } + + @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; + } + + /** + * Ignore this class. It must be public to satisfy Android requirements. + */ + @SuppressWarnings("deprecation") + public static final class DialogFragmentWrapper extends DialogFragment { + + private Dialog dialog; + @Nullable + private DialogFragmentOnStartAction onStartAction; + + @Override + public void onSaveInstanceState(Bundle outState) { + // Do not call super method to prevent state saving. + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + return dialog; + } + + @Override + public void onStart() { + try { + super.onStart(); + + if (onStartAction != null) { + onStartAction.onStart((AlertDialog) getDialog()); + } + } catch (Exception ex) { + Logger.printException(() -> "onStart failure: " + dialog.getClass().getSimpleName(), ex); + } + } + } + + /** + * Interface for {@link #showDialog(Activity, AlertDialog, boolean, DialogFragmentOnStartAction)}. + */ + @FunctionalInterface + public interface DialogFragmentOnStartAction { + void onStart(AlertDialog dialog); + } + + public static void showDialog(Activity activity, AlertDialog dialog) { + showDialog(activity, dialog, true, null); + } + + /** + * Utility method to allow showing an AlertDialog on top of other alert dialogs. + * Calling this will always display the dialog on top of all other dialogs + * previously called using this method. + *
+ * Be aware the on start action can be called multiple times for some situations, + * such as the user switching apps without dismissing the dialog then switching back to this app. + *
+ * This method is only useful during app startup and multiple patches may show their own dialog, + * and the most important dialog can be called last (using a delay) so it's always on top. + *
+ * For all other situations it's better to not use this method and + * call {@link AlertDialog#show()} on the dialog. + */ + @SuppressWarnings("deprecation") + public static void showDialog(Activity activity, + AlertDialog dialog, + boolean isCancelable, + @Nullable DialogFragmentOnStartAction onStartAction) { + verifyOnMainThread(); + + DialogFragmentWrapper fragment = new DialogFragmentWrapper(); + fragment.dialog = dialog; + fragment.onStartAction = onStartAction; + fragment.setCancelable(isCancelable); + + fragment.show(activity.getFragmentManager(), null); + } + + /** + * 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() { + return Looper.getMainLooper().isCurrentThread(); + } + + /** + * @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 { + NONE, + MOBILE, + OTHER, + } + + public static boolean isNetworkConnected() { + NetworkType networkType = getNetworkType(); + return networkType == NetworkType.MOBILE + || networkType == NetworkType.OTHER; + } + + @SuppressLint("MissingPermission") // permission already included in YouTube + public static NetworkType getNetworkType() { + Context networkContext = getContext(); + if (networkContext == null) { + return NetworkType.NONE; + } + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + var networkInfo = cm.getActiveNetworkInfo(); + + if (networkInfo == null || !networkInfo.isConnected()) { + return NetworkType.NONE; + } + var type = networkInfo.getType(); + return (type == ConnectivityManager.TYPE_MOBILE) + || (type == ConnectivityManager.TYPE_BLUETOOTH) ? NetworkType.MOBILE : NetworkType.OTHER; + } + + /** + * Hide a view by setting its layout params to 0x0 + * @param view The view to hide. + */ + public static void hideViewByLayoutParams(View view) { + 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); + } + } + + /** + * {@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 Pattern punctuationPattern = Pattern.compile("\\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 punctuationPattern.matcher(original).replaceAll("").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) { + sortPreferenceGroups((PreferenceGroup) preference); + 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()); + break; + case BY_KEY: + sortValue = preference.getKey(); + break; + 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++; + + // Move any screens, intents, and the one off About preference to the top. + if (pref instanceof PreferenceScreen || pref instanceof ReVancedAboutPreference + || pref.getIntent() != null) { + // Arbitrary high number. + order -= 1000; + } + + pref.setOrder(order); + } + } + + /** + * 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]. + */ + public static void setEditTextDialogTheme(AlertDialog.Builder builder) { + final int editTextDialogStyle = getResourceIdentifier( + "revanced_edit_text_dialog_style", "style"); + if (editTextDialogStyle != 0) { + builder.getContext().setTheme(editTextDialogStyle); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/checks/Check.java b/extensions/shared/src/main/java/app/revanced/extension/shared/checks/Check.java new file mode 100644 index 000000000..855e6003b --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/checks/Check.java @@ -0,0 +1,164 @@ +package app.revanced.extension.shared.checks; + +import static android.text.Html.FROM_HTML_MODE_COMPACT; +import static app.revanced.extension.shared.StringRef.str; +import static app.revanced.extension.shared.Utils.DialogFragmentOnStartAction; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.net.Uri; +import android.text.Html; +import android.widget.Button; + +import androidx.annotation.Nullable; + +import java.util.Collection; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.settings.Settings; + +abstract class Check { + private static final int NUMBER_OF_TIMES_TO_IGNORE_WARNING_BEFORE_DISABLING = 2; + + private static final int SECONDS_BEFORE_SHOWING_IGNORE_BUTTON = 15; + private static final int SECONDS_BEFORE_SHOWING_WEBSITE_BUTTON = 10; + + private static final Uri GOOD_SOURCE = Uri.parse("https://revanced.app"); + + /** + * @return If the check conclusively passed or failed. A null value indicates it neither passed nor failed. + */ + @Nullable + protected abstract Boolean check(); + + protected abstract String failureReason(); + + /** + * Specifies a sorting order for displaying the checks that failed. + * A lower value indicates to show first before other checks. + */ + public abstract int uiSortingValue(); + + /** + * For debugging and development only. + * Forces all checks to be performed and the check failed dialog to be shown. + * Can be enabled by importing settings text with {@link Settings#CHECK_ENVIRONMENT_WARNINGS_ISSUED} + * set to -1. + */ + static boolean debugAlwaysShowWarning() { + final boolean alwaysShowWarning = Settings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get() < 0; + if (alwaysShowWarning) { + Logger.printInfo(() -> "Debug forcing environment check warning to show"); + } + + return alwaysShowWarning; + } + + static boolean shouldRun() { + return Settings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get() + < NUMBER_OF_TIMES_TO_IGNORE_WARNING_BEFORE_DISABLING; + } + + static void disableForever() { + Logger.printInfo(() -> "Environment checks disabled forever"); + + Settings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.save(Integer.MAX_VALUE); + } + + @SuppressLint("NewApi") + static void issueWarning(Activity activity, Collection failedChecks) { + final var reasons = new StringBuilder(); + + reasons.append("

    "); + for (var check : failedChecks) { + // Add a non breaking space to fix bullet points spacing issue. + reasons.append("
  •  ").append(check.failureReason()); + } + reasons.append("
"); + + var message = Html.fromHtml( + str("revanced_check_environment_failed_message", reasons.toString()), + FROM_HTML_MODE_COMPACT + ); + + Utils.runOnMainThreadDelayed(() -> { + AlertDialog alert = new AlertDialog.Builder(activity) + .setCancelable(false) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setTitle(str("revanced_check_environment_failed_title")) + .setMessage(message) + .setPositiveButton( + " ", + (dialog, which) -> { + final var intent = new Intent(Intent.ACTION_VIEW, GOOD_SOURCE); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + activity.startActivity(intent); + + // Shutdown to prevent the user from navigating back to this app, + // which is no longer showing a warning dialog. + activity.finishAffinity(); + System.exit(0); + } + ).setNegativeButton( + " ", + (dialog, which) -> { + // Cleanup data if the user incorrectly imported a huge negative number. + final int current = Math.max(0, Settings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get()); + Settings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.save(current + 1); + + dialog.dismiss(); + } + ).create(); + + Utils.showDialog(activity, alert, false, new DialogFragmentOnStartAction() { + boolean hasRun; + @Override + public void onStart(AlertDialog dialog) { + // Only run this once, otherwise if the user changes to a different app + // then changes back, this handler will run again and disable the buttons. + if (hasRun) { + return; + } + hasRun = true; + + var openWebsiteButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE); + openWebsiteButton.setEnabled(false); + + var dismissButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE); + dismissButton.setEnabled(false); + + getCountdownRunnable(dismissButton, openWebsiteButton).run(); + } + }); + }, 1000); // Use a delay, so this dialog is shown on top of any other startup dialogs. + } + + private static Runnable getCountdownRunnable(Button dismissButton, Button openWebsiteButton) { + return new Runnable() { + private int secondsRemaining = SECONDS_BEFORE_SHOWING_IGNORE_BUTTON; + + @Override + public void run() { + Utils.verifyOnMainThread(); + + if (secondsRemaining > 0) { + if (secondsRemaining - SECONDS_BEFORE_SHOWING_WEBSITE_BUTTON == 0) { + openWebsiteButton.setText(str("revanced_check_environment_dialog_open_official_source_button")); + openWebsiteButton.setEnabled(true); + } + + secondsRemaining--; + + Utils.runOnMainThreadDelayed(this, 1000); + } else { + dismissButton.setText(str("revanced_check_environment_dialog_ignore_button")); + dismissButton.setEnabled(true); + } + } + }; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/checks/CheckEnvironmentPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/checks/CheckEnvironmentPatch.java new file mode 100644 index 000000000..d63f8b7e3 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/checks/CheckEnvironmentPatch.java @@ -0,0 +1,341 @@ +package app.revanced.extension.shared.checks; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.Build; +import android.util.Base64; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.*; + +import static app.revanced.extension.shared.StringRef.str; +import static app.revanced.extension.shared.checks.Check.debugAlwaysShowWarning; +import static app.revanced.extension.shared.checks.PatchInfo.Build.*; + +/** + * This class is used to check if the app was patched by the user + * and not downloaded pre-patched, because pre-patched apps are difficult to trust. + *
+ * Various indicators help to detect if the app was patched by the user. + */ +@SuppressWarnings("unused") +public final class CheckEnvironmentPatch { + private static final boolean DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG = debugAlwaysShowWarning(); + + private enum InstallationType { + /** + * CLI patching, manual installation of a previously patched using adb, + * or root installation if stock app is first installed using adb. + */ + ADB((String) null), + ROOT_MOUNT_ON_APP_STORE("com.android.vending"), + MANAGER("app.revanced.manager.flutter", + "app.revanced.manager", + "app.revanced.manager.debug"); + + @Nullable + static InstallationType installTypeFromPackageName(@Nullable String packageName) { + for (InstallationType type : values()) { + for (String installPackageName : type.packageNames) { + if (Objects.equals(installPackageName, packageName)) { + return type; + } + } + } + + return null; + } + + /** + * Array elements can be null. + */ + final String[] packageNames; + + InstallationType(String... packageNames) { + this.packageNames = packageNames; + } + } + + /** + * Check if the app is installed by the manager, the app store, or through adb/CLI. + *
+ * Does not conclusively + * If the app is installed by the manager or the app store, it is likely, the app was patched using the manager, + * or installed manually via ADB (in the case of ReVanced CLI for example). + *
+ * If the app is not installed by the manager or the app store, then the app was likely downloaded pre-patched + * and installed by the browser or another unknown app. + */ + private static class CheckExpectedInstaller extends Check { + @Nullable + InstallationType installerFound; + + @NonNull + @Override + protected Boolean check() { + final var context = Utils.getContext(); + + final var installerPackageName = + context.getPackageManager().getInstallerPackageName(context.getPackageName()); + + Logger.printInfo(() -> "Installed by: " + installerPackageName); + + installerFound = InstallationType.installTypeFromPackageName(installerPackageName); + final boolean passed = (installerFound != null); + + Logger.printInfo(() -> passed + ? "Apk was not installed from an unknown source" + : "Apk was installed from an unknown source"); + + return passed; + } + + @Override + protected String failureReason() { + return str("revanced_check_environment_manager_not_expected_installer"); + } + + @Override + public int uiSortingValue() { + return -100; // Show first. + } + } + + /** + * Check if the build properties are the same as during the patch. + *
+ * If the build properties are the same as during the patch, it is likely, the app was patched on the same device. + *
+ * If the build properties are different, the app was likely downloaded pre-patched or patched on another device. + */ + private static class CheckWasPatchedOnSameDevice extends Check { + @SuppressLint({"NewApi", "HardwareIds"}) + @Override + protected Boolean check() { + if (PATCH_BOARD.isEmpty()) { + // Did not patch with Manager, and cannot conclusively say where this was from. + Logger.printInfo(() -> "APK does not contain a hardware signature and cannot compare to current device"); + return null; + } + + //noinspection deprecation + final var passed = buildFieldEqualsHash("BOARD", Build.BOARD, PATCH_BOARD) & + buildFieldEqualsHash("BOOTLOADER", Build.BOOTLOADER, PATCH_BOOTLOADER) & + buildFieldEqualsHash("BRAND", Build.BRAND, PATCH_BRAND) & + buildFieldEqualsHash("CPU_ABI", Build.CPU_ABI, PATCH_CPU_ABI) & + buildFieldEqualsHash("CPU_ABI2", Build.CPU_ABI2, PATCH_CPU_ABI2) & + buildFieldEqualsHash("DEVICE", Build.DEVICE, PATCH_DEVICE) & + buildFieldEqualsHash("DISPLAY", Build.DISPLAY, PATCH_DISPLAY) & + buildFieldEqualsHash("FINGERPRINT", Build.FINGERPRINT, PATCH_FINGERPRINT) & + buildFieldEqualsHash("HARDWARE", Build.HARDWARE, PATCH_HARDWARE) & + buildFieldEqualsHash("HOST", Build.HOST, PATCH_HOST) & + buildFieldEqualsHash("ID", Build.ID, PATCH_ID) & + buildFieldEqualsHash("MANUFACTURER", Build.MANUFACTURER, PATCH_MANUFACTURER) & + buildFieldEqualsHash("MODEL", Build.MODEL, PATCH_MODEL) & + buildFieldEqualsHash("PRODUCT", Build.PRODUCT, PATCH_PRODUCT) & + buildFieldEqualsHash("RADIO", Build.RADIO, PATCH_RADIO) & + buildFieldEqualsHash("TAGS", Build.TAGS, PATCH_TAGS) & + buildFieldEqualsHash("TYPE", Build.TYPE, PATCH_TYPE) & + buildFieldEqualsHash("USER", Build.USER, PATCH_USER); + + Logger.printInfo(() -> passed + ? "Device hardware signature matches current device" + : "Device hardware signature does not match current device"); + + return passed; + } + + @Override + protected String failureReason() { + return str("revanced_check_environment_not_same_patching_device"); + } + + @Override + public int uiSortingValue() { + return 0; // Show in the middle. + } + } + + /** + * Check if the app was installed within the last 30 minutes after being patched. + *
+ * If the app was installed within the last 30 minutes, it is likely, the app was patched by the user. + *
+ * If the app was installed much later than the patch time, it is likely the app was + * downloaded pre-patched or the user waited too long to install the app. + */ + private static class CheckIsNearPatchTime extends Check { + /** + * How soon after patching the app must be installed to pass. + */ + static final int INSTALL_AFTER_PATCHING_DURATION_THRESHOLD = 30 * 60 * 1000; // 30 minutes. + + /** + * Milliseconds between the time the app was patched, and when it was installed/updated. + */ + long durationBetweenPatchingAndInstallation; + + @NonNull + @Override + protected Boolean check() { + try { + Context context = Utils.getContext(); + PackageManager packageManager = context.getPackageManager(); + PackageInfo packageInfo = packageManager.getPackageInfo(context.getPackageName(), 0); + + // Duration since initial install or last update, which ever is sooner. + durationBetweenPatchingAndInstallation = packageInfo.lastUpdateTime - PatchInfo.PATCH_TIME; + Logger.printInfo(() -> "App was installed/updated: " + + (durationBetweenPatchingAndInstallation / (60 * 1000) + " minutes after patching")); + + if (durationBetweenPatchingAndInstallation < 0) { + // Patch time is in the future and clearly wrong. + return false; + } + + if (durationBetweenPatchingAndInstallation < INSTALL_AFTER_PATCHING_DURATION_THRESHOLD) { + return true; + } + } catch (PackageManager.NameNotFoundException ex) { + Logger.printException(() -> "Package name not found exception", ex); // Will never happen. + } + + // User installed more than 30 minutes after patching. + return false; + } + + @Override + protected String failureReason() { + if (durationBetweenPatchingAndInstallation < 0) { + // Could happen if the user has their device clock incorrectly set in the past, + // but assume that isn't the case and the apk was patched on a device with the wrong system time. + return str("revanced_check_environment_not_near_patch_time_invalid"); + } + + // If patched over 1 day ago, show how old this pre-patched apk is. + // Showing the age can help convey it's better to patch yourself and know it's the latest. + final long oneDay = 24 * 60 * 60 * 1000; + final long daysSincePatching = durationBetweenPatchingAndInstallation / oneDay; + if (daysSincePatching > 1) { // Use over 1 day to avoid singular vs plural strings. + return str("revanced_check_environment_not_near_patch_time_days", daysSincePatching); + } + + return str("revanced_check_environment_not_near_patch_time"); + } + + @Override + public int uiSortingValue() { + return 100; // Show last. + } + } + + /** + * Injection point. + */ + public static void check(Activity context) { + // If the warning was already issued twice, or if the check was successful in the past, + // do not run the checks again. + if (!Check.shouldRun() && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) { + Logger.printDebug(() -> "Environment checks are disabled"); + return; + } + + Utils.runOnBackgroundThread(() -> { + try { + Logger.printInfo(() -> "Running environment checks"); + List failedChecks = new ArrayList<>(); + + CheckWasPatchedOnSameDevice sameHardware = new CheckWasPatchedOnSameDevice(); + Boolean hardwareCheckPassed = sameHardware.check(); + if (hardwareCheckPassed != null) { + if (hardwareCheckPassed && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) { + // Patched on the same device using Manager, + // and no further checks are needed. + Check.disableForever(); + return; + } + + failedChecks.add(sameHardware); + } + + CheckExpectedInstaller installerCheck = new CheckExpectedInstaller(); + if (installerCheck.check() && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) { + // If the installer package is Manager but this code is reached, + // that means it must not be the right Manager otherwise the hardware hash + // signatures would be present and this check would not have run. + if (installerCheck.installerFound == InstallationType.MANAGER) { + failedChecks.add(installerCheck); + // Also could not have been patched on this device. + failedChecks.add(sameHardware); + } else if (failedChecks.isEmpty()) { + // ADB install of CLI build. Allow even if patched a long time ago. + Check.disableForever(); + return; + } + } else { + failedChecks.add(installerCheck); + } + + CheckIsNearPatchTime nearPatchTime = new CheckIsNearPatchTime(); + Boolean timeCheckPassed = nearPatchTime.check(); + if (timeCheckPassed && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) { + // Allow installing recently patched apks, + // even if the install source is not Manager or ADB. + Check.disableForever(); + return; + } else { + failedChecks.add(nearPatchTime); + } + + if (DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) { + // Show all failures for debugging layout. + failedChecks = Arrays.asList( + sameHardware, + nearPatchTime, + installerCheck + ); + } + + //noinspection ComparatorCombinators + Collections.sort(failedChecks, (o1, o2) -> o1.uiSortingValue() - o2.uiSortingValue()); + + Check.issueWarning( + context, + failedChecks + ); + } catch (Exception ex) { + Logger.printException(() -> "check failure", ex); + } + }); + } + + private static boolean buildFieldEqualsHash(String buildFieldName, String buildFieldValue, @Nullable String hash) { + try { + final var sha1 = MessageDigest.getInstance("SHA-1") + .digest(buildFieldValue.getBytes(StandardCharsets.UTF_8)); + + // Must be careful to use same base64 encoding Kotlin uses. + String runtimeHash = new String(Base64.encode(sha1, Base64.NO_WRAP), StandardCharsets.ISO_8859_1); + final boolean equals = runtimeHash.equals(hash); + if (!equals) { + Logger.printInfo(() -> "Hashes do not match. " + buildFieldName + ": '" + buildFieldValue + + "' runtimeHash: '" + runtimeHash + "' patchTimeHash: '" + hash + "'"); + } + + return equals; + } catch (NoSuchAlgorithmException ex) { + Logger.printException(() -> "buildFieldEqualsHash failure", ex); // Will never happen. + + return false; + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/checks/PatchInfo.java b/extensions/shared/src/main/java/app/revanced/extension/shared/checks/PatchInfo.java new file mode 100644 index 000000000..62144d753 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/checks/PatchInfo.java @@ -0,0 +1,28 @@ +package app.revanced.extension.shared.checks; + +// Fields are set by the patch. Do not modify. +// Fields are not final, because the compiler is inlining them. +final class PatchInfo { + static long PATCH_TIME = 0L; + + final static class Build { + static String PATCH_BOARD = ""; + static String PATCH_BOOTLOADER = ""; + static String PATCH_BRAND = ""; + static String PATCH_CPU_ABI = ""; + static String PATCH_CPU_ABI2 = ""; + static String PATCH_DEVICE = ""; + static String PATCH_DISPLAY = ""; + static String PATCH_FINGERPRINT = ""; + static String PATCH_HARDWARE = ""; + static String PATCH_HOST = ""; + static String PATCH_ID = ""; + static String PATCH_MANUFACTURER = ""; + static String PATCH_MODEL = ""; + static String PATCH_PRODUCT = ""; + static String PATCH_RADIO = ""; + static String PATCH_TAGS = ""; + static String PATCH_TYPE = ""; + static String PATCH_USER = ""; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/fixes/slink/BaseFixSLinksPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/fixes/slink/BaseFixSLinksPatch.java new file mode 100644 index 000000000..a8a6bf504 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/fixes/slink/BaseFixSLinksPatch.java @@ -0,0 +1,208 @@ +package app.revanced.extension.shared.fixes.slink; + + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import androidx.annotation.NonNull; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.util.Objects; + +import static app.revanced.extension.shared.Utils.getContext; + + +/** + * Base class to implement /s/ link resolution in 3rd party Reddit apps. + *
+ *
+ * Usage: + *
+ *
+ * An implementation of this class must have two static methods that are called by the app: + *
    + *
  • public static boolean patchResolveSLink(String link)
  • + *
  • public static void patchSetAccessToken(String accessToken)
  • + *
+ * The static methods must call the instance methods of the base class. + *
+ * The singleton pattern can be used to access the instance of the class: + *
+ * {@code
+ * {
+ *     INSTANCE = new FixSLinksPatch();
+ * }
+ * }
+ * 
+ * Set the app's web view activity class as a fallback to open /s/ links if the resolution fails: + *
+ * {@code
+ * private FixSLinksPatch() {
+ *     webViewActivityClass = WebViewActivity.class;
+ * }
+ * }
+ * 
+ * Hook the app's navigation handler to call this method before doing any of its own resolution: + *
+ * {@code
+ * public static boolean patchResolveSLink(Context context, String link) {
+ *     return INSTANCE.resolveSLink(context, link);
+ * }
+ * }
+ * 
+ * If this method returns true, the app should early return and not do any of its own resolution. + *
+ *
+ * Hook the app's access token so that this class can use it to resolve /s/ links: + *
+ * {@code
+ * public static void patchSetAccessToken(String accessToken) {
+ *     INSTANCE.setAccessToken(access_token);
+ * }
+ * }
+ * 
+ */ +public abstract class BaseFixSLinksPatch { + /** + * The class of the activity used to open links in a web view if resolving them fails. + */ + protected Class webViewActivityClass; + + /** + * The access token used to resolve the /s/ link. + */ + protected String accessToken; + + /** + * The URL that was trying to be resolved before the access token was set. + * If this is not null, the URL will be resolved right after the access token is set. + */ + protected String pendingUrl; + + /** + * The singleton instance of the class. + */ + protected static BaseFixSLinksPatch INSTANCE; + + public boolean resolveSLink(String link) { + switch (resolveLink(link)) { + case ACCESS_TOKEN_START: { + pendingUrl = link; + return true; + } + case DO_NOTHING: + return true; + default: + return false; + } + } + + private ResolveResult resolveLink(String link) { + Context context = getContext(); + if (link.matches(".*reddit\\.com/r/[^/]+/s/[^/]+")) { + // A link ends with #bypass if it failed to resolve below. + // resolveLink is called with the same link again but this time with #bypass + // so that the link is opened in the app browser instead of trying to resolve it again. + if (link.endsWith("#bypass")) { + openInAppBrowser(context, link); + + return ResolveResult.DO_NOTHING; + } + + Logger.printDebug(() -> "Resolving " + link); + + if (accessToken == null) { + // This is not optimal. + // However, an accessToken is necessary to make an authenticated request to Reddit. + // in case Reddit has banned the IP - e.g. VPN. + Intent startIntent = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName()); + context.startActivity(startIntent); + + return ResolveResult.ACCESS_TOKEN_START; + } + + + Utils.runOnBackgroundThread(() -> { + String bypassLink = link + "#bypass"; + + String finalLocation = bypassLink; + try { + HttpURLConnection connection = getHttpURLConnection(link, accessToken); + connection.connect(); + String location = connection.getHeaderField("location"); + connection.disconnect(); + + Objects.requireNonNull(location, "Location is null"); + + finalLocation = location; + Logger.printDebug(() -> "Resolved " + link + " to " + location); + } catch (SocketTimeoutException e) { + Logger.printException(() -> "Timeout when trying to resolve " + link, e); + finalLocation = bypassLink; + } catch (Exception e) { + Logger.printException(() -> "Failed to resolve " + link, e); + finalLocation = bypassLink; + } finally { + Intent startIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(finalLocation)); + startIntent.setPackage(context.getPackageName()); + startIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(startIntent); + } + }); + + return ResolveResult.DO_NOTHING; + } + + return ResolveResult.CONTINUE; + } + + public void setAccessToken(String accessToken) { + Logger.printDebug(() -> "Setting access token"); + + this.accessToken = accessToken; + + // In case a link was trying to be resolved before access token was set. + // The link is resolved now, after the access token is set. + if (pendingUrl != null) { + String link = pendingUrl; + pendingUrl = null; + + Logger.printDebug(() -> "Opening pending URL"); + + resolveLink(link); + } + } + + private void openInAppBrowser(Context context, String link) { + Intent intent = new Intent(context, webViewActivityClass); + intent.putExtra("url", link); + context.startActivity(intent); + } + + @NonNull + private HttpURLConnection getHttpURLConnection(String link, String accessToken) throws IOException { + URL url = new URL(link); + + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setInstanceFollowRedirects(false); + connection.setRequestMethod("HEAD"); + connection.setConnectTimeout(2000); + connection.setReadTimeout(2000); + + if (accessToken != null) { + Logger.printDebug(() -> "Setting access token to make /s/ request"); + + connection.setRequestProperty("Authorization", "Bearer " + accessToken); + } else { + Logger.printDebug(() -> "Not setting access token to make /s/ request, because it is null"); + } + + return connection; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/fixes/slink/ResolveResult.java b/extensions/shared/src/main/java/app/revanced/extension/shared/fixes/slink/ResolveResult.java new file mode 100644 index 000000000..8026c2058 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/fixes/slink/ResolveResult.java @@ -0,0 +1,10 @@ +package app.revanced.extension.shared.fixes.slink; + +public enum ResolveResult { + // Let app handle rest of stuff + CONTINUE, + // Start app, to make it cache its access_token + ACCESS_TOKEN_START, + // Don't do anything - we started resolving + DO_NOTHING +} 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 000000000..70d7589e8 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BaseSettings.java @@ -0,0 +1,17 @@ +package app.revanced.extension.shared.settings; + +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; +import static app.revanced.extension.shared.settings.Setting.parent; + +/** + * 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 DEBUG = new BooleanSetting("revanced_debug", FALSE); + public static final BooleanSetting DEBUG_STACKTRACE = new BooleanSetting("revanced_debug_stacktrace", FALSE, parent(DEBUG)); + public static final BooleanSetting DEBUG_TOAST_ON_ERROR = new BooleanSetting("revanced_debug_toast_on_error", TRUE, "revanced_debug_toast_on_error_user_dialog_message"); +} 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 000000000..7e84034d0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BooleanSetting.java @@ -0,0 +1,79 @@ +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); + } + + @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 000000000..a2b82dd21 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/EnumSetting.java @@ -0,0 +1,117 @@ +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.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); + } + + @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 000000000..7419741e0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/FloatSetting.java @@ -0,0 +1,69 @@ +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); + } + + @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 000000000..58f39a910 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/IntegerSetting.java @@ -0,0 +1,69 @@ +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); + } + + @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 000000000..4d7f8114f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/LongSetting.java @@ -0,0 +1,69 @@ +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); + } + + @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 000000000..7507d802a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/Setting.java @@ -0,0 +1,437 @@ +package app.revanced.extension.shared.settings; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.StringRef; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.shared.settings.preference.SharedPrefCategory; +import app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings; +import org.jetbrains.annotations.NotNull; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.*; + +import static app.revanced.extension.shared.StringRef.str; + +@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 extension 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_prefs"); + + @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() { + Collections.sort(SETTINGS, (Setting o1, Setting 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.showToastLong(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 == newSetting) throw new IllegalArgumentException(); + + 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); + + @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} + */ + 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); + } + } + 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 ""; + } + } + + /** + * @return if any settings that require a reboot were changed. + */ + public static boolean importFromJSON(@NonNull String settingsJsonString) { + 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. + SponsorBlockSettings.updateFromImportedSettings(); + + Utils.showToastLong(numberOfSettingsImported == 0 + ? str("revanced_settings_import_reset") + : str("revanced_settings_import_success", numberOfSettingsImported)); + + return rebootSettingChanged; + } catch (JSONException | IllegalArgumentException ex) { + Utils.showToastLong(str("revanced_settings_import_failure_parse", 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 000000000..0fa5e03fc --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/StringSetting.java @@ -0,0 +1,69 @@ +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); + } + + @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 000000000..3c1ad706a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java @@ -0,0 +1,274 @@ +package app.revanced.extension.shared.settings.preference; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.*; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.shared.settings.Setting; + +import static app.revanced.extension.shared.StringRef.str; + +@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 restartDialogButtonText, restartDialogTitle, confirmDialogTitle; + + /** + * Used to prevent showing reboot dialog, if user cancels a setting user dialog. + */ + private boolean showingUserDialogMessage; + + private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> { + try { + Setting setting = Setting.getSettingFromPath(str); + if (setting == null) { + return; + } + Preference pref = findPreference(str); + if (pref == null) { + return; + } + Logger.printDebug(() -> "Preference changed: " + setting.key); + + // 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(getContext()); + } + } + + } 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 var identifier = Utils.getResourceIdentifier("revanced_prefs", "xml"); + + if (identifier == 0) return; + addPreferencesFromResource(identifier); + Utils.sortPreferenceGroups(getPreferenceScreen()); + } + + private void showSettingUserDialogConfirmation(SwitchPreference switchPref, BooleanSetting setting) { + Utils.verifyOnMainThread(); + + final var context = getContext(); + if (confirmDialogTitle == null) { + confirmDialogTitle = str("revanced_settings_confirm_user_dialog_title"); + } + showingUserDialogMessage = true; + new AlertDialog.Builder(context) + .setTitle(confirmDialogTitle) + .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) { + updatePreferenceScreen((PreferenceScreen) pref, syncSettingValue, applySettingToPreference); + } else if (pref.hasKey()) { + String key = pref.getKey(); + Setting setting = Setting.getSettingFromPath(key); + + if (setting != null) { + updatePreference(pref, setting, syncSettingValue, applySettingToPreference); + } else if (BaseSettings.DEBUG.get() && (pref instanceof SwitchPreference + || pref instanceof EditTextPreference || pref instanceof ListPreference)) { + // Probably a typo in the patches preference declaration. + Logger.printException(() -> "Preference key has no setting: " + key); + } + } + } + } + + /** + * 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 switchPref = (SwitchPreference) pref; + BooleanSetting boolSetting = (BooleanSetting) setting; + if (applySettingToPreference) { + switchPref.setChecked(boolSetting.get()); + } else { + BooleanSetting.privateSetValue(boolSetting, switchPref.isChecked()); + } + } else if (pref instanceof EditTextPreference) { + EditTextPreference editPreference = (EditTextPreference) pref; + if (applySettingToPreference) { + editPreference.setText(setting.get().toString()); + } else { + Setting.privateSetValueFromString(setting, editPreference.getText()); + } + } else if (pref instanceof ListPreference) { + ListPreference listPref = (ListPreference) pref; + if (applySettingToPreference) { + listPref.setValue(setting.get().toString()); + } else { + Setting.privateSetValueFromString(setting, listPref.getValue()); + } + updateListPreferenceSummary(listPref, 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()); + } + + protected void updateListPreferenceSummary(ListPreference listPreference, Setting setting) { + String objectStringValue = setting.get().toString(); + final int entryIndex = listPreference.findIndexOfValue(objectStringValue); + if (entryIndex >= 0) { + listPreference.setSummary(listPreference.getEntries()[entryIndex]); + } else { + // Value is not an available option. + // User manually edited import data, or options changed and current selection is no longer available. + // Still show the value in the summary, so it's clear that something is selected. + listPreference.setSummary(objectStringValue); + } + } + + public static void showRestartDialog(@NonNull final Context context) { + Utils.verifyOnMainThread(); + if (restartDialogTitle == null) { + restartDialogTitle = str("revanced_settings_restart_title"); + } + if (restartDialogButtonText == null) { + restartDialogButtonText = str("revanced_settings_restart"); + } + new AlertDialog.Builder(context) + .setMessage(restartDialogTitle) + .setPositiveButton(restartDialogButtonText, (dialog, id) + -> Utils.restartApp(context)) + .setNegativeButton(android.R.string.cancel, null) + .setCancelable(false) + .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 onDestroy() { + getPreferenceManager().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(listener); + super.onDestroy(); + } +} 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 000000000..c750ca3f1 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java @@ -0,0 +1,99 @@ +package app.revanced.extension.shared.settings.preference; + +import android.app.AlertDialog; +import android.content.Context; +import android.os.Build; +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.Logger; +import app.revanced.extension.shared.Utils; + +import static app.revanced.extension.shared.StringRef.str; + +@SuppressWarnings({"unused", "deprecation"}) +public class ImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener { + + private String existingSettings; + + private void init() { + setSelectable(true); + + EditText editText = getEditText(); + editText.setTextIsSelectable(true); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + 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); + + // Show the user the settings in JSON format. + builder.setNeutralButton(str("revanced_settings_import_copy"), (dialog, which) -> { + Utils.setClipboard(getEditText().getText().toString()); + }).setPositiveButton(str("revanced_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/ReVancedAboutPreference.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ReVancedAboutPreference.java new file mode 100644 index 000000000..89fbe80e9 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ReVancedAboutPreference.java @@ -0,0 +1,325 @@ +package app.revanced.extension.shared.settings.preference; + +import static app.revanced.extension.shared.StringRef.sf; +import static app.revanced.extension.shared.StringRef.str; +import static app.revanced.extension.youtube.requests.Route.Method.GET; + +import android.annotation.SuppressLint; +import android.app.Dialog; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.graphics.Color; +import android.net.Uri; +import android.os.Bundle; +import android.preference.Preference; +import android.util.AttributeSet; +import android.view.Window; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.requests.Requester; +import app.revanced.extension.youtube.requests.Route; + +/** + * Opens a dialog showing the links from {@link SocialLinksRoutes}. + */ +@SuppressWarnings({"unused", "deprecation"}) +public class ReVancedAboutPreference extends Preference { + + private static String useNonBreakingHyphens(String text) { + // Replace any dashes with non breaking dashes, so the English text 'pre-release' + // and the dev release number does not break and cover two lines. + return text.replace("-", "‑"); // #8209 = non breaking hyphen. + } + + private static String getColorHexString(int color) { + return String.format("#%06X", (0x00FFFFFF & color)); + } + + protected boolean isDarkModeEnabled() { + Configuration config = getContext().getResources().getConfiguration(); + final int currentNightMode = config.uiMode & Configuration.UI_MODE_NIGHT_MASK; + return currentNightMode == Configuration.UI_MODE_NIGHT_YES; + } + + /** + * Subclasses can override this and provide a themed color. + */ + protected int getLightColor() { + return Color.WHITE; + } + + /** + * Subclasses can override this and provide a themed color. + */ + protected int getDarkColor() { + return Color.BLACK; + } + + private String createDialogHtml(WebLink[] socialLinks) { + final boolean isNetworkConnected = Utils.isNetworkConnected(); + + StringBuilder builder = new StringBuilder(); + builder.append(""); + builder.append(""); + + final boolean isDarkMode = isDarkModeEnabled(); + String backgroundColorHex = getColorHexString(isDarkMode ? getDarkColor() : getLightColor()); + String foregroundColorHex = getColorHexString(isDarkMode ? getLightColor() : getDarkColor()); + // Apply light/dark mode colors. + builder.append(String.format( + "", + backgroundColorHex, foregroundColorHex, foregroundColorHex)); + + if (isNetworkConnected) { + builder.append(""); + } + + String patchesVersion = Utils.getPatchesReleaseVersion(); + + // Add the title. + builder.append("

") + .append("ReVanced") + .append("

"); + + builder.append("

") + // Replace hyphens with non breaking dashes so the version number does not break lines. + .append(useNonBreakingHyphens(str("revanced_settings_about_links_body", patchesVersion))) + .append("

"); + + // Add a disclaimer if using a dev release. + if (patchesVersion.contains("dev")) { + builder.append("

") + // English text 'Pre-release' can break lines. + .append(useNonBreakingHyphens(str("revanced_settings_about_links_dev_header"))) + .append("

"); + + builder.append("

") + .append(str("revanced_settings_about_links_dev_body")) + .append("

"); + } + + builder.append("

") + .append(str("revanced_settings_about_links_header")) + .append("

"); + + builder.append("
"); + for (WebLink social : socialLinks) { + builder.append("
"); + builder.append(String.format("%s", social.url, social.name)); + builder.append("
"); + } + builder.append("
"); + + builder.append(""); + return builder.toString(); + } + + { + setOnPreferenceClickListener(pref -> { + // Show a progress spinner if the social links are not fetched yet. + if (!SocialLinksRoutes.hasFetchedLinks() && Utils.isNetworkConnected()) { + ProgressDialog progress = new ProgressDialog(getContext()); + progress.setProgressStyle(ProgressDialog.STYLE_SPINNER); + progress.show(); + Utils.runOnBackgroundThread(() -> fetchLinksAndShowDialog(progress)); + } else { + // No network call required and can run now. + fetchLinksAndShowDialog(null); + } + + return false; + }); + } + + private void fetchLinksAndShowDialog(@Nullable ProgressDialog progress) { + WebLink[] socialLinks = SocialLinksRoutes.fetchSocialLinks(); + String htmlDialog = createDialogHtml(socialLinks); + + Utils.runOnMainThreadNowOrLater(() -> { + if (progress != null) { + progress.dismiss(); + } + new WebViewDialog(getContext(), htmlDialog).show(); + }); + } + + public ReVancedAboutPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + public ReVancedAboutPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + public ReVancedAboutPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + public ReVancedAboutPreference(Context context) { + super(context); + } +} + +/** + * Displays html content as a dialog. Any links a user taps on are opened in an external browser. + */ +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; + } + } +} + +class WebLink { + final boolean preferred; + final String name; + final String url; + + WebLink(JSONObject json) throws JSONException { + this(json.getBoolean("preferred"), + json.getString("name"), + json.getString("url") + ); + } + + WebLink(boolean preferred, String name, String url) { + this.preferred = preferred; + this.name = name; + this.url = url; + } + + @NonNull + @Override + public String toString() { + return "ReVancedSocialLink{" + + "preferred=" + preferred + + ", name='" + name + '\'' + + ", url='" + url + '\'' + + '}'; + } +} + +class SocialLinksRoutes { + /** + * Simple link to the website donate page, + * rather than fetching and parsing the donation links using the API. + */ + public static final WebLink DONATE_LINK = new WebLink(true, + sf("revanced_settings_about_links_donate").toString(), + "https://revanced.app/donate"); + + /** + * Links to use if fetch links api call fails. + */ + private static final WebLink[] NO_CONNECTION_STATIC_LINKS = { + new WebLink(true, "ReVanced.app", "https://revanced.app"), + DONATE_LINK, + }; + + private static final String SOCIAL_LINKS_PROVIDER = "https://api.revanced.app/v2"; + private static final Route.CompiledRoute GET_SOCIAL = new Route(GET, "/socials").compile(); + + @Nullable + private static volatile WebLink[] fetchedLinks; + + static boolean hasFetchedLinks() { + return fetchedLinks != null; + } + + static WebLink[] fetchSocialLinks() { + try { + if (hasFetchedLinks()) return fetchedLinks; + + // Check if there is no internet connection. + if (!Utils.isNetworkConnected()) return NO_CONNECTION_STATIC_LINKS; + + HttpURLConnection connection = Requester.getConnectionFromCompiledRoute(SOCIAL_LINKS_PROVIDER, GET_SOCIAL); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + Logger.printDebug(() -> "Fetching social links from: " + connection.getURL()); + + // Do not show an exception toast if the server is down + final int responseCode = connection.getResponseCode(); + if (responseCode != 200) { + Logger.printDebug(() -> "Failed to get social links. Response code: " + responseCode); + return NO_CONNECTION_STATIC_LINKS; + } + + JSONObject json = Requester.parseJSONObjectAndDisconnect(connection); + JSONArray socials = json.getJSONArray("socials"); + + List links = new ArrayList<>(); + + links.add(DONATE_LINK); // Show donate link first. + for (int i = 0, length = socials.length(); i < length; i++) { + WebLink link = new WebLink(socials.getJSONObject(i)); + links.add(link); + } + + Logger.printDebug(() -> "links: " + links); + + return fetchedLinks = links.toArray(new WebLink[0]); + + } catch (SocketTimeoutException ex) { + Logger.printInfo(() -> "Could not fetch social links", ex); // No toast. + } catch (JSONException ex) { + Logger.printException(() -> "Could not parse about information", ex); + } catch (Exception ex) { + Logger.printException(() -> "Failed to get about information", ex); + } + + return NO_CONNECTION_STATIC_LINKS; + } +} 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 000000000..3e9a96961 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ResettableEditTextPreference.java @@ -0,0 +1,67 @@ +package app.revanced.extension.shared.settings.preference; + +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 app.revanced.extension.shared.Utils; +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.Logger; + +import java.util.Objects; + +import static app.revanced.extension.shared.StringRef.str; + +@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) { + super.onPrepareDialogBuilder(builder); + Utils.setEditTextDialogTheme(builder); + + Setting setting = Setting.getSettingFromPath(getKey()); + if (setting != null) { + builder.setNeutralButton(str("revanced_settings_reset"), null); + } + } + + @Override + protected void showDialog(Bundle state) { + super.showDialog(state); + + // Override the button click listener to prevent dismissing the dialog. + Button button = ((AlertDialog) getDialog()).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 000000000..4e9c1f2e0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/SharedPrefCategory.java @@ -0,0 +1,190 @@ +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 app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; + +import java.util.Objects; + +/** + * 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); + } + } 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/syncforreddit/FixRedditVideoDownloadPatch.java b/extensions/shared/src/main/java/app/revanced/extension/syncforreddit/FixRedditVideoDownloadPatch.java new file mode 100644 index 000000000..e006e31e6 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/syncforreddit/FixRedditVideoDownloadPatch.java @@ -0,0 +1,77 @@ +package app.revanced.extension.syncforreddit; + +import android.util.Pair; +import androidx.annotation.Nullable; +import org.w3c.dom.Element; +import org.xml.sax.SAXException; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; + +/** + * @noinspection unused + */ +public class FixRedditVideoDownloadPatch { + private static @Nullable Pair getBestMpEntry(Element element) { + var representations = element.getElementsByTagName("Representation"); + var entries = new ArrayList>(); + + for (int i = 0; i < representations.getLength(); i++) { + Element representation = (Element) representations.item(i); + var bandwidthStr = representation.getAttribute("bandwidth"); + try { + var bandwidth = Integer.parseInt(bandwidthStr); + var baseUrl = representation.getElementsByTagName("BaseURL").item(0); + if (baseUrl != null) { + entries.add(new Pair<>(bandwidth, baseUrl.getTextContent())); + } + } catch (NumberFormatException ignored) { + } + } + + if (entries.isEmpty()) { + return null; + } + + Collections.sort(entries, (e1, e2) -> e2.first - e1.first); + return entries.get(0); + } + + private static String[] parse(byte[] data) throws ParserConfigurationException, IOException, SAXException { + var adaptionSets = DocumentBuilderFactory + .newInstance() + .newDocumentBuilder() + .parse(new ByteArrayInputStream(data)) + .getElementsByTagName("AdaptationSet"); + + String videoUrl = null; + String audioUrl = null; + + for (int i = 0; i < adaptionSets.getLength(); i++) { + Element element = (Element) adaptionSets.item(i); + var contentType = element.getAttribute("contentType"); + var bestEntry = getBestMpEntry(element); + if (bestEntry == null) continue; + + if (contentType.equalsIgnoreCase("video")) { + videoUrl = bestEntry.second; + } else if (contentType.equalsIgnoreCase("audio")) { + audioUrl = bestEntry.second; + } + } + + return new String[]{videoUrl, audioUrl}; + } + + public static String[] getLinks(byte[] data) { + try { + return parse(data); + } catch (ParserConfigurationException | IOException | SAXException e) { + return new String[]{null, null}; + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/syncforreddit/FixSLinksPatch.java b/extensions/shared/src/main/java/app/revanced/extension/syncforreddit/FixSLinksPatch.java new file mode 100644 index 000000000..de6a96c12 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/syncforreddit/FixSLinksPatch.java @@ -0,0 +1,24 @@ +package app.revanced.extension.syncforreddit; + +import com.laurencedawson.reddit_sync.ui.activities.WebViewActivity; + +import app.revanced.extension.shared.fixes.slink.BaseFixSLinksPatch; + +/** @noinspection unused*/ +public class FixSLinksPatch extends BaseFixSLinksPatch { + static { + INSTANCE = new FixSLinksPatch(); + } + + private FixSLinksPatch() { + webViewActivityClass = WebViewActivity.class; + } + + public static boolean patchResolveSLink(String link) { + return INSTANCE.resolveSLink(link); + } + + public static void patchSetAccessToken(String accessToken) { + INSTANCE.setAccessToken(accessToken); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/Utils.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/Utils.java new file mode 100644 index 000000000..277965921 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/Utils.java @@ -0,0 +1,25 @@ +package app.revanced.extension.tiktok; + +import app.revanced.extension.shared.settings.StringSetting; + +public class Utils { + + // Edit: This could be handled using a custom Setting class + // that saves its value to preferences and JSON using the formatted String created here. + public static long[] parseMinMax(StringSetting setting) { + final String[] minMax = setting.get().split("-"); + if (minMax.length == 2) { + try { + final long min = Long.parseLong(minMax[0]); + final long max = Long.parseLong(minMax[1]); + + if (min <= max && min >= 0) return new long[]{min, max}; + + } catch (NumberFormatException ignored) { + } + } + + setting.save("0-" + Long.MAX_VALUE); + return new long[]{0L, Long.MAX_VALUE}; + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/cleardisplay/RememberClearDisplayPatch.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/cleardisplay/RememberClearDisplayPatch.java new file mode 100644 index 000000000..e436b5dcd --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/cleardisplay/RememberClearDisplayPatch.java @@ -0,0 +1,13 @@ +package app.revanced.extension.tiktok.cleardisplay; + +import app.revanced.extension.tiktok.settings.Settings; + +@SuppressWarnings("unused") +public class RememberClearDisplayPatch { + public static boolean getClearDisplayState() { + return Settings.CLEAR_DISPLAY.get(); + } + public static void rememberClearDisplayState(boolean newState) { + Settings.CLEAR_DISPLAY.save(newState); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/download/DownloadsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/download/DownloadsPatch.java new file mode 100644 index 000000000..c55d62878 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/download/DownloadsPatch.java @@ -0,0 +1,14 @@ +package app.revanced.extension.tiktok.download; + +import app.revanced.extension.tiktok.settings.Settings; + +@SuppressWarnings("unused") +public class DownloadsPatch { + public static String getDownloadPath() { + return Settings.DOWNLOAD_PATH.get(); + } + + public static boolean shouldRemoveWatermark() { + return Settings.DOWNLOAD_WATERMARK.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/AdsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/AdsFilter.java new file mode 100644 index 000000000..31a982c68 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/AdsFilter.java @@ -0,0 +1,16 @@ +package app.revanced.extension.tiktok.feedfilter; + +import app.revanced.extension.tiktok.settings.Settings; +import com.ss.android.ugc.aweme.feed.model.Aweme; + +public class AdsFilter implements IFilter { + @Override + public boolean getEnabled() { + return Settings.REMOVE_ADS.get(); + } + + @Override + public boolean getFiltered(Aweme item) { + return item.isAd() || item.isWithPromotionalMusic(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/FeedItemsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/FeedItemsFilter.java new file mode 100644 index 000000000..e1e0add8e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/FeedItemsFilter.java @@ -0,0 +1,34 @@ +package app.revanced.extension.tiktok.feedfilter; + +import com.ss.android.ugc.aweme.feed.model.Aweme; +import com.ss.android.ugc.aweme.feed.model.FeedItemList; + +import java.util.Iterator; +import java.util.List; + +public final class FeedItemsFilter { + private static final List FILTERS = List.of( + new AdsFilter(), + new LiveFilter(), + new StoryFilter(), + new ImageVideoFilter(), + new ViewCountFilter(), + new LikeCountFilter() + ); + + public static void filter(FeedItemList feedItemList) { + Iterator feedItemListIterator = feedItemList.items.iterator(); + while (feedItemListIterator.hasNext()) { + Aweme item = feedItemListIterator.next(); + if (item == null) continue; + + for (IFilter filter : FILTERS) { + boolean enabled = filter.getEnabled(); + if (enabled && filter.getFiltered(item)) { + feedItemListIterator.remove(); + break; + } + } + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/IFilter.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/IFilter.java new file mode 100644 index 000000000..57639258d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/IFilter.java @@ -0,0 +1,9 @@ +package app.revanced.extension.tiktok.feedfilter; + +import com.ss.android.ugc.aweme.feed.model.Aweme; + +public interface IFilter { + boolean getEnabled(); + + boolean getFiltered(Aweme item); +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/ImageVideoFilter.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/ImageVideoFilter.java new file mode 100644 index 000000000..ed3e7cdb9 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/ImageVideoFilter.java @@ -0,0 +1,16 @@ +package app.revanced.extension.tiktok.feedfilter; + +import app.revanced.extension.tiktok.settings.Settings; +import com.ss.android.ugc.aweme.feed.model.Aweme; + +public class ImageVideoFilter implements IFilter { + @Override + public boolean getEnabled() { + return Settings.HIDE_IMAGE.get(); + } + + @Override + public boolean getFiltered(Aweme item) { + return item.isImage() || item.isPhotoMode(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/LikeCountFilter.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/LikeCountFilter.java new file mode 100644 index 000000000..57eb665ea --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/LikeCountFilter.java @@ -0,0 +1,32 @@ +package app.revanced.extension.tiktok.feedfilter; + +import app.revanced.extension.tiktok.settings.Settings; +import com.ss.android.ugc.aweme.feed.model.Aweme; +import com.ss.android.ugc.aweme.feed.model.AwemeStatistics; + +import static app.revanced.extension.tiktok.Utils.parseMinMax; + +public final class LikeCountFilter implements IFilter { + final long minLike; + final long maxLike; + + LikeCountFilter() { + long[] minMax = parseMinMax(Settings.MIN_MAX_LIKES); + minLike = minMax[0]; + maxLike = minMax[1]; + } + + @Override + public boolean getEnabled() { + return true; + } + + @Override + public boolean getFiltered(Aweme item) { + AwemeStatistics statistics = item.getStatistics(); + if (statistics == null) return false; + + long likeCount = statistics.getDiggCount(); + return likeCount < minLike || likeCount > maxLike; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/LiveFilter.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/LiveFilter.java new file mode 100644 index 000000000..db6ab0af0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/LiveFilter.java @@ -0,0 +1,16 @@ +package app.revanced.extension.tiktok.feedfilter; + +import app.revanced.extension.tiktok.settings.Settings; +import com.ss.android.ugc.aweme.feed.model.Aweme; + +public class LiveFilter implements IFilter { + @Override + public boolean getEnabled() { + return Settings.HIDE_LIVE.get(); + } + + @Override + public boolean getFiltered(Aweme item) { + return item.isLive() || item.isLiveReplay(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/StoryFilter.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/StoryFilter.java new file mode 100644 index 000000000..85d0a7088 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/StoryFilter.java @@ -0,0 +1,16 @@ +package app.revanced.extension.tiktok.feedfilter; + +import app.revanced.extension.tiktok.settings.Settings; +import com.ss.android.ugc.aweme.feed.model.Aweme; + +public class StoryFilter implements IFilter { + @Override + public boolean getEnabled() { + return Settings.HIDE_STORY.get(); + } + + @Override + public boolean getFiltered(Aweme item) { + return item.getIsTikTokStory(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/ViewCountFilter.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/ViewCountFilter.java new file mode 100644 index 000000000..ca9156f84 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/ViewCountFilter.java @@ -0,0 +1,32 @@ +package app.revanced.extension.tiktok.feedfilter; + +import app.revanced.extension.tiktok.settings.Settings; +import com.ss.android.ugc.aweme.feed.model.Aweme; +import com.ss.android.ugc.aweme.feed.model.AwemeStatistics; + +import static app.revanced.extension.tiktok.Utils.parseMinMax; + +public class ViewCountFilter implements IFilter { + final long minView; + final long maxView; + + ViewCountFilter() { + long[] minMax = parseMinMax(Settings.MIN_MAX_VIEWS); + minView = minMax[0]; + maxView = minMax[1]; + } + + @Override + public boolean getEnabled() { + return true; + } + + @Override + public boolean getFiltered(Aweme item) { + AwemeStatistics statistics = item.getStatistics(); + if (statistics == null) return false; + + long playCount = statistics.getPlayCount(); + return playCount < minView || playCount > maxView; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/AdPersonalizationActivityHook.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/AdPersonalizationActivityHook.java new file mode 100644 index 000000000..11304eb1e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/AdPersonalizationActivityHook.java @@ -0,0 +1,82 @@ +package app.revanced.extension.tiktok.settings; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.preference.PreferenceFragment; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.tiktok.settings.preference.ReVancedPreferenceFragment; +import com.bytedance.ies.ugc.aweme.commercialize.compliance.personalization.AdPersonalizationActivity; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; + +/** + * Hooks AdPersonalizationActivity. + *

+ * This class is responsible for injecting our own fragment by replacing the AdPersonalizationActivity. + * + * @noinspection unused + */ +public class AdPersonalizationActivityHook { + public static Object createSettingsEntry(String entryClazzName, String entryInfoClazzName) { + try { + Class entryClazz = Class.forName(entryClazzName); + Class entryInfoClazz = Class.forName(entryInfoClazzName); + Constructor entryConstructor = entryClazz.getConstructor(entryInfoClazz); + Constructor entryInfoConstructor = entryInfoClazz.getDeclaredConstructors()[0]; + Object buttonInfo = entryInfoConstructor.newInstance("ReVanced settings", null, (View.OnClickListener) view -> startSettingsActivity(), "revanced"); + return entryConstructor.newInstance(buttonInfo); + } catch (ClassNotFoundException | NoSuchMethodException | InvocationTargetException | IllegalAccessException | + InstantiationException e) { + throw new RuntimeException(e); + } + } + + /*** + * Initialize the settings menu. + * @param base The activity to initialize the settings menu on. + * @return Whether the settings menu should be initialized. + */ + public static boolean initialize(AdPersonalizationActivity base) { + Bundle extras = base.getIntent().getExtras(); + if (extras != null && !extras.getBoolean("revanced", false)) return false; + + SettingsStatus.load(); + + LinearLayout linearLayout = new LinearLayout(base); + linearLayout.setLayoutParams(new LinearLayout.LayoutParams(-1, -1)); + linearLayout.setOrientation(LinearLayout.VERTICAL); + linearLayout.setFitsSystemWindows(true); + linearLayout.setTransitionGroup(true); + + FrameLayout fragment = new FrameLayout(base); + fragment.setLayoutParams(new FrameLayout.LayoutParams(-1, -1)); + int fragmentId = View.generateViewId(); + fragment.setId(fragmentId); + + linearLayout.addView(fragment); + base.setContentView(linearLayout); + + PreferenceFragment preferenceFragment = new ReVancedPreferenceFragment(); + base.getFragmentManager().beginTransaction().replace(fragmentId, preferenceFragment).commit(); + + return true; + } + + private static void startSettingsActivity() { + Context appContext = Utils.getContext(); + if (appContext != null) { + Intent intent = new Intent(appContext, AdPersonalizationActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra("revanced", true); + appContext.startActivity(intent); + } else { + Logger.printDebug(() -> "Utils.getContext() return null"); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/Settings.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/Settings.java new file mode 100644 index 000000000..22a2d84d9 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/Settings.java @@ -0,0 +1,26 @@ +package app.revanced.extension.tiktok.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; +import app.revanced.extension.shared.settings.FloatSetting; +import app.revanced.extension.shared.settings.StringSetting; + +public class Settings extends BaseSettings { + public static final BooleanSetting REMOVE_ADS = new BooleanSetting("remove_ads", TRUE, true); + public static final BooleanSetting HIDE_LIVE = new BooleanSetting("hide_live", FALSE, true); + public static final BooleanSetting HIDE_STORY = new BooleanSetting("hide_story", FALSE, true); + public static final BooleanSetting HIDE_IMAGE = new BooleanSetting("hide_image", FALSE, true); + public static final StringSetting MIN_MAX_VIEWS = new StringSetting("min_max_views", "0-" + Long.MAX_VALUE, true); + public static final StringSetting MIN_MAX_LIKES = new StringSetting("min_max_likes", "0-" + Long.MAX_VALUE, true); + public static final StringSetting DOWNLOAD_PATH = new StringSetting("down_path", "DCIM/TikTok"); + public static final BooleanSetting DOWNLOAD_WATERMARK = new BooleanSetting("down_watermark", TRUE); + public static final BooleanSetting CLEAR_DISPLAY = new BooleanSetting("clear_display", FALSE); + public static final FloatSetting REMEMBERED_SPEED = new FloatSetting("REMEMBERED_SPEED", 1.0f); + public static final BooleanSetting SIM_SPOOF = new BooleanSetting("simspoof", TRUE, true); + public static final StringSetting SIM_SPOOF_ISO = new StringSetting("simspoof_iso", "us"); + public static final StringSetting SIMSPOOF_MCCMNC = new StringSetting("simspoof_mccmnc", "310160"); + public static final StringSetting SIMSPOOF_OP_NAME = new StringSetting("simspoof_op_name", "T-Mobile"); +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/SettingsStatus.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/SettingsStatus.java new file mode 100644 index 000000000..7333b1798 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/SettingsStatus.java @@ -0,0 +1,23 @@ +package app.revanced.extension.tiktok.settings; + +public class SettingsStatus { + public static boolean feedFilterEnabled = false; + public static boolean downloadEnabled = false; + public static boolean simSpoofEnabled = false; + + public static void enableFeedFilter() { + feedFilterEnabled = true; + } + + public static void enableDownload() { + downloadEnabled = true; + } + + public static void enableSimSpoof() { + simSpoofEnabled = true; + } + + public static void load() { + + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/DownloadPathPreference.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/DownloadPathPreference.java new file mode 100644 index 000000000..ae4759c79 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/DownloadPathPreference.java @@ -0,0 +1,124 @@ +package app.revanced.extension.tiktok.settings.preference; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Environment; +import android.preference.DialogPreference; +import android.text.Editable; +import android.text.InputType; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.View; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.RadioButton; +import android.widget.RadioGroup; + +import app.revanced.extension.shared.settings.StringSetting; + +@SuppressWarnings("deprecation") +public class DownloadPathPreference extends DialogPreference { + private final Context context; + private final String[] entryValues = {"DCIM", "Movies", "Pictures"}; + private String mValue; + + private boolean mValueSet; + private int mediaPathIndex; + private String childDownloadPath; + + public DownloadPathPreference(Context context, String title, StringSetting setting) { + super(context); + this.context = context; + this.setTitle(title); + this.setSummary(Environment.getExternalStorageDirectory().getPath() + "/" + setting.get()); + this.setKey(setting.key); + this.setValue(setting.get()); + } + + public String getValue() { + return this.mValue; + } + + public void setValue(String value) { + final boolean changed = !TextUtils.equals(mValue, value); + if (changed || !mValueSet) { + mValue = value; + mValueSet = true; + persistString(value); + if (changed) { + notifyDependencyChange(shouldDisableDependents()); + notifyChanged(); + } + } + } + + @Override + protected View onCreateDialogView() { + String currentMedia = getValue().split("/")[0]; + childDownloadPath = getValue().substring(getValue().indexOf("/") + 1); + mediaPathIndex = findIndexOf(currentMedia); + + LinearLayout dialogView = new LinearLayout(context); + RadioGroup mediaPath = new RadioGroup(context); + mediaPath.setLayoutParams(new RadioGroup.LayoutParams(-1, -2)); + for (String entryValue : entryValues) { + RadioButton radioButton = new RadioButton(context); + radioButton.setText(entryValue); + radioButton.setId(View.generateViewId()); + mediaPath.addView(radioButton); + } + mediaPath.setOnCheckedChangeListener((radioGroup, id) -> { + RadioButton radioButton = radioGroup.findViewById(id); + mediaPathIndex = findIndexOf(radioButton.getText().toString()); + }); + mediaPath.check(mediaPath.getChildAt(mediaPathIndex).getId()); + EditText downloadPath = new EditText(context); + downloadPath.setInputType(InputType.TYPE_CLASS_TEXT); + downloadPath.setText(childDownloadPath); + downloadPath.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { + + } + + @Override + public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { + + } + + @Override + public void afterTextChanged(Editable editable) { + childDownloadPath = editable.toString(); + } + }); + dialogView.setLayoutParams(new LinearLayout.LayoutParams(-1, -1)); + dialogView.setOrientation(LinearLayout.VERTICAL); + dialogView.addView(mediaPath); + dialogView.addView(downloadPath); + return dialogView; + } + + @Override + protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { + builder.setTitle("Download Path"); + builder.setPositiveButton(android.R.string.ok, (dialog, which) -> this.onClick(dialog, DialogInterface.BUTTON_POSITIVE)); + builder.setNegativeButton(android.R.string.cancel, null); + } + + @Override + protected void onDialogClosed(boolean positiveResult) { + if (positiveResult && mediaPathIndex >= 0) { + String newValue = entryValues[mediaPathIndex] + "/" + childDownloadPath; + setSummary(Environment.getExternalStorageDirectory().getPath() + "/" + newValue); + setValue(newValue); + } + } + + private int findIndexOf(String str) { + for (int i = 0; i < entryValues.length; i++) { + if (str.equals(entryValues[i])) return i; + } + return -1; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/InputTextPreference.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/InputTextPreference.java new file mode 100644 index 000000000..b80380e51 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/InputTextPreference.java @@ -0,0 +1,17 @@ +package app.revanced.extension.tiktok.settings.preference; + +import android.content.Context; +import android.preference.EditTextPreference; + +import app.revanced.extension.shared.settings.StringSetting; + +public class InputTextPreference extends EditTextPreference { + + public InputTextPreference(Context context, String title, String summary, StringSetting setting) { + super(context); + this.setTitle(title); + this.setSummary(summary); + this.setKey(setting.key); + this.setText(setting.get()); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/RangeValuePreference.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/RangeValuePreference.java new file mode 100644 index 000000000..8eaf98ac5 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/RangeValuePreference.java @@ -0,0 +1,130 @@ +package app.revanced.extension.tiktok.settings.preference; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.preference.DialogPreference; +import android.text.Editable; +import android.text.InputType; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.View; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; + +import app.revanced.extension.shared.settings.StringSetting; + +@SuppressWarnings("deprecation") +public class RangeValuePreference extends DialogPreference { + private final Context context; + + private String minValue; + + private String maxValue; + + private String mValue; + + private boolean mValueSet; + + public RangeValuePreference(Context context, String title, String summary, StringSetting setting) { + super(context); + this.context = context; + setTitle(title); + setSummary(summary); + setKey(setting.key); + setValue(setting.get()); + } + + public void setValue(String value) { + final boolean changed = !TextUtils.equals(mValue, value); + if (changed || !mValueSet) { + mValue = value; + mValueSet = true; + persistString(value); + if (changed) { + notifyDependencyChange(shouldDisableDependents()); + notifyChanged(); + } + } + } + + public String getValue() { + return mValue; + } + + @Override + protected View onCreateDialogView() { + minValue = getValue().split("-")[0]; + maxValue = getValue().split("-")[1]; + LinearLayout dialogView = new LinearLayout(context); + dialogView.setOrientation(LinearLayout.VERTICAL); + LinearLayout minView = new LinearLayout(context); + minView.setOrientation(LinearLayout.HORIZONTAL); + TextView min = new TextView(context); + min.setText("Min: "); + minView.addView(min); + EditText minEditText = new EditText(context); + minEditText.setInputType(InputType.TYPE_CLASS_NUMBER); + minEditText.setText(minValue); + minView.addView(minEditText); + dialogView.addView(minView); + LinearLayout maxView = new LinearLayout(context); + maxView.setOrientation(LinearLayout.HORIZONTAL); + TextView max = new TextView(context); + max.setText("Max: "); + maxView.addView(max); + EditText maxEditText = new EditText(context); + maxEditText.setInputType(InputType.TYPE_CLASS_NUMBER); + maxEditText.setText(maxValue); + maxView.addView(maxEditText); + dialogView.addView(maxView); + minEditText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { + + } + + @Override + public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { + + } + + @Override + public void afterTextChanged(Editable editable) { + minValue = editable.toString(); + } + }); + maxEditText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { + + } + + @Override + public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { + + } + + @Override + public void afterTextChanged(Editable editable) { + maxValue = editable.toString(); + } + }); + return dialogView; + } + + @Override + protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { + builder.setPositiveButton(android.R.string.ok, (dialog, which) -> this.onClick(dialog, DialogInterface.BUTTON_POSITIVE)); + builder.setNegativeButton(android.R.string.cancel, null); + } + + @Override + protected void onDialogClosed(boolean positiveResult) { + if (positiveResult) { + String newValue = minValue + "-" + maxValue; + setValue(newValue); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/ReVancedPreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/ReVancedPreferenceFragment.java new file mode 100644 index 000000000..43ab69297 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/ReVancedPreferenceFragment.java @@ -0,0 +1,54 @@ +package app.revanced.extension.tiktok.settings.preference; + +import android.preference.Preference; +import android.preference.PreferenceScreen; +import androidx.annotation.NonNull; +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment; +import app.revanced.extension.tiktok.settings.preference.categories.DownloadsPreferenceCategory; +import app.revanced.extension.tiktok.settings.preference.categories.FeedFilterPreferenceCategory; +import app.revanced.extension.tiktok.settings.preference.categories.ExtensionPreferenceCategory; +import app.revanced.extension.tiktok.settings.preference.categories.SimSpoofPreferenceCategory; +import org.jetbrains.annotations.NotNull; + +/** + * 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) { + if (pref instanceof RangeValuePreference) { + RangeValuePreference rangeValuePref = (RangeValuePreference) pref; + Setting.privateSetValueFromString(setting, rangeValuePref.getValue()); + } else if (pref instanceof DownloadPathPreference) { + DownloadPathPreference downloadPathPref = (DownloadPathPreference) pref; + Setting.privateSetValueFromString(setting, downloadPathPref.getValue()); + } else { + super.syncSettingWithPreference(pref, setting, applySettingToPreference); + } + } + + @Override + protected void initialize() { + final var context = getContext(); + + // Currently no resources can be compiled for TikTok (fails with aapt error). + // So all TikTok Strings are hard coded in the extension. + restartDialogTitle = "Refresh and restart"; + restartDialogButtonText = "Restart"; + confirmDialogTitle = "Do you wish to proceed?"; + + PreferenceScreen preferenceScreen = getPreferenceManager().createPreferenceScreen(context); + setPreferenceScreen(preferenceScreen); + + // Custom categories reference app specific Settings class. + new FeedFilterPreferenceCategory(context, preferenceScreen); + new DownloadsPreferenceCategory(context, preferenceScreen); + new SimSpoofPreferenceCategory(context, preferenceScreen); + new ExtensionPreferenceCategory(context, preferenceScreen); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/TogglePreference.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/TogglePreference.java new file mode 100644 index 000000000..788b0d67d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/TogglePreference.java @@ -0,0 +1,17 @@ +package app.revanced.extension.tiktok.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/tiktok/settings/preference/categories/ConditionalPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/ConditionalPreferenceCategory.java new file mode 100644 index 000000000..d9f865ee9 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/ConditionalPreferenceCategory.java @@ -0,0 +1,22 @@ +package app.revanced.extension.tiktok.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/tiktok/settings/preference/categories/DownloadsPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/DownloadsPreferenceCategory.java new file mode 100644 index 000000000..1ba3defa4 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/DownloadsPreferenceCategory.java @@ -0,0 +1,35 @@ +package app.revanced.extension.tiktok.settings.preference.categories; + +import android.content.Context; +import android.preference.PreferenceScreen; +import app.revanced.extension.tiktok.settings.Settings; +import app.revanced.extension.tiktok.settings.SettingsStatus; +import app.revanced.extension.tiktok.settings.preference.DownloadPathPreference; +import app.revanced.extension.tiktok.settings.preference.TogglePreference; + +@SuppressWarnings("deprecation") +public class DownloadsPreferenceCategory extends ConditionalPreferenceCategory { + public DownloadsPreferenceCategory(Context context, PreferenceScreen screen) { + super(context, screen); + setTitle("Downloads"); + } + + @Override + public boolean getSettingsStatus() { + return SettingsStatus.downloadEnabled; + } + + @Override + public void addPreferences(Context context) { + addPreference(new DownloadPathPreference( + context, + "Download path", + Settings.DOWNLOAD_PATH + )); + addPreference(new TogglePreference( + context, + "Remove watermark", "", + Settings.DOWNLOAD_WATERMARK + )); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/ExtensionPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/ExtensionPreferenceCategory.java new file mode 100644 index 000000000..ad49df688 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/ExtensionPreferenceCategory.java @@ -0,0 +1,29 @@ +package app.revanced.extension.tiktok.settings.preference.categories; + +import android.content.Context; +import android.preference.PreferenceScreen; + +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.tiktok.settings.preference.TogglePreference; + +@SuppressWarnings("deprecation") +public class ExtensionPreferenceCategory extends ConditionalPreferenceCategory { + public ExtensionPreferenceCategory(Context context, PreferenceScreen screen) { + super(context, screen); + setTitle("Extension"); + } + + @Override + public boolean getSettingsStatus() { + return true; + } + + @Override + public void addPreferences(Context context) { + addPreference(new TogglePreference(context, + "Enable debug log", + "Show extension debug log.", + BaseSettings.DEBUG + )); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/FeedFilterPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/FeedFilterPreferenceCategory.java new file mode 100644 index 000000000..bcd56bc7e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/FeedFilterPreferenceCategory.java @@ -0,0 +1,55 @@ +package app.revanced.extension.tiktok.settings.preference.categories; + +import android.content.Context; +import android.preference.PreferenceScreen; +import app.revanced.extension.tiktok.settings.preference.RangeValuePreference; +import app.revanced.extension.tiktok.settings.Settings; +import app.revanced.extension.tiktok.settings.SettingsStatus; +import app.revanced.extension.tiktok.settings.preference.TogglePreference; + +@SuppressWarnings("deprecation") +public class FeedFilterPreferenceCategory extends ConditionalPreferenceCategory { + public FeedFilterPreferenceCategory(Context context, PreferenceScreen screen) { + super(context, screen); + setTitle("Feed filter"); + } + + @Override + public boolean getSettingsStatus() { + return SettingsStatus.feedFilterEnabled; + } + + @Override + public void addPreferences(Context context) { + addPreference(new TogglePreference( + context, + "Remove feed ads", "Remove ads from feed.", + Settings.REMOVE_ADS + )); + addPreference(new TogglePreference( + context, + "Hide livestreams", "Hide livestreams from feed.", + Settings.HIDE_LIVE + )); + addPreference(new TogglePreference( + context, + "Hide story", "Hide story from feed.", + Settings.HIDE_STORY + )); + addPreference(new TogglePreference( + context, + "Hide image video", "Hide image video from feed.", + Settings.HIDE_IMAGE + )); + addPreference(new RangeValuePreference( + context, + "Min/Max views", "The minimum or maximum views of a video to show.", + Settings.MIN_MAX_VIEWS + )); + addPreference(new RangeValuePreference( + context, + "Min/Max likes", "The minimum or maximum likes of a video to show.", + Settings.MIN_MAX_LIKES + )); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/SimSpoofPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/SimSpoofPreferenceCategory.java new file mode 100644 index 000000000..0a820dc39 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/SimSpoofPreferenceCategory.java @@ -0,0 +1,47 @@ +package app.revanced.extension.tiktok.settings.preference.categories; + +import android.content.Context; +import android.preference.PreferenceScreen; +import app.revanced.extension.tiktok.settings.Settings; +import app.revanced.extension.tiktok.settings.SettingsStatus; +import app.revanced.extension.tiktok.settings.preference.InputTextPreference; +import app.revanced.extension.tiktok.settings.preference.TogglePreference; + +@SuppressWarnings("deprecation") +public class SimSpoofPreferenceCategory extends ConditionalPreferenceCategory { + public SimSpoofPreferenceCategory(Context context, PreferenceScreen screen) { + super(context, screen); + setTitle("Bypass regional restriction"); + } + + + @Override + public boolean getSettingsStatus() { + return SettingsStatus.simSpoofEnabled; + } + + @Override + public void addPreferences(Context context) { + addPreference(new TogglePreference( + context, + "Fake sim card info", + "Bypass regional restriction by fake sim card information.", + Settings.SIM_SPOOF + )); + addPreference(new InputTextPreference( + context, + "Country ISO", "us, uk, jp, ...", + Settings.SIM_SPOOF_ISO + )); + addPreference(new InputTextPreference( + context, + "Operator mcc+mnc", "mcc+mnc", + Settings.SIMSPOOF_MCCMNC + )); + addPreference(new InputTextPreference( + context, + "Operator name", "Name of the operator.", + Settings.SIMSPOOF_OP_NAME + )); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/speed/PlaybackSpeedPatch.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/speed/PlaybackSpeedPatch.java new file mode 100644 index 000000000..3b078ab89 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/speed/PlaybackSpeedPatch.java @@ -0,0 +1,13 @@ +package app.revanced.extension.tiktok.speed; + +import app.revanced.extension.tiktok.settings.Settings; + +public class PlaybackSpeedPatch { + public static void rememberPlaybackSpeed(float newSpeed) { + Settings.REMEMBERED_SPEED.save(newSpeed); + } + + public static float getPlaybackSpeed() { + return Settings.REMEMBERED_SPEED.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/spoof/sim/SpoofSimPatch.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/spoof/sim/SpoofSimPatch.java new file mode 100644 index 000000000..94910bdb6 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/spoof/sim/SpoofSimPatch.java @@ -0,0 +1,37 @@ +package app.revanced.extension.tiktok.spoof.sim; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.tiktok.settings.Settings; + +@SuppressWarnings("unused") +public class SpoofSimPatch { + + private static final boolean ENABLED = Settings.SIM_SPOOF.get(); + + public static String getCountryIso(String value) { + if (ENABLED) { + String iso = Settings.SIM_SPOOF_ISO.get(); + Logger.printDebug(() -> "Spoofing sim ISO from: " + value + " to: " + iso); + return iso; + } + return value; + } + + public static String getOperator(String value) { + if (ENABLED) { + String mcc_mnc = Settings.SIMSPOOF_MCCMNC.get(); + Logger.printDebug(() -> "Spoofing sim MCC-MNC from: " + value + " to: " + mcc_mnc); + return mcc_mnc; + } + return value; + } + + public static String getOperatorName(String value) { + if (ENABLED) { + String operator = Settings.SIMSPOOF_OP_NAME.get(); + Logger.printDebug(() -> "Spoofing sim operator from: " + value + " to: " + operator); + return operator; + } + return value; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tudortmund/lockscreen/ShowOnLockscreenPatch.java b/extensions/shared/src/main/java/app/revanced/extension/tudortmund/lockscreen/ShowOnLockscreenPatch.java new file mode 100644 index 000000000..f2868cf4e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tudortmund/lockscreen/ShowOnLockscreenPatch.java @@ -0,0 +1,46 @@ +package app.revanced.extension.tudortmund.lockscreen; + +import android.content.Context; +import android.hardware.display.DisplayManager; +import android.os.Build; +import android.view.Display; +import android.view.Window; +import androidx.appcompat.app.AppCompatActivity; + +import static android.view.WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD; +import static android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED; + +public class ShowOnLockscreenPatch { + /** + * @noinspection deprecation + */ + public static Window getWindow(AppCompatActivity activity, float brightness) { + Window window = activity.getWindow(); + + if (brightness >= 0) { + // High brightness set, therefore show on lockscreen. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) activity.setShowWhenLocked(true); + else window.addFlags(FLAG_SHOW_WHEN_LOCKED | FLAG_DISMISS_KEYGUARD); + } else { + // Ignore brightness reset when the screen is turned off. + DisplayManager displayManager = (DisplayManager) activity.getSystemService(Context.DISPLAY_SERVICE); + + boolean isScreenOn = false; + for (Display display : displayManager.getDisplays()) { + if (display.getState() == Display.STATE_OFF) continue; + + isScreenOn = true; + break; + } + + if (isScreenOn) { + // Hide on lockscreen. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) activity.setShowWhenLocked(false); + else window.clearFlags(FLAG_SHOW_WHEN_LOCKED | FLAG_DISMISS_KEYGUARD); + } + } + + return window; + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/tumblr/patches/TimelineFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/tumblr/patches/TimelineFilterPatch.java new file mode 100644 index 000000000..bb3a2473d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/tumblr/patches/TimelineFilterPatch.java @@ -0,0 +1,32 @@ +package app.revanced.extension.tumblr.patches; + +import com.tumblr.rumblr.model.TimelineObject; +import com.tumblr.rumblr.model.Timelineable; + +import java.util.HashSet; +import java.util.List; + +public final class TimelineFilterPatch { + private static final HashSet blockedObjectTypes = new HashSet<>(); + + static { + // This dummy gets removed by the TimelineFilterPatch and in its place, + // equivalent instructions with a different constant string + // will be inserted for each Timeline object type filter. + // Modifying this line may break the patch. + blockedObjectTypes.add("BLOCKED_OBJECT_DUMMY"); + } + + // Calls to this method are injected where the list of Timeline objects is first received. + // We modify the list filter out elements that we want to hide. + public static void filterTimeline(final List> timelineObjects) { + final var iterator = timelineObjects.iterator(); + while (iterator.hasNext()) { + var timelineElement = iterator.next(); + if (timelineElement == null) continue; + + String elementType = timelineElement.getData().getTimelineObjectType().toString(); + if (blockedObjectTypes.contains(elementType)) iterator.remove(); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/Utils.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/Utils.java new file mode 100644 index 000000000..73c363ff8 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/Utils.java @@ -0,0 +1,14 @@ +package app.revanced.extension.twitch; + +public class Utils { + + /* Called from SettingsPatch smali */ + public static int getStringId(String name) { + return app.revanced.extension.shared.Utils.getResourceIdentifier(name, "string"); + } + + /* Called from SettingsPatch smali */ + public static int getDrawableId(String name) { + return app.revanced.extension.shared.Utils.getResourceIdentifier(name, "drawable"); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/IAdblockService.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/IAdblockService.java new file mode 100644 index 000000000..457ecdf97 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/IAdblockService.java @@ -0,0 +1,26 @@ +package app.revanced.extension.twitch.adblock; + +import okhttp3.Request; + +public interface IAdblockService { + String friendlyName(); + + Integer maxAttempts(); + + Boolean isAvailable(); + + Request rewriteHlsRequest(Request originalRequest); + + static boolean isVod(Request request){ + return request.url().pathSegments().contains("vod"); + } + + static String channelName(Request request) { + for (String pathSegment : request.url().pathSegments()) { + if (pathSegment.endsWith(".m3u8")) { + return pathSegment.replace(".m3u8", ""); + } + } + return null; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/LuminousService.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/LuminousService.java new file mode 100644 index 000000000..ef217daf0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/LuminousService.java @@ -0,0 +1,47 @@ +package app.revanced.extension.twitch.adblock; + +import app.revanced.extension.shared.Logger; +import okhttp3.HttpUrl; +import okhttp3.Request; + +import static app.revanced.extension.shared.StringRef.str; + +public class LuminousService implements IAdblockService { + @Override + public String friendlyName() { + return str("revanced_proxy_luminous"); + } + + @Override + public Integer maxAttempts() { + return 2; + } + + @Override + public Boolean isAvailable() { + return true; + } + + @Override + public Request rewriteHlsRequest(Request originalRequest) { + var type = IAdblockService.isVod(originalRequest) ? "vod" : "playlist"; + var url = HttpUrl.parse("https://eu.luminous.dev/" + + type + + "/" + + IAdblockService.channelName(originalRequest) + + ".m3u8" + + "%3Fallow_source%3Dtrue%26allow_audio_only%3Dtrue%26fast_bread%3Dtrue" + ); + + if (url == null) { + Logger.printException(() -> "Failed to parse rewritten URL"); + return null; + } + + // Overwrite old request + return new Request.Builder() + .get() + .url(url) + .build(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/PurpleAdblockService.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/PurpleAdblockService.java new file mode 100644 index 000000000..ba1bd183a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/PurpleAdblockService.java @@ -0,0 +1,96 @@ +package app.revanced.extension.twitch.adblock; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.twitch.api.RetrofitClient; +import okhttp3.HttpUrl; +import okhttp3.Request; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static app.revanced.extension.shared.StringRef.str; + +public class PurpleAdblockService implements IAdblockService { + private final Map tunnels = new HashMap<>() {{ + put("https://eu1.jupter.ga", false); + put("https://eu2.jupter.ga", false); + }}; + + @Override + public String friendlyName() { + return str("revanced_proxy_purpleadblock"); + } + + @Override + public Integer maxAttempts() { + return 3; + } + + @Override + public Boolean isAvailable() { + for (String tunnel : tunnels.keySet()) { + var success = true; + + try { + var response = RetrofitClient.getInstance().getPurpleAdblockApi().ping(tunnel).execute(); + if (!response.isSuccessful()) { + Logger.printException(() -> + "PurpleAdBlock tunnel $tunnel returned an error: HTTP code " + response.code() + ); + Logger.printDebug(response::message); + + try (var errorBody = response.errorBody()) { + if (errorBody != null) { + Logger.printDebug(() -> { + try { + return errorBody.string(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + } + + success = false; + } + } catch (Exception ex) { + Logger.printException(() -> "PurpleAdBlock tunnel $tunnel is unavailable", ex); + success = false; + } + + // Cache availability data + tunnels.put(tunnel, success); + + if (success) + return true; + } + + return false; + } + + @Override + public Request rewriteHlsRequest(Request originalRequest) { + for (Map.Entry entry : tunnels.entrySet()) { + if (!entry.getValue()) continue; + + var server = entry.getKey(); + + // Compose new URL + var url = HttpUrl.parse(server + "/channel/" + IAdblockService.channelName(originalRequest)); + if (url == null) { + Logger.printException(() -> "Failed to parse rewritten URL"); + return null; + } + + // Overwrite old request + return new Request.Builder() + .get() + .url(url) + .build(); + } + + Logger.printException(() -> "No tunnels are available"); + return null; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/api/PurpleAdblockApi.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/api/PurpleAdblockApi.java new file mode 100644 index 000000000..519a3fe8a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/api/PurpleAdblockApi.java @@ -0,0 +1,12 @@ +package app.revanced.extension.twitch.api; + +import okhttp3.ResponseBody; +import retrofit2.Call; +import retrofit2.http.GET; +import retrofit2.http.Url; + +/* only used for service pings */ +public interface PurpleAdblockApi { + @GET /* root */ + Call ping(@Url String baseUrl); +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/api/RequestInterceptor.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/api/RequestInterceptor.java new file mode 100644 index 000000000..2309bcf64 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/api/RequestInterceptor.java @@ -0,0 +1,120 @@ +package app.revanced.extension.twitch.api; + +import androidx.annotation.NonNull; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.twitch.adblock.IAdblockService; +import app.revanced.extension.twitch.adblock.LuminousService; +import app.revanced.extension.twitch.adblock.PurpleAdblockService; +import app.revanced.extension.twitch.settings.Settings; +import okhttp3.Interceptor; +import okhttp3.Response; + +import java.io.IOException; + +import static app.revanced.extension.shared.StringRef.str; + +public class RequestInterceptor implements Interceptor { + private IAdblockService activeService = null; + + private static final String PROXY_DISABLED = str("revanced_block_embedded_ads_entry_1"); + private static final String LUMINOUS_SERVICE = str("revanced_block_embedded_ads_entry_2"); + private static final String PURPLE_ADBLOCK_SERVICE = str("revanced_block_embedded_ads_entry_3"); + + + @NonNull + @Override + public Response intercept(@NonNull Chain chain) throws IOException { + var originalRequest = chain.request(); + + if (Settings.BLOCK_EMBEDDED_ADS.get().equals(PROXY_DISABLED)) { + return chain.proceed(originalRequest); + } + + Logger.printDebug(() -> "Intercepted request to URL:" + originalRequest.url()); + + // Skip if not HLS manifest request + if (!originalRequest.url().host().contains("usher.ttvnw.net")) { + return chain.proceed(originalRequest); + } + + final String isVod; + if (IAdblockService.isVod(originalRequest)) isVod = "yes"; + else isVod = "no"; + + Logger.printDebug(() -> "Found HLS manifest request. Is VOD? " + + isVod + + "; Channel: " + + IAdblockService.channelName(originalRequest) + ); + + // None of the services support VODs currently + if (IAdblockService.isVod(originalRequest)) return chain.proceed(originalRequest); + + updateActiveService(); + + if (activeService != null) { + var available = activeService.isAvailable(); + var rewritten = activeService.rewriteHlsRequest(originalRequest); + + + if (!available || rewritten == null) { + Utils.showToastShort(String.format( + str("revanced_embedded_ads_service_unavailable"), activeService.friendlyName() + )); + return chain.proceed(originalRequest); + } + + Logger.printDebug(() -> "Rewritten HLS stream URL: " + rewritten.url()); + + var maxAttempts = activeService.maxAttempts(); + + for (var i = 1; i <= maxAttempts; i++) { + // Execute rewritten request and close body to allow multiple proceed() calls + var response = chain.proceed(rewritten); + response.close(); + + if (!response.isSuccessful()) { + int attempt = i; + Logger.printException(() -> "Request failed (attempt " + + attempt + + "/" + maxAttempts + "): HTTP error " + + response.code() + + " (" + response.message() + ")" + ); + + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Logger.printException(() -> "Failed to sleep", e); + } + } else { + // Accept response from ad blocker + Logger.printDebug(() -> "Ad-blocker used"); + return chain.proceed(rewritten); + } + } + + // maxAttempts exceeded; giving up on using the ad blocker + Utils.showToastLong(String.format( + str("revanced_embedded_ads_service_failed"), + activeService.friendlyName()) + ); + } + + // Adblock disabled + return chain.proceed(originalRequest); + + } + + private void updateActiveService() { + var current = Settings.BLOCK_EMBEDDED_ADS.get(); + + if (current.equals(LUMINOUS_SERVICE) && !(activeService instanceof LuminousService)) + activeService = new LuminousService(); + else if (current.equals(PURPLE_ADBLOCK_SERVICE) && !(activeService instanceof PurpleAdblockService)) + activeService = new PurpleAdblockService(); + else if (current.equals(PROXY_DISABLED)) + activeService = null; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/api/RetrofitClient.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/api/RetrofitClient.java new file mode 100644 index 000000000..24f4060b6 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/api/RetrofitClient.java @@ -0,0 +1,25 @@ +package app.revanced.extension.twitch.api; + +import retrofit2.Retrofit; + +public class RetrofitClient { + + private static RetrofitClient instance = null; + private final PurpleAdblockApi purpleAdblockApi; + + private RetrofitClient() { + Retrofit retrofit = new Retrofit.Builder().baseUrl("http://localhost" /* dummy */).build(); + purpleAdblockApi = retrofit.create(PurpleAdblockApi.class); + } + + public static synchronized RetrofitClient getInstance() { + if (instance == null) { + instance = new RetrofitClient(); + } + return instance; + } + + public PurpleAdblockApi getPurpleAdblockApi() { + return purpleAdblockApi; + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/AudioAdsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/AudioAdsPatch.java new file mode 100644 index 000000000..77b7cbd5a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/AudioAdsPatch.java @@ -0,0 +1,10 @@ +package app.revanced.extension.twitch.patches; + +import app.revanced.extension.twitch.settings.Settings; + +@SuppressWarnings("unused") +public class AudioAdsPatch { + public static boolean shouldBlockAudioAds() { + return Settings.BLOCK_AUDIO_ADS.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/AutoClaimChannelPointsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/AutoClaimChannelPointsPatch.java new file mode 100644 index 000000000..55c32c7ea --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/AutoClaimChannelPointsPatch.java @@ -0,0 +1,10 @@ +package app.revanced.extension.twitch.patches; + +import app.revanced.extension.twitch.settings.Settings; + +@SuppressWarnings("unused") +public class AutoClaimChannelPointsPatch { + public static boolean shouldAutoClaim() { + return Settings.AUTO_CLAIM_CHANNEL_POINTS.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/DebugModePatch.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/DebugModePatch.java new file mode 100644 index 000000000..dc4ab8094 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/DebugModePatch.java @@ -0,0 +1,10 @@ +package app.revanced.extension.twitch.patches; + +import app.revanced.extension.twitch.settings.Settings; + +@SuppressWarnings("unused") +public class DebugModePatch { + public static boolean isDebugModeEnabled() { + return Settings.TWITCH_DEBUG_MODE.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/EmbeddedAdsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/EmbeddedAdsPatch.java new file mode 100644 index 000000000..bb172d1a8 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/EmbeddedAdsPatch.java @@ -0,0 +1,10 @@ +package app.revanced.extension.twitch.patches; + +import app.revanced.extension.twitch.api.RequestInterceptor; + +@SuppressWarnings("unused") +public class EmbeddedAdsPatch { + public static RequestInterceptor createRequestInterceptor() { + return new RequestInterceptor(); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/ShowDeletedMessagesPatch.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/ShowDeletedMessagesPatch.java new file mode 100644 index 000000000..747a6b94d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/ShowDeletedMessagesPatch.java @@ -0,0 +1,51 @@ +package app.revanced.extension.twitch.patches; + +import static app.revanced.extension.shared.StringRef.str; + +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.SpannedString; +import android.text.style.ForegroundColorSpan; +import android.text.style.StrikethroughSpan; +import android.text.style.StyleSpan; + +import androidx.annotation.Nullable; + +import app.revanced.extension.twitch.settings.Settings; +import tv.twitch.android.shared.chat.util.ClickableUsernameSpan; + +@SuppressWarnings("unused") +public class ShowDeletedMessagesPatch { + + /** + * Injection point. + */ + public static boolean shouldUseSpoiler() { + return "spoiler".equals(Settings.SHOW_DELETED_MESSAGES.get()); + } + + public static boolean shouldCrossOut() { + return "cross-out".equals(Settings.SHOW_DELETED_MESSAGES.get()); + } + + @Nullable + public static Spanned reformatDeletedMessage(Spanned original) { + if (!shouldCrossOut()) + return null; + + SpannableStringBuilder ssb = new SpannableStringBuilder(original); + ssb.setSpan(new StrikethroughSpan(), 0, original.length(), 0); + ssb.append(" (").append(str("revanced_deleted_msg")).append(")"); + ssb.setSpan(new StyleSpan(Typeface.ITALIC), original.length(), ssb.length(), 0); + + // Gray-out username + ClickableUsernameSpan[] usernameSpans = original.getSpans(0, original.length(), ClickableUsernameSpan.class); + if (usernameSpans.length > 0) { + ssb.setSpan(new ForegroundColorSpan(Color.parseColor("#ADADB8")), 0, original.getSpanEnd(usernameSpans[0]), 0); + } + + return new SpannedString(ssb); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/VideoAdsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/VideoAdsPatch.java new file mode 100644 index 000000000..6c7b739af --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/VideoAdsPatch.java @@ -0,0 +1,10 @@ +package app.revanced.extension.twitch.patches; + +import app.revanced.extension.twitch.settings.Settings; + +@SuppressWarnings("unused") +public class VideoAdsPatch { + public static boolean shouldBlockVideoAds() { + return Settings.BLOCK_VIDEO_ADS.get(); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/AppCompatActivityHook.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/AppCompatActivityHook.java new file mode 100644 index 000000000..e617cf9b2 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/AppCompatActivityHook.java @@ -0,0 +1,112 @@ +package app.revanced.extension.twitch.settings; + +import android.content.Intent; +import android.os.Bundle; +import androidx.appcompat.app.ActionBar; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.twitch.settings.preference.ReVancedPreferenceFragment; +import tv.twitch.android.feature.settings.menu.SettingsMenuGroup; +import tv.twitch.android.settings.SettingsActivity; + +import java.util.ArrayList; +import java.util.List; + +/** + * Hooks AppCompatActivity. + *

+ * This class is responsible for injecting our own fragment by replacing the AppCompatActivity. + * @noinspection unused + */ +public class AppCompatActivityHook { + private static final int REVANCED_SETTINGS_MENU_ITEM_ID = 0x7; + private static final String EXTRA_REVANCED_SETTINGS = "app.revanced.twitch.settings"; + + /** + * Launches SettingsActivity and show ReVanced settings + */ + public static void startSettingsActivity() { + Logger.printDebug(() -> "Launching ReVanced settings"); + + final var context = Utils.getContext(); + + if (context != null) { + Intent intent = new Intent(context, SettingsActivity.class); + Bundle bundle = new Bundle(); + bundle.putBoolean(EXTRA_REVANCED_SETTINGS, true); + intent.putExtras(bundle); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + } + + /** + * Helper for easy access in smali + * @return Returns string resource id + */ + public static int getReVancedSettingsString() { + return app.revanced.extension.twitch.Utils.getStringId("revanced_settings"); + } + + /** + * Intercepts settings menu group list creation in SettingsMenuPresenter$Event.MenuGroupsUpdated + * @return Returns a modified list of menu groups + */ + public static List handleSettingMenuCreation(List settingGroups, Object revancedEntry) { + List groups = new ArrayList<>(settingGroups); + + if (groups.isEmpty()) { + // Create new menu group if none exist yet + List items = new ArrayList<>(); + items.add(revancedEntry); + groups.add(new SettingsMenuGroup(items)); + } else { + // Add to last menu group + int groupIdx = groups.size() - 1; + List items = new ArrayList<>(groups.remove(groupIdx).getSettingsMenuItems()); + items.add(revancedEntry); + groups.add(new SettingsMenuGroup(items)); + } + + Logger.printDebug(() -> settingGroups.size() + " menu groups in list"); + return groups; + } + + /** + * Intercepts settings menu group onclick events + * @return Returns true if handled, otherwise false + */ + @SuppressWarnings("rawtypes") + public static boolean handleSettingMenuOnClick(Enum item) { + Logger.printDebug(() -> "item " + item.ordinal() + " clicked"); + if (item.ordinal() != REVANCED_SETTINGS_MENU_ITEM_ID) { + return false; + } + + startSettingsActivity(); + return true; + } + + /** + * Intercepts fragment loading in SettingsActivity.onCreate + * @return Returns true if the revanced settings have been requested by the user, otherwise false + */ + public static boolean handleSettingsCreation(androidx.appcompat.app.AppCompatActivity base) { + if (!base.getIntent().getBooleanExtra(EXTRA_REVANCED_SETTINGS, false)) { + Logger.printDebug(() -> "Revanced settings not requested"); + return false; // User wants to enter another settings fragment + } + Logger.printDebug(() -> "ReVanced settings requested"); + + ReVancedPreferenceFragment fragment = new ReVancedPreferenceFragment(); + ActionBar supportActionBar = base.getSupportActionBar(); + if (supportActionBar != null) + supportActionBar.setTitle(app.revanced.extension.twitch.Utils.getStringId("revanced_settings")); + + base.getFragmentManager() + .beginTransaction() + .replace(Utils.getResourceIdentifier("fragment_container", "id"), fragment) + .commit(); + return true; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/Settings.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/Settings.java new file mode 100644 index 000000000..aa5fed4b2 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/Settings.java @@ -0,0 +1,25 @@ +package app.revanced.extension.twitch.settings; + +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.shared.settings.StringSetting; + +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; + +public class Settings extends BaseSettings { + /* Ads */ + public static final BooleanSetting BLOCK_VIDEO_ADS = new BooleanSetting("revanced_block_video_ads", TRUE); + public static final BooleanSetting BLOCK_AUDIO_ADS = new BooleanSetting("revanced_block_audio_ads", TRUE); + public static final StringSetting BLOCK_EMBEDDED_ADS = new StringSetting("revanced_block_embedded_ads", "luminous"); + + /* Chat */ + public static final StringSetting SHOW_DELETED_MESSAGES = new StringSetting("revanced_show_deleted_messages", "cross-out"); + public static final BooleanSetting AUTO_CLAIM_CHANNEL_POINTS = new BooleanSetting("revanced_auto_claim_channel_points", TRUE); + + /* Misc */ + /** + * Not to be confused with {@link BaseSettings#DEBUG}. + */ + public static final BooleanSetting TWITCH_DEBUG_MODE = new BooleanSetting("revanced_twitch_debug_mode", FALSE, true); +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/preference/CustomPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/preference/CustomPreferenceCategory.java new file mode 100644 index 000000000..5cef4a0b7 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/preference/CustomPreferenceCategory.java @@ -0,0 +1,23 @@ +package app.revanced.extension.twitch.settings.preference; + +import android.content.Context; +import android.graphics.Color; +import android.preference.PreferenceCategory; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +public class CustomPreferenceCategory extends PreferenceCategory { + public CustomPreferenceCategory(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onBindView(View rootView) { + super.onBindView(rootView); + + if(rootView instanceof TextView) { + ((TextView) rootView).setTextColor(Color.parseColor("#8161b3")); + } + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/preference/ReVancedPreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/preference/ReVancedPreferenceFragment.java new file mode 100644 index 000000000..d77d06e19 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/preference/ReVancedPreferenceFragment.java @@ -0,0 +1,21 @@ +package app.revanced.extension.twitch.settings.preference; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment; +import app.revanced.extension.twitch.settings.Settings; + +/** + * Preference fragment for ReVanced settings + */ +public class ReVancedPreferenceFragment extends AbstractPreferenceFragment { + + @Override + protected void initialize() { + super.initialize(); + + // Do anything that forces this apps Settings bundle to load. + if (Settings.BLOCK_VIDEO_ADS.get()) { + Logger.printDebug(() -> "Block video ads enabled"); // Any statement that references the app settings. + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/BaseJsonHook.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/BaseJsonHook.kt new file mode 100644 index 000000000..306b58e0a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/BaseJsonHook.kt @@ -0,0 +1,9 @@ +package app.revanced.extension.twitter.patches.hook.json + +import org.json.JSONObject + +abstract class BaseJsonHook : JsonHook { + abstract fun apply(json: JSONObject) + + override fun transform(json: JSONObject) = json.apply { apply(json) } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/JsonHook.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/JsonHook.kt new file mode 100644 index 000000000..2d6441be7 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/JsonHook.kt @@ -0,0 +1,15 @@ +package app.revanced.extension.twitter.patches.hook.json + +import app.revanced.extension.twitter.patches.hook.patch.Hook +import org.json.JSONObject + +interface JsonHook : Hook { + /** + * Transform a JSONObject. + * + * @param json The JSONObject. + */ + fun transform(json: JSONObject): JSONObject + + override fun hook(type: JSONObject) = transform(type) +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/JsonHookPatch.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/JsonHookPatch.kt new file mode 100644 index 000000000..4d82c8b4e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/JsonHookPatch.kt @@ -0,0 +1,30 @@ +package app.revanced.extension.twitter.patches.hook.json + +import app.revanced.extension.twitter.patches.hook.patch.dummy.DummyHook +import app.revanced.extension.twitter.utils.json.JsonUtils.parseJson +import app.revanced.extension.twitter.utils.stream.StreamUtils +import org.json.JSONException +import java.io.IOException +import java.io.InputStream + +object JsonHookPatch { + // Additional hooks added by corresponding patch. + private val hooks = buildList { + add(DummyHook) + } + + @JvmStatic + fun parseJsonHook(jsonInputStream: InputStream): InputStream { + var jsonObject = try { + parseJson(jsonInputStream) + } catch (ignored: IOException) { + return jsonInputStream // Unreachable. + } catch (ignored: JSONException) { + return jsonInputStream + } + + for (hook in hooks) jsonObject = hook.hook(jsonObject) + + return StreamUtils.fromString(jsonObject.toString()) + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/Hook.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/Hook.kt new file mode 100644 index 000000000..3211e40e8 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/Hook.kt @@ -0,0 +1,9 @@ +package app.revanced.extension.twitter.patches.hook.patch + +interface Hook { + /** + * Hook the given type. + * @param type The type to hook + */ + fun hook(type: T): T +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/ads/HideAdsHook.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/ads/HideAdsHook.kt new file mode 100644 index 000000000..de2f7b2fa --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/ads/HideAdsHook.kt @@ -0,0 +1,15 @@ +package app.revanced.extension.twitter.patches.hook.patch.ads + +import app.revanced.extension.twitter.patches.hook.json.BaseJsonHook +import app.revanced.extension.twitter.patches.hook.twifucker.TwiFucker +import org.json.JSONObject + +@Suppress("unused") +object HideAdsHook : BaseJsonHook() { + /** + * Strips JSONObject from promoted ads. + * + * @param json The JSONObject. + */ + override fun apply(json: JSONObject) = TwiFucker.hidePromotedAds(json) +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/dummy/DummyHook.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/dummy/DummyHook.kt new file mode 100644 index 000000000..9dd620d91 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/dummy/DummyHook.kt @@ -0,0 +1,14 @@ +package app.revanced.extension.twitter.patches.hook.patch.dummy + +import app.revanced.extension.twitter.patches.hook.json.BaseJsonHook +import app.revanced.extension.twitter.patches.hook.json.JsonHookPatch +import org.json.JSONObject + +/** + * Dummy hook to reserve a register in [JsonHookPatch.hooks] list. + */ +object DummyHook : BaseJsonHook() { + override fun apply(json: JSONObject) { + // Do nothing. + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/recommendation/RecommendedUsersHook.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/recommendation/RecommendedUsersHook.kt new file mode 100644 index 000000000..161801dc2 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/recommendation/RecommendedUsersHook.kt @@ -0,0 +1,14 @@ +package app.revanced.extension.twitter.patches.hook.patch.recommendation + +import app.revanced.extension.twitter.patches.hook.json.BaseJsonHook +import app.revanced.extension.twitter.patches.hook.twifucker.TwiFucker +import org.json.JSONObject + +object RecommendedUsersHook : BaseJsonHook() { + /** + * Strips JSONObject from recommended users. + * + * @param json The JSONObject. + */ + override fun apply(json: JSONObject) = TwiFucker.hideRecommendedUsers(json) +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/twifucker/TwiFucker.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/twifucker/TwiFucker.kt new file mode 100644 index 000000000..af5b0312e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/twifucker/TwiFucker.kt @@ -0,0 +1,218 @@ +package app.revanced.extension.twitter.patches.hook.twifucker + +import android.util.Log +import app.revanced.extension.twitter.patches.hook.twifucker.TwiFuckerUtils.forEach +import app.revanced.extension.twitter.patches.hook.twifucker.TwiFuckerUtils.forEachIndexed +import org.json.JSONArray +import org.json.JSONObject + +// https://raw.githubusercontent.com/Dr-TSNG/TwiFucker/880cdf1c1622e54ab45561ffcb4f53d94ed97bae/app/src/main/java/icu/nullptr/twifucker/hook/JsonHook.kt +internal object TwiFucker { + // root + private fun JSONObject.jsonGetInstructions(): JSONArray? = optJSONObject("timeline")?.optJSONArray("instructions") + + private fun JSONObject.jsonGetData(): JSONObject? = optJSONObject("data") + + private fun JSONObject.jsonHasRecommendedUsers(): Boolean = has("recommended_users") + + private fun JSONObject.jsonRemoveRecommendedUsers() { + remove("recommended_users") + } + + private fun JSONObject.jsonCheckAndRemoveRecommendedUsers() { + if (jsonHasRecommendedUsers()) { + Log.d("ReVanced", "Handle recommended users: $this") + jsonRemoveRecommendedUsers() + } + } + + private fun JSONObject.jsonHasThreads(): Boolean = has("threads") + + private fun JSONObject.jsonRemoveThreads() { + remove("threads") + } + + private fun JSONObject.jsonCheckAndRemoveThreads() { + if (jsonHasThreads()) { + Log.d("ReVanced", "Handle threads: $this") + jsonRemoveThreads() + } + } + + // data + private fun JSONObject.dataGetInstructions(): JSONArray? { + val timeline = + optJSONObject("user_result")?.optJSONObject("result") + ?.optJSONObject("timeline_response")?.optJSONObject("timeline") + ?: optJSONObject("timeline_response")?.optJSONObject("timeline") + ?: optJSONObject("search")?.optJSONObject("timeline_response")?.optJSONObject("timeline") + ?: optJSONObject("timeline_response") + return timeline?.optJSONArray("instructions") + } + + private fun JSONObject.dataCheckAndRemove() { + dataGetInstructions()?.forEach { instruction -> + instruction.instructionCheckAndRemove { it.entriesRemoveAnnoyance() } + } + } + + private fun JSONObject.dataGetLegacy(): JSONObject? = + optJSONObject("tweet_result")?.optJSONObject("result")?.let { + if (it.has("tweet")) { + it.optJSONObject("tweet") + } else { + it + } + }?.optJSONObject("legacy") + + // entry + private fun JSONObject.entryHasPromotedMetadata(): Boolean = + optJSONObject("content")?.optJSONObject("item")?.optJSONObject("content") + ?.optJSONObject("tweet") + ?.has("promotedMetadata") == true || optJSONObject("content")?.optJSONObject("content") + ?.has("tweetPromotedMetadata") == true || optJSONObject("item")?.optJSONObject("content") + ?.has("tweetPromotedMetadata") == true + + private fun JSONObject.entryGetContentItems(): JSONArray? = + optJSONObject("content")?.optJSONArray("items") + ?: optJSONObject("content")?.optJSONObject("timelineModule")?.optJSONArray("items") + + private fun JSONObject.entryIsTweetDetailRelatedTweets(): Boolean = optString("entryId").startsWith("tweetdetailrelatedtweets-") + + private fun JSONObject.entryGetTrends(): JSONArray? = optJSONObject("content")?.optJSONObject("timelineModule")?.optJSONArray("items") + + // trend + private fun JSONObject.trendHasPromotedMetadata(): Boolean = + optJSONObject("item")?.optJSONObject("content")?.optJSONObject("trend") + ?.has("promotedMetadata") == true + + private fun JSONArray.trendRemoveAds() { + val trendRemoveIndex = mutableListOf() + forEachIndexed { trendIndex, trend -> + if (trend.trendHasPromotedMetadata()) { + Log.d("ReVanced", "Handle trends ads $trendIndex $trend") + trendRemoveIndex.add(trendIndex) + } + } + for (i in trendRemoveIndex.asReversed()) { + remove(i) + } + } + + // instruction + private fun JSONObject.instructionTimelineAddEntries(): JSONArray? = optJSONArray("entries") + + private fun JSONObject.instructionGetAddEntries(): JSONArray? = optJSONObject("addEntries")?.optJSONArray("entries") + + private fun JSONObject.instructionCheckAndRemove(action: (JSONArray) -> Unit) { + instructionTimelineAddEntries()?.let(action) + instructionGetAddEntries()?.let(action) + } + + // entries + private fun JSONArray.entriesRemoveTimelineAds() { + val removeIndex = mutableListOf() + forEachIndexed { entryIndex, entry -> + entry.entryGetTrends()?.trendRemoveAds() + + if (entry.entryHasPromotedMetadata()) { + Log.d("ReVanced", "Handle timeline ads $entryIndex $entry") + removeIndex.add(entryIndex) + } + + val innerRemoveIndex = mutableListOf() + val contentItems = entry.entryGetContentItems() + contentItems?.forEachIndexed inner@{ itemIndex, item -> + if (item.entryHasPromotedMetadata()) { + Log.d("ReVanced", "Handle timeline replies ads $entryIndex $entry") + if (contentItems.length() == 1) { + removeIndex.add(entryIndex) + } else { + innerRemoveIndex.add(itemIndex) + } + return@inner + } + } + for (i in innerRemoveIndex.asReversed()) { + contentItems?.remove(i) + } + } + for (i in removeIndex.reversed()) { + remove(i) + } + } + + private fun JSONArray.entriesRemoveTweetDetailRelatedTweets() { + val removeIndex = mutableListOf() + forEachIndexed { entryIndex, entry -> + + if (entry.entryIsTweetDetailRelatedTweets()) { + Log.d("ReVanced", "Handle tweet detail related tweets $entryIndex $entry") + removeIndex.add(entryIndex) + } + } + for (i in removeIndex.reversed()) { + remove(i) + } + } + + private fun JSONArray.entriesRemoveAnnoyance() { + entriesRemoveTimelineAds() + entriesRemoveTweetDetailRelatedTweets() + } + + private fun JSONObject.entryIsWhoToFollow(): Boolean = + optString("entryId").let { + it.startsWith("whoToFollow-") || it.startsWith("who-to-follow-") || it.startsWith("connect-module-") + } + + private fun JSONObject.itemContainsPromotedUser(): Boolean = + optJSONObject("item")?.optJSONObject("content") + ?.has("userPromotedMetadata") == true || optJSONObject("item")?.optJSONObject("content") + ?.optJSONObject("user") + ?.has("userPromotedMetadata") == true || optJSONObject("item")?.optJSONObject("content") + ?.optJSONObject("user")?.has("promotedMetadata") == true + + fun JSONArray.entriesRemoveWhoToFollow() { + val entryRemoveIndex = mutableListOf() + forEachIndexed { entryIndex, entry -> + if (!entry.entryIsWhoToFollow()) return@forEachIndexed + + Log.d("ReVanced", "Handle whoToFollow $entryIndex $entry") + entryRemoveIndex.add(entryIndex) + + val items = entry.entryGetContentItems() + val userRemoveIndex = mutableListOf() + items?.forEachIndexed { index, item -> + item.itemContainsPromotedUser().let { + if (it) { + Log.d("ReVanced", "Handle whoToFollow promoted user $index $item") + userRemoveIndex.add(index) + } + } + } + for (i in userRemoveIndex.reversed()) { + items?.remove(i) + } + } + for (i in entryRemoveIndex.reversed()) { + remove(i) + } + } + + fun hideRecommendedUsers(json: JSONObject) { + json.filterInstructions { it.entriesRemoveWhoToFollow() } + json.jsonCheckAndRemoveRecommendedUsers() + } + + fun hidePromotedAds(json: JSONObject) { + json.filterInstructions { it.entriesRemoveAnnoyance() } + json.jsonGetData()?.dataCheckAndRemove() + } + + private fun JSONObject.filterInstructions(action: (JSONArray) -> Unit) { + jsonGetInstructions()?.forEach { instruction -> + instruction.instructionCheckAndRemove(action) + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/twifucker/TwiFuckerUtils.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/twifucker/TwiFuckerUtils.kt new file mode 100644 index 000000000..4872d95aa --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/twifucker/TwiFuckerUtils.kt @@ -0,0 +1,22 @@ +package app.revanced.extension.twitter.patches.hook.twifucker + +import org.json.JSONArray +import org.json.JSONObject + +internal object TwiFuckerUtils { + inline fun JSONArray.forEach(action: (JSONObject) -> Unit) { + (0 until this.length()).forEach { i -> + if (this[i] is JSONObject) { + action(this[i] as JSONObject) + } + } + } + + inline fun JSONArray.forEachIndexed(action: (index: Int, JSONObject) -> Unit) { + (0 until this.length()).forEach { i -> + if (this[i] is JSONObject) { + action(i, this[i] as JSONObject) + } + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/links/ChangeLinkSharingDomainPatch.java b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/links/ChangeLinkSharingDomainPatch.java new file mode 100644 index 000000000..808a8de03 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/links/ChangeLinkSharingDomainPatch.java @@ -0,0 +1,16 @@ +package app.revanced.extension.twitter.patches.links; + +public final class ChangeLinkSharingDomainPatch { + private static final String DOMAIN_NAME = "https://fxtwitter.com"; + private static final String LINK_FORMAT = "%s/%s/status/%s"; + + public static String formatResourceLink(Object... formatArgs) { + String username = (String) formatArgs[0]; + String tweetId = (String) formatArgs[1]; + return String.format(LINK_FORMAT, DOMAIN_NAME, username, tweetId); + } + + public static String formatLink(long tweetId, String username) { + return String.format(LINK_FORMAT, DOMAIN_NAME, username, tweetId); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/links/OpenLinksWithAppChooserPatch.java b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/links/OpenLinksWithAppChooserPatch.java new file mode 100644 index 000000000..2b4bdc124 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/links/OpenLinksWithAppChooserPatch.java @@ -0,0 +1,15 @@ +package app.revanced.extension.twitter.patches.links; + +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +public final class OpenLinksWithAppChooserPatch { + public static void openWithChooser(final Context context, final Intent intent) { + Log.d("ReVanced", "Opening intent with chooser: " + intent); + + intent.setAction("android.intent.action.VIEW"); + + context.startActivity(Intent.createChooser(intent, null)); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/utils/json/JsonUtils.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/utils/json/JsonUtils.kt new file mode 100644 index 000000000..d046c6370 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/utils/json/JsonUtils.kt @@ -0,0 +1,13 @@ +package app.revanced.extension.twitter.utils.json + +import app.revanced.extension.twitter.utils.stream.StreamUtils +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.io.InputStream + +object JsonUtils { + @JvmStatic + @Throws(IOException::class, JSONException::class) + fun parseJson(jsonInputStream: InputStream) = JSONObject(StreamUtils.toString(jsonInputStream)) +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/utils/stream/StreamUtils.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/utils/stream/StreamUtils.kt new file mode 100644 index 000000000..ff33c4409 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/utils/stream/StreamUtils.kt @@ -0,0 +1,24 @@ +package app.revanced.extension.twitter.utils.stream + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream + +object StreamUtils { + @Throws(IOException::class) + fun toString(inputStream: InputStream): String { + ByteArrayOutputStream().use { result -> + val buffer = ByteArray(1024) + var length: Int + while (inputStream.read(buffer).also { length = it } != -1) { + result.write(buffer, 0, length) + } + return result.toString() + } + } + + fun fromString(string: String): InputStream { + return ByteArrayInputStream(string.toByteArray()) + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/ByteTrieSearch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/ByteTrieSearch.java new file mode 100644 index 000000000..162e0b040 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/ByteTrieSearch.java @@ -0,0 +1,45 @@ +package app.revanced.extension.youtube; + +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/youtube/Event.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/Event.kt new file mode 100644 index 000000000..72323949c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/Event.kt @@ -0,0 +1,29 @@ +package app.revanced.extension.youtube + +/** + * 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) + } + + 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/youtube/StringTrieSearch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/StringTrieSearch.java new file mode 100644 index 000000000..fbff9beba --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/StringTrieSearch.java @@ -0,0 +1,34 @@ +package app.revanced.extension.youtube; + +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/youtube/ThemeHelper.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/ThemeHelper.java new file mode 100644 index 000000000..6e0ea5974 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/ThemeHelper.java @@ -0,0 +1,85 @@ +package app.revanced.extension.youtube; + +import android.app.Activity; +import android.graphics.Color; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; + +public class ThemeHelper { + @Nullable + private static Integer darkThemeColor, lightThemeColor; + private static int themeValue; + + /** + * Injection point. + */ + @SuppressWarnings("unused") + 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 void setActivityTheme(Activity activity) { + final var theme = isDarkTheme() + ? "Theme.YouTube.Settings.Dark" + : "Theme.YouTube.Settings"; + activity.setTheme(Utils.getResourceIdentifier(theme, "style")); + } + + /** + * Injection point. + */ + @SuppressWarnings("SameReturnValue") + private static String darkThemeResourceName() { + // Value is changed by Theme patch, if included. + return "@color/yt_black3"; + } + + /** + * @return The dark theme color as specified by the Theme patch (if included), + * or the dark mode background color unpatched YT uses. + */ + public static int getDarkThemeColor() { + if (darkThemeColor == null) { + darkThemeColor = getColorInt(darkThemeResourceName()); + } + return darkThemeColor; + } + + /** + * Injection point. + */ + @SuppressWarnings("SameReturnValue") + private static String lightThemeResourceName() { + // Value is changed by Theme patch, if included. + return "@color/yt_white1"; + } + + /** + * @return The light theme color as specified by the Theme patch (if included), + * or the non dark mode background color unpatched YT uses. + */ + public static int getLightThemeColor() { + if (lightThemeColor == null) { + lightThemeColor = getColorInt(lightThemeResourceName()); + } + return lightThemeColor; + } + + private static int getColorInt(String colorString) { + if (colorString.startsWith("#")) { + return Color.parseColor(colorString); + } + return Utils.getResourceColor(colorString); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/TrieSearch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/TrieSearch.java new file mode 100644 index 000000000..74fb4685d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/TrieSearch.java @@ -0,0 +1,412 @@ +package app.revanced.extension.youtube; + +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); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/AlternativeThumbnailsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/AlternativeThumbnailsPatch.java new file mode 100644 index 000000000..92be08433 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/AlternativeThumbnailsPatch.java @@ -0,0 +1,710 @@ +package app.revanced.extension.youtube.patches; + +import static app.revanced.extension.shared.StringRef.str; +import static app.revanced.extension.youtube.settings.Settings.*; +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 app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.NavigationBar; +import app.revanced.extension.youtube.shared.PlayerType; + +/** + * 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. + */ +@SuppressWarnings("unused") +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. + + /** + * 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("Invalid DeArrow API URL. Using default"); + 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 (PlayerType.getCurrent().isMaximizedOrFullscreen()) { + return ALT_THUMBNAIL_PLAYER.get(); + } + + // Must check second, as search can be from any tab. + if (NavigationBar.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 if not available NULL. + */ + @Nullable + 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 null; + } + + /** + * 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. + String fallbackUrl = null; + if (option.useStillImages) { + fallbackUrl = buildYouTubeVideoStillURL(decodedUrl, qualityToUse); + } + if (fallbackUrl == null) { + fallbackUrl = decodedUrl.sanitizedUrl; + } + + sanitizedReplacementUrl = buildDeArrowThumbnailURL(decodedUrl.videoId, fallbackUrl); + } else if (option.useStillImages) { + includeTracking = true; // Include view tracking parameters if present. + sanitizedReplacementUrl = buildYouTubeVideoStillURL(decodedUrl, qualityToUse); + if (sanitizedReplacementUrl == null) { + return originalUrl; // Still capture is not available. Return the untouched original url. + } + } 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: " + decodedUrl.sanitizedUrl); + + 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 through the extension 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 { + private static final String YOUTUBE_THUMBNAIL_DOMAIN = "https://i.ytimg.com/"; + + @Nullable + static DecodedThumbnailUrl decodeImageUrl(String url) { + final int urlPathStartIndex = url.indexOf('/', "https://".length()) + 1; + if (urlPathStartIndex <= 0) return null; + + final int urlPathEndIndex = url.indexOf('/', urlPathStartIndex); + if (urlPathEndIndex < 0) return null; + + final int videoIdStartIndex = url.indexOf('/', urlPathEndIndex) + 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, urlPathStartIndex, urlPathEndIndex, videoIdStartIndex, videoIdEndIndex, + imageSizeStartIndex, imageSizeEndIndex, imageExtensionEndIndex); + } + + final String originalFullUrl; + /** Full usable url, but stripped of any tracking information. */ + final String sanitizedUrl; + /** Url path, such as 'vi' or 'vi_webp' */ + final String urlPath; + 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 urlPathStartIndex, int urlPathEndIndex, int videoIdStartIndex, int videoIdEndIndex, + int imageSizeStartIndex, int imageSizeEndIndex, int imageExtensionEndIndex) { + originalFullUrl = fullUrl; + sanitizedUrl = fullUrl.substring(0, imageExtensionEndIndex); + urlPath = fullUrl.substring(urlPathStartIndex, urlPathEndIndex); + 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 network response has been observed, despite the alt webp image being a smaller file). + StringBuilder builder = new StringBuilder(originalFullUrl.length() + 2); + // Many different "i.ytimage.com" domains exist such as "i9.ytimg.com", + // but still captures are frequently not available on the other domains (especially newly uploaded videos). + // So always use the primary domain for a higher success rate. + builder.append(YOUTUBE_THUMBNAIL_DOMAIN).append(urlPath).append('/'); + 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/AutoRepeatPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/AutoRepeatPatch.java new file mode 100644 index 000000000..21409e739 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/AutoRepeatPatch.java @@ -0,0 +1,11 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class AutoRepeatPatch { + //Used by app.revanced.patches.youtube.layout.autorepeat.patch.AutoRepeatPatch + public static boolean shouldAutoRepeat() { + return Settings.AUTO_REPEAT.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BackgroundPlaybackPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BackgroundPlaybackPatch.java new file mode 100644 index 000000000..d3fc82ae2 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BackgroundPlaybackPatch.java @@ -0,0 +1,46 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.shared.PlayerType; + +@SuppressWarnings("unused") +public class BackgroundPlaybackPatch { + + /** + * Injection point. + */ + public static boolean allowBackgroundPlayback(boolean original) { + if (original) return true; + + // Steps to verify most edge cases: + // 1. Open a regular video + // 2. Minimize app (PIP should appear) + // 3. Reopen app + // 4. Open a Short (without closing the regular video) + // (try opening both Shorts in the video player suggestions AND Shorts from the home feed) + // 5. Minimize the app (PIP should not appear) + // 6. Reopen app + // 7. Close the Short + // 8. Resume playing the regular video + // 9. Minimize the app (PIP should appear) + + if (!VideoInformation.lastVideoIdIsShort()) { + return true; // Definitely is not a Short. + } + + // Might be a Short, or might be a prior regular video on screen again after a Short was closed. + // This incorrectly prevents PIP if player is in WATCH_WHILE_MINIMIZED after closing a Short, + // But there's no way around this unless an additional hook is added to definitively detect + // the Shorts player is on screen. This use case is unusual anyways so it's not a huge concern. + return !PlayerType.getCurrent().isNoneHiddenOrMinimized(); + } + + /** + * Injection point. + */ + public static boolean overrideBackgroundPlaybackAvailable() { + // This could be done entirely in the patch, + // but having a unique method to search for makes manually inspecting the patched apk much easier. + return true; + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BypassImageRegionRestrictionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BypassImageRegionRestrictionsPatch.java new file mode 100644 index 000000000..ccc853d4c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BypassImageRegionRestrictionsPatch.java @@ -0,0 +1,46 @@ +package app.revanced.extension.youtube.patches; + +import static app.revanced.extension.youtube.settings.Settings.BYPASS_IMAGE_REGION_RESTRICTIONS; + +import java.util.regex.Pattern; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class BypassImageRegionRestrictionsPatch { + + private static final boolean BYPASS_IMAGE_REGION_RESTRICTIONS_ENABLED = BYPASS_IMAGE_REGION_RESTRICTIONS.get(); + + private static final String REPLACEMENT_IMAGE_DOMAIN = "https://yt4.ggpht.com"; + + /** + * 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("^https://(yt3|lh[3-6]|play-lh)\\.(ggpht|googleusercontent)\\.com"); + + /** + * Injection point. Called off the main thread and by multiple threads at the same time. + * + * @param originalUrl Image url for all image urls loaded. + */ + public static String overrideImageURL(String originalUrl) { + try { + if (BYPASS_IMAGE_REGION_RESTRICTIONS_ENABLED) { + String replacement = YOUTUBE_STATIC_IMAGE_DOMAIN_PATTERN + .matcher(originalUrl).replaceFirst(REPLACEMENT_IMAGE_DOMAIN); + + if (Settings.DEBUG.get() && !replacement.equals(originalUrl)) { + Logger.printDebug(() -> "Replaced: '" + originalUrl + "' with: '" + replacement + "'"); + } + + return replacement; + } + } catch (Exception ex) { + Logger.printException(() -> "overrideImageURL failure", ex); + } + + return originalUrl; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BypassURLRedirectsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BypassURLRedirectsPatch.java new file mode 100644 index 000000000..ebce7cd35 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BypassURLRedirectsPatch.java @@ -0,0 +1,31 @@ +package app.revanced.extension.youtube.patches; + +import android.net.Uri; + +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.shared.Logger; + +@SuppressWarnings("unused") +public class BypassURLRedirectsPatch { + private static final String YOUTUBE_REDIRECT_PATH = "/redirect"; + + /** + * Convert the YouTube redirect URI string to the redirect query URI. + * + * @param uri The YouTube redirect URI string. + * @return The redirect query URI. + */ + public static Uri parseRedirectUri(String uri) { + final var parsed = Uri.parse(uri); + + if (Settings.BYPASS_URL_REDIRECTS.get() && parsed.getPath().equals(YOUTUBE_REDIRECT_PATH)) { + var query = Uri.parse(Uri.decode(parsed.getQueryParameter("q"))); + + Logger.printDebug(() -> "Bypassing YouTube redirect URI: " + query); + + return query; + } + + return parsed; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ChangeStartPagePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ChangeStartPagePatch.java new file mode 100644 index 000000000..350e5787d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ChangeStartPagePatch.java @@ -0,0 +1,129 @@ +package app.revanced.extension.youtube.patches; + +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 app.revanced.extension.shared.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 TRUE.equals(isBrowseId); + } + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private boolean isIntentAction() { + return FALSE.equals(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(); + + /** + * 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 (appLaunched) { + Logger.printDebug(() -> "Ignore override browseId as the app already launched"); + return original; + } + appLaunched = true; + + Logger.printDebug(() -> "Changing browseId to " + START_PAGE.id); + return START_PAGE.id; + } + + public static void overrideIntentAction(@NonNull Intent intent) { + if (!START_PAGE.isIntentAction()) { + return; + } + + if (!ACTION_MAIN.equals(intent.getAction())) { + Logger.printDebug(() -> "Ignore override intent action" + + " as the current activity is not the entry point of the application"); + return; + } + + if (appLaunched) { + Logger.printDebug(() -> "Ignore override intent action as the app already launched"); + return; + } + appLaunched = true; + + final String intentAction = START_PAGE.id; + Logger.printDebug(() -> "Changing intent action to " + intentAction); + intent.setAction(intentAction); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CheckWatchHistoryDomainNameResolutionPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CheckWatchHistoryDomainNameResolutionPatch.java new file mode 100644 index 000000000..ccc2cc8c9 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CheckWatchHistoryDomainNameResolutionPatch.java @@ -0,0 +1,84 @@ +package app.revanced.extension.youtube.patches; + +import static app.revanced.extension.shared.StringRef.str; + +import android.app.Activity; +import android.text.Html; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class CheckWatchHistoryDomainNameResolutionPatch { + + private static final String HISTORY_TRACKING_ENDPOINT = "s.youtube.com"; + + private static final String SINKHOLE_IPV4 = "0.0.0.0"; + private static final String SINKHOLE_IPV6 = "::"; + + private static boolean domainResolvesToValidIP(String host) { + try { + InetAddress address = InetAddress.getByName(host); + String hostAddress = address.getHostAddress(); + + if (address.isLoopbackAddress()) { + Logger.printDebug(() -> host + " resolves to localhost"); + } else if (SINKHOLE_IPV4.equals(hostAddress) || SINKHOLE_IPV6.equals(hostAddress)) { + Logger.printDebug(() -> host + " resolves to sinkhole ip"); + } else { + return true; // Domain is not blocked. + } + } catch (UnknownHostException e) { + Logger.printDebug(() -> host + " failed to resolve"); + } + + return false; + } + + /** + * Injection point. + * + * Checks if s.youtube.com is blacklisted and playback history will fail to work. + */ + public static void checkDnsResolver(Activity context) { + if (!Utils.isNetworkConnected() || !Settings.CHECK_WATCH_HISTORY_DOMAIN_NAME.get()) return; + + Utils.runOnBackgroundThread(() -> { + try { + // If the user has a flaky DNS server, or they just lost internet connectivity + // and the isNetworkConnected() check has not detected it yet (it can take a few + // seconds after losing connection), then the history tracking endpoint will + // show a resolving error but it's actually an internet connection problem. + // + // Prevent this false positive by verify youtube.com resolves. + // If youtube.com does not resolve, then it's not a watch history domain resolving error + // because the entire app will not work since no domains are resolving. + if (domainResolvesToValidIP(HISTORY_TRACKING_ENDPOINT) + || !domainResolvesToValidIP("youtube.com")) { + return; + } + + Utils.runOnMainThread(() -> { + var alert = new android.app.AlertDialog.Builder(context) + .setTitle(str("revanced_check_watch_history_domain_name_dialog_title")) + .setMessage(Html.fromHtml(str("revanced_check_watch_history_domain_name_dialog_message"))) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + dialog.dismiss(); + }).setNegativeButton(str("revanced_check_watch_history_domain_name_dialog_ignore"), (dialog, which) -> { + Settings.CHECK_WATCH_HISTORY_DOMAIN_NAME.save(false); + dialog.dismiss(); + }).create(); + + Utils.showDialog(context, alert, false, null); + }); + } catch (Exception ex) { + Logger.printException(() -> "checkDnsResolver failure", ex); + } + }); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CopyVideoUrlPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CopyVideoUrlPatch.java new file mode 100644 index 000000000..db3338f52 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CopyVideoUrlPatch.java @@ -0,0 +1,47 @@ +package app.revanced.extension.youtube.patches; + +import static app.revanced.extension.shared.StringRef.str; + +import android.os.Build; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; + +public class CopyVideoUrlPatch { + + public static void copyUrl(boolean withTimestamp) { + try { + StringBuilder builder = new StringBuilder("https://youtu.be/"); + builder.append(VideoInformation.getVideoId()); + final long currentVideoTimeInSeconds = VideoInformation.getVideoTime() / 1000; + if (withTimestamp && currentVideoTimeInSeconds > 0) { + final long hour = currentVideoTimeInSeconds / (60 * 60); + final long minute = (currentVideoTimeInSeconds / 60) % 60; + final long second = currentVideoTimeInSeconds % 60; + builder.append("?t="); + if (hour > 0) { + builder.append(hour).append("h"); + } + if (minute > 0) { + builder.append(minute).append("m"); + } + if (second > 0) { + builder.append(second).append("s"); + } + } + + Utils.setClipboard(builder.toString()); + // 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 (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2 || (withTimestamp && currentVideoTimeInSeconds > 0)) { + Utils.showToastShort(withTimestamp && currentVideoTimeInSeconds > 0 + ? str("revanced_share_copy_url_timestamp_success") + : str("revanced_share_copy_url_success")); + } + } catch (Exception e) { + Logger.printException(() -> "Failed to generate video url", e); + } + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CustomPlayerOverlayOpacityPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CustomPlayerOverlayOpacityPatch.java new file mode 100644 index 000000000..4f13deaac --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CustomPlayerOverlayOpacityPatch.java @@ -0,0 +1,33 @@ +package app.revanced.extension.youtube.patches; + +import static app.revanced.extension.shared.StringRef.str; + +import android.widget.ImageView; + +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class CustomPlayerOverlayOpacityPatch { + + private static final int PLAYER_OVERLAY_OPACITY_LEVEL; + + static { + int opacity = Settings.PLAYER_OVERLAY_OPACITY.get(); + + if (opacity < 0 || opacity > 100) { + Utils.showToastLong(str("revanced_player_overlay_opacity_invalid_toast")); + Settings.PLAYER_OVERLAY_OPACITY.resetToDefault(); + opacity = Settings.PLAYER_OVERLAY_OPACITY.defaultValue; + } + + PLAYER_OVERLAY_OPACITY_LEVEL = (opacity * 255) / 100; + } + + /** + * Injection point. + */ + public static void changeOpacity(ImageView imageView) { + imageView.setImageAlpha(PLAYER_OVERLAY_OPACITY_LEVEL); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableAutoCaptionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableAutoCaptionsPatch.java new file mode 100644 index 000000000..9d43159a5 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableAutoCaptionsPatch.java @@ -0,0 +1,20 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.PlayerType; + +@SuppressWarnings("unused") +public class DisableAutoCaptionsPatch { + + /** + * Used by injected code. Do not delete. + */ + public static boolean captionsButtonDisabled; + + public static boolean autoCaptionsEnabled() { + return Settings.AUTO_CAPTIONS.get() + // Do not use auto captions for Shorts. + && !PlayerType.getCurrent().isNoneHiddenOrSlidingMinimized(); + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableFullscreenAmbientModePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableFullscreenAmbientModePatch.java new file mode 100644 index 000000000..962a0d7b7 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableFullscreenAmbientModePatch.java @@ -0,0 +1,10 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +/** @noinspection unused*/ +public final class DisableFullscreenAmbientModePatch { + public static boolean enableFullScreenAmbientMode() { + return !Settings.DISABLE_FULLSCREEN_AMBIENT_MODE.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisablePlayerPopupPanelsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisablePlayerPopupPanelsPatch.java new file mode 100644 index 000000000..dd2e0ca8f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisablePlayerPopupPanelsPatch.java @@ -0,0 +1,11 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class DisablePlayerPopupPanelsPatch { + //Used by app.revanced.patches.youtube.layout.playerpopuppanels.patch.PlayerPopupPanelsPatch + public static boolean disablePlayerPopupPanels() { + return Settings.PLAYER_POPUP_PANELS.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisablePreciseSeekingGesturePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisablePreciseSeekingGesturePatch.java new file mode 100644 index 000000000..c6a80c6a0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisablePreciseSeekingGesturePatch.java @@ -0,0 +1,10 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class DisablePreciseSeekingGesturePatch { + public static boolean isGestureDisabled() { + return Settings.DISABLE_PRECISE_SEEKING_GESTURE.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableResumingStartupShortsPlayerPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableResumingStartupShortsPlayerPatch.java new file mode 100644 index 000000000..938ac5458 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableResumingStartupShortsPlayerPatch.java @@ -0,0 +1,14 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +/** @noinspection unused*/ +public class DisableResumingStartupShortsPlayerPatch { + + /** + * Injection point. + */ + public static boolean disableResumingStartupShortsPlayer() { + return Settings.DISABLE_RESUMING_SHORTS_PLAYER.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableRollingNumberAnimationsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableRollingNumberAnimationsPatch.java new file mode 100644 index 000000000..f600f391a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableRollingNumberAnimationsPatch.java @@ -0,0 +1,13 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class DisableRollingNumberAnimationsPatch { + /** + * Injection point. + */ + public static boolean disableRollingNumberAnimations() { + return Settings.DISABLE_ROLLING_NUMBER_ANIMATIONS.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableSuggestedVideoEndScreenPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableSuggestedVideoEndScreenPatch.java new file mode 100644 index 000000000..7cd91d9c2 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableSuggestedVideoEndScreenPatch.java @@ -0,0 +1,24 @@ +package app.revanced.extension.youtube.patches; + +import android.annotation.SuppressLint; +import android.widget.ImageView; + +import app.revanced.extension.youtube.settings.Settings; + +/** @noinspection unused*/ +public final class DisableSuggestedVideoEndScreenPatch { + @SuppressLint("StaticFieldLeak") + private static ImageView lastView; + + public static void closeEndScreen(final ImageView imageView) { + if (!Settings.DISABLE_SUGGESTED_VIDEO_END_SCREEN.get()) return; + + // Prevent adding the listener multiple times. + if (lastView == imageView) return; + lastView = imageView; + + imageView.getViewTreeObserver().addOnGlobalLayoutListener(() -> { + if (imageView.isShown()) imageView.callOnClick(); + }); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DownloadsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DownloadsPatch.java new file mode 100644 index 000000000..6da31b6a4 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DownloadsPatch.java @@ -0,0 +1,103 @@ +package app.revanced.extension.youtube.patches; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; + +import androidx.annotation.NonNull; + +import java.lang.ref.WeakReference; +import java.util.Objects; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.StringRef; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class DownloadsPatch { + + private static WeakReference activityRef = new WeakReference<>(null); + + /** + * Injection point. + */ + public static void activityCreated(Activity mainActivity) { + activityRef = new WeakReference<>(mainActivity); + } + + /** + * 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 inAppDownloadButtonOnClick(@NonNull String videoId) { + try { + if (!Settings.EXTERNAL_DOWNLOADER_ACTION_BUTTON.get()) { + return false; + } + + // If possible, use the main activity as the context. + // Otherwise fall back on using the application context. + Context context = activityRef.get(); + boolean isActivityContext = true; + if (context == null) { + // Utils context is the application context, and not an activity context. + context = Utils.getContext(); + isActivityContext = false; + } + + launchExternalDownloader(videoId, context, isActivityContext); + return true; + } catch (Exception ex) { + Logger.printException(() -> "inAppDownloadButtonOnClick failure", ex); + } + return false; + } + + /** + * @param isActivityContext If the context parameter is for an Activity. If this is false, then + * the downloader is opened as a new task (which forces YT to minimize). + */ + public static void launchExternalDownloader(@NonNull String videoId, + @NonNull Context context, boolean isActivityContext) { + try { + Objects.requireNonNull(videoId); + Logger.printDebug(() -> "Launching external downloader with context: " + context); + + // Trim string to avoid any accidental whitespace. + var downloaderPackageName = Settings.EXTERNAL_DOWNLOADER_PACKAGE_NAME.get().trim(); + + boolean packageEnabled = false; + try { + packageEnabled = context.getPackageManager().getApplicationInfo(downloaderPackageName, 0).enabled; + } catch (PackageManager.NameNotFoundException error) { + Logger.printDebug(() -> "External downloader could not be found: " + error); + } + + // If the package is not installed, show the toast + if (!packageEnabled) { + Utils.showToastLong(StringRef.str("revanced_external_downloader_not_installed_warning", downloaderPackageName)); + return; + } + + String content = "https://youtu.be/" + videoId; + Intent intent = new Intent("android.intent.action.SEND"); + intent.setType("text/plain"); + intent.setPackage(downloaderPackageName); + intent.putExtra("android.intent.extra.TEXT", content); + if (!isActivityContext) { + Logger.printDebug(() -> "Using new task intent"); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + } + context.startActivity(intent); + } catch (Exception ex) { + Logger.printException(() -> "launchExternalDownloader failure", ex); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/FixBackToExitGesturePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/FixBackToExitGesturePatch.java new file mode 100644 index 000000000..8fe4dbb18 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/FixBackToExitGesturePatch.java @@ -0,0 +1,44 @@ +package app.revanced.extension.youtube.patches; + +import android.app.Activity; + +import app.revanced.extension.shared.Logger; + +@SuppressWarnings("unused") +public class FixBackToExitGesturePatch { + /** + * State whether the scroll position reaches the top. + */ + public static boolean isTopView = false; + + /** + * Handle the event after clicking the back button. + * + * @param activity The activity, the app is launched with to finish. + */ + public static void onBackPressed(Activity activity) { + if (!isTopView) return; + + Logger.printDebug(() -> "Activity is closed"); + + activity.finish(); + } + + /** + * Handle the event when the homepage list of views is being scrolled. + */ + public static void onScrollingViews() { + Logger.printDebug(() -> "Views are scrolling"); + + isTopView = false; + } + + /** + * Handle the event when the homepage list of views reached the top. + */ + public static void onTopView() { + Logger.printDebug(() -> "Scrolling reached the top"); + + isTopView = true; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/FullscreenPanelsRemoverPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/FullscreenPanelsRemoverPatch.java new file mode 100644 index 000000000..f454b361c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/FullscreenPanelsRemoverPatch.java @@ -0,0 +1,12 @@ +package app.revanced.extension.youtube.patches; + +import android.view.View; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class FullscreenPanelsRemoverPatch { + public static int getFullscreenPanelsVisibility() { + return Settings.HIDE_FULLSCREEN_PANELS.get() ? View.GONE : View.VISIBLE; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HDRAutoBrightnessPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HDRAutoBrightnessPatch.java new file mode 100644 index 000000000..1aa9650cf --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HDRAutoBrightnessPatch.java @@ -0,0 +1,42 @@ +package app.revanced.extension.youtube.patches; + +import android.view.WindowManager; + +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.swipecontrols.SwipeControlsHostActivity; + +/** + * Patch class for 'hdr-auto-brightness' patch. + * + * Edit: This patch no longer does anything, as YT already uses BRIGHTNESS_OVERRIDE_NONE + * as the default brightness level. The hooked code was also removed from YT 19.09+ as well. + */ +@Deprecated +@SuppressWarnings("unused") +public class HDRAutoBrightnessPatch { + /** + * get brightness override for HDR brightness + * + * @param original brightness youtube would normally set + * @return brightness to set on HRD video + */ + public static float getHDRBrightness(float original) { + // do nothing if disabled + if (!Settings.HDR_AUTO_BRIGHTNESS.get()) { + return original; + } + + // override with brightness set by swipe-controls + // only when swipe-controls is active and has overridden the brightness + final SwipeControlsHostActivity swipeControlsHost = SwipeControlsHostActivity.getCurrentHost().get(); + if (swipeControlsHost != null + && swipeControlsHost.getScreen() != null + && swipeControlsHost.getConfig().getEnableBrightnessControl() + && !swipeControlsHost.getScreen().isDefaultBrightness()) { + return swipeControlsHost.getScreen().getRawScreenBrightness(); + } + + // otherwise, set the brightness to auto + return WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideEmailAddressPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideEmailAddressPatch.java new file mode 100644 index 000000000..656f36c38 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideEmailAddressPatch.java @@ -0,0 +1,17 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +/** + * Patch is obsolete and will be deleted in a future release + */ +@SuppressWarnings("unused") +@Deprecated() +public class HideEmailAddressPatch { + //Used by app.revanced.patches.youtube.layout.personalinformation.patch.HideEmailAddressPatch + public static int hideEmailAddress(int originalValue) { + if (Settings.HIDE_EMAIL_ADDRESS.get()) + return 8; + return originalValue; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideEndscreenCardsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideEndscreenCardsPatch.java new file mode 100644 index 000000000..89261d119 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideEndscreenCardsPatch.java @@ -0,0 +1,14 @@ +package app.revanced.extension.youtube.patches; + +import android.view.View; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class HideEndscreenCardsPatch { + //Used by app.revanced.patches.youtube.layout.hideendscreencards.bytecode.patch.HideEndscreenCardsPatch + public static void hideEndscreen(View view) { + if (!Settings.HIDE_ENDSCREEN_CARDS.get()) return; + view.setVisibility(View.GONE); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideGetPremiumPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideGetPremiumPatch.java new file mode 100644 index 000000000..35592c0ff --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideGetPremiumPatch.java @@ -0,0 +1,13 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class HideGetPremiumPatch { + /** + * Injection point. + */ + public static boolean hideGetPremiumView() { + return Settings.HIDE_GET_PREMIUM.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideInfoCardsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideInfoCardsPatch.java new file mode 100644 index 000000000..e01c4a394 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideInfoCardsPatch.java @@ -0,0 +1,17 @@ +package app.revanced.extension.youtube.patches; + +import android.view.View; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class HideInfoCardsPatch { + public static void hideInfoCardsIncognito(View view) { + if (!Settings.HIDE_INFO_CARDS.get()) return; + view.setVisibility(View.GONE); + } + + public static boolean hideInfoCardsMethodCall() { + return Settings.HIDE_INFO_CARDS.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HidePlayerOverlayButtonsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HidePlayerOverlayButtonsPatch.java new file mode 100644 index 000000000..c1a0065db --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HidePlayerOverlayButtonsPatch.java @@ -0,0 +1,72 @@ +package app.revanced.extension.youtube.patches; + +import android.view.View; +import android.widget.ImageView; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class HidePlayerOverlayButtonsPatch { + + private static final boolean HIDE_AUTOPLAY_BUTTON_ENABLED = Settings.HIDE_AUTOPLAY_BUTTON.get(); + + /** + * Injection point. + */ + public static boolean hideAutoPlayButton() { + return HIDE_AUTOPLAY_BUTTON_ENABLED; + } + + /** + * Injection point. + */ + public static int getCastButtonOverrideV2(int original) { + return Settings.HIDE_CAST_BUTTON.get() ? View.GONE : original; + } + + /** + * Injection point. + */ + public static void hideCaptionsButton(ImageView imageView) { + imageView.setVisibility(Settings.HIDE_CAPTIONS_BUTTON.get() ? ImageView.GONE : ImageView.VISIBLE); + } + + private static final boolean HIDE_PLAYER_PREVIOUS_NEXT_BUTTONS_ENABLED + = Settings.HIDE_PLAYER_PREVIOUS_NEXT_BUTTONS.get(); + + private static final int PLAYER_CONTROL_PREVIOUS_BUTTON_TOUCH_AREA_ID = + Utils.getResourceIdentifier("player_control_previous_button_touch_area", "id"); + + private static final int PLAYER_CONTROL_NEXT_BUTTON_TOUCH_AREA_ID = + Utils.getResourceIdentifier("player_control_next_button_touch_area", "id"); + + /** + * Injection point. + */ + public static void hidePreviousNextButtons(View parentView) { + if (!HIDE_PLAYER_PREVIOUS_NEXT_BUTTONS_ENABLED) { + return; + } + + // Must use a deferred call to main thread to hide the button. + // Otherwise the layout crashes if set to hidden now. + Utils.runOnMainThread(() -> { + hideView(parentView, PLAYER_CONTROL_PREVIOUS_BUTTON_TOUCH_AREA_ID); + hideView(parentView, PLAYER_CONTROL_NEXT_BUTTON_TOUCH_AREA_ID); + }); + } + + private static void hideView(View parentView, int resourceId) { + View nextPreviousButton = parentView.findViewById(resourceId); + + if (nextPreviousButton == null) { + Logger.printException(() -> "Could not find player previous/next button"); + return; + } + + Logger.printDebug(() -> "Hiding previous/next button"); + Utils.hideViewByRemovingFromParentUnderCondition(true, nextPreviousButton); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideSeekbarPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideSeekbarPatch.java new file mode 100644 index 000000000..98065d7ec --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideSeekbarPatch.java @@ -0,0 +1,10 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class HideSeekbarPatch { + public static boolean hideSeekbar() { + return Settings.HIDE_SEEKBAR.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideTimestampPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideTimestampPatch.java new file mode 100644 index 000000000..a502bb690 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideTimestampPatch.java @@ -0,0 +1,10 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class HideTimestampPatch { + public static boolean hideTimestamp() { + return Settings.HIDE_TIMESTAMP.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/MiniplayerPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/MiniplayerPatch.java new file mode 100644 index 000000000..782ca2540 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/MiniplayerPatch.java @@ -0,0 +1,328 @@ +package app.revanced.extension.youtube.patches; + +import static app.revanced.extension.shared.StringRef.str; +import static app.revanced.extension.youtube.patches.MiniplayerPatch.MiniplayerType.*; +import static app.revanced.extension.youtube.patches.VersionCheckPatch.*; + +import android.util.DisplayMetrics; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings({"unused", "SpellCheckingInspection"}) +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), + /** + * Half broken miniplayer, that might be work in progress or left over abandoned code. + * Can force this type by editing the import/export settings. + */ + MODERN_4(null, 4); + + /** + * 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; + } + } + + private static final int MINIPLAYER_SIZE; + + static { + // YT appears to use the device screen dip width, plus an unknown fixed horizontal padding size. + DisplayMetrics displayMetrics = Utils.getContext().getResources().getDisplayMetrics(); + final int deviceDipWidth = (int) (displayMetrics.widthPixels / displayMetrics.density); + + // YT seems to use a minimum height to calculate the minimum miniplayer width based on the video. + // 170 seems to be the smallest that can be used and using less makes no difference. + final int WIDTH_DIP_MIN = 170; // Seems to be the smallest that works. + final int HORIZONTAL_PADDING_DIP = 15; // Estimated padding. + // Round down to the nearest 5 pixels, to keep any error toasts easier to read. + final int WIDTH_DIP_MAX = 5 * ((deviceDipWidth - HORIZONTAL_PADDING_DIP) / 5); + Logger.printDebug(() -> "Screen dip width: " + deviceDipWidth + " maxWidth: " + WIDTH_DIP_MAX); + + int dipWidth = Settings.MINIPLAYER_WIDTH_DIP.get(); + + if (dipWidth < WIDTH_DIP_MIN || dipWidth > WIDTH_DIP_MAX) { + Utils.showToastLong(str("revanced_miniplayer_width_dip_invalid_toast", + WIDTH_DIP_MIN, WIDTH_DIP_MAX)); + + // Instead of resetting, clamp the size at the bounds. + dipWidth = Math.max(WIDTH_DIP_MIN, Math.min(dipWidth, WIDTH_DIP_MAX)); + Settings.MINIPLAYER_WIDTH_DIP.save(dipWidth); + } + + MINIPLAYER_SIZE = dipWidth; + } + + /** + * 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 + = Utils.getResourceIdentifier("modern_miniplayer_subtitle_text", "id"); + + private static final MiniplayerType CURRENT_TYPE = Settings.MINIPLAYER_TYPE.get(); + + /** + * Cannot turn off double tap with modern 2 or 3 with later targets, + * as forcing it off breakings tapping the miniplayer. + */ + private static final boolean DOUBLE_TAP_ACTION_ENABLED = + // 19.29+ is very broken if double tap is not enabled. + IS_19_29_OR_GREATER || + (CURRENT_TYPE.isModern() && Settings.MINIPLAYER_DOUBLE_TAP_ACTION.get()); + + private static final boolean DRAG_AND_DROP_ENABLED = + CURRENT_TYPE.isModern() && Settings.MINIPLAYER_DRAG_AND_DROP.get(); + + private static final boolean HIDE_EXPAND_CLOSE_ENABLED = + Settings.MINIPLAYER_HIDE_EXPAND_CLOSE.get() + && Settings.MINIPLAYER_HIDE_EXPAND_CLOSE.isAvailable(); + + 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 boolean MINIPLAYER_ROUNDED_CORNERS_ENABLED = + Settings.MINIPLAYER_ROUNDED_CORNERS.get(); + + /** + * Remove a broken and always present subtitle text that is only + * present with {@link MiniplayerType#MODERN_2}. Bug was fixed in 19.21. + */ + private static final boolean HIDE_BROKEN_MODERN_2_SUBTITLE = + CURRENT_TYPE == MODERN_2 && !IS_19_21_OR_GREATER; + + private static final int OPACITY_LEVEL; + + public static final class MiniplayerHideExpandCloseAvailability implements Setting.Availability { + @Override + public boolean isAvailable() { + MiniplayerType type = Settings.MINIPLAYER_TYPE.get(); + return (!IS_19_20_OR_GREATER && (type == MODERN_1 || type == MODERN_3)) + || (!IS_19_26_OR_GREATER && type == MODERN_1 + && !Settings.MINIPLAYER_DOUBLE_TAP_ACTION.get() && !Settings.MINIPLAYER_DRAG_AND_DROP.get()) + || (IS_19_29_OR_GREATER && type == MODERN_3); + } + } + + static { + int opacity = Settings.MINIPLAYER_OPACITY.get(); + + if (opacity < 0 || opacity > 100) { + Utils.showToastLong(str("revanced_miniplayer_opacity_invalid_toast")); + Settings.MINIPLAYER_OPACITY.resetToDefault(); + opacity = Settings.MINIPLAYER_OPACITY.defaultValue; + } + + 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 getModernFeatureFlagsActiveOverride(boolean original) { + if (original) Logger.printDebug(() -> "getModernFeatureFlagsActiveOverride original: " + original); + + if (CURRENT_TYPE == ORIGINAL) { + return original; + } + + return CURRENT_TYPE.isModern(); + } + + /** + * Injection point. + */ + public static boolean enableMiniplayerDoubleTapAction(boolean original) { + if (original) Logger.printDebug(() -> "enableMiniplayerDoubleTapAction original: " + true); + + if (CURRENT_TYPE == ORIGINAL) { + return original; + } + + return DOUBLE_TAP_ACTION_ENABLED; + } + + /** + * Injection point. + */ + public static boolean enableMiniplayerDragAndDrop(boolean original) { + if (original) Logger.printDebug(() -> "enableMiniplayerDragAndDrop original: " + true); + + if (CURRENT_TYPE == ORIGINAL) { + return original; + } + + return DRAG_AND_DROP_ENABLED; + } + + + /** + * Injection point. + */ + public static boolean setRoundedCorners(boolean original) { + if (original) Logger.printDebug(() -> "setRoundedCorners original: " + true); + + if (CURRENT_TYPE.isModern()) { + return MINIPLAYER_ROUNDED_CORNERS_ENABLED; + } + + return original; + } + + /** + * Injection point. + */ + public static int setMiniplayerDefaultSize(int original) { + if (CURRENT_TYPE.isModern()) { + return MINIPLAYER_SIZE; + } + + return original; + } + + /** + * Injection point. + */ + public static float setMovementBoundFactor(float original) { + // Not clear if customizing this is useful or not. + // So for now just log this and use the original value. + if (original != 1.0) Logger.printDebug(() -> "setMovementBoundFactor original: " + original); + + return original; + } + + /** + * Injection point. + */ + public static boolean setDropShadow(boolean original) { + if (original) Logger.printDebug(() -> "setViewElevation original: " + true); + + return original; + } + + /** + * 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 void hideMiniplayerSubTexts(View view) { + try { + // Different subviews are passed in, but only TextView is of interest here. + if (HIDE_SUBTEXT_ENABLED && view instanceof TextView) { + Logger.printDebug(() -> "Hiding subtext view"); + Utils.hideViewByRemovingFromParentUnderCondition(true, view); + } + } catch (Exception ex) { + Logger.printException(() -> "hideMiniplayerSubTexts failure", ex); + } + } + + /** + * Injection point. + */ + public static void playerOverlayGroupCreated(View group) { + try { + if (HIDE_BROKEN_MODERN_2_SUBTITLE && MODERN_OVERLAY_SUBTITLE_TEXT != 0) { + if (group instanceof ViewGroup) { + View subtitleText = Utils.getChildView((ViewGroup) group, true, + view -> view.getId() == MODERN_OVERLAY_SUBTITLE_TEXT); + + if (subtitleText != null) { + subtitleText.setVisibility(View.GONE); + Logger.printDebug(() -> "Modern overlay subtitle view set to hidden"); + } + } + } + } catch (Exception ex) { + Logger.printException(() -> "playerOverlayGroupCreated failure", ex); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/NavigationButtonsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/NavigationButtonsPatch.java new file mode 100644 index 000000000..8c581fc1c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/NavigationButtonsPatch.java @@ -0,0 +1,51 @@ +package app.revanced.extension.youtube.patches; + +import static app.revanced.extension.shared.Utils.hideViewUnderCondition; +import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton; + +import android.view.View; + +import java.util.EnumMap; +import java.util.Map; + +import android.widget.TextView; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class NavigationButtonsPatch { + + private static final Map shouldHideMap = new EnumMap<>(NavigationButton.class) { + { + put(NavigationButton.HOME, Settings.HIDE_HOME_BUTTON.get()); + put(NavigationButton.CREATE, Settings.HIDE_CREATE_BUTTON.get()); + put(NavigationButton.SHORTS, Settings.HIDE_SHORTS_BUTTON.get()); + put(NavigationButton.SUBSCRIPTIONS, Settings.HIDE_SUBSCRIPTIONS_BUTTON.get()); + } + }; + + private static final boolean SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON + = Settings.SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON.get(); + + /** + * Injection point. + */ + public static boolean switchCreateWithNotificationButton() { + return SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON; + } + + /** + * Injection point. + */ + public static void navigationTabCreated(NavigationButton button, View tabView) { + if (Boolean.TRUE.equals(shouldHideMap.get(button))) { + tabView.setVisibility(View.GONE); + } + } + + /** + * Injection point. + */ + public static void hideNavigationButtonLabels(TextView navigationLabelsView) { + hideViewUnderCondition(Settings.HIDE_NAVIGATION_BUTTON_LABELS, navigationLabelsView); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/OpenLinksExternallyPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/OpenLinksExternallyPatch.java new file mode 100644 index 000000000..1f5d52c5b --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/OpenLinksExternallyPatch.java @@ -0,0 +1,18 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class OpenLinksExternallyPatch { + /** + * Return the intent to open links with. If empty, the link will be opened with the default browser. + * + * @param originalIntent The original intent to open links with. + * @return The intent to open links with. Empty means the link will be opened with the default browser. + */ + public static String getIntent(String originalIntent) { + if (Settings.EXTERNAL_BROWSER.get()) return ""; + + return originalIntent; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerControlsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerControlsPatch.java new file mode 100644 index 000000000..5dc3dde26 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerControlsPatch.java @@ -0,0 +1,44 @@ +package app.revanced.extension.youtube.patches; + +import android.view.View; +import android.view.ViewTreeObserver; +import android.widget.ImageView; + +import app.revanced.extension.shared.Logger; + +@SuppressWarnings("unused") +public class PlayerControlsPatch { + /** + * Injection point. + */ + public static void setFullscreenCloseButton(ImageView imageButton) { + // Add a global listener, since the protected method + // View#onVisibilityChanged() does not have any call backs. + imageButton.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + int lastVisibility = View.VISIBLE; + + @Override + public void onGlobalLayout() { + try { + final int visibility = imageButton.getVisibility(); + if (lastVisibility != visibility) { + lastVisibility = visibility; + + Logger.printDebug(() -> "fullscreen button visibility: " + + (visibility == View.VISIBLE ? "VISIBLE" : + visibility == View.GONE ? "GONE" : "INVISIBLE")); + + fullscreenButtonVisibilityChanged(visibility == View.VISIBLE); + } + } catch (Exception ex) { + Logger.printDebug(() -> "OnGlobalLayoutListener failure", ex); + } + } + }); + } + + // noinspection EmptyMethod + public static void fullscreenButtonVisibilityChanged(boolean isVisible) { + // Code added during patching. + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerOverlaysHookPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerOverlaysHookPatch.java new file mode 100644 index 000000000..cf44c9dd5 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerOverlaysHookPatch.java @@ -0,0 +1,15 @@ +package app.revanced.extension.youtube.patches; + +import android.view.ViewGroup; + +import app.revanced.extension.youtube.shared.PlayerOverlays; + +@SuppressWarnings("unused") +public class PlayerOverlaysHookPatch { + /** + * Injection point. + */ + public static void playerOverlayInflated(ViewGroup group) { + PlayerOverlays.attach(group); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerTypeHookPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerTypeHookPatch.java new file mode 100644 index 000000000..3f1591950 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerTypeHookPatch.java @@ -0,0 +1,27 @@ +package app.revanced.extension.youtube.patches; + +import androidx.annotation.Nullable; + +import app.revanced.extension.youtube.shared.PlayerType; +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()); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RemoveTrackingQueryParameterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RemoveTrackingQueryParameterPatch.java new file mode 100644 index 000000000..3b05a239a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RemoveTrackingQueryParameterPatch.java @@ -0,0 +1,17 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class RemoveTrackingQueryParameterPatch { + private static final String NEW_TRACKING_PARAMETER_REGEX = ".si=.+"; + private static final String OLD_TRACKING_PARAMETER_REGEX = ".feature=.+"; + + public static String sanitize(String url) { + if (!Settings.REMOVE_TRACKING_QUERY_PARAMETER.get()) return url; + + return url + .replaceAll(NEW_TRACKING_PARAMETER_REGEX, "") + .replaceAll(OLD_TRACKING_PARAMETER_REGEX, ""); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RemoveViewerDiscretionDialogPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RemoveViewerDiscretionDialogPatch.java new file mode 100644 index 000000000..0260b2c69 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RemoveViewerDiscretionDialogPatch.java @@ -0,0 +1,19 @@ +package app.revanced.extension.youtube.patches; + +import android.app.AlertDialog; +import app.revanced.extension.youtube.settings.Settings; + +/** @noinspection unused*/ +public class RemoveViewerDiscretionDialogPatch { + public static void confirmDialog(AlertDialog dialog) { + if (!Settings.REMOVE_VIEWER_DISCRETION_DIALOG.get()) { + // Since the patch replaces the AlertDialog#show() method, we need to call the original method here. + dialog.show(); + return; + } + + final var button = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + button.setSoundEffectsEnabled(false); + button.performClick(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RestoreOldSeekbarThumbnailsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RestoreOldSeekbarThumbnailsPatch.java new file mode 100644 index 000000000..8322ea70b --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RestoreOldSeekbarThumbnailsPatch.java @@ -0,0 +1,10 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class RestoreOldSeekbarThumbnailsPatch { + public static boolean useFullscreenSeekbarThumbnails() { + return !Settings.RESTORE_OLD_SEEKBAR_THUMBNAILS.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ReturnYouTubeDislikePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ReturnYouTubeDislikePatch.java new file mode 100644 index 000000000..6a554fbd6 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ReturnYouTubeDislikePatch.java @@ -0,0 +1,734 @@ +package app.revanced.extension.youtube.patches; + +import android.graphics.Rect; +import android.graphics.drawable.ShapeDrawable; +import android.os.Build; +import android.text.*; +import android.view.View; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import app.revanced.extension.youtube.patches.components.ReturnYouTubeDislikeFilterPatch; +import app.revanced.extension.youtube.patches.spoof.SpoofAppVersionPatch; +import app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike; +import app.revanced.extension.youtube.returnyoutubedislike.requests.ReturnYouTubeDislikeApi; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.PlayerType; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike.Vote; + +/** + * 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 = + SpoofAppVersionPatch.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(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; + 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. + } + + // + // 17.x non litho regular video player. + // + + /** + * Resource identifier of old UI dislike button. + */ + private static final int OLD_UI_DISLIKE_BUTTON_RESOURCE_ID + = Utils.getResourceIdentifier("dislike_button", "id"); + + /** + * Dislikes text label used by old UI. + */ + @NonNull + private static WeakReference oldUITextViewRef = new WeakReference<>(null); + + /** + * Original old UI 'Dislikes' text before patch modifications. + * Required to reset the dislikes when changing videos and RYD is not available. + * Set only once during the first load. + */ + private static Spanned oldUIOriginalSpan; + + /** + * Replacement span that contains dislike value. Used by {@link #oldUiTextWatcher}. + */ + @Nullable + private static Spanned oldUIReplacementSpan; + + /** + * Old UI dislikes can be set multiple times by YouTube. + * To prevent reverting changes made here, this listener overrides any future changes YouTube makes. + */ + private static final TextWatcher oldUiTextWatcher = 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) { + if (oldUIReplacementSpan == null || oldUIReplacementSpan.toString().equals(s.toString())) { + return; + } + s.replace(0, s.length(), oldUIReplacementSpan); // Causes a recursive call back into this listener + } + }; + + private static void updateOldUIDislikesTextView() { + ReturnYouTubeDislike videoData = currentVideoData; + if (videoData == null) { + return; + } + TextView oldUITextView = oldUITextViewRef.get(); + if (oldUITextView == null) { + return; + } + oldUIReplacementSpan = videoData.getDislikesSpanForRegularVideo(oldUIOriginalSpan, false, false); + if (!oldUIReplacementSpan.equals(oldUITextView.getText())) { + oldUITextView.setText(oldUIReplacementSpan); + } + } + + /** + * Injection point. Called on main thread. + * + * Used when spoofing to 16.x and 17.x versions. + */ + public static void setOldUILayoutDislikes(int buttonViewResourceId, @Nullable TextView textView) { + try { + if (!Settings.RYD_ENABLED.get() + || buttonViewResourceId != OLD_UI_DISLIKE_BUTTON_RESOURCE_ID + || textView == null) { + return; + } + Logger.printDebug(() -> "setOldUILayoutDislikes"); + + if (oldUIOriginalSpan == null) { + // Use value of the first instance, as it appears TextViews can be recycled + // and might contain dislikes previously added by the patch. + oldUIOriginalSpan = (Spanned) textView.getText(); + } + oldUITextViewRef = new WeakReference<>(textView); + // No way to check if a listener is already attached, so remove and add again. + textView.removeTextChangedListener(oldUiTextWatcher); + textView.addTextChangedListener(oldUiTextWatcher); + + updateOldUIDislikesTextView(); + + } catch (Exception ex) { + Logger.printException(() -> "setOldUILayoutDislikes failure", ex); + } + } + + + // + // 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); + } + + /** + * 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; + } + + 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; + } + // 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())) { + // 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() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // YouTube requires Android N or greater + 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); + + 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) { + if (lastLithoShortsVideoData != null) { + lithoShortsShouldUseCurrentData = true; + } + updateOldUIDislikesTextView(); + } + + 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/SeekbarTappingPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/SeekbarTappingPatch.java new file mode 100644 index 000000000..dbbb363e1 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/SeekbarTappingPatch.java @@ -0,0 +1,10 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class SeekbarTappingPatch { + public static boolean seekbarTappingEnabled() { + return Settings.SEEKBAR_TAPPING.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ShortsAutoplayPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ShortsAutoplayPatch.java new file mode 100644 index 000000000..32576479d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ShortsAutoplayPatch.java @@ -0,0 +1,119 @@ +package app.revanced.extension.youtube.patches; + +import android.app.Activity; +import android.os.Build; + +import androidx.annotation.RequiresApi; + +import java.lang.ref.WeakReference; +import java.util.Objects; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class ShortsAutoplayPatch { + + private enum ShortsLoopBehavior { + UNKNOWN, + /** + * Repeat the same Short forever! + */ + REPEAT, + /** + * Play once, then advanced to the next Short. + */ + SINGLE_PLAY, + /** + * Pause playback after 1 play. + */ + END_SCREEN; + + static void setYTEnumValue(Enum ytBehavior) { + for (ShortsLoopBehavior rvBehavior : values()) { + if (ytBehavior.name().endsWith(rvBehavior.name())) { + rvBehavior.ytEnumValue = ytBehavior; + + Logger.printDebug(() -> rvBehavior + " set to YT enum: " + ytBehavior.name()); + return; + } + } + + Logger.printException(() -> "Unknown Shorts loop behavior: " + ytBehavior.name()); + } + + /** + * YouTube enum value of the obfuscated enum type. + */ + private Enum ytEnumValue; + } + + private static WeakReference mainActivityRef = new WeakReference<>(null); + + + public static void setMainActivity(Activity activity) { + mainActivityRef = new WeakReference<>(activity); + } + + /** + * @return If the app is currently in background PiP mode. + */ + @RequiresApi(api = Build.VERSION_CODES.N) + private static boolean isAppInBackgroundPiPMode() { + Activity activity = mainActivityRef.get(); + return activity != null && activity.isInPictureInPictureMode(); + } + + /** + * Injection point. + */ + public static void setYTShortsRepeatEnum(Enum ytEnum) { + try { + for (Enum ytBehavior : Objects.requireNonNull(ytEnum.getClass().getEnumConstants())) { + ShortsLoopBehavior.setYTEnumValue(ytBehavior); + } + } catch (Exception ex) { + Logger.printException(() -> "setYTShortsRepeatEnum failure", ex); + } + } + + /** + * Injection point. + */ + @RequiresApi(api = Build.VERSION_CODES.N) + public static Enum changeShortsRepeatBehavior(Enum original) { + try { + final boolean autoplay; + + if (isAppInBackgroundPiPMode()) { + if (!VersionCheckPatch.IS_19_34_OR_GREATER) { + // 19.34+ is required to set background play behavior. + Logger.printDebug(() -> "PiP Shorts not supported, using original repeat behavior"); + + return original; + } + + autoplay = Settings.SHORTS_AUTOPLAY_BACKGROUND.get(); + } else { + autoplay = Settings.SHORTS_AUTOPLAY.get(); + } + + final ShortsLoopBehavior behavior = autoplay + ? ShortsLoopBehavior.SINGLE_PLAY + : ShortsLoopBehavior.REPEAT; + + if (behavior.ytEnumValue != null) { + Logger.printDebug(() -> behavior.ytEnumValue == original + ? "Changing Shorts repeat behavior from: " + original.name() + " to: " + behavior.ytEnumValue + : "Behavior setting is same as original. Using original: " + original.name() + ); + + return behavior.ytEnumValue; + } + } catch (Exception ex) { + Logger.printException(() -> "changeShortsRepeatState failure", ex); + } + + return original; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/SlideToSeekPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/SlideToSeekPatch.java new file mode 100644 index 000000000..d17a1f866 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/SlideToSeekPatch.java @@ -0,0 +1,14 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class SlideToSeekPatch { + private static final Boolean SLIDE_TO_SEEK_DISABLED = !Settings.SLIDE_TO_SEEK.get(); + + public static boolean isSlideToSeekDisabled(boolean isDisabled) { + if (!isDisabled) return isDisabled; + + return SLIDE_TO_SEEK_DISABLED; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/TabletLayoutPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/TabletLayoutPatch.java new file mode 100644 index 000000000..f2ae03598 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/TabletLayoutPatch.java @@ -0,0 +1,16 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class TabletLayoutPatch { + + private static final boolean TABLET_LAYOUT_ENABLED = Settings.TABLET_LAYOUT.get(); + + /** + * Injection point. + */ + public static boolean getTabletLayoutEnabled() { + return TABLET_LAYOUT_ENABLED; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VersionCheckPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VersionCheckPatch.java new file mode 100644 index 000000000..2d3817ce9 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VersionCheckPatch.java @@ -0,0 +1,11 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.shared.Utils; + +public class VersionCheckPatch { + public static final boolean IS_19_20_OR_GREATER = Utils.getAppVersionName().compareTo("19.20.00") >= 0; + public static final boolean IS_19_21_OR_GREATER = Utils.getAppVersionName().compareTo("19.21.00") >= 0; + public static final boolean IS_19_26_OR_GREATER = Utils.getAppVersionName().compareTo("19.26.00") >= 0; + public static final boolean IS_19_29_OR_GREATER = Utils.getAppVersionName().compareTo("19.29.00") >= 0; + public static final boolean IS_19_34_OR_GREATER = Utils.getAppVersionName().compareTo("19.34.00") >= 0; +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VideoAdsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VideoAdsPatch.java new file mode 100644 index 000000000..9950de5e0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VideoAdsPatch.java @@ -0,0 +1,14 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class VideoAdsPatch { + + // Used by app.revanced.patches.youtube.ad.general.video.patch.VideoAdsPatch + // depends on Whitelist patch (still needs to be written) + public static boolean shouldShowAds() { + return !Settings.HIDE_VIDEO_ADS.get(); // TODO && Whitelist.shouldShowAds(); + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VideoInformation.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VideoInformation.java new file mode 100644 index 000000000..6b64ade12 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VideoInformation.java @@ -0,0 +1,359 @@ +package app.revanced.extension.youtube.patches; + +import androidx.annotation.NonNull; +import app.revanced.extension.youtube.patches.playback.speed.RememberPlaybackSpeedPatch; +import app.revanced.extension.youtube.shared.VideoState; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; + +import java.lang.ref.WeakReference; +import java.util.Objects; + +/** + * Hooking class for the current playing video. + * @noinspection unused + */ +public final class VideoInformation { + + public interface PlaybackController { + // Methods are added to YT classes during patching. + boolean seekTo(long videoTime); + void seekToRelative(long videoTimeOffset); + } + + private static final float DEFAULT_YOUTUBE_PLAYBACK_SPEED = 1.0f; + /** + * Prefix present in all Short player parameters signature. + */ + private static final String SHORTS_PLAYER_PARAMETERS = "8AEB"; + + private static WeakReference playerControllerRef = new WeakReference<>(null); + private static WeakReference mdxPlayerDirectorRef = new WeakReference<>(null); + + @NonNull + private static String videoId = ""; + private static long videoLength = 0; + private static long videoTime = -1; + + @NonNull + private static volatile String playerResponseVideoId = ""; + private static volatile boolean playerResponseVideoIdIsShort; + private static volatile boolean videoIdIsShort; + + /** + * The current playback speed + */ + private static float playbackSpeed = DEFAULT_YOUTUBE_PLAYBACK_SPEED; + + /** + * Injection point. + * + * @param playerController player controller object. + */ + public static void initialize(@NonNull PlaybackController playerController) { + try { + playerControllerRef = new WeakReference<>(Objects.requireNonNull(playerController)); + videoTime = -1; + videoLength = 0; + playbackSpeed = DEFAULT_YOUTUBE_PLAYBACK_SPEED; + } catch (Exception ex) { + Logger.printException(() -> "Failed to initialize", ex); + } + } + + /** + * Injection point. + * + * @param mdxPlayerDirector MDX player director object (casting mode). + */ + public static void initializeMdx(@NonNull PlaybackController mdxPlayerDirector) { + try { + mdxPlayerDirectorRef = new WeakReference<>(Objects.requireNonNull(mdxPlayerDirector)); + } catch (Exception ex) { + Logger.printException(() -> "Failed to initialize MDX", ex); + } + } + + /** + * Injection point. + * + * @param newlyLoadedVideoId id of the current video + */ + public static void setVideoId(@NonNull String newlyLoadedVideoId) { + if (!videoId.equals(newlyLoadedVideoId)) { + Logger.printDebug(() -> "New video id: " + newlyLoadedVideoId); + videoId = newlyLoadedVideoId; + } + } + + /** + * @return If the player parameters are for a Short. + */ + public static boolean playerParametersAreShort(@NonNull String parameters) { + return parameters.startsWith(SHORTS_PLAYER_PARAMETERS); + } + + /** + * Injection point. + */ + public static String newPlayerResponseSignature(@NonNull String signature, String videoId, boolean isShortAndOpeningOrPlaying) { + final boolean isShort = playerParametersAreShort(signature); + playerResponseVideoIdIsShort = isShort; + if (!isShort || isShortAndOpeningOrPlaying) { + if (videoIdIsShort != isShort) { + videoIdIsShort = isShort; + Logger.printDebug(() -> "videoIdIsShort: " + isShort); + } + } + return signature; // 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)) { + Logger.printDebug(() -> "New player response video id: " + videoId); + playerResponseVideoId = videoId; + } + } + + /** + * Injection point. + * Called when user selects a playback speed. + * + * @param userSelectedPlaybackSpeed The playback speed the user selected + */ + public static void userSelectedPlaybackSpeed(float userSelectedPlaybackSpeed) { + Logger.printDebug(() -> "User selected playback speed: " + userSelectedPlaybackSpeed); + playbackSpeed = userSelectedPlaybackSpeed; + } + + /** + * Overrides the current playback speed. + *

+ * Used exclusively by {@link RememberPlaybackSpeedPatch} + */ + public static void overridePlaybackSpeed(float speedOverride) { + if (playbackSpeed != speedOverride) { + Logger.printDebug(() -> "Overriding playback speed to: " + speedOverride); + playbackSpeed = speedOverride; + } + } + + /** + * Injection point. + * + * @param length The length of the video in milliseconds. + */ + public static void setVideoLength(final long length) { + if (videoLength != length) { + Logger.printDebug(() -> "Current video length: " + length); + videoLength = length; + } + } + + /** + * 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; + } + + /** + * 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) { + Utils.verifyOnMainThread(); + try { + final long videoTime = getVideoTime(); + final long videoLength = getVideoLength(); + + // Prevent issues such as play/ pause button or autoplay not working. + final long adjustedSeekTime = Math.min(seekTime, videoLength - 250); + if (videoTime <= seekTime && videoTime >= adjustedSeekTime) { + // Both the current video time and the seekTo are in the last 250ms of the video. + // Ignore this seek call, otherwise if a video ends with multiple closely timed segments + // then seeking here can create an infinite loop of skip attempts. + Logger.printDebug(() -> "Ignoring seekTo call as video playback is almost finished. " + + " videoTime: " + videoTime + " videoLength: " + videoLength + " seekTo: " + seekTime); + return false; + } + + Logger.printDebug(() -> "Seeking to: " + adjustedSeekTime); + + // Try regular playback controller first, and it will not succeed if casting. + PlaybackController controller = playerControllerRef.get(); + if (controller == null) { + Logger.printDebug(() -> "Cannot seekTo because player controller is null"); + } else { + if (controller.seekTo(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; + } + + controller = mdxPlayerDirectorRef.get(); + if (controller == null) { + Logger.printDebug(() -> "Cannot seekTo MXD because player controller is null"); + return false; + } + + return controller.seekTo(adjustedSeekTime); + } catch (Exception ex) { + Logger.printException(() -> "Failed to seek", ex); + return false; + } + } + + /** + * Seeks a relative amount. Should always be used over {@link #seekTo(long)} + * when the desired seek time is an offset of the current time. + */ + public static void seekToRelative(long seekTime) { + Utils.verifyOnMainThread(); + try { + Logger.printDebug(() -> "Seeking relative to: " + seekTime); + + // 19.39+ does not have a boolean return type for relative seek. + // But can call both methods and it works correctly for both situations. + PlaybackController controller = playerControllerRef.get(); + if (controller == null) { + Logger.printDebug(() -> "Cannot seek relative as player controller is null"); + } else { + controller.seekToRelative(seekTime); + } + + // 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; + if (seekTime < 0) { + adjustedSeekTime = Math.min(seekTime, -1000); + } else { + adjustedSeekTime = Math.max(seekTime, 1000); + } + + controller = mdxPlayerDirectorRef.get(); + if (controller == null) { + Logger.printDebug(() -> "Cannot seek relative as MXD player controller is null"); + } else { + controller.seekToRelative(adjustedSeekTime); + } + } catch (Exception ex) { + Logger.printException(() -> "Failed to seek relative", 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; + } + + /** + * 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 The current playback speed. + */ + public static float getPlaybackSpeed() { + return playbackSpeed; + } + + /** + * 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; + } + + /** + * @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 + */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public static boolean isAtEndOfVideo() { + return videoTime >= videoLength && videoLength > 0; + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/WideSearchbarPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/WideSearchbarPatch.java new file mode 100644 index 000000000..57ec7442f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/WideSearchbarPatch.java @@ -0,0 +1,11 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class WideSearchbarPatch { + + public static boolean enableWideSearchbar(boolean original) { + return Settings.WIDE_SEARCHBAR.get() || original; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ZoomHapticsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ZoomHapticsPatch.java new file mode 100644 index 000000000..0367eb868 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ZoomHapticsPatch.java @@ -0,0 +1,10 @@ +package app.revanced.extension.youtube.patches; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class ZoomHapticsPatch { + public static boolean shouldVibrate() { + return !Settings.DISABLE_ZOOM_HAPTICS.get(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/announcements/AnnouncementsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/announcements/AnnouncementsPatch.java new file mode 100644 index 000000000..0bea72373 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/announcements/AnnouncementsPatch.java @@ -0,0 +1,157 @@ +package app.revanced.extension.youtube.patches.announcements; + +import android.app.Activity; +import android.app.AlertDialog; +import android.os.Build; +import android.text.Html; +import android.text.method.LinkMovementMethod; +import android.widget.TextView; +import androidx.annotation.RequiresApi; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.patches.announcements.requests.AnnouncementsRoutes; +import app.revanced.extension.youtube.requests.Requester; +import app.revanced.extension.youtube.settings.Settings; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Locale; + +import static android.text.Html.FROM_HTML_MODE_COMPACT; +import static app.revanced.extension.shared.StringRef.str; +import static app.revanced.extension.youtube.patches.announcements.requests.AnnouncementsRoutes.GET_LATEST_ANNOUNCEMENT; + +@SuppressWarnings("unused") +public final class AnnouncementsPatch { + private AnnouncementsPatch() { + } + + @RequiresApi(api = Build.VERSION_CODES.O) + public static void showAnnouncement(final Activity context) { + if (!Settings.ANNOUNCEMENTS.get()) return; + + // Check if there is internet connection + if (!Utils.isNetworkConnected()) return; + + Utils.runOnBackgroundThread(() -> { + try { + HttpURLConnection connection = AnnouncementsRoutes.getAnnouncementsConnectionFromRoute( + GET_LATEST_ANNOUNCEMENT, Locale.getDefault().toLanguageTag()); + + Logger.printDebug(() -> "Get latest announcement route connection url: " + connection.getURL()); + + try { + // Do not show the announcement if the request failed. + if (connection.getResponseCode() != 200) { + if (Settings.ANNOUNCEMENT_LAST_ID.isSetToDefault()) + return; + + Settings.ANNOUNCEMENT_LAST_ID.resetToDefault(); + Utils.showToastLong(str("revanced_announcements_connection_failed")); + + return; + } + } catch (IOException ex) { + final var message = "Failed connecting to announcements provider"; + + Logger.printException(() -> message, ex); + return; + } + + var jsonString = Requester.parseStringAndDisconnect(connection); + + + // Parse the announcement. Fall-back to raw string if it fails. + int id = Settings.ANNOUNCEMENT_LAST_ID.defaultValue; + String title; + String message; + Level level = Level.INFO; + try { + final var announcement = new JSONObject(jsonString); + + id = announcement.getInt("id"); + title = announcement.getString("title"); + message = announcement.getJSONObject("content").getString("message"); + if (!announcement.isNull("level")) level = Level.fromInt(announcement.getInt("level")); + + } catch (Throwable ex) { + Logger.printException(() -> "Failed to parse announcement. Fall-backing to raw string", ex); + + title = "Announcement"; + message = jsonString; + } + + // TODO: Remove this migration code after a few months. + if (!Settings.DEPRECATED_ANNOUNCEMENT_LAST_HASH.isSetToDefault()){ + final byte[] hashBytes = MessageDigest + .getInstance("SHA-256") + .digest(jsonString.getBytes(StandardCharsets.UTF_8)); + + final var hash = java.util.Base64.getEncoder().encodeToString(hashBytes); + + // Migrate to saving the id instead of the hash. + if (hash.equals(Settings.DEPRECATED_ANNOUNCEMENT_LAST_HASH.get())) { + Settings.ANNOUNCEMENT_LAST_ID.save(id); + } + + Settings.DEPRECATED_ANNOUNCEMENT_LAST_HASH.resetToDefault(); + } + + // Do not show the announcement, if the last announcement id is the same as the current one. + if (Settings.ANNOUNCEMENT_LAST_ID.get() == id) return; + + int finalId = id; + final var finalTitle = title; + final var finalMessage = Html.fromHtml(message, FROM_HTML_MODE_COMPACT); + final Level finalLevel = level; + + Utils.runOnMainThread(() -> { + // Show the announcement. + var alert = new AlertDialog.Builder(context) + .setTitle(finalTitle) + .setMessage(finalMessage) + .setIcon(finalLevel.icon) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + Settings.ANNOUNCEMENT_LAST_ID.save(finalId); + dialog.dismiss(); + }).setNegativeButton(str("revanced_announcements_dialog_dismiss"), (dialog, which) -> { + dialog.dismiss(); + }) + .setCancelable(false) + .create(); + + Utils.showDialog(context, alert, false, (AlertDialog dialog) -> { + // Make links clickable. + ((TextView) dialog.findViewById(android.R.id.message)) + .setMovementMethod(LinkMovementMethod.getInstance()); + }); + }); + } catch (Exception e) { + final var message = "Failed to get announcement"; + + Logger.printException(() -> message, e); + } + }); + } + + // TODO: Use better icons. + private enum Level { + INFO(android.R.drawable.ic_dialog_info), + WARNING(android.R.drawable.ic_dialog_alert), + SEVERE(android.R.drawable.ic_dialog_alert); + + public final int icon; + + Level(int icon) { + this.icon = icon; + } + + public static Level fromInt(int value) { + return values()[Math.min(value, values().length - 1)]; + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/announcements/requests/AnnouncementsRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/announcements/requests/AnnouncementsRoutes.java new file mode 100644 index 000000000..94d340e6e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/announcements/requests/AnnouncementsRoutes.java @@ -0,0 +1,25 @@ +package app.revanced.extension.youtube.patches.announcements.requests; + +import app.revanced.extension.youtube.requests.Requester; +import app.revanced.extension.youtube.requests.Route; + +import java.io.IOException; +import java.net.HttpURLConnection; + +import static app.revanced.extension.youtube.requests.Route.Method.GET; + +public class AnnouncementsRoutes { + private static final String ANNOUNCEMENTS_PROVIDER = "https://api.revanced.app/v2"; + + /** + * 'language' parameter is IETF format (for USA it would be 'en-us'). + */ + public static final Route GET_LATEST_ANNOUNCEMENT = new Route(GET, "/announcements/youtube/latest?language={language}"); + + private AnnouncementsRoutes() { + } + + public static HttpURLConnection getAnnouncementsConnectionFromRoute(Route route, String... params) throws IOException { + return Requester.getConnectionFromRoute(ANNOUNCEMENTS_PROVIDER, route, params); + } +} \ No newline at end of file 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 000000000..0bf9e9c3f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/AdsFilter.java @@ -0,0 +1,240 @@ +package app.revanced.extension.youtube.patches.components; + +import static app.revanced.extension.shared.StringRef.str; + +import android.app.Instrumentation; +import android.view.KeyEvent; +import android.view.View; + +import androidx.annotation.Nullable; + +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.StringTrieSearch; + +@SuppressWarnings("unused") +public final class AdsFilter extends Filter { + // region Fullscreen ad + private static volatile long lastTimeClosedFullscreenAd; + private static final Instrumentation instrumentation = new Instrumentation(); + private final StringFilterGroup fullscreenAd; + + // endregion + + private final StringTrieSearch exceptions = new StringTrieSearch(); + + private final StringFilterGroup playerShoppingShelf; + private final ByteArrayFilterGroup playerShoppingShelfBuffer; + + private final StringFilterGroup channelProfile; + private final ByteArrayFilterGroup visitStoreButton; + + private final StringFilterGroup shoppingLinks; + + public AdsFilter() { + exceptions.addPatterns( + "home_video_with_context", // Don't filter anything in the home page video component. + "related_video_with_context", // Don't filter anything in the related video component. + "comment_thread", // Don't filter anything in the comments. + "|comment.", // Don't filter anything in the comments replies. + "library_recent_shelf" + ); + + // Identifiers. + + + final var carouselAd = new StringFilterGroup( + Settings.HIDE_GENERAL_ADS, + "carousel_ad" + ); + addIdentifierCallbacks(carouselAd); + + // Paths. + + fullscreenAd = new StringFilterGroup( + Settings.HIDE_FULLSCREEN_ADS, + "_interstitial" + ); + + final var buttonedAd = new StringFilterGroup( + Settings.HIDE_BUTTONED_ADS, + "_ad_with", + "_buttoned_layout", + // text_image_button_group_layout, landscape_image_button_group_layout, full_width_square_image_button_group_layout + "image_button_group_layout", + "full_width_square_image_layout", + "video_display_button_group_layout", + "landscape_image_wide_button_layout", + "video_display_carousel_button_group_layout" + ); + + final var generalAds = new StringFilterGroup( + Settings.HIDE_GENERAL_ADS, + "ads_video_with_context", + "banner_text_icon", + "square_image_layout", + "watch_metadata_app_promo", + "video_display_full_layout", + "hero_promo_image", + "statement_banner", + "carousel_footered_layout", + "text_image_button_layout", + "primetime_promo", + "product_details", + "composite_concurrent_carousel_layout", + "carousel_headered_layout", + "full_width_portrait_image_layout", + "brand_video_shelf" + ); + + final var movieAds = new StringFilterGroup( + Settings.HIDE_MOVIES_SECTION, + "browsy_bar", + "compact_movie", + "horizontal_movie_shelf", + "movie_and_show_upsell_card", + "compact_tvfilm_item", + "offer_module_root" + ); + + final var viewProducts = new StringFilterGroup( + Settings.HIDE_PRODUCTS_BANNER, + "product_item", + "products_in_video" + ); + + shoppingLinks = new StringFilterGroup( + Settings.HIDE_SHOPPING_LINKS, + "expandable_list" + ); + + channelProfile = new StringFilterGroup( + null, + "channel_profile.eml" + ); + + playerShoppingShelf = new StringFilterGroup( + null, + "horizontal_shelf.eml" + ); + + playerShoppingShelfBuffer = new ByteArrayFilterGroup( + Settings.HIDE_PLAYER_STORE_SHELF, + "shopping_item_card_list.eml" + ); + + visitStoreButton = new ByteArrayFilterGroup( + Settings.HIDE_VISIT_STORE_BUTTON, + "header_store_button" + ); + + final var webLinkPanel = new StringFilterGroup( + Settings.HIDE_WEB_SEARCH_RESULTS, + "web_link_panel" + ); + + final var merchandise = new StringFilterGroup( + Settings.HIDE_MERCHANDISE_BANNERS, + "product_carousel" + ); + + final var selfSponsor = new StringFilterGroup( + Settings.HIDE_SELF_SPONSOR, + "cta_shelf_card" + ); + + addPathCallbacks( + generalAds, + buttonedAd, + merchandise, + viewProducts, + selfSponsor, + fullscreenAd, + channelProfile, + webLinkPanel, + shoppingLinks, + playerShoppingShelf, + movieAds + ); + } + + @Override + boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (matchedGroup == playerShoppingShelf) { + if (contentIndex == 0 && playerShoppingShelfBuffer.check(protobufBufferArray).isFiltered()) { + return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + return false; + } + + if (exceptions.matches(path)) + return false; + + if (matchedGroup == fullscreenAd) { + if (path.contains("|ImageType|")) closeFullscreenAd(); + + return false; // Do not actually filter the fullscreen ad otherwise it will leave a dimmed screen. + } + + if (matchedGroup == channelProfile) { + if (visitStoreButton.check(protobufBufferArray).isFiltered()) { + return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + return false; + } + + // Check for the index because of likelihood of false positives. + if (matchedGroup == shoppingLinks && contentIndex != 0) + return false; + + return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + + /** + * Hide the view, which shows ads in the homepage. + * + * @param view The view, which shows ads. + */ + public static void hideAdAttributionView(View view) { + Utils.hideViewBy0dpUnderCondition(Settings.HIDE_GENERAL_ADS, view); + } + + /** + * Close the fullscreen ad. + *

+ * The strategy is to send a back button event to the app to close the fullscreen ad using the back button event. + */ + private static void closeFullscreenAd() { + final var currentTime = System.currentTimeMillis(); + + // Prevent spamming the back button. + if (currentTime - lastTimeClosedFullscreenAd < 10000) return; + lastTimeClosedFullscreenAd = currentTime; + + Logger.printDebug(() -> "Closing fullscreen ad"); + + Utils.runOnMainThreadDelayed(() -> { + // Must run off main thread (Odd, but whatever). + Utils.runOnBackgroundThread(() -> { + try { + instrumentation.sendKeyDownUpSync(KeyEvent.KEYCODE_BACK); + } catch (Exception ex) { + // Injecting user events on Android 10+ requires the manifest to include + // INJECT_EVENTS, and it's usage is heavily restricted + // and requires the user to manually approve the permission in the device settings. + // + // And no matter what, permissions cannot be added for root installations + // as manifest changes are ignored for mount installations. + // + // Instead, catch the SecurityException and turn off hide full screen ads + // since this functionality does not work for these devices. + Logger.printInfo(() -> "Could not inject back button event", ex); + Settings.HIDE_FULLSCREEN_ADS.save(false); + Utils.showToastLong(str("revanced_hide_fullscreen_ads_feature_not_available_toast")); + } + }); + }, 1000); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ButtonsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ButtonsFilter.java new file mode 100644 index 000000000..35337bee0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ButtonsFilter.java @@ -0,0 +1,103 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +final class ButtonsFilter extends Filter { + private static final String VIDEO_ACTION_BAR_PATH = "video_action_bar.eml"; + + private final StringFilterGroup actionBarGroup; + private final StringFilterGroup bufferFilterPathGroup; + private final ByteArrayFilterGroupList bufferButtonsGroupList = new ByteArrayFilterGroupList(); + + public ButtonsFilter() { + actionBarGroup = new StringFilterGroup( + null, + VIDEO_ACTION_BAR_PATH + ); + addIdentifierCallbacks(actionBarGroup); + + + bufferFilterPathGroup = new StringFilterGroup( + null, + "|ContainerType|button.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_PLAYLIST_BUTTON, + "|save_to_playlist_button" + ), + new StringFilterGroup( + Settings.HIDE_CLIP_BUTTON, + "|clip_button.eml|" + ), + bufferFilterPathGroup + ); + + 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" + ), + // Check for clip button both here and using a path filter, + // as there's a chance the path is a generic action button and won't contain 'clip_button' + new ByteArrayFilterGroup( + Settings.HIDE_CLIP_BUTTON, + "yt_outline_scissors" + ), + new ByteArrayFilterGroup( + Settings.HIDE_THANKS_BUTTON, + "yt_outline_dollar_sign_heart" + ) + ); + } + + private boolean isEveryFilterGroupEnabled() { + for (var group : pathCallbacks) + if (!group.isEnabled()) return false; + + for (var group : bufferButtonsGroupList) + if (!group.isEnabled()) return false; + + return true; + } + + @Override + boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + // If the current matched group is the action bar group, + // in case every filter group is enabled, hide the action bar. + if (matchedGroup == actionBarGroup) { + if (!isEveryFilterGroupEnabled()) { + return false; + } + } else if (matchedGroup == bufferFilterPathGroup) { + // Make sure the current path is the right one + // to avoid false positives. + if (!path.startsWith(VIDEO_ACTION_BAR_PATH)) return false; + + // In case the group list has no match, return false. + if (!bufferButtonsGroupList.check(protobufBufferArray).isFiltered()) return false; + } + + return super.isFiltered(identifier, path, 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 000000000..0e7ebc440 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CommentsFilter.java @@ -0,0 +1,83 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +final class CommentsFilter extends Filter { + + private static final String TIMESTAMP_OR_EMOJI_BUTTONS_ENDS_WITH_PATH + = "|CellType|ContainerType|ContainerType|ContainerType|ContainerType|ContainerType|"; + + private final StringFilterGroup commentComposer; + private final ByteArrayFilterGroup emojiPickerBufferGroup; + + public CommentsFilter() { + var commentsByMembers = new StringFilterGroup( + Settings.HIDE_COMMENTS_BY_MEMBERS_HEADER, + "sponsorships_comments_header.eml", + "sponsorships_comments_footer.eml" + ); + + var comments = new StringFilterGroup( + Settings.HIDE_COMMENTS_SECTION, + "video_metadata_carousel", + "_comments" + ); + + var createAShort = new StringFilterGroup( + Settings.HIDE_COMMENTS_CREATE_A_SHORT_BUTTON, + "composer_short_creation_button.eml" + ); + + var previewComment = new StringFilterGroup( + Settings.HIDE_COMMENTS_PREVIEW_COMMENT, + "|carousel_item", + "comments_entry_point_teaser", + "comments_entry_point_simplebox" + ); + + var thanksButton = new StringFilterGroup( + Settings.HIDE_COMMENTS_THANKS_BUTTON, + "super_thanks_button.eml" + ); + + commentComposer = new StringFilterGroup( + Settings.HIDE_COMMENTS_TIMESTAMP_AND_EMOJI_BUTTONS, + "comment_composer.eml" + ); + + emojiPickerBufferGroup = new ByteArrayFilterGroup( + null, + "id.comment.quick_emoji.button" + ); + + addPathCallbacks( + commentsByMembers, + comments, + createAShort, + previewComment, + thanksButton, + commentComposer + ); + } + + @Override + boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (matchedGroup == commentComposer) { + // To completely hide the emoji buttons (and leave no empty space), the timestamp button is + // also hidden because the buffer is exactly the same and there's no way selectively hide. + if (contentIndex == 0 + && path.endsWith(TIMESTAMP_OR_EMOJI_BUTTONS_ENDS_WITH_PATH) + && emojiPickerBufferGroup.check(protobufBufferArray).isFiltered()) { + return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + + return false; + } + + return super.isFiltered(identifier, path, 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 000000000..37062d6e2 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CustomFilter.java @@ -0,0 +1,161 @@ +package app.revanced.extension.youtube.patches.components; + +import static app.revanced.extension.shared.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.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.ByteTrieSearch; +import app.revanced.extension.youtube.settings.Settings; + +/** + * Allows custom filtering using a path and optionally a proto buffer string. + */ +@SuppressWarnings("unused") +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 + boolean isFiltered(@Nullable String identifier, String path, 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(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/DescriptionComponentsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/DescriptionComponentsFilter.java new file mode 100644 index 000000000..2ddd8489c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/DescriptionComponentsFilter.java @@ -0,0 +1,88 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import app.revanced.extension.youtube.StringTrieSearch; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +final class DescriptionComponentsFilter extends Filter { + + private final StringTrieSearch exceptions = new StringTrieSearch(); + + private final ByteArrayFilterGroupList macroMarkersCarouselGroupList = new ByteArrayFilterGroupList(); + + private final StringFilterGroup macroMarkersCarousel; + + public DescriptionComponentsFilter() { + exceptions.addPatterns( + "compact_channel", + "description", + "grid_video", + "inline_expander", + "metadata" + ); + + final StringFilterGroup attributesSection = new StringFilterGroup( + Settings.HIDE_ATTRIBUTES_SECTION, + "gaming_section", + "music_section", + "video_attributes_section" + ); + + final StringFilterGroup infoCardsSection = new StringFilterGroup( + Settings.HIDE_INFO_CARDS_SECTION, + "infocards_section" + ); + + final StringFilterGroup podcastSection = new StringFilterGroup( + Settings.HIDE_PODCAST_SECTION, + "playlist_section" + ); + + final StringFilterGroup transcriptSection = new StringFilterGroup( + Settings.HIDE_TRANSCRIPT_SECTION, + "transcript_section" + ); + + macroMarkersCarousel = new StringFilterGroup( + null, + "macro_markers_carousel.eml" + ); + + macroMarkersCarouselGroupList.addAll( + new ByteArrayFilterGroup( + Settings.HIDE_CHAPTERS_SECTION, + "chapters_horizontal_shelf" + ), + new ByteArrayFilterGroup( + Settings.HIDE_KEY_CONCEPTS_SECTION, + "learning_concept_macro_markers_carousel_shelf" + ) + ); + + addPathCallbacks( + attributesSection, + infoCardsSection, + podcastSection, + transcriptSection, + macroMarkersCarousel + ); + } + + @Override + boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (exceptions.matches(path)) return false; + + if (matchedGroup == macroMarkersCarousel) { + if (contentIndex == 0 && macroMarkersCarouselGroupList.check(protobufBufferArray).isFiltered()) { + return super.isFiltered(path, identifier, protobufBufferArray, matchedGroup, contentType, contentIndex); + } + + return false; + } + + return super.isFiltered(path, identifier, protobufBufferArray, matchedGroup, contentType, contentIndex); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/Filter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/Filter.java new file mode 100644 index 000000000..42b86d589 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/Filter.java @@ -0,0 +1,90 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.settings.BaseSettings; + +/** + * 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, 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. + */ +abstract class Filter { + + public enum FilterContentType { + IDENTIFIER, + PATH, + 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<>(); + + /** + * Adds callbacks to {@link #isFiltered(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, byte[], StringFilterGroup, FilterContentType, int)} + * if any of the groups are found. + */ + protected final void addPathCallbacks(StringFilterGroup... groups) { + pathCallbacks.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. + */ + boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (BaseSettings.DEBUG.get()) { + String filterSimpleName = getClass().getSimpleName(); + if (contentType == FilterContentType.IDENTIFIER) { + Logger.printDebug(() -> filterSimpleName + " Filtered identifier: " + identifier); + } else { + Logger.printDebug(() -> filterSimpleName + " Filtered path: " + path); + } + } + return true; + } +} + diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FilterGroup.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FilterGroup.java new file mode 100644 index 000000000..4e20bc82a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FilterGroup.java @@ -0,0 +1,214 @@ +package app.revanced.extension.youtube.patches.components; + +import androidx.annotation.NonNull; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.youtube.ByteTrieSearch; + +abstract class FilterGroup { + 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); +} + +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); + } +} + +/** + * 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. + */ +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/youtube/patches/components/FilterGroupList.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FilterGroupList.java new file mode 100644 index 000000000..ac0e23ca8 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FilterGroupList.java @@ -0,0 +1,85 @@ +package app.revanced.extension.youtube.patches.components; + +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import java.util.*; +import java.util.function.Consumer; + +import app.revanced.extension.youtube.ByteTrieSearch; +import app.revanced.extension.youtube.StringTrieSearch; +import app.revanced.extension.youtube.TrieSearch; + +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, matchedLength); + return true; + } + return false; + }); + } + } + } + + @NonNull + @Override + public Iterator iterator() { + return filterGroups.iterator(); + } + + @RequiresApi(api = Build.VERSION_CODES.N) + @Override + public void forEach(@NonNull Consumer action) { + filterGroups.forEach(action); + } + + @RequiresApi(api = Build.VERSION_CODES.N) + @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(); +} + +final class StringFilterGroupList extends FilterGroupList { + protected StringTrieSearch createSearchGraph() { + return new StringTrieSearch(); + } +} + +/** + * 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. + */ +final class ByteArrayFilterGroupList extends FilterGroupList { + protected ByteTrieSearch createSearchGraph() { + return new ByteTrieSearch(); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/HideInfoCardsFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/HideInfoCardsFilterPatch.java new file mode 100644 index 000000000..ce92b592e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/HideInfoCardsFilterPatch.java @@ -0,0 +1,16 @@ +package app.revanced.extension.youtube.patches.components; + +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class HideInfoCardsFilterPatch extends Filter { + + public HideInfoCardsFilterPatch() { + addIdentifierCallbacks( + new StringFilterGroup( + Settings.HIDE_INFO_CARDS, + "info_card_teaser_overlay.eml" + ) + ); + } +} 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 000000000..b451fd282 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/KeywordContentFilter.java @@ -0,0 +1,597 @@ +package app.revanced.extension.youtube.patches.components; + +import static app.revanced.extension.shared.StringRef.str; +import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton; +import static java.lang.Character.UnicodeBlock.*; + +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.ByteTrieSearch; +import app.revanced.extension.youtube.StringTrieSearch; +import app.revanced.extension.youtube.TrieSearch; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.NavigationBar; +import app.revanced.extension.youtube.shared.PlayerType; + +/** + *

+ * 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")
+@RequiresApi(api = Build.VERSION_CODES.N)
+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.
+            "home_video_with_context.eml",
+            "search_video_with_context.eml",
+            "video_with_context.eml", // Subscription tab videos.
+            "related_video_with_context.eml",
+            // A/B test for subscribed video, and sometimes when tablet layout is enabled.
+            "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 backoff is in effect.
+     */
+    private volatile long timeToResumeFiltering;
+
+    /**
+     * 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;
+
+    /**
+     * 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() {
+        // Keywords are parsed on first call to isFiltered()
+        addPathCallbacks(startsWithFilter, containsFilter);
+    }
+
+    private boolean hideKeywordSettingIsActive() {
+        if (timeToResumeFiltering != 0) {
+            if (System.currentTimeMillis() < timeToResumeFiltering) {
+                return false;
+            }
+
+            timeToResumeFiltering = 0;
+            filteredVideosPercentage = 0;
+            Logger.printDebug(() -> "Resuming keyword filtering");
+        }
+
+        // Must check player type first, as search bar can be active behind the player.
+        if (PlayerType.getCurrent().isMaximizedOrFullscreen()) {
+            // For now, consider the under video results the same as the home feed.
+            return Settings.HIDE_KEYWORD_CONTENT_HOME.get();
+        }
+
+        // Must check second, as search can be from any tab.
+        if (NavigationBar.isSearchBarActive()) {
+            return Settings.HIDE_KEYWORD_CONTENT_SEARCH.get();
+        }
+
+        // Avoid checking navigation button status if all other settings are off.
+        final boolean hideHome = Settings.HIDE_KEYWORD_CONTENT_HOME.get();
+        final boolean hideSubscriptions = Settings.HIDE_KEYWORD_CONTENT_SUBSCRIPTIONS.get();
+        if (!hideHome && !hideSubscriptions) {
+            return false;
+        }
+
+        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
+    boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+                       StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+        if (contentIndex != 0 && matchedGroup == startsWithFilter) {
+            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 (!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(identifier, path, 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 000000000..891124987
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/LayoutComponentsFilter.java
@@ -0,0 +1,474 @@
+package app.revanced.extension.youtube.patches.components;
+
+import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
+
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.StringTrieSearch;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.NavigationBar;
+import app.revanced.extension.youtube.shared.PlayerType;
+
+@SuppressWarnings("unused")
+public final class LayoutComponentsFilter extends Filter {
+    private static final String COMPACT_CHANNEL_BAR_PATH_PREFIX = "compact_channel_bar.eml";
+    private static final String VIDEO_ACTION_BAR_PATH_PREFIX = "video_action_bar.eml";
+    private static final String ANIMATED_VECTOR_TYPE_PATH = "AnimatedVectorType";
+
+    private static final StringTrieSearch mixPlaylistsExceptions = new StringTrieSearch(
+            "V.ED", // Playlist browse id.
+            "java.lang.ref.WeakReference"
+    );
+    private static final ByteArrayFilterGroup mixPlaylistsExceptions2 = new ByteArrayFilterGroup(
+            null,
+            "cell_description_body"
+    );
+    private static final ByteArrayFilterGroup mixPlaylists = new ByteArrayFilterGroup(
+            Settings.HIDE_MIX_PLAYLISTS,
+            "&list="
+    );
+
+    private final StringTrieSearch exceptions = new StringTrieSearch();
+    private final StringFilterGroup searchResultShelfHeader;
+    private final StringFilterGroup inFeedSurvey;
+    private final StringFilterGroup notifyMe;
+    private final StringFilterGroup expandableMetadata;
+    private final ByteArrayFilterGroup searchResultRecommendations;
+    private final StringFilterGroup searchResultVideo;
+    private final StringFilterGroup compactChannelBarInner;
+    private final StringFilterGroup compactChannelBarInnerButton;
+    private final ByteArrayFilterGroup joinMembershipButton;
+    private final StringFilterGroup likeSubscribeGlow;
+    private final StringFilterGroup horizontalShelves;
+
+    @RequiresApi(api = Build.VERSION_CODES.N)
+    public LayoutComponentsFilter() {
+        exceptions.addPatterns(
+                "home_video_with_context",
+                "related_video_with_context",
+                "search_video_with_context",
+                "comment_thread", // Whitelist comments
+                "|comment.", // Whitelist comment replies
+                "library_recent_shelf"
+        );
+
+        // Identifiers.
+
+        final var graySeparator = new StringFilterGroup(
+                Settings.HIDE_GRAY_SEPARATOR,
+                "cell_divider" // layout residue (gray line above the buttoned ad),
+        );
+
+        final var chipsShelf = new StringFilterGroup(
+                Settings.HIDE_CHIPS_SHELF,
+                "chips_shelf"
+        );
+
+        addIdentifierCallbacks(
+                graySeparator,
+                chipsShelf
+        );
+
+        // Paths.
+
+        final var communityPosts = new StringFilterGroup(
+                Settings.HIDE_COMMUNITY_POSTS,
+                "post_base_wrapper",
+                "text_post_root.eml",
+                "images_post_root.eml",
+                "images_post_slim.eml",
+                "images_post_root_slim.eml",
+                "text_post_root_slim.eml",
+                "post_base_wrapper_slim.eml"
+        );
+
+        final var communityGuidelines = new StringFilterGroup(
+                Settings.HIDE_COMMUNITY_GUIDELINES,
+                "community_guidelines"
+        );
+
+        final var subscribersCommunityGuidelines = new StringFilterGroup(
+                Settings.HIDE_SUBSCRIBERS_COMMUNITY_GUIDELINES,
+                "sponsorships_comments_upsell"
+        );
+
+        final var channelMemberShelf = new StringFilterGroup(
+                Settings.HIDE_CHANNEL_MEMBER_SHELF,
+                "member_recognition_shelf"
+        );
+
+        final var compactBanner = new StringFilterGroup(
+                Settings.HIDE_COMPACT_BANNER,
+                "compact_banner"
+        );
+
+        inFeedSurvey = new StringFilterGroup(
+                Settings.HIDE_FEED_SURVEY,
+                "in_feed_survey",
+                "slimline_survey"
+        );
+
+        final var medicalPanel = new StringFilterGroup(
+                Settings.HIDE_MEDICAL_PANELS,
+                "medical_panel"
+        );
+
+        final var paidPromotion = new StringFilterGroup(
+                Settings.HIDE_PAID_PROMOTION_LABEL,
+                "paid_content_overlay"
+        );
+
+        final var infoPanel = new StringFilterGroup(
+                Settings.HIDE_HIDE_INFO_PANELS,
+                "publisher_transparency_panel",
+                "single_item_information_panel"
+        );
+
+        final var latestPosts = new StringFilterGroup(
+                Settings.HIDE_HIDE_LATEST_POSTS,
+                "post_shelf"
+        );
+
+        final var channelGuidelines = new StringFilterGroup(
+                Settings.HIDE_HIDE_CHANNEL_GUIDELINES,
+                "channel_guidelines_entry_banner"
+        );
+
+        final var emergencyBox = new StringFilterGroup(
+                Settings.HIDE_EMERGENCY_BOX,
+                "emergency_onebox"
+        );
+
+        // The player audio track button does the exact same function as the audio track flyout menu option.
+        // Previously this was a setting to show/hide the player button.
+        // But it was decided it's simpler to always hide this button because:
+        // - 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 var audioTrackButton = new StringFilterGroup(
+                null,
+                "multi_feed_icon_button"
+        );
+
+        final var artistCard = new StringFilterGroup(
+                Settings.HIDE_ARTIST_CARDS,
+                "official_card"
+        );
+
+        expandableMetadata = new StringFilterGroup(
+                Settings.HIDE_EXPANDABLE_CHIP,
+                "inline_expander"
+        );
+
+        final var channelBar = new StringFilterGroup(
+                Settings.HIDE_CHANNEL_BAR,
+                "channel_bar"
+        );
+
+        final var relatedVideos = new StringFilterGroup(
+                Settings.HIDE_RELATED_VIDEOS,
+                "fullscreen_related_videos"
+        );
+
+        final var playables = new StringFilterGroup(
+                Settings.HIDE_PLAYABLES,
+                "horizontal_gaming_shelf.eml",
+                "mini_game_card.eml"
+        );
+
+        final var quickActions = new StringFilterGroup(
+                Settings.HIDE_QUICK_ACTIONS,
+                "quick_actions"
+        );
+
+        final var imageShelf = new StringFilterGroup(
+                Settings.HIDE_IMAGE_SHELF,
+                "image_shelf"
+        );
+
+
+        final var timedReactions = new StringFilterGroup(
+                Settings.HIDE_TIMED_REACTIONS,
+                "emoji_control_panel",
+                "timed_reaction"
+        );
+
+        searchResultShelfHeader = new StringFilterGroup(
+                Settings.HIDE_SEARCH_RESULT_SHELF_HEADER,
+                "shelf_header.eml"
+        );
+
+        notifyMe = new StringFilterGroup(
+                Settings.HIDE_NOTIFY_ME_BUTTON,
+                "set_reminder_button"
+        );
+
+        compactChannelBarInner = new StringFilterGroup(
+                Settings.HIDE_JOIN_MEMBERSHIP_BUTTON,
+                "compact_channel_bar_inner"
+        );
+
+        compactChannelBarInnerButton = new StringFilterGroup(
+                null,
+                "|button.eml|"
+        );
+
+        joinMembershipButton = new ByteArrayFilterGroup(
+                null,
+                "sponsorships"
+        );
+
+        likeSubscribeGlow = new StringFilterGroup(
+                Settings.DISABLE_LIKE_SUBSCRIBE_GLOW,
+                "animated_button_border.eml"
+        );
+
+        final var channelWatermark = new StringFilterGroup(
+                Settings.HIDE_VIDEO_CHANNEL_WATERMARK,
+                "featured_channel_watermark_overlay"
+        );
+
+        final var forYouShelf = new StringFilterGroup(
+                Settings.HIDE_FOR_YOU_SHELF,
+                "mixed_content_shelf"
+        );
+
+        searchResultVideo = new StringFilterGroup(
+                Settings.HIDE_SEARCH_RESULT_RECOMMENDATIONS,
+                "search_video_with_context.eml"
+        );
+
+        searchResultRecommendations = new ByteArrayFilterGroup(
+                Settings.HIDE_SEARCH_RESULT_RECOMMENDATIONS,
+                "endorsement_header_footer"
+        );
+
+        horizontalShelves = new StringFilterGroup(
+                Settings.HIDE_HORIZONTAL_SHELVES,
+                "horizontal_video_shelf.eml",
+                "horizontal_shelf.eml",
+                "horizontal_shelf_inline.eml",
+                "horizontal_tile_shelf.eml"
+        );
+
+        addPathCallbacks(
+                expandableMetadata,
+                inFeedSurvey,
+                notifyMe,
+                likeSubscribeGlow,
+                channelBar,
+                communityPosts,
+                paidPromotion,
+                searchResultVideo,
+                latestPosts,
+                channelWatermark,
+                communityGuidelines,
+                playables,
+                quickActions,
+                relatedVideos,
+                compactBanner,
+                compactChannelBarInner,
+                medicalPanel,
+                infoPanel,
+                emergencyBox,
+                subscribersCommunityGuidelines,
+                channelGuidelines,
+                audioTrackButton,
+                artistCard,
+                timedReactions,
+                imageShelf,
+                channelMemberShelf,
+                forYouShelf,
+                horizontalShelves
+        );
+    }
+
+    @Override
+    boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+                       StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+        if (matchedGroup == searchResultVideo) {
+            if (searchResultRecommendations.check(protobufBufferArray).isFiltered()) {
+                return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+            }
+            return false;
+        }
+
+        if (matchedGroup == likeSubscribeGlow) {
+            if ((path.startsWith(VIDEO_ACTION_BAR_PATH_PREFIX) || path.startsWith(COMPACT_CHANNEL_BAR_PATH_PREFIX))
+                    && path.contains(ANIMATED_VECTOR_TYPE_PATH)) {
+                return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+            }
+
+            return false;
+        }
+
+        // The groups are excluded from the filter due to the exceptions list below.
+        // Filter them separately here.
+        if (matchedGroup == notifyMe || matchedGroup == inFeedSurvey || matchedGroup == expandableMetadata)
+        {
+            return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+        }
+
+        if (exceptions.matches(path)) return false; // Exceptions are not filtered.
+
+        if (matchedGroup == compactChannelBarInner) {
+            if (compactChannelBarInnerButton.check(path).isFiltered()) {
+                // The filter may be broad, but in the context of a compactChannelBarInnerButton,
+                // it's safe to assume that the button is the only thing that should be hidden.
+                if (joinMembershipButton.check(protobufBufferArray).isFiltered()) {
+                    return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+                }
+            }
+
+            return false;
+        }
+
+        // TODO: This also hides the feed Shorts shelf header
+        if (matchedGroup == searchResultShelfHeader && contentIndex != 0) return false;
+
+        if (matchedGroup == horizontalShelves) {
+            if (contentIndex == 0 && hideShelves()) {
+                return super.isFiltered(path, identifier, protobufBufferArray, matchedGroup, contentType, contentIndex);
+            }
+
+            return false;
+        }
+
+        return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+    }
+
+    /**
+     * Injection point.
+     * Called from a different place then the other filters.
+     */
+    public static boolean filterMixPlaylists(final Object conversionContext, @Nullable final byte[] bytes) {
+        try {
+            if (bytes == null) {
+                Logger.printDebug(() -> "bytes is null");
+                return false;
+            }
+
+            // Prevent playlist items being hidden, if a mix playlist is present in it.
+            if (mixPlaylistsExceptions.matches(conversionContext.toString())) {
+                return false;
+            }
+
+            // Prevent hiding the description of some videos accidentally.
+            if (mixPlaylistsExceptions2.check(bytes).isFiltered()) {
+                return false;
+            }
+
+            if (mixPlaylists.check(bytes).isFiltered()) {
+                Logger.printDebug(() -> "Filtered mix playlist");
+                return true;
+            }
+        } catch (Exception ex) {
+            Logger.printException(() -> "filterMixPlaylists failure", ex);
+        }
+
+        return false;
+    }
+
+    /**
+     * Injection point.
+     */
+    public static boolean showWatermark() {
+        return !Settings.HIDE_VIDEO_CHANNEL_WATERMARK.get();
+    }
+
+    /**
+     * Injection point.
+     */
+    public static void hideAlbumCard(View view) {
+        Utils.hideViewBy0dpUnderCondition(Settings.HIDE_ALBUM_CARDS, view);
+    }
+
+    /**
+     * Injection point.
+     */
+    public static void hideCrowdfundingBox(View view) {
+        Utils.hideViewBy0dpUnderCondition(Settings.HIDE_CROWDFUNDING_BOX, view);
+    }
+
+    /**
+     * Injection point.
+     */
+    public static boolean hideFloatingMicrophoneButton(final boolean original) {
+        return original || Settings.HIDE_FLOATING_MICROPHONE_BUTTON.get();
+    }
+
+    /**
+     * Injection point.
+     */
+    public static int hideInFeed(final int height) {
+        return Settings.HIDE_FILTER_BAR_FEED_IN_FEED.get()
+                ? 0
+                : height;
+    }
+
+    /**
+     * Injection point.
+     */
+    public static int hideInSearch(int height) {
+        return Settings.HIDE_FILTER_BAR_FEED_IN_SEARCH.get()
+                ? 0
+                : height;
+    }
+
+    /**
+     * Injection point.
+     */
+    public static void hideInRelatedVideos(View chipView) {
+        Utils.hideViewBy0dpUnderCondition(Settings.HIDE_FILTER_BAR_FEED_IN_RELATED_VIDEOS, chipView);
+    }
+
+    private static final boolean HIDE_DOODLES_ENABLED = Settings.HIDE_DOODLES.get();
+
+    /**
+     * Injection point.
+     */
+    @Nullable
+    public static Drawable hideYoodles(Drawable animatedYoodle) {
+        if (HIDE_DOODLES_ENABLED) {
+            return null;
+        }
+
+        return animatedYoodle;
+    }
+
+    private static final boolean HIDE_SHOW_MORE_BUTTON_ENABLED = Settings.HIDE_SHOW_MORE_BUTTON.get();
+
+    /**
+     * Injection point.
+     */
+    public static void hideShowMoreButton(View view) {
+        if (HIDE_SHOW_MORE_BUTTON_ENABLED
+                && NavigationBar.isSearchBarActive()
+                // Search bar can be active but behind the player.
+                && !PlayerType.getCurrent().isMaximizedOrFullscreen()) {
+            Utils.hideViewByLayoutParams(view);
+        }
+    }
+
+    private static boolean hideShelves() {
+        // If the player is opened while library is selected,
+        // then filter any recommendations below the player.
+        if (PlayerType.getCurrent().isMaximizedOrFullscreen()
+                // Or if the search is active while library is selected, then also filter.
+                || NavigationBar.isSearchBarActive()) {
+            return true;
+        }
+
+        // Check navigation button last.
+        // Only filter if the library tab is not selected.
+        // This check is important as the shelf layout is used for the library tab playlists.
+        return NavigationButton.getSelectedNavigationButton() != NavigationButton.LIBRARY;
+    }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/LithoFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/LithoFilterPatch.java
new file mode 100644
index 000000000..0e7ac8ab4
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/LithoFilterPatch.java
@@ -0,0 +1,189 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.nio.ByteBuffer;
+import java.util.List;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.youtube.StringTrieSearch;
+import app.revanced.extension.youtube.settings.Settings;
+
+@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 byte[] protoBuffer;
+
+        LithoFilterParameters(@Nullable String lithoIdentifier, String lithoPath, byte[] protoBuffer) {
+            this.identifier = lithoIdentifier;
+            this.path = lithoPath;
+            this.protoBuffer = protoBuffer;
+        }
+
+        @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( "ID: ");
+            builder.append(identifier);
+            builder.append(" Path: ");
+            builder.append(path);
+            if (Settings.DEBUG_PROTOBUFFER.get()) {
+                builder.append(" BufferStrings: ");
+                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 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);
+        }
+
+        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.identifier, parameters.path, parameters.protoBuffer,
+                                    group, type, matchedStartIndex);
+                        }
+                );
+            }
+        }
+    }
+
+    /**
+     * Injection point.  Called off the main thread.
+     */
+    @SuppressWarnings("unused")
+    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.
+        if (protobufBuffer == null) {
+            // It appears the buffer can be cleared out just before the call to #filter()
+            // Ignore this null value and retain the last buffer that was set.
+            Logger.printDebug(() -> "Ignoring null protobuffer");
+        } else {
+            bufferThreadLocal.set(protobufBuffer);
+        }
+    }
+
+    /**
+     * Injection point.  Called off the main thread, and commonly called by multiple threads at the same time.
+     */
+    @SuppressWarnings("unused")
+    public static boolean filter(@Nullable String lithoIdentifier, @NonNull StringBuilder pathBuilder) {
+        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/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(lithoIdentifier,
+                    pathBuilder.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;
+            }
+        } 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/youtube/patches/components/PlaybackSpeedMenuFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlaybackSpeedMenuFilterPatch.java
new file mode 100644
index 000000000..e49ff0853
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlaybackSpeedMenuFilterPatch.java
@@ -0,0 +1,28 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.youtube.patches.playback.speed.CustomPlaybackSpeedPatch;
+
+/**
+ * Abuse LithoFilter for {@link CustomPlaybackSpeedPatch}.
+ */
+public final class PlaybackSpeedMenuFilterPatch 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 isPlaybackSpeedMenuVisible;
+
+    public PlaybackSpeedMenuFilterPatch() {
+        addPathCallbacks(new StringFilterGroup(
+                null,
+                "playback_speed_sheet_content.eml-js"
+        ));
+    }
+
+    @Override
+    boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+                       StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+        isPlaybackSpeedMenuVisible = true;
+
+        return false;
+    }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerFlyoutMenuItemsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerFlyoutMenuItemsFilter.java
new file mode 100644
index 000000000..3469bbb85
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerFlyoutMenuItemsFilter.java
@@ -0,0 +1,104 @@
+package app.revanced.extension.youtube.patches.components;
+
+import android.os.Build;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.PlayerType;
+
+@SuppressWarnings("unused")
+public class PlayerFlyoutMenuItemsFilter extends Filter {
+
+    private final ByteArrayFilterGroupList flyoutFilterGroupList = new ByteArrayFilterGroupList();
+
+    private final ByteArrayFilterGroup exception;
+    private final StringFilterGroup videoQualityMenuFooter;
+
+    @RequiresApi(api = Build.VERSION_CODES.N)
+    public PlayerFlyoutMenuItemsFilter() {
+        exception = new ByteArrayFilterGroup(
+                // Whitelist Quality menu item when "Hide Additional settings menu" is enabled
+                Settings.HIDE_ADDITIONAL_SETTINGS_MENU,
+                "quality_sheet"
+        );
+
+        videoQualityMenuFooter = new StringFilterGroup(
+                Settings.HIDE_VIDEO_QUALITY_MENU_FOOTER,
+                "quality_sheet_footer"
+        );
+
+        addPathCallbacks(
+                videoQualityMenuFooter,
+                new StringFilterGroup(null, "overflow_menu_item.eml|")
+        );
+
+        flyoutFilterGroupList.addAll(
+                new ByteArrayFilterGroup(
+                        Settings.HIDE_CAPTIONS_MENU,
+                        "closed_caption"
+                ),
+                new ByteArrayFilterGroup(
+                        Settings.HIDE_ADDITIONAL_SETTINGS_MENU,
+                        "yt_outline_gear"
+                ),
+                new ByteArrayFilterGroup(
+                        Settings.HIDE_LOOP_VIDEO_MENU,
+                        "yt_outline_arrow_repeat_1_"
+                ),
+                new ByteArrayFilterGroup(
+                        Settings.HIDE_AMBIENT_MODE_MENU,
+                        "yt_outline_screen_light"
+                ),
+                new ByteArrayFilterGroup(
+                        Settings.HIDE_HELP_MENU,
+                        "yt_outline_question_circle"
+                ),
+                new ByteArrayFilterGroup(
+                        Settings.HIDE_MORE_INFO_MENU,
+                        "yt_outline_info_circle"
+                ),
+                new ByteArrayFilterGroup(
+                        Settings.HIDE_LOCK_SCREEN_MENU,
+                        "yt_outline_lock"
+                ),
+                new ByteArrayFilterGroup(
+                        Settings.HIDE_SPEED_MENU,
+                        "yt_outline_play_arrow_half_circle"
+                ),
+                new ByteArrayFilterGroup(
+                        Settings.HIDE_AUDIO_TRACK_MENU,
+                        "yt_outline_person_radar"
+                ),
+                new ByteArrayFilterGroup(
+                        Settings.HIDE_WATCH_IN_VR_MENU,
+                        "yt_outline_vr"
+                )
+        );
+    }
+
+    @Override
+    boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+                       StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+        if (matchedGroup == videoQualityMenuFooter) {
+            return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+        }
+
+        if (contentIndex != 0) {
+            return false; // Overflow menu is always the start of the path.
+        }
+
+        // Shorts also use this player flyout panel
+        if (PlayerType.getCurrent().isNoneOrHidden() || exception.check(protobufBufferArray).isFiltered()) {
+            return false;
+        }
+
+        if (flyoutFilterGroupList.check(protobufBufferArray).isFiltered()) {
+            // Super class handles logging.
+            return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+        }
+
+        return false;
+    }
+}
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 000000000..bac1d4ce7
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java
@@ -0,0 +1,144 @@
+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.youtube.patches.ReturnYouTubeDislikePatch;
+import app.revanced.extension.youtube.patches.VideoInformation;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.youtube.TrieSearch;
+
+/**
+ * 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(Entry eldest) {
+            return size() > NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK;
+        }
+    };
+
+    /**
+     * Injection point.
+     */
+    @SuppressWarnings("unused")
+    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);
+        }
+    }
+
+    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")
+        );
+    }
+
+    @Override
+    boolean isFiltered(@Nullable String identifier, String path, 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;
+        }
+    }
+
+    /**
+     * 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;
+    }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsFilter.java
new file mode 100644
index 000000000..5e994a003
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsFilter.java
@@ -0,0 +1,444 @@
+package app.revanced.extension.youtube.patches.components;
+
+import static app.revanced.extension.shared.Utils.hideViewUnderCondition;
+import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
+
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+import com.google.android.libraries.youtube.rendering.ui.pivotbar.PivotBar;
+
+import java.lang.ref.WeakReference;
+import java.util.Arrays;
+import java.util.List;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.NavigationBar;
+import app.revanced.extension.youtube.shared.PlayerType;
+
+@SuppressWarnings("unused")
+public final class ShortsFilter extends Filter {
+    public static final Boolean HIDE_SHORTS_NAVIGATION_BAR = Settings.HIDE_SHORTS_NAVIGATION_BAR.get();
+    private final static String REEL_CHANNEL_BAR_PATH = "reel_channel_bar.eml";
+
+    /**
+     * For paid promotion label and subscribe button that appears in the channel bar.
+     */
+    private final static String REEL_METAPANEL_PATH = "reel_metapanel.eml";
+
+    /**
+     * Tags that appears when opening the Shorts player.
+     */
+    private static final List REEL_WATCH_FRAGMENT_INIT_PLAYBACK = Arrays.asList("r_fs", "r_ts");
+
+    /**
+     * Vertical padding between the bottom of the screen and the seekbar, when the Shorts navigation bar is hidden.
+     */
+    public static final int HIDDEN_NAVIGATION_BAR_VERTICAL_HEIGHT = 100;
+
+    private static WeakReference pivotBarRef = new WeakReference<>(null);
+
+    private final StringFilterGroup shortsCompactFeedVideoPath;
+    private final ByteArrayFilterGroup shortsCompactFeedVideoBuffer;
+
+    private final StringFilterGroup subscribeButton;
+    private final StringFilterGroup joinButton;
+    private final StringFilterGroup paidPromotionButton;
+    private final StringFilterGroup shelfHeader;
+
+    private final StringFilterGroup suggestedAction;
+    private final ByteArrayFilterGroupList suggestedActionsGroupList = new ByteArrayFilterGroupList();
+
+    private final StringFilterGroup actionBar;
+    private final ByteArrayFilterGroupList videoActionButtonGroupList = new ByteArrayFilterGroupList();
+
+    public ShortsFilter() {
+        //
+        // Identifier components.
+        //
+
+        var shortsIdentifiers = new StringFilterGroup(
+                null, // Setting is based on navigation state.
+                "shorts_shelf",
+                "inline_shorts",
+                "shorts_grid",
+                "shorts_video_cell",
+                "shorts_pivot_item"
+        );
+
+        // Feed Shorts shelf header.
+        // Use a different filter group for this pattern, as it requires an additional check after matching.
+        shelfHeader = new StringFilterGroup(
+                null,
+                "shelf_header.eml"
+        );
+
+        addIdentifierCallbacks(shortsIdentifiers, shelfHeader);
+
+        //
+        // Path components.
+        //
+
+        shortsCompactFeedVideoPath = new StringFilterGroup(null,
+                // Shorts that appear in the feed/search when the device is using tablet layout.
+                "compact_video.eml",
+                // 'video_lockup_with_attachment.eml' is shown instead of 'compact_video.eml' for some users
+                "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.
+        shortsCompactFeedVideoBuffer = new ByteArrayFilterGroup(null, "/frame0.jpg");
+
+        // Shorts player components.
+        StringFilterGroup pausedOverlayButtons = new StringFilterGroup(
+                Settings.HIDE_SHORTS_PAUSED_OVERLAY_BUTTONS,
+                "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 soundButton = new StringFilterGroup(
+                Settings.HIDE_SHORTS_SOUND_BUTTON,
+                "reel_pivot_button"
+        );
+
+        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 likeFountain = new StringFilterGroup(
+                Settings.HIDE_SHORTS_LIKE_FOUNTAIN,
+                "like_fountain.eml"
+        );
+
+        joinButton = new StringFilterGroup(
+                Settings.HIDE_SHORTS_JOIN_BUTTON,
+                "sponsor_button"
+        );
+
+        subscribeButton = new StringFilterGroup(
+                Settings.HIDE_SHORTS_SUBSCRIBE_BUTTON,
+                "subscribe_button"
+        );
+
+        paidPromotionButton = new StringFilterGroup(
+                Settings.HIDE_PAID_PROMOTION_LABEL,
+                "reel_player_disclosure.eml"
+        );
+
+        actionBar = new StringFilterGroup(
+                null,
+                "shorts_action_bar"
+        );
+
+        suggestedAction = new StringFilterGroup(
+                null,
+                "suggested_action.eml"
+        );
+
+        addPathCallbacks(
+                shortsCompactFeedVideoPath, suggestedAction, actionBar, joinButton, subscribeButton,
+                paidPromotionButton, pausedOverlayButtons, channelBar, fullVideoLinkLabel, videoTitle,
+                reelSoundMetadata, soundButton, infoPanel, stickers, likeFountain
+        );
+
+        //
+        // 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"
+                )
+        );
+
+        //
+        // Suggested actions.
+        //
+        suggestedActionsGroupList.addAll(
+                new ByteArrayFilterGroup(
+                        Settings.HIDE_SHORTS_SHOP_BUTTON,
+                        "yt_outline_bag_"
+                ),
+                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_LOCATION_LABEL,
+                        "yt_outline_location_point_"
+                ),
+                new ByteArrayFilterGroup(
+                        Settings.HIDE_SHORTS_SAVE_SOUND_BUTTON,
+                        "yt_outline_bookmark_",
+                        // 'Save sound' button. It seems this has been removed and only 'Save music' is used.
+                        // Still hide this in case it's still present.
+                        "yt_outline_list_add_",
+                        // 'Use this sound' button. It seems this has been removed and only 'Save music' is used.
+                        // Still hide this in case it's still present.
+                        "yt_outline_camera_"
+                ),
+                new ByteArrayFilterGroup(
+                        Settings.HIDE_SHORTS_SEARCH_SUGGESTIONS,
+                        "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_UPCOMING_BUTTON,
+                        "yt_outline_bell_"
+                ),
+                new ByteArrayFilterGroup(
+                        Settings.HIDE_SHORTS_GREEN_SCREEN_BUTTON,
+                        "greenscreen_temp"
+                ),
+                new ByteArrayFilterGroup(
+                        Settings.HIDE_SHORTS_HASHTAG_BUTTON,
+                        "yt_outline_hashtag_"
+                )
+        );
+    }
+
+    private boolean isEverySuggestedActionFilterEnabled() {
+        for (ByteArrayFilterGroup group : suggestedActionsGroupList) {
+            if (!group.isEnabled()) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    @Override
+    boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+                       StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+        if (contentType == FilterContentType.PATH) {
+            if (matchedGroup == subscribeButton || matchedGroup == joinButton || matchedGroup == paidPromotionButton) {
+                // Selectively filter to avoid false positive filtering of other subscribe/join buttons.
+                if (path.startsWith(REEL_CHANNEL_BAR_PATH) || path.startsWith(REEL_METAPANEL_PATH)) {
+                    return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+                }
+                return false;
+            }
+
+            if (matchedGroup == shortsCompactFeedVideoPath) {
+                if (shouldHideShortsFeedItems() && shortsCompactFeedVideoBuffer.check(protobufBufferArray).isFiltered()) {
+                    return super.isFiltered(identifier, path, 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(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+                }
+                return false;
+            }
+
+            if (matchedGroup == suggestedAction) {
+                // Skip searching the buffer if all suggested actions are set to hidden.
+                // This has a secondary effect of hiding all new un-identified actions
+                // under the assumption that the user wants all actions hidden.
+                if (isEverySuggestedActionFilterEnabled()) {
+                    return super.isFiltered(path, identifier, protobufBufferArray, matchedGroup, contentType, contentIndex);
+                }
+
+                if (suggestedActionsGroupList.check(protobufBufferArray).isFiltered()) {
+                    return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+                }
+                return false;
+            }
+
+        } else {
+            // Feed/search identifier components.
+            if (matchedGroup == shelfHeader) {
+                // 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 (!shouldHideShortsFeedItems()) return false;
+        }
+
+        // Super class handles logging.
+        return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+    }
+
+    private static boolean shouldHideShortsFeedItems() {
+        final boolean hideHome = Settings.HIDE_SHORTS_HOME.get();
+        final boolean hideSubscriptions = Settings.HIDE_SHORTS_SUBSCRIPTIONS.get();
+        final boolean hideSearch = Settings.HIDE_SHORTS_SEARCH.get();
+
+        if (hideHome && 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 (PlayerType.getCurrent().isMaximizedOrFullscreen()) {
+            // 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 (NavigationBar.isSearchBarActive()) {
+            return hideSearch;
+        }
+
+        // Avoid checking navigation button status if all other Shorts should show.
+        if (!hideHome && !hideSubscriptions) {
+            return false;
+        }
+
+        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 must be in the library tab.  Don't hide the history or any playlists here.
+        return false;
+    }
+
+    public static void hideShortsShelf(final View shortsShelfView) {
+        if (shouldHideShortsFeedItems()) {
+            Utils.hideViewByLayoutParams(shortsShelfView);
+        }
+    }
+
+    public static int getSoundButtonSize(int original) {
+        if (Settings.HIDE_SHORTS_SOUND_BUTTON.get()) {
+            return 0;
+        }
+
+        return original;
+    }
+
+    // region Hide the buttons in older versions of YouTube. New versions use Litho.
+
+    public static void hideLikeButton(final View likeButtonView) {
+        // Cannot set the visibility to gone for like/dislike,
+        // as some other unknown YT code also sets the visibility after this hook.
+        //
+        // Setting the view to 0dp works, but that leaves a blank space where
+        // the button was (only relevant for dislikes button).
+        //
+        // Instead remove the view from the parent.
+        Utils.hideViewByRemovingFromParentUnderCondition(Settings.HIDE_SHORTS_LIKE_BUTTON, likeButtonView);
+    }
+
+    public static void hideDislikeButton(final View dislikeButtonView) {
+        Utils.hideViewByRemovingFromParentUnderCondition(Settings.HIDE_SHORTS_DISLIKE_BUTTON, dislikeButtonView);
+    }
+
+    public static void hideShortsCommentsButton(final View commentsButtonView) {
+        hideViewUnderCondition(Settings.HIDE_SHORTS_COMMENTS_BUTTON, commentsButtonView);
+    }
+
+    public static void hideShortsRemixButton(final View remixButtonView) {
+        hideViewUnderCondition(Settings.HIDE_SHORTS_REMIX_BUTTON, remixButtonView);
+    }
+
+    public static void hideShortsShareButton(final View shareButtonView) {
+        hideViewUnderCondition(Settings.HIDE_SHORTS_SHARE_BUTTON, shareButtonView);
+    }
+
+    // endregion
+
+    public static void setNavigationBar(PivotBar view) {
+        Logger.printDebug(() -> "Setting navigation bar");
+        pivotBarRef = new WeakReference<>(view);
+    }
+
+    public static void hideNavigationBar(String tag) {
+        if (HIDE_SHORTS_NAVIGATION_BAR) {
+            if (REEL_WATCH_FRAGMENT_INIT_PLAYBACK.contains(tag)) {
+                var pivotBar = pivotBarRef.get();
+                if (pivotBar == null) return;
+
+                Logger.printDebug(() -> "Hiding navbar by setting to GONE");
+                pivotBar.setVisibility(View.GONE);
+            } else {
+                Logger.printDebug(() -> "Ignoring tag: " + tag);
+            }
+        }
+    }
+
+    public static int getNavigationBarHeight(int original) {
+        if (HIDE_SHORTS_NAVIGATION_BAR) {
+            return HIDDEN_NAVIGATION_BAR_VERTICAL_HEIGHT;
+        }
+
+        return original;
+    }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/VideoQualityMenuFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/VideoQualityMenuFilterPatch.java
new file mode 100644
index 000000000..7ee3cab77
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/VideoQualityMenuFilterPatch.java
@@ -0,0 +1,30 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.youtube.patches.playback.quality.RestoreOldVideoQualityMenuPatch;
+import app.revanced.extension.youtube.settings.Settings;
+
+/**
+ * Abuse LithoFilter for {@link RestoreOldVideoQualityMenuPatch}.
+ */
+public final class VideoQualityMenuFilterPatch 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 VideoQualityMenuFilterPatch() {
+        addPathCallbacks(new StringFilterGroup(
+                Settings.RESTORE_OLD_VIDEO_QUALITY_MENU,
+                "quick_quality_sheet_content.eml-js"
+        ));
+    }
+
+    @Override
+    boolean isFiltered(@Nullable String identifier, String path, 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/playback/quality/RememberVideoQualityPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/quality/RememberVideoQualityPatch.java
new file mode 100644
index 000000000..785603895
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/quality/RememberVideoQualityPatch.java
@@ -0,0 +1,167 @@
+package app.revanced.extension.youtube.patches.playback.quality;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.Utils.NetworkType;
+
+import androidx.annotation.Nullable;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.IntegerSetting;
+import app.revanced.extension.youtube.patches.VideoInformation;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class RememberVideoQualityPatch {
+    private static final int AUTOMATIC_VIDEO_QUALITY_VALUE = -2;
+    private static final IntegerSetting wifiQualitySetting = Settings.VIDEO_QUALITY_DEFAULT_WIFI;
+    private static final IntegerSetting mobileQualitySetting = Settings.VIDEO_QUALITY_DEFAULT_MOBILE;
+
+    private static boolean qualityNeedsUpdating;
+
+    /**
+     * If the user selected a new quality from the flyout menu,
+     * and {@link Settings#REMEMBER_VIDEO_QUALITY_LAST_SELECTED} is enabled.
+     */
+    private static boolean userChangedDefaultQuality;
+
+    /**
+     * Index of the video quality chosen by the user from the flyout menu.
+     */
+    private static int userSelectedQualityIndex;
+
+    /**
+     * The available qualities of the current video in human readable form: [1080, 720, 480]
+     */
+    @Nullable
+    private static List videoQualities;
+
+    private static void changeDefaultQuality(int defaultQuality) {
+        String networkTypeMessage;
+        if (Utils.getNetworkType() == NetworkType.MOBILE) {
+            mobileQualitySetting.save(defaultQuality);
+            networkTypeMessage = str("revanced_remember_video_quality_mobile");
+        } else {
+            wifiQualitySetting.save(defaultQuality);
+            networkTypeMessage = str("revanced_remember_video_quality_wifi");
+        }
+        Utils.showToastShort(
+                str("revanced_remember_video_quality_toast", networkTypeMessage, (defaultQuality + "p")));
+    }
+
+    /**
+     * Injection point.
+     *
+     * @param qualities Video qualities available, ordered from largest to smallest, with index 0 being the 'automatic' value of -2
+     * @param originalQualityIndex quality index to use, as chosen by YouTube
+     */
+    public static int setVideoQuality(Object[] qualities, final int originalQualityIndex, Object qInterface, String qIndexMethod) {
+        try {
+            if (!(qualityNeedsUpdating || userChangedDefaultQuality) || qInterface == null) {
+                return originalQualityIndex;
+            }
+            qualityNeedsUpdating = false;
+
+            final int preferredQuality;
+            if (Utils.getNetworkType() == NetworkType.MOBILE) {
+                preferredQuality = mobileQualitySetting.get();
+            } else {
+                preferredQuality = wifiQualitySetting.get();
+            }
+            if (!userChangedDefaultQuality && preferredQuality == AUTOMATIC_VIDEO_QUALITY_VALUE) {
+                return originalQualityIndex; // nothing to do
+            }
+
+            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);
+            }
+
+            if (userChangedDefaultQuality) {
+                userChangedDefaultQuality = false;
+                final int quality = videoQualities.get(userSelectedQualityIndex);
+                Logger.printDebug(() -> "User changed default quality to: " + quality);
+                changeDefaultQuality(quality);
+                return userSelectedQualityIndex;
+            }
+
+            // find the highest quality that is equal to or less than the preferred
+            int qualityToUse = videoQualities.get(0); // first element is automatic mode
+            int qualityIndexToUse = 0;
+            int i = 0;
+            for (Integer quality : videoQualities) {
+                if (quality <= preferredQuality && qualityToUse < quality)  {
+                    qualityToUse = quality;
+                    qualityIndexToUse = i;
+                }
+                i++;
+            }
+
+            // If the desired quality index is equal to the original index,
+            // then the video is already set to the desired default quality.
+            //
+            // The method could return here, but the UI video quality flyout will still
+            // show 'Auto' (ie: Auto (480p))
+            // It appears that "Auto" picks the resolution on video load,
+            // and it does not appear to change the resolution during playback.
+            //
+            // To prevent confusion, set the video index anyways (even if it matches the existing index)
+            // As that will force the UI picker to not display "Auto" which may confuse the user.
+            if (qualityIndexToUse == originalQualityIndex) {
+                Logger.printDebug(() -> "Video is already preferred quality: " + preferredQuality);
+            } else {
+                final int qualityToUseLog = qualityToUse;
+                Logger.printDebug(() -> "Quality changed from: "
+                        + videoQualities.get(originalQualityIndex) + " to: " + qualityToUseLog);
+            }
+
+            Method m = qInterface.getClass().getMethod(qIndexMethod, Integer.TYPE);
+            m.invoke(qInterface, qualityToUse);
+            return qualityIndexToUse;
+        } catch (Exception ex) {
+            Logger.printException(() -> "Failed to set quality", ex);
+            return originalQualityIndex;
+        }
+    }
+
+    /**
+     * Injection point.  Old quality menu.
+     */
+    public static void userChangedQuality(int selectedQualityIndex) {
+        if (!Settings.REMEMBER_VIDEO_QUALITY_LAST_SELECTED.get()) return;
+
+        userSelectedQualityIndex = selectedQualityIndex;
+        userChangedDefaultQuality = true;
+    }
+
+    /**
+     * Injection point.  New quality menu.
+     */
+    public static void userChangedQualityInNewFlyout(int selectedQuality) {
+        if (!Settings.REMEMBER_VIDEO_QUALITY_LAST_SELECTED.get()) return;
+
+        changeDefaultQuality(selectedQuality); // Quality is human readable resolution (ie: 1080).
+    }
+
+    /**
+     * Injection point.
+     */
+    public static void newVideoStarted(VideoInformation.PlaybackController ignoredPlayerController) {
+        Logger.printDebug(() -> "newVideoStarted");
+        qualityNeedsUpdating = true;
+        videoQualities = null;
+    }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/quality/RestoreOldVideoQualityMenuPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/quality/RestoreOldVideoQualityMenuPatch.java
new file mode 100644
index 000000000..ac74bc810
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/quality/RestoreOldVideoQualityMenuPatch.java
@@ -0,0 +1,110 @@
+package app.revanced.extension.youtube.patches.playback.quality;
+
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.widget.ListView;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.patches.components.VideoQualityMenuFilterPatch;
+import app.revanced.extension.youtube.settings.Settings;
+
+/**
+ * This patch contains the logic to show the old video quality menu.
+ * Two methods are required, because the quality menu is a RecyclerView in the new YouTube version
+ * and a ListView in the old one.
+ */
+@SuppressWarnings("unused")
+public final class RestoreOldVideoQualityMenuPatch {
+
+    /**
+     * Injection point.
+     */
+    public static void onFlyoutMenuCreate(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 (!VideoQualityMenuFilterPatch.isVideoQualityMenuVisible || recyclerView.getChildCount() == 0) {
+                    return;
+                }
+                VideoQualityMenuFilterPatch.isVideoQualityMenuVisible = false;
+
+                ViewParent quickQualityViewParent = Utils.getParentView(recyclerView, 3);
+                if (!(quickQualityViewParent instanceof ViewGroup)) {
+                    return;
+                }
+
+                View firstChild = recyclerView.getChildAt(0);
+                if (!(firstChild instanceof ViewGroup)) {
+                    return;
+                }
+
+                ViewGroup advancedQualityParentView = (ViewGroup) firstChild;
+                if (advancedQualityParentView.getChildCount() < 4) {
+                    return;
+                }
+
+                View advancedQualityView = advancedQualityParentView.getChildAt(3);
+                if (advancedQualityView == null) {
+                    return;
+                }
+
+                ((ViewGroup) quickQualityViewParent).setVisibility(View.GONE);
+
+                // Click the "Advanced" quality menu to show the "old" quality menu.
+                advancedQualityView.setSoundEffectsEnabled(false);
+                advancedQualityView.performClick();
+            } catch (Exception ex) {
+                Logger.printException(() -> "onFlyoutMenuCreate failure", ex);
+            }
+        });
+    }
+
+
+    /**
+     * Injection point.
+     *
+     * Used to force the creation of the advanced menu item for the Shorts quality flyout.
+     */
+    public static boolean forceAdvancedVideoQualityMenuCreation(boolean original) {
+        return Settings.RESTORE_OLD_VIDEO_QUALITY_MENU.get() || original;
+    }
+
+    /**
+     * Injection point.
+     *
+     * Used if spoofing to an old app version, and also used for the Shorts video quality flyout.
+     */
+    public static void showOldVideoQualityMenu(final ListView listView) {
+        if (!Settings.RESTORE_OLD_VIDEO_QUALITY_MENU.get()) return;
+
+        listView.setOnHierarchyChangeListener(new ViewGroup.OnHierarchyChangeListener() {
+            @Override
+            public void onChildViewAdded(View parent, View child) {
+                try {
+                    parent.setVisibility(View.GONE);
+
+                    final var indexOfAdvancedQualityMenuItem = 4;
+                    if (listView.indexOfChild(child) != indexOfAdvancedQualityMenuItem) return;
+
+                    Logger.printDebug(() -> "Found advanced menu item in old type of quality menu");
+
+                    listView.setSoundEffectsEnabled(false);
+                    final var qualityItemMenuPosition = 4;
+                    listView.performItemClick(null, qualityItemMenuPosition, 0);
+
+                } catch (Exception ex) {
+                    Logger.printException(() -> "showOldVideoQualityMenu failure", ex);
+                }
+            }
+
+            @Override
+            public void onChildViewRemoved(View parent, View child) {
+            }
+        });
+    }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/speed/CustomPlaybackSpeedPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/speed/CustomPlaybackSpeedPatch.java
new file mode 100644
index 000000000..cad6050fb
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/speed/CustomPlaybackSpeedPatch.java
@@ -0,0 +1,175 @@
+package app.revanced.extension.youtube.patches.playback.speed;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.preference.ListPreference;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.youtube.patches.components.PlaybackSpeedMenuFilterPatch;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+
+import java.util.Arrays;
+
+@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;
+
+    /**
+     * Custom playback speeds.
+     */
+    public static float[] customPlaybackSpeeds;
+
+    /**
+     * The last time the old playback menu was forcefully called.
+     */
+    private static long lastTimeOldPlaybackMenuInvoked;
+
+    /**
+     * PreferenceList entries and values, of all available playback speeds.
+     */
+    private static String[] preferenceListEntries, preferenceListEntryValues;
+
+    static {
+        loadCustomSpeeds();
+    }
+
+    private static void resetCustomSpeeds(@NonNull String toastMessage) {
+        Utils.showToastLong(toastMessage);
+        Settings.CUSTOM_PLAYBACK_SPEEDS.resetToDefault();
+    }
+
+    private 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 arrayContains(float[] array, float value) {
+        for (float arrayValue : array) {
+            if (arrayValue == value) return true;
+        }
+        return false;
+    }
+
+    /**
+     * Initialize a settings preference list with the available playback speeds.
+     */
+    public static void initializeListPreference(ListPreference preference) {
+        if (preferenceListEntries == null) {
+            preferenceListEntries = new String[customPlaybackSpeeds.length];
+            preferenceListEntryValues = new String[customPlaybackSpeeds.length];
+            int i = 0;
+            for (float speed : customPlaybackSpeeds) {
+                String speedString = String.valueOf(speed);
+                preferenceListEntries[i] = speedString + "x";
+                preferenceListEntryValues[i] = speedString;
+                i++;
+            }
+        }
+        preference.setEntries(preferenceListEntries);
+        preference.setEntryValues(preferenceListEntryValues);
+    }
+
+    /**
+     * Injection point.
+     */
+    public static void onFlyoutMenuCreate(RecyclerView recyclerView) {
+        recyclerView.getViewTreeObserver().addOnDrawListener(() -> {
+            try {
+                // For some reason, the custom playback speed flyout panel is activated when the user opens the share panel. (A/B tests)
+                // Check the child count of playback speed flyout panel to prevent this issue.
+                // Child count of playback speed flyout panel is always 8.
+                if (!PlaybackSpeedMenuFilterPatch.isPlaybackSpeedMenuVisible || recyclerView.getChildCount() == 0) {
+                    return;
+                }
+
+                View firstChild = recyclerView.getChildAt(0);
+                if (!(firstChild instanceof ViewGroup)) {
+                    return;
+                }
+                ViewGroup PlaybackSpeedParentView = (ViewGroup) firstChild;
+                if (PlaybackSpeedParentView.getChildCount() != 8) {
+                    return;
+                }
+
+                PlaybackSpeedMenuFilterPatch.isPlaybackSpeedMenuVisible = false;
+
+                ViewParent parentView3rd = Utils.getParentView(recyclerView, 3);
+                if (!(parentView3rd instanceof ViewGroup)) {
+                    return;
+                }
+                ViewParent parentView4th = parentView3rd.getParent();
+                if (!(parentView4th instanceof ViewGroup)) {
+                    return;
+                }
+
+                // Dismiss View [R.id.touch_outside] is the 1st ChildView of the 4th ParentView.
+                // This only shows in phone layout.
+                final var touchInsidedView = ((ViewGroup) parentView4th).getChildAt(0);
+                touchInsidedView.setSoundEffectsEnabled(false);
+                touchInsidedView.performClick();
+
+                // In tablet layout there is no Dismiss View, instead we just hide all two parent views.
+                ((ViewGroup) parentView3rd).setVisibility(View.GONE);
+                ((ViewGroup) parentView4th).setVisibility(View.GONE);
+
+                // This works without issues for both tablet and phone layouts,
+                // So no code is needed to check whether the current device is a tablet or phone.
+
+                // Close the new Playback speed menu and show the old one.
+                showOldPlaybackSpeedMenu();
+            } catch (Exception ex) {
+                Logger.printException(() -> "onFlyoutMenuCreate failure", ex);
+            }
+        });
+    }
+
+    public static void showOldPlaybackSpeedMenu() {
+        // 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) {
+            Logger.printDebug(() -> "Ignoring call to showOldPlaybackSpeedMenu");
+            return;
+        }
+        lastTimeOldPlaybackMenuInvoked = now;
+        Logger.printDebug(() -> "Old video quality menu shown");
+
+        // Rest of the implementation added by patch.
+    }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/speed/RememberPlaybackSpeedPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/speed/RememberPlaybackSpeedPatch.java
new file mode 100644
index 000000000..2d6d0f781
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/speed/RememberPlaybackSpeedPatch.java
@@ -0,0 +1,56 @@
+package app.revanced.extension.youtube.patches.playback.speed;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.patches.VideoInformation;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class RememberPlaybackSpeedPatch {
+
+    private static final long TOAST_DELAY_MILLISECONDS = 750;
+
+    private static long lastTimeSpeedChanged;
+
+    /**
+     * Injection point.
+     */
+    public static void newVideoStarted(VideoInformation.PlaybackController ignoredPlayerController) {
+        Logger.printDebug(() -> "newVideoStarted");
+        VideoInformation.overridePlaybackSpeed(Settings.PLAYBACK_SPEED_DEFAULT.get());
+    }
+
+    /**
+     * Injection point.
+     * Called when user selects a playback speed.
+     *
+     * @param playbackSpeed The playback speed the user selected
+     */
+    public static void userSelectedPlaybackSpeed(float playbackSpeed) {
+        if (Settings.REMEMBER_PLAYBACK_SPEED_LAST_SELECTED.get()) {
+            Settings.PLAYBACK_SPEED_DEFAULT.save(playbackSpeed);
+
+            // 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;
+
+            Utils.runOnMainThreadDelayed(() -> {
+                if (lastTimeSpeedChanged == now) {
+                    Utils.showToastLong(str("revanced_remember_playback_speed_toast", (playbackSpeed + "x")));
+                } // else, the user made additional speed adjustments and this call is outdated.
+            }, TOAST_DELAY_MILLISECONDS);
+        }
+    }
+
+    /**
+     * Injection point.
+     * Overrides the video speed.  Called after video loads, and immediately after user selects a different playback speed
+     */
+    public static float getPlaybackSpeedOverride() {
+        return VideoInformation.getPlaybackSpeed();
+    }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/ClientType.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/ClientType.java
new file mode 100644
index 000000000..de6a2a12c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/ClientType.java
@@ -0,0 +1,79 @@
+package app.revanced.extension.youtube.patches.spoof;
+
+import static app.revanced.extension.youtube.patches.spoof.DeviceHardwareSupport.allowAV1;
+import static app.revanced.extension.youtube.patches.spoof.DeviceHardwareSupport.allowVP9;
+
+import android.os.Build;
+
+import androidx.annotation.Nullable;
+
+public enum ClientType {
+    // https://dumps.tadiphone.dev/dumps/oculus/eureka
+    IOS(5,
+            // iPhone 15 supports AV1 hardware decoding.
+            // Only use if this Android device also has hardware decoding.
+            allowAV1()
+                    ? "iPhone16,2"  // 15 Pro Max
+                    : "iPhone11,4", // XS Max
+            // iOS 14+ forces VP9.
+            allowVP9()
+                    ? "17.5.1.21F90"
+                    : "13.7.17H35",
+            allowVP9()
+                    ? "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 17_5_1 like Mac OS X)"
+                    : "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 13_7 like Mac OS X)",
+            null,
+            // Version number should be a valid iOS release.
+            // https://www.ipa4fun.com/history/185230
+            "19.10.7"
+    ),
+    ANDROID_VR(28,
+            "Quest 3",
+            "12",
+            "com.google.android.apps.youtube.vr.oculus/1.56.21 (Linux; U; Android 12; GB) gzip",
+            "32", // Android 12.1
+            "1.56.21"
+    );
+
+    /**
+     * YouTube
+     * client type
+     */
+    public final int id;
+
+    /**
+     * Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model)
+     */
+    public final String model;
+
+    /**
+     * 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.
+     */
+    @Nullable
+    public final String androidSdkVersion;
+
+    /**
+     * App version.
+     */
+    public final String appVersion;
+
+    ClientType(int id, String model, String osVersion, String userAgent, @Nullable String androidSdkVersion, String appVersion) {
+        this.id = id;
+        this.model = model;
+        this.osVersion = osVersion;
+        this.userAgent = userAgent;
+        this.androidSdkVersion = androidSdkVersion;
+        this.appVersion = appVersion;
+    }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/DeviceHardwareSupport.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/DeviceHardwareSupport.java
new file mode 100644
index 000000000..3adc6befb
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/DeviceHardwareSupport.java
@@ -0,0 +1,53 @@
+package app.revanced.extension.youtube.patches.spoof;
+
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import android.os.Build;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+
+public class DeviceHardwareSupport {
+    public static final boolean DEVICE_HAS_HARDWARE_DECODING_VP9;
+    public 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 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
+
+        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_VIDEO_STREAMS_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/spoof/SpoofAppVersionPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofAppVersionPatch.java
new file mode 100644
index 000000000..25ad35d64
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofAppVersionPatch.java
@@ -0,0 +1,23 @@
+package app.revanced.extension.youtube.patches.spoof;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class SpoofAppVersionPatch {
+
+    private static final boolean SPOOF_APP_VERSION_ENABLED = Settings.SPOOF_APP_VERSION.get();
+    private static final String SPOOF_APP_VERSION_TARGET = Settings.SPOOF_APP_VERSION_TARGET.get();
+
+    /**
+     * Injection point
+     */
+    public static String getYouTubeVersionOverride(String version) {
+        if (SPOOF_APP_VERSION_ENABLED) return SPOOF_APP_VERSION_TARGET;
+        return version;
+    }
+
+    public static boolean isSpoofingToLessThan(String version) {
+        return SPOOF_APP_VERSION_ENABLED && SPOOF_APP_VERSION_TARGET.compareTo(version) < 0;
+    }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofDeviceDimensionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofDeviceDimensionsPatch.java
new file mode 100644
index 000000000..6df52a4a3
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofDeviceDimensionsPatch.java
@@ -0,0 +1,17 @@
+package app.revanced.extension.youtube.patches.spoof;
+
+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/spoof/SpoofVideoStreamsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofVideoStreamsPatch.java
new file mode 100644
index 000000000..b50e3f4ff
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofVideoStreamsPatch.java
@@ -0,0 +1,168 @@
+package app.revanced.extension.youtube.patches.spoof;
+
+import android.net.Uri;
+
+import androidx.annotation.Nullable;
+
+import java.nio.ByteBuffer;
+import java.util.Map;
+import java.util.Objects;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.youtube.patches.spoof.requests.StreamingDataRequest;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class SpoofVideoStreamsPatch {
+    public static final class ForceiOSAVCAvailability implements Setting.Availability {
+        @Override
+        public boolean isAvailable() {
+            return Settings.SPOOF_VIDEO_STREAMS.get() && Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.IOS;
+        }
+    }
+
+    private static final boolean SPOOF_STREAMING_DATA = Settings.SPOOF_VIDEO_STREAMS.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 (BaseSettings.DEBUG.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; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/requests/PlayerRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/requests/PlayerRoutes.java new file mode 100644 index 000000000..364dc173a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/requests/PlayerRoutes.java @@ -0,0 +1,74 @@ +package app.revanced.extension.youtube.patches.spoof.requests; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.HttpURLConnection; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.youtube.patches.spoof.ClientType; +import app.revanced.extension.youtube.requests.Requester; +import app.revanced.extension.youtube.requests.Route; + +final class PlayerRoutes { + private static final String YT_API_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(); + + /** + * TCP connection and HTTP read timeout + */ + private static final int CONNECTION_TIMEOUT_MILLISECONDS = 10 * 1000; // 10 Seconds. + + private PlayerRoutes() { + } + + static String createInnertubeBody(ClientType clientType) { + JSONObject innerTubeBody = new JSONObject(); + + try { + JSONObject context = new JSONObject(); + + JSONObject client = new JSONObject(); + client.put("clientName", clientType.name()); + client.put("clientVersion", clientType.appVersion); + client.put("deviceModel", clientType.model); + client.put("osVersion", clientType.osVersion); + if (clientType.androidSdkVersion != null) { + client.put("androidSdkVersion", clientType.androidSdkVersion); + } + + context.put("client", client); + + innerTubeBody.put("context", context); + innerTubeBody.put("contentCheckOk", true); + innerTubeBody.put("racyCheckOk", true); + innerTubeBody.put("videoId", "%s"); + } 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(YT_API_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/spoof/requests/StreamingDataRequest.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/requests/StreamingDataRequest.java new file mode 100644 index 000000000..e66f4d885 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/requests/StreamingDataRequest.java @@ -0,0 +1,223 @@ +package app.revanced.extension.youtube.patches.spoof.requests; + +import static app.revanced.extension.youtube.patches.spoof.requests.PlayerRoutes.GET_STREAMING_DATA; + +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.net.SocketTimeoutException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.*; +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.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.youtube.patches.spoof.ClientType; +import app.revanced.extension.youtube.settings.Settings; + +/** + * Video streaming data. Fetching is tied to the behavior YT uses, + * where this class fetches the streams only when YT fetches. + * + * Effectively the cache expiration of these fetches is the same as the stock app, + * since the stock app would not use expired streams and therefor + * the extension replace stream hook is called only if YT + * did use its own client streams. + */ +public class StreamingDataRequest { + + private static final ClientType[] CLIENT_ORDER_TO_USE; + + static { + ClientType[] allClientTypes = ClientType.values(); + ClientType preferredClient = Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get(); + + CLIENT_ORDER_TO_USE = new ClientType[allClientTypes.length]; + CLIENT_ORDER_TO_USE[0] = preferredClient; + + int i = 1; + for (ClientType c : allClientTypes) { + if (c != preferredClient) { + CLIENT_ORDER_TO_USE[i++] = c; + } + } + } + + private static final String[] REQUEST_HEADER_KEYS = { + "Authorization", // Available only to logged in users. + "X-GOOG-API-FORMAT-VERSION", + "X-Goog-Visitor-Id" + }; + + /** + * 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; + + 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(String videoId, Map fetchHeaders) { + // Always fetch, even if there is a existing request for the same video. + cache.put(videoId, new StreamingDataRequest(videoId, fetchHeaders)); + } + + @Nullable + public static StreamingDataRequest getRequestForVideoId(String videoId) { + return cache.get(videoId); + } + + private static void handleConnectionError(String toastMessage, @Nullable Exception ex, boolean showToast) { + if (showToast) Utils.showToastShort(toastMessage); + Logger.printInfo(() -> toastMessage, ex); + } + + @Nullable + private static HttpURLConnection send(ClientType clientType, String videoId, + Map playerHeaders, + boolean showErrorToasts) { + 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); + connection.setConnectTimeout(HTTP_TIMEOUT_MILLISECONDS); + connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS); + + for (String key : REQUEST_HEADER_KEYS) { + String value = playerHeaders.get(key); + if (value != null) { + connection.setRequestProperty(key, value); + } + } + + String innerTubeBody = String.format(PlayerRoutes.createInnertubeBody(clientType), 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 connection; + + handleConnectionError(clientTypeName + " not available with response code: " + + responseCode + " message: " + connection.getResponseMessage(), + null, showErrorToasts); + } catch (SocketTimeoutException ex) { + handleConnectionError("Connection timeout", ex, showErrorToasts); + } catch (IOException ex) { + handleConnectionError("Network error", ex, showErrorToasts); + } catch (Exception ex) { + Logger.printException(() -> "send failed", ex); + } finally { + Logger.printDebug(() -> "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms"); + } + + return null; + } + + private static ByteBuffer fetch(String videoId, Map playerHeaders) { + final boolean debugEnabled = BaseSettings.DEBUG.get(); + + // Retry with different client if empty response body is received. + int i = 0; + for (ClientType clientType : CLIENT_ORDER_TO_USE) { + // Show an error if the last client type fails, or if the debug is enabled then show for all attempts. + final boolean showErrorToast = (++i == CLIENT_ORDER_TO_USE.length) || debugEnabled; + + HttpURLConnection connection = send(clientType, videoId, playerHeaders, showErrorToast); + if (connection != null) { + try { + // gzip encoding doesn't response with content length (-1), + // but empty response body does. + if (connection.getContentLength() != 0) { + try (InputStream inputStream = new BufferedInputStream(connection.getInputStream())) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + byte[] buffer = new byte[2048]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) >= 0) { + baos.write(buffer, 0, bytesRead); + } + + 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, debugEnabled); + 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/theme/ProgressBarDrawable.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/theme/ProgressBarDrawable.java new file mode 100644 index 000000000..bf0284f79 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/theme/ProgressBarDrawable.java @@ -0,0 +1,48 @@ +package app.revanced.extension.youtube.patches.theme; + +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.patches.HideSeekbarPatch; +import app.revanced.extension.youtube.settings.Settings; + +/** + * Used by {@link SeekbarColorPatch} change the color of the seekbar. + * and {@link HideSeekbarPatch} to hide the seekbar of the feed and watch history. + */ +@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(SeekbarColorPatch.getSeekbarColor()); + 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/theme/SeekbarColorPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/theme/SeekbarColorPatch.java new file mode 100644 index 000000000..aea5c227c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/theme/SeekbarColorPatch.java @@ -0,0 +1,192 @@ +package app.revanced.extension.youtube.patches.theme; + +import static app.revanced.extension.shared.StringRef.str; + +import android.graphics.Color; + +import java.util.Arrays; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class SeekbarColorPatch { + + private static final boolean SEEKBAR_CUSTOM_COLOR_ENABLED = Settings.SEEKBAR_CUSTOM_COLOR.get(); + + /** + * Default color of the seekbar. + */ + private static final int ORIGINAL_SEEKBAR_COLOR = 0xFFFF0000; + + /** + * Default colors of the gradient seekbar. + */ + private static final int[] ORIGINAL_SEEKBAR_GRADIENT_COLORS = { 0xFFFF0033, 0xFFFF2791 }; + + /** + * Default positions of the gradient seekbar. + */ + private static final float[] ORIGINAL_SEEKBAR_GRADIENT_POSITIONS = { 0.8f, 1.0f }; + + /** + * Default YouTube seekbar color brightness. + */ + private static final float ORIGINAL_SEEKBAR_COLOR_BRIGHTNESS; + + /** + * If {@link Settings#SEEKBAR_CUSTOM_COLOR} is enabled, + * this is the color value of {@link Settings#SEEKBAR_CUSTOM_COLOR_VALUE}. + * Otherwise this is {@link #ORIGINAL_SEEKBAR_COLOR}. + */ + private static int seekbarColor = ORIGINAL_SEEKBAR_COLOR; + + /** + * Custom seekbar hue, saturation, and brightness values. + */ + private static final float[] customSeekbarColorHSV = new float[3]; + + static { + float[] hsv = new float[3]; + Color.colorToHSV(ORIGINAL_SEEKBAR_COLOR, hsv); + ORIGINAL_SEEKBAR_COLOR_BRIGHTNESS = hsv[2]; + + if (SEEKBAR_CUSTOM_COLOR_ENABLED) { + loadCustomSeekbarColor(); + } + } + + private static void loadCustomSeekbarColor() { + try { + seekbarColor = Color.parseColor(Settings.SEEKBAR_CUSTOM_COLOR_VALUE.get()); + Color.colorToHSV(seekbarColor, customSeekbarColorHSV); + } catch (Exception ex) { + Utils.showToastShort(str("revanced_seekbar_custom_color_invalid")); + Settings.SEEKBAR_CUSTOM_COLOR_VALUE.resetToDefault(); + loadCustomSeekbarColor(); + } + } + + public static int getSeekbarColor() { + return seekbarColor; + } + + public static boolean playerSeekbarGradientEnabled(boolean original) { + if (SEEKBAR_CUSTOM_COLOR_ENABLED) return false; + + return original; + } + + /** + * Injection point. + * + * Overrides all Litho components 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 getLithoColor(int colorValue) { + if (colorValue == ORIGINAL_SEEKBAR_COLOR) { + if (Settings.HIDE_SEEKBAR_THUMBNAIL.get()) { + return 0x00000000; + } + + return getSeekbarColorValue(ORIGINAL_SEEKBAR_COLOR); + } + return colorValue; + } + + /** + * Injection point. + */ + public static void setLinearGradient(int[] colors, float[] positions) { + final boolean hideSeekbar = Settings.HIDE_SEEKBAR_THUMBNAIL.get(); + + if (SEEKBAR_CUSTOM_COLOR_ENABLED || hideSeekbar) { + // Most litho usage of linear gradients is hooked here, + // so must only change if the values are those for the seekbar. + if (Arrays.equals(ORIGINAL_SEEKBAR_GRADIENT_COLORS, colors) + && Arrays.equals(ORIGINAL_SEEKBAR_GRADIENT_POSITIONS, positions)) { + Arrays.fill(colors, hideSeekbar + ? 0x00000000 + : seekbarColor); + return; + } + + Logger.printDebug(() -> "Ignoring gradient colors: " + Arrays.toString(colors) + + " positions: " + Arrays.toString(positions)); + } + } + + /** + * Injection point. + * + * Overrides color when video player seekbar is clicked. + */ + public static int getVideoPlayerSeekbarClickedColor(int colorValue) { + if (!SEEKBAR_CUSTOM_COLOR_ENABLED) { + return colorValue; + } + + return colorValue == ORIGINAL_SEEKBAR_COLOR + ? getSeekbarColorValue(ORIGINAL_SEEKBAR_COLOR) + : colorValue; + } + + /** + * Injection point. + * + * Overrides color used for the video player seekbar. + */ + public static int getVideoPlayerSeekbarColor(int originalColor) { + if (!SEEKBAR_CUSTOM_COLOR_ENABLED) { + return originalColor; + } + + return getSeekbarColorValue(originalColor); + } + + /** + * Color parameter is changed to the custom seekbar color, while retaining + * the brightness and alpha changes of the parameter value compared to the original seekbar color. + */ + private static int getSeekbarColorValue(int originalColor) { + try { + if (!SEEKBAR_CUSTOM_COLOR_ENABLED || originalColor == seekbarColor) { + return originalColor; // nothing to do + } + + final int alphaDifference = Color.alpha(originalColor) - Color.alpha(ORIGINAL_SEEKBAR_COLOR); + + // The seekbar uses the same color but different brightness for different situations. + float[] hsv = new float[3]; + Color.colorToHSV(originalColor, hsv); + final float brightnessDifference = hsv[2] - ORIGINAL_SEEKBAR_COLOR_BRIGHTNESS; + + // Apply the brightness difference to the custom seekbar color. + hsv[0] = customSeekbarColorHSV[0]; + hsv[1] = customSeekbarColorHSV[1]; + hsv[2] = clamp(customSeekbarColorHSV[2] + brightnessDifference, 0, 1); + + final int replacementAlpha = clamp(Color.alpha(seekbarColor) + alphaDifference, 0, 255); + final int replacementColor = Color.HSVToColor(replacementAlpha, hsv); + Logger.printDebug(() -> String.format("Original color: #%08X replacement color: #%08X", + originalColor, replacementColor)); + return replacementColor; + } catch (Exception ex) { + Logger.printException(() -> "getSeekbarColorValue failure", ex); + return originalColor; + } + } + + /** @noinspection SameParameterValue */ + private static int clamp(int value, int lower, int upper) { + return Math.max(lower, Math.min(value, upper)); + } + + /** @noinspection SameParameterValue */ + private static float clamp(float value, float lower, float upper) { + return Math.max(lower, Math.min(value, upper)); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/theme/ThemePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/theme/ThemePatch.java new file mode 100644 index 000000000..77372e400 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/theme/ThemePatch.java @@ -0,0 +1,63 @@ +package app.revanced.extension.youtube.patches.theme; + +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.ThemeHelper; + +@SuppressWarnings("unused") +public class ThemePatch { + // color constants used in relation with litho components + 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, // explore drawer 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; + + // Used by app.revanced.patches.youtube.layout.theme.patch.LithoThemePatch + /** + * Change the color of Litho components. + * If the color of the component matches one of the values, return the background color . + * + * @param originalValue The original color value. + * @return The new or original color value + */ + public static int getValue(int originalValue) { + if (ThemeHelper.isDarkTheme()) { + if (anyEquals(originalValue, DARK_VALUES)) return getBlackColor(); + } else { + if (anyEquals(originalValue, WHITE_VALUES)) return getWhiteColor(); + } + return originalValue; + } + + public static boolean gradientLoadingScreenEnabled() { + return Settings.GRADIENT_LOADING_SCREEN.get(); + } + + private static int getBlackColor() { + if (blackColor == 0) blackColor = Utils.getResourceColor("yt_black1"); + return blackColor; + } + + private static int getWhiteColor() { + if (whiteColor == 0) whiteColor = Utils.getResourceColor("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/requests/Requester.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/requests/Requester.java new file mode 100644 index 000000000..69d43a4be --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/requests/Requester.java @@ -0,0 +1,145 @@ +package app.revanced.extension.youtube.requests; + +import app.revanced.extension.shared.Utils; +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; + +public class Requester { + private 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()); + String agentString = System.getProperty("http.agent") + + "; ReVanced/" + Utils.getAppVersionName() + + " (" + Utils.getPatchesReleaseVersion() + ")"; + connection.setRequestProperty("User-Agent", agentString); + + 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/youtube/requests/Route.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/requests/Route.java new file mode 100644 index 000000000..c25d108b9 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/requests/Route.java @@ -0,0 +1,66 @@ +package app.revanced.extension.youtube.requests; + +public class Route { + private final String route; + private final Method method; + private final int paramCount; + + public 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 Method getMethod() { + return method; + } + + public 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 CompiledRoute(this, compiledRoute.toString()); + } + + 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 Method getMethod() { + return baseRoute.method; + } + } + + 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 + } +} \ 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 000000000..a67a96fa8 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/ReturnYouTubeDislike.java @@ -0,0 +1,730 @@ +package app.revanced.extension.youtube.returnyoutubedislike; + +import static app.revanced.extension.shared.StringRef.str; + +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.os.Build; +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.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.*; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.ThemeHelper; +import app.revanced.extension.youtube.patches.spoof.SpoofAppVersionPatch; +import app.revanced.extension.youtube.returnyoutubedislike.requests.RYDVoteData; +import app.revanced.extension.youtube.returnyoutubedislike.requests.ReturnYouTubeDislikeApi; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.PlayerType; + +/** + * 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 { + + public enum Vote { + LIKE(1), + DISLIKE(-1), + LIKE_REMOVE(0); + + public final int value; + + Vote(int value) { + this.value = value; + } + } + + /** + * 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: + * https://developer.android.com/topic/performance/vitals/anr + */ + 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' + + private static final boolean IS_SPOOFING_TO_OLD_SEPARATOR_COLOR + = SpoofAppVersionPatch.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; + + 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, 18, 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); + } + + 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 ThemeHelper.isDarkTheme() + ? 0x29AAAAAA // transparent dark gray + : 0xFFD9D9D9; // light gray + } + return ThemeHelper.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)) { + // 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()); + } + + 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) { + return (SpannableString) sourceStyle; // 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize + if (dislikeCountFormatter == null) { + Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale; + 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize + if (dislikePercentageFormatter == null) { + Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale; + dislikePercentageFormatter = NumberFormat.getPercentInstance(locale); + + // Want to set the digit strings, and the simplest way is to cast to the implementation NumberFormat returns. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + && dislikePercentageFormatter instanceof DecimalFormat) { + DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale); + symbols.setDigitStrings(DecimalFormatSymbols.getInstance(Locale.ENGLISH).getDigitStrings()); + ((DecimalFormat) dislikePercentageFormatter).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. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + final long now = System.currentTimeMillis(); + fetchCache.values().removeIf(value -> { + final boolean expired = value.isExpired(now); + if (expired) + Logger.printDebug(() -> "Removing expired fetch: " + value.videoId); + return expired; + }); + } + + 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; + } + + @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; + } + + 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 { + PlayerType currentType = PlayerType.getCurrent(); + if (isShort != currentType.isNoneHiddenOrMinimized()) { + Logger.printDebug(() -> "Cannot vote for video: " + videoId + + " as current player type does not match: " + currentType); + + // 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(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/returnyoutubedislike/requests/RYDVoteData.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/requests/RYDVoteData.java new file mode 100644 index 000000000..b57eadcfd --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/requests/RYDVoteData.java @@ -0,0 +1,179 @@ +package app.revanced.extension.youtube.returnyoutubedislike.requests; + +import static app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike.Vote; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import app.revanced.extension.shared.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. + */ +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/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java new file mode 100644 index 000000000..07c8f3c55 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java @@ -0,0 +1,610 @@ +package app.revanced.extension.youtube.returnyoutubedislike.requests; + +import static app.revanced.extension.shared.StringRef.str; +import static app.revanced.extension.youtube.returnyoutubedislike.requests.ReturnYouTubeDislikeRoutes.getRYDConnectionFromRoute; + +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.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.requests.Requester; +import app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike; +import app.revanced.extension.youtube.settings.Settings; + +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; + + /** + * 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; + + /** + * Number of times {@link #HTTP_STATUS_CODE_RATE_LIMIT} was requested by RYD api. + * Does not include network calls attempted while rate limit is in effect, + * and does not include rate limit imposed if a fetch fails. + */ + private static volatile int numberOfRateLimitRequestsEncountered; + + /** + * Number of network calls made in {@link #fetchVotes(String)} + */ + private static volatile int fetchCallCount; + + /** + * Number of times {@link #fetchVotes(String)} failed due to timeout or any other error. + * This does not include when rate limit requests are encountered. + */ + private static volatile int fetchCallNumberOfFailures; + + /** + * Total time spent waiting for {@link #fetchVotes(String)} network call to complete. + * Value does does not persist on app shut down. + */ + private static volatile long fetchCallResponseTimeTotal; + + /** + * Round trip network time for the most recent call to {@link #fetchVotes(String)} + */ + private static volatile long fetchCallResponseTimeLast; + private static volatile long fetchCallResponseTimeMin; + private static volatile long fetchCallResponseTimeMax; + + public static final int FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT = -1; + + /** + * If rate limit was hit, this returns {@link #FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT} + */ + public static long getFetchCallResponseTimeLast() { + return fetchCallResponseTimeLast; + } + public static long getFetchCallResponseTimeMin() { + return fetchCallResponseTimeMin; + } + public static long getFetchCallResponseTimeMax() { + return fetchCallResponseTimeMax; + } + public static long getFetchCallResponseTimeAverage() { + return fetchCallCount == 0 ? 0 : (fetchCallResponseTimeTotal / fetchCallCount); + } + public static int getFetchCallCount() { + return fetchCallCount; + } + public static int getFetchCallNumberOfFailures() { + return fetchCallNumberOfFailures; + } + public static int getNumberOfRateLimitRequestsEncountered() { + return numberOfRateLimitRequestsEncountered; + } + + private ReturnYouTubeDislikeApi() { + } // utility class + + /** + * Simulates a slow response by doing meaningless calculations. + * Used to debug the app UI and verify UI timeout logic works + */ + private static void randomlyWaitIfLocallyDebugging() { + final boolean DEBUG_RANDOMLY_DELAY_NETWORK_CALLS = false; // set true to debug UI + if (DEBUG_RANDOMLY_DELAY_NETWORK_CALLS) { + final long amountOfTimeToWaste = (long) (Math.random() + * (API_GET_VOTES_TCP_TIMEOUT_MILLISECONDS + API_GET_VOTES_HTTP_TIMEOUT_MILLISECONDS)); + Utils.doNothingForDuration(amountOfTimeToWaste); + } + } + + /** + * 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) { + final boolean DEBUG_RATE_LIMIT = false; // set to true, to verify rate limit works + if (DEBUG_RATE_LIMIT) { + final double RANDOM_RATE_LIMIT_PERCENTAGE = 0.2; // 20% chance of a triggering a rate limit + if (Math.random() < RANDOM_RATE_LIMIT_PERCENTAGE) { + Logger.printDebug(() -> "Artificially triggering rate limit for debug purposes"); + httpResponseCode = HTTP_STATUS_CODE_RATE_LIMIT; + } + } + return httpResponseCode == HTTP_STATUS_CODE_RATE_LIMIT; + } + + @SuppressWarnings("NonAtomicOperationOnVolatileField") // Don't care, fields are only estimates. + private static void updateRateLimitAndStats(long timeNetworkCallStarted, boolean connectionError, boolean rateLimitHit) { + if (connectionError && rateLimitHit) { + throw new IllegalArgumentException(); + } + final long responseTimeOfFetchCall = System.currentTimeMillis() - timeNetworkCallStarted; + fetchCallResponseTimeTotal += responseTimeOfFetchCall; + fetchCallResponseTimeMin = (fetchCallResponseTimeMin == 0) ? responseTimeOfFetchCall : Math.min(responseTimeOfFetchCall, fetchCallResponseTimeMin); + fetchCallResponseTimeMax = Math.max(responseTimeOfFetchCall, fetchCallResponseTimeMax); + fetchCallCount++; + if (connectionError) { + timeToResumeAPICalls = System.currentTimeMillis() + BACKOFF_CONNECTION_ERROR_MILLISECONDS; + fetchCallResponseTimeLast = responseTimeOfFetchCall; + fetchCallNumberOfFailures++; + 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; + numberOfRateLimitRequestsEncountered++; + fetchCallResponseTimeLast = FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT; + if (!lastApiCallFailed && Settings.RYD_TOAST_ON_CONNECTION_ERROR.get()) { + Utils.showToastLong(str("revanced_ryd_failure_client_rate_limit_requested")); + } + lastApiCallFailed = true; + } else { + fetchCallResponseTimeLast = responseTimeOfFetchCall; + lastApiCallFailed = false; + } + } + + private static void handleConnectionError(@NonNull String toastMessage, + @Nullable Exception ex, + boolean showLongToast) { + if (!lastApiCallFailed && Settings.RYD_TOAST_ON_CONNECTION_ERROR.get()) { + if (showLongToast) { + Utils.showToastLong(toastMessage); + } else { + Utils.showToastShort(toastMessage); + } + } + lastApiCallFailed = true; + + 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); + final long timeNetworkCallStarted = System.currentTimeMillis(); + + 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 + + randomlyWaitIfLocallyDebugging(); + + final int responseCode = connection.getResponseCode(); + if (checkIfRateLimitWasHit(responseCode)) { + connection.disconnect(); // rate limit hit, should disconnect + updateRateLimitAndStats(timeNetworkCallStarted, 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(timeNetworkCallStarted, 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 { + // Unexpected response code. Most likely RYD is temporarily broken. + handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), + null, true); + } + connection.disconnect(); // Something went wrong, might as well disconnect. + } catch (SocketTimeoutException ex) { + handleConnectionError((str("revanced_ryd_failure_connection_timeout")), ex, false); + } catch (IOException ex) { + handleConnectionError((str("revanced_ryd_failure_generic", ex.getMessage())), ex, true); + } catch (Exception ex) { + // should never happen + Logger.printException(() -> "Failed to fetch votes", ex, str("revanced_ryd_failure_generic", ex.getMessage())); + } + + updateRateLimitAndStats(timeNetworkCallStarted, 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(36); + 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, true); + connection.disconnect(); + } catch (SocketTimeoutException ex) { + handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex, false); + } catch (IOException ex) { + handleConnectionError(str("revanced_ryd_failure_generic", "registration failed"), ex, true); + } 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, true); + } catch (SocketTimeoutException ex) { + handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex, false); + } catch (IOException ex) { + handleConnectionError(str("revanced_ryd_failure_generic", "confirm registration failed"), + ex, true); + } catch (Exception ex) { + Logger.printException(() -> "Failed to confirm registration for user: " + userId + + "solution: " + solution, ex); + } + return 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 = registerAsNewUser(); + if (userId != null) { + Settings.RYD_USER_ID.save(userId); + } + return userId; + } + + public static boolean sendVote(String videoId, ReturnYouTubeDislike.Vote vote) { + Utils.verifyOffMainThread(); + Objects.requireNonNull(videoId); + Objects.requireNonNull(vote); + + try { + String userId = getUserId(); + if (userId == null) return false; + + if (checkIfRateLimitInEffect("sendVote")) { + return false; + } + 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 false; + } + 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 confirmVote(videoId, userId, solution); + } + + 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, true); + connection.disconnect(); // something went wrong, might as well disconnect + } catch (SocketTimeoutException ex) { + handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex, false); + } catch (IOException ex) { + handleConnectionError(str("revanced_ryd_failure_generic", "send vote failed"), ex, true); + } catch (Exception ex) { + // should never happen + Logger.printException(() -> "Failed to send vote for video: " + videoId + " vote: " + vote, ex); + } + return false; + } + + private static boolean confirmVote(String videoId, String userId, String solution) { + Utils.verifyOffMainThread(); + Objects.requireNonNull(videoId); + Objects.requireNonNull(userId); + Objects.requireNonNull(solution); + + try { + if (checkIfRateLimitInEffect("confirmVote")) { + return false; + } + 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 false; + } + if (responseCode == HTTP_STATUS_CODE_SUCCESS) { + Logger.printDebug(() -> "Vote confirm successful for video: " + videoId); + return true; + } + + // 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, true); + } catch (SocketTimeoutException ex) { + handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex, false); + } catch (IOException ex) { + handleConnectionError(str("revanced_ryd_failure_generic", "confirm vote failed"), + ex, true); + } catch (Exception ex) { + Logger.printException(() -> "Failed to confirm vote for video: " + videoId + + " solution: " + solution, ex); // should never happen + } + return false; + } + + 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) { + final long timeSolveStarted = System.currentTimeMillis(); + 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) { + String solution = Base64.encodeToString(new byte[]{buffer[0], buffer[1], buffer[2], buffer[3]}, Base64.NO_WRAP); + Logger.printDebug(() -> "Found puzzle solution: " + solution + " of difficulty: " + difficulty + + " in: " + (System.currentTimeMillis() - timeSolveStarted) + " ms"); + return solution; + } + } + + // should never be reached + throw new IllegalStateException("Failed to solve puzzle challenge: " + challenge + " difficulty: " + difficulty); + } + + // https://stackoverflow.com/a/157202 + private static String randomString(int len) { + String AB = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + SecureRandom rnd = new SecureRandom(); + + StringBuilder sb = new StringBuilder(len); + for (int i = 0; i < len; i++) + sb.append(AB.charAt(rnd.nextInt(AB.length()))); + return sb.toString(); + } + + private static int countLeadingZeroes(byte[] uInt8View) { + int zeroes = 0; + for (byte b : uInt8View) { + int 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/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeRoutes.java new file mode 100644 index 000000000..2c2ae7255 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeRoutes.java @@ -0,0 +1,28 @@ +package app.revanced.extension.youtube.returnyoutubedislike.requests; + +import static app.revanced.extension.youtube.requests.Route.Method.GET; +import static app.revanced.extension.youtube.requests.Route.Method.POST; + +import java.io.IOException; +import java.net.HttpURLConnection; + +import app.revanced.extension.youtube.requests.Requester; +import app.revanced.extension.youtube.requests.Route; + +class ReturnYouTubeDislikeRoutes { + static final String RYD_API_URL = "https://returnyoutubedislikeapi.com/"; + + static final Route SEND_VOTE = new Route(POST, "interact/vote"); + static final Route CONFIRM_VOTE = new Route(POST, "interact/confirmVote"); + static final Route GET_DISLIKES = new Route(GET, "votes?videoId={video_id}"); + static final Route GET_REGISTRATION = new Route(GET, "puzzle/registration?userId={user_id}"); + static final Route CONFIRM_REGISTRATION = new Route(POST, "puzzle/registration?userId={user_id}"); + + private ReturnYouTubeDislikeRoutes() { + } + + 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/youtube/settings/LicenseActivityHook.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/LicenseActivityHook.java new file mode 100644 index 000000000..acf565712 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/LicenseActivityHook.java @@ -0,0 +1,99 @@ +package app.revanced.extension.youtube.settings; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.preference.PreferenceFragment; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.TextView; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.youtube.ThemeHelper; +import app.revanced.extension.youtube.settings.preference.ReVancedPreferenceFragment; +import app.revanced.extension.youtube.settings.preference.ReturnYouTubeDislikePreferenceFragment; +import app.revanced.extension.youtube.settings.preference.SponsorBlockPreferenceFragment; + +import java.util.Objects; + +import static app.revanced.extension.shared.Utils.getChildView; +import static app.revanced.extension.shared.Utils.getResourceIdentifier; + +/** + * Hooks LicenseActivity. + *

+ * This class is responsible for injecting our own fragment by replacing the LicenseActivity. + */ +@SuppressWarnings("unused") +public class LicenseActivityHook { + + /** + * Injection point. + *

+ * Hooks LicenseActivity#onCreate in order to inject our own fragment. + */ + public static void initialize(Activity licenseActivity) { + try { + ThemeHelper.setActivityTheme(licenseActivity); + licenseActivity.setContentView( + getResourceIdentifier("revanced_settings_with_toolbar", "layout")); + setBackButton(licenseActivity); + + PreferenceFragment fragment; + String toolbarTitleResourceName; + String dataString = licenseActivity.getIntent().getDataString(); + switch (dataString) { + case "revanced_sb_settings_intent": + toolbarTitleResourceName = "revanced_sb_settings_title"; + fragment = new SponsorBlockPreferenceFragment(); + break; + case "revanced_ryd_settings_intent": + toolbarTitleResourceName = "revanced_ryd_settings_title"; + fragment = new ReturnYouTubeDislikePreferenceFragment(); + break; + case "revanced_settings_intent": + toolbarTitleResourceName = "revanced_settings_title"; + fragment = new ReVancedPreferenceFragment(); + break; + default: + Logger.printException(() -> "Unknown setting: " + dataString); + return; + } + + setToolbarTitle(licenseActivity, toolbarTitleResourceName); + licenseActivity.getFragmentManager() + .beginTransaction() + .replace(getResourceIdentifier("revanced_settings_fragments", "id"), fragment) + .commit(); + } catch (Exception ex) { + Logger.printException(() -> "onCreate failure", ex); + } + } + + private static void setToolbarTitle(Activity activity, String toolbarTitleResourceName) { + ViewGroup toolbar = activity.findViewById(getToolbarResourceId()); + TextView toolbarTextView = Objects.requireNonNull(getChildView(toolbar, false, + view -> view instanceof TextView)); + toolbarTextView.setText(getResourceIdentifier(toolbarTitleResourceName, "string")); + } + + @SuppressLint("UseCompatLoadingForDrawables") + private static void setBackButton(Activity activity) { + ViewGroup toolbar = activity.findViewById(getToolbarResourceId()); + ImageButton imageButton = Objects.requireNonNull(getChildView(toolbar, false, + view -> view instanceof ImageButton)); + final int backButtonResource = getResourceIdentifier(ThemeHelper.isDarkTheme() + ? "yt_outline_arrow_left_white_24" + : "yt_outline_arrow_left_black_24", + "drawable"); + imageButton.setImageDrawable(activity.getResources().getDrawable(backButtonResource)); + imageButton.setOnClickListener(view -> activity.onBackPressed()); + } + + private static int getToolbarResourceId() { + final int toolbarResourceId = getResourceIdentifier("revanced_toolbar", "id"); + if (toolbarResourceId == 0) { + throw new IllegalStateException("Could not find back button resource"); + } + return toolbarResourceId; + } + +} 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 000000000..479080623 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java @@ -0,0 +1,448 @@ +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.*; +import static app.revanced.extension.youtube.patches.ChangeStartPagePatch.StartPage; +import static app.revanced.extension.youtube.patches.MiniplayerPatch.MiniplayerHideExpandCloseAvailability; +import static app.revanced.extension.youtube.patches.MiniplayerPatch.MiniplayerType; +import static app.revanced.extension.youtube.patches.MiniplayerPatch.MiniplayerType.*; +import static app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour.*; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.settings.*; +import app.revanced.extension.shared.settings.preference.SharedPrefCategory; +import app.revanced.extension.youtube.patches.AlternativeThumbnailsPatch.DeArrowAvailability; +import app.revanced.extension.youtube.patches.AlternativeThumbnailsPatch.StillImagesAvailability; +import app.revanced.extension.youtube.patches.AlternativeThumbnailsPatch.ThumbnailOption; +import app.revanced.extension.youtube.patches.AlternativeThumbnailsPatch.ThumbnailStillTime; +import app.revanced.extension.youtube.patches.spoof.ClientType; +import app.revanced.extension.youtube.patches.spoof.SpoofAppVersionPatch; +import app.revanced.extension.youtube.patches.spoof.SpoofVideoStreamsPatch; +import app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings; + +@SuppressWarnings("deprecation") +public class Settings extends BaseSettings { + // Video + public static final BooleanSetting RESTORE_OLD_VIDEO_QUALITY_MENU = new BooleanSetting("revanced_restore_old_video_quality_menu", TRUE); + public static final BooleanSetting REMEMBER_VIDEO_QUALITY_LAST_SELECTED = new BooleanSetting("revanced_remember_video_quality_last_selected", FALSE); + public static final IntegerSetting VIDEO_QUALITY_DEFAULT_WIFI = new IntegerSetting("revanced_video_quality_default_wifi", -2); + public static final IntegerSetting VIDEO_QUALITY_DEFAULT_MOBILE = new IntegerSetting("revanced_video_quality_default_mobile", -2); + public static final BooleanSetting REMEMBER_PLAYBACK_SPEED_LAST_SELECTED = new BooleanSetting("revanced_remember_playback_speed_last_selected", FALSE); + public static final FloatSetting PLAYBACK_SPEED_DEFAULT = new FloatSetting("revanced_playback_speed_default", 1.0f); + public static final StringSetting CUSTOM_PLAYBACK_SPEEDS = new StringSetting("revanced_custom_playback_speeds", + "0.25\n0.5\n0.75\n0.9\n0.95\n1.0\n1.05\n1.1\n1.25\n1.5\n1.75\n2.0\n3.0\n4.0\n5.0", true); + @Deprecated // Patch is obsolete and no longer works with 19.09+ + public static final BooleanSetting HDR_AUTO_BRIGHTNESS = new BooleanSetting("revanced_hdr_auto_brightness", TRUE); + + // Ads + public static final BooleanSetting HIDE_FULLSCREEN_ADS = new BooleanSetting("revanced_hide_fullscreen_ads", TRUE); + public static final BooleanSetting HIDE_BUTTONED_ADS = new BooleanSetting("revanced_hide_buttoned_ads", TRUE); + 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); + public static final BooleanSetting HIDE_HIDE_LATEST_POSTS = new BooleanSetting("revanced_hide_latest_posts_ads", TRUE); + public static final BooleanSetting HIDE_MERCHANDISE_BANNERS = new BooleanSetting("revanced_hide_merchandise_banners", TRUE); + public static final BooleanSetting HIDE_PAID_PROMOTION_LABEL = new BooleanSetting("revanced_hide_paid_promotion_label", TRUE); + public static final BooleanSetting HIDE_PRODUCTS_BANNER = new BooleanSetting("revanced_hide_products_banner", TRUE); + public static final BooleanSetting HIDE_PLAYER_STORE_SHELF = new BooleanSetting("revanced_hide_player_store_shelf", TRUE); + public static final BooleanSetting HIDE_SHOPPING_LINKS = new BooleanSetting("revanced_hide_shopping_links", TRUE); + public static final BooleanSetting HIDE_SELF_SPONSOR = new BooleanSetting("revanced_hide_self_sponsor_ads", TRUE); + public static final BooleanSetting HIDE_VIDEO_ADS = new BooleanSetting("revanced_hide_video_ads", TRUE, true); + public static final BooleanSetting HIDE_VISIT_STORE_BUTTON = new BooleanSetting("revanced_hide_visit_store_button", TRUE); + public static final BooleanSetting HIDE_WEB_SEARCH_RESULTS = new BooleanSetting("revanced_hide_web_search_results", TRUE); + + // Feed + public static final BooleanSetting HIDE_ALBUM_CARDS = new BooleanSetting("revanced_hide_album_cards", FALSE, true); + public static final BooleanSetting HIDE_ARTIST_CARDS = new BooleanSetting("revanced_hide_artist_cards", FALSE); + public static final BooleanSetting HIDE_EXPANDABLE_CHIP = new BooleanSetting("revanced_hide_expandable_chip", TRUE); + public static final BooleanSetting HIDE_DOODLES = new BooleanSetting("revanced_hide_doodles", FALSE, true, "revanced_hide_doodles_user_dialog_message"); + + // 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_subscription", 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", TRUE, 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()); + + // Hide keyword content + public static final BooleanSetting HIDE_KEYWORD_CONTENT_HOME = new BooleanSetting("revanced_hide_keyword_content_home", FALSE); + public static final BooleanSetting HIDE_KEYWORD_CONTENT_SUBSCRIPTIONS = new BooleanSetting("revanced_hide_keyword_content_subscriptions", FALSE); + public static final BooleanSetting HIDE_KEYWORD_CONTENT_SEARCH = new BooleanSetting("revanced_hide_keyword_content_search", FALSE); + public static final StringSetting HIDE_KEYWORD_CONTENT_PHRASES = new StringSetting("revanced_hide_keyword_content_phrases", "", + parentsAny(HIDE_KEYWORD_CONTENT_HOME, HIDE_KEYWORD_CONTENT_SUBSCRIPTIONS, HIDE_KEYWORD_CONTENT_SEARCH)); + + // Uncategorized layout related settings. Do not add to this section, and instead move these out and categorize them. + public static final BooleanSetting DISABLE_SUGGESTED_VIDEO_END_SCREEN = new BooleanSetting("revanced_disable_suggested_video_end_screen", FALSE, true); + public static final BooleanSetting GRADIENT_LOADING_SCREEN = new BooleanSetting("revanced_gradient_loading_screen", FALSE); + public static final BooleanSetting HIDE_HORIZONTAL_SHELVES = new BooleanSetting("revanced_hide_horizontal_shelves", TRUE); + public static final BooleanSetting HIDE_CAPTIONS_BUTTON = new BooleanSetting("revanced_hide_captions_button", FALSE); + public static final BooleanSetting HIDE_CHANNEL_BAR = new BooleanSetting("revanced_hide_channel_bar", FALSE); + public static final BooleanSetting HIDE_CHANNEL_MEMBER_SHELF = new BooleanSetting("revanced_hide_channel_member_shelf", TRUE); + public static final BooleanSetting HIDE_CHIPS_SHELF = new BooleanSetting("revanced_hide_chips_shelf", TRUE); + public static final BooleanSetting HIDE_COMMUNITY_GUIDELINES = new BooleanSetting("revanced_hide_community_guidelines", TRUE); + public static final BooleanSetting HIDE_COMMUNITY_POSTS = new BooleanSetting("revanced_hide_community_posts", FALSE); + public static final BooleanSetting HIDE_COMPACT_BANNER = new BooleanSetting("revanced_hide_compact_banner", TRUE); + public static final BooleanSetting HIDE_CROWDFUNDING_BOX = new BooleanSetting("revanced_hide_crowdfunding_box", FALSE, true); + @Deprecated public static final BooleanSetting HIDE_EMAIL_ADDRESS = new BooleanSetting("revanced_hide_email_address", FALSE); + public static final BooleanSetting HIDE_EMERGENCY_BOX = new BooleanSetting("revanced_hide_emergency_box", TRUE); + public static final BooleanSetting HIDE_ENDSCREEN_CARDS = new BooleanSetting("revanced_hide_endscreen_cards", FALSE); + public static final BooleanSetting HIDE_FEED_SURVEY = new BooleanSetting("revanced_hide_feed_survey", TRUE); + public static final BooleanSetting HIDE_FILTER_BAR_FEED_IN_FEED = new BooleanSetting("revanced_hide_filter_bar_feed_in_feed", FALSE, true); + public static final BooleanSetting HIDE_FILTER_BAR_FEED_IN_RELATED_VIDEOS = new BooleanSetting("revanced_hide_filter_bar_feed_in_related_videos", FALSE, true); + public static final BooleanSetting HIDE_FILTER_BAR_FEED_IN_SEARCH = new BooleanSetting("revanced_hide_filter_bar_feed_in_search", FALSE, true); + public static final BooleanSetting HIDE_FLOATING_MICROPHONE_BUTTON = new BooleanSetting("revanced_hide_floating_microphone_button", TRUE, true); + public static final BooleanSetting HIDE_FULLSCREEN_PANELS = new BooleanSetting("revanced_hide_fullscreen_panels", TRUE, true); + public static final BooleanSetting HIDE_GRAY_SEPARATOR = new BooleanSetting("revanced_hide_gray_separator", TRUE); + public static final BooleanSetting HIDE_HIDE_CHANNEL_GUIDELINES = new BooleanSetting("revanced_hide_channel_guidelines", TRUE); + public static final BooleanSetting HIDE_HIDE_INFO_PANELS = new BooleanSetting("revanced_hide_info_panels", TRUE); + public static final BooleanSetting HIDE_IMAGE_SHELF = new BooleanSetting("revanced_hide_image_shelf", TRUE); + public static final BooleanSetting HIDE_INFO_CARDS = new BooleanSetting("revanced_hide_info_cards", FALSE); + public static final BooleanSetting HIDE_JOIN_MEMBERSHIP_BUTTON = new BooleanSetting("revanced_hide_join_membership_button", TRUE); + @Deprecated public static final BooleanSetting HIDE_LOAD_MORE_BUTTON = new BooleanSetting("revanced_hide_load_more_button", TRUE); + public static final BooleanSetting HIDE_SHOW_MORE_BUTTON = new BooleanSetting("revanced_hide_show_more_button", TRUE, true); + public static final BooleanSetting HIDE_MEDICAL_PANELS = new BooleanSetting("revanced_hide_medical_panels", TRUE); + public static final BooleanSetting HIDE_MIX_PLAYLISTS = new BooleanSetting("revanced_hide_mix_playlists", TRUE); + public static final BooleanSetting HIDE_MOVIES_SECTION = new BooleanSetting("revanced_hide_movies_section", TRUE); + public static final BooleanSetting HIDE_NOTIFY_ME_BUTTON = new BooleanSetting("revanced_hide_notify_me_button", TRUE); + public static final BooleanSetting HIDE_PLAYABLES = new BooleanSetting("revanced_hide_playables", TRUE); + public static final BooleanSetting HIDE_QUICK_ACTIONS = new BooleanSetting("revanced_hide_quick_actions", FALSE); + public static final BooleanSetting HIDE_RELATED_VIDEOS = new BooleanSetting("revanced_hide_related_videos", FALSE); + public static final BooleanSetting HIDE_SEARCH_RESULT_SHELF_HEADER = new BooleanSetting("revanced_hide_search_result_shelf_header", FALSE); + public static final BooleanSetting HIDE_SUBSCRIBERS_COMMUNITY_GUIDELINES = new BooleanSetting("revanced_hide_subscribers_community_guidelines", TRUE); + public static final BooleanSetting HIDE_TIMED_REACTIONS = new BooleanSetting("revanced_hide_timed_reactions", TRUE); + public static final BooleanSetting HIDE_TIMESTAMP = new BooleanSetting("revanced_hide_timestamp", FALSE); + public static final BooleanSetting HIDE_VIDEO_CHANNEL_WATERMARK = new BooleanSetting("revanced_hide_channel_watermark", TRUE); + public static final BooleanSetting HIDE_FOR_YOU_SHELF = new BooleanSetting("revanced_hide_for_you_shelf", TRUE); + public static final BooleanSetting HIDE_SEARCH_RESULT_RECOMMENDATIONS = new BooleanSetting("revanced_hide_search_result_recommendations", TRUE); + public static final IntegerSetting PLAYER_OVERLAY_OPACITY = new IntegerSetting("revanced_player_overlay_opacity",100, true); + public static final BooleanSetting PLAYER_POPUP_PANELS = new BooleanSetting("revanced_hide_player_popup_panels", FALSE); + + // Player + public static final BooleanSetting DISABLE_FULLSCREEN_AMBIENT_MODE = new BooleanSetting("revanced_disable_fullscreen_ambient_mode", TRUE, true); + public static final BooleanSetting DISABLE_ROLLING_NUMBER_ANIMATIONS = new BooleanSetting("revanced_disable_rolling_number_animations", FALSE); + public static final BooleanSetting DISABLE_LIKE_SUBSCRIBE_GLOW = new BooleanSetting("revanced_disable_like_subscribe_glow", FALSE); + public static final BooleanSetting HIDE_AUTOPLAY_BUTTON = new BooleanSetting("revanced_hide_autoplay_button", TRUE, true); + public static final BooleanSetting HIDE_CAST_BUTTON = new BooleanSetting("revanced_hide_cast_button", TRUE, true); + public static final BooleanSetting HIDE_PLAYER_PREVIOUS_NEXT_BUTTONS = new BooleanSetting("revanced_hide_player_previous_next_buttons", FALSE, true); + @Deprecated + public static final BooleanSetting HIDE_PLAYER_BUTTONS = new BooleanSetting("revanced_hide_player_buttons", FALSE, true); + public static final BooleanSetting COPY_VIDEO_URL = new BooleanSetting("revanced_copy_video_url", FALSE); + public static final BooleanSetting COPY_VIDEO_URL_TIMESTAMP = new BooleanSetting("revanced_copy_video_url_timestamp", TRUE); + public static final BooleanSetting PLAYBACK_SPEED_DIALOG_BUTTON = new BooleanSetting("revanced_playback_speed_dialog_button", FALSE); + + // Miniplayer + public static final EnumSetting MINIPLAYER_TYPE = new EnumSetting<>("revanced_miniplayer_type", MiniplayerType.ORIGINAL, true); + private static final Availability MINIPLAYER_ANY_MODERN = MINIPLAYER_TYPE.availability(MODERN_1, MODERN_2, MODERN_3, MODERN_4); + public static final BooleanSetting MINIPLAYER_DOUBLE_TAP_ACTION = new BooleanSetting("revanced_miniplayer_double_tap_action", TRUE, true, MINIPLAYER_ANY_MODERN); + public static final BooleanSetting MINIPLAYER_DRAG_AND_DROP = new BooleanSetting("revanced_miniplayer_drag_and_drop", TRUE, true, MINIPLAYER_ANY_MODERN); + public static final BooleanSetting MINIPLAYER_HIDE_EXPAND_CLOSE = new BooleanSetting("revanced_miniplayer_hide_expand_close", FALSE, true, new MiniplayerHideExpandCloseAvailability()); + 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 BooleanSetting MINIPLAYER_ROUNDED_CORNERS = new BooleanSetting("revanced_miniplayer_rounded_corners", TRUE, true, MINIPLAYER_ANY_MODERN); + public static final IntegerSetting MINIPLAYER_WIDTH_DIP = new IntegerSetting("revanced_miniplayer_width_dip", 192, true, MINIPLAYER_ANY_MODERN); + public static final IntegerSetting MINIPLAYER_OPACITY = new IntegerSetting("revanced_miniplayer_opacity", 100, true, MINIPLAYER_TYPE.availability(MODERN_1)); + + // External downloader + public static final BooleanSetting EXTERNAL_DOWNLOADER = new BooleanSetting("revanced_external_downloader", FALSE); + public static final BooleanSetting EXTERNAL_DOWNLOADER_ACTION_BUTTON = new BooleanSetting("revanced_external_downloader_action_button", FALSE); + public static final StringSetting EXTERNAL_DOWNLOADER_PACKAGE_NAME = new StringSetting("revanced_external_downloader_name", + "org.schabi.newpipe" /* NewPipe */, parentsAny(EXTERNAL_DOWNLOADER, EXTERNAL_DOWNLOADER_ACTION_BUTTON)); + + // Comments + public static final BooleanSetting HIDE_COMMENTS_BY_MEMBERS_HEADER = new BooleanSetting("revanced_hide_comments_by_members_header", FALSE); + public static final BooleanSetting HIDE_COMMENTS_SECTION = new BooleanSetting("revanced_hide_comments_section", FALSE); + public static final BooleanSetting HIDE_COMMENTS_CREATE_A_SHORT_BUTTON = new BooleanSetting("revanced_hide_comments_create_a_short_button", TRUE); + public static final BooleanSetting HIDE_COMMENTS_PREVIEW_COMMENT = new BooleanSetting("revanced_hide_comments_preview_comment", FALSE); + public static final BooleanSetting HIDE_COMMENTS_THANKS_BUTTON = new BooleanSetting("revanced_hide_comments_thanks_button", TRUE); + public static final BooleanSetting HIDE_COMMENTS_TIMESTAMP_AND_EMOJI_BUTTONS = new BooleanSetting("revanced_hide_comments_timestamp_and_emoji_buttons", TRUE); + + // Description + 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", TRUE); + public static final BooleanSetting HIDE_INFO_CARDS_SECTION = new BooleanSetting("revanced_hide_info_cards_section", TRUE); + 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", TRUE); + public static final BooleanSetting HIDE_TRANSCRIPT_SECTION = new BooleanSetting("revanced_hide_transcript_section", TRUE); + + // Action buttons + public static final BooleanSetting HIDE_LIKE_DISLIKE_BUTTON = new BooleanSetting("revanced_hide_like_dislike_button", FALSE); + public static final BooleanSetting HIDE_SHARE_BUTTON = new BooleanSetting("revanced_hide_share_button", FALSE); + public static final BooleanSetting HIDE_REPORT_BUTTON = new BooleanSetting("revanced_hide_report_button", FALSE); + public static final BooleanSetting HIDE_REMIX_BUTTON = new BooleanSetting("revanced_hide_remix_button", TRUE); + public static final BooleanSetting HIDE_DOWNLOAD_BUTTON = new BooleanSetting("revanced_hide_download_button", FALSE); + public static final BooleanSetting HIDE_THANKS_BUTTON = new BooleanSetting("revanced_hide_thanks_button", TRUE); + public static final BooleanSetting HIDE_CLIP_BUTTON = new BooleanSetting("revanced_hide_clip_button", TRUE); + public static final BooleanSetting HIDE_PLAYLIST_BUTTON = new BooleanSetting("revanced_hide_playlist_button", FALSE); + + // Player flyout menu items + public static final BooleanSetting HIDE_CAPTIONS_MENU = new BooleanSetting("revanced_hide_player_flyout_captions", FALSE); + public static final BooleanSetting HIDE_ADDITIONAL_SETTINGS_MENU = new BooleanSetting("revanced_hide_player_flyout_additional_settings", FALSE); + public static final BooleanSetting HIDE_LOOP_VIDEO_MENU = new BooleanSetting("revanced_hide_player_flyout_loop_video", FALSE); + public static final BooleanSetting HIDE_AMBIENT_MODE_MENU = new BooleanSetting("revanced_hide_player_flyout_ambient_mode", FALSE); + public static final BooleanSetting HIDE_HELP_MENU = new BooleanSetting("revanced_hide_player_flyout_help", TRUE); + public static final BooleanSetting HIDE_SPEED_MENU = new BooleanSetting("revanced_hide_player_flyout_speed", FALSE); + public static final BooleanSetting HIDE_MORE_INFO_MENU = new BooleanSetting("revanced_hide_player_flyout_more_info", TRUE); + public static final BooleanSetting HIDE_LOCK_SCREEN_MENU = new BooleanSetting("revanced_hide_player_flyout_lock_screen", FALSE); + public static final BooleanSetting HIDE_AUDIO_TRACK_MENU = new BooleanSetting("revanced_hide_player_flyout_audio_track", FALSE); + public static final BooleanSetting HIDE_WATCH_IN_VR_MENU = new BooleanSetting("revanced_hide_player_flyout_watch_in_vr", TRUE); + public static final BooleanSetting HIDE_VIDEO_QUALITY_MENU_FOOTER = new BooleanSetting("revanced_hide_video_quality_menu_footer", FALSE); + + // General layout + public static final EnumSetting CHANGE_START_PAGE = new EnumSetting<>("revanced_change_start_page", StartPage.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", "17.33.42", true, parent(SPOOF_APP_VERSION)); + public static final BooleanSetting TABLET_LAYOUT = new BooleanSetting("revanced_tablet_layout", FALSE, true, "revanced_tablet_layout_user_dialog_message"); + public static final BooleanSetting WIDE_SEARCHBAR = new BooleanSetting("revanced_wide_searchbar", FALSE, true); + public static final BooleanSetting BYPASS_IMAGE_REGION_RESTRICTIONS = new BooleanSetting("revanced_bypass_image_region_restrictions", FALSE, true); + public static final BooleanSetting REMOVE_VIEWER_DISCRETION_DIALOG = new BooleanSetting("revanced_remove_viewer_discretion_dialog", FALSE, + "revanced_remove_viewer_discretion_dialog_user_dialog_message"); + + // 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)); + + // Navigation buttons + public static final BooleanSetting HIDE_HOME_BUTTON = new BooleanSetting("revanced_hide_home_button", FALSE, true); + public static final BooleanSetting HIDE_CREATE_BUTTON = new BooleanSetting("revanced_hide_create_button", TRUE, true); + public static final BooleanSetting HIDE_SHORTS_BUTTON = new BooleanSetting("revanced_hide_shorts_button", TRUE, true); + public static final BooleanSetting HIDE_SUBSCRIPTIONS_BUTTON = new BooleanSetting("revanced_hide_subscriptions_button", FALSE, true); + public static final BooleanSetting HIDE_NAVIGATION_BUTTON_LABELS = new BooleanSetting("revanced_hide_navigation_button_labels", FALSE, true); + public static final BooleanSetting SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON = new BooleanSetting("revanced_switch_create_with_notifications_button", TRUE, true); + + // Shorts + public static final BooleanSetting DISABLE_RESUMING_SHORTS_PLAYER = new BooleanSetting("revanced_disable_resuming_shorts_player", FALSE); + public static final BooleanSetting HIDE_SHORTS_HOME = new BooleanSetting("revanced_hide_shorts_home", FALSE); + public static final BooleanSetting HIDE_SHORTS_SUBSCRIPTIONS = new BooleanSetting("revanced_hide_shorts_subscriptions", FALSE); + public static final BooleanSetting HIDE_SHORTS_SEARCH = new BooleanSetting("revanced_hide_shorts_search", FALSE); + 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_OVERLAY_BUTTONS = new BooleanSetting("revanced_hide_shorts_paused_overlay_buttons", FALSE); + public static final BooleanSetting HIDE_SHORTS_SHOP_BUTTON = new BooleanSetting("revanced_hide_shorts_shop_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_TAGGED_PRODUCTS = new BooleanSetting("revanced_hide_shorts_tagged_products", TRUE); + public static final BooleanSetting HIDE_SHORTS_LOCATION_LABEL = new BooleanSetting("revanced_hide_shorts_location_label", FALSE); + public static final BooleanSetting HIDE_SHORTS_SAVE_SOUND_BUTTON = new BooleanSetting("revanced_hide_shorts_save_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_UPCOMING_BUTTON = new BooleanSetting("revanced_hide_shorts_upcoming_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_GREEN_SCREEN_BUTTON = new BooleanSetting("revanced_hide_shorts_green_screen_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_HASHTAG_BUTTON = new BooleanSetting("revanced_hide_shorts_hashtag_button", TRUE); + public static final BooleanSetting HIDE_SHORTS_SEARCH_SUGGESTIONS = new BooleanSetting("revanced_hide_shorts_search_suggestions", FALSE); + public static final BooleanSetting HIDE_SHORTS_STICKERS = new BooleanSetting("revanced_hide_shorts_stickers", 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_LIKE_FOUNTAIN = new BooleanSetting("revanced_hide_shorts_like_fountain", TRUE); + 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_INFO_PANEL = new BooleanSetting("revanced_hide_shorts_info_panel", TRUE); + public static final BooleanSetting HIDE_SHORTS_SOUND_BUTTON = new BooleanSetting("revanced_hide_shorts_sound_button", 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", FALSE); + public static final BooleanSetting HIDE_SHORTS_FULL_VIDEO_LINK_LABEL = new BooleanSetting("revanced_hide_shorts_full_video_link_label", FALSE); + public static final BooleanSetting HIDE_SHORTS_NAVIGATION_BAR = new BooleanSetting("revanced_hide_shorts_navigation_bar", FALSE, true); + public static final BooleanSetting SHORTS_AUTOPLAY = new BooleanSetting("revanced_shorts_autoplay", FALSE); + public static final BooleanSetting SHORTS_AUTOPLAY_BACKGROUND = new BooleanSetting("revanced_shorts_autoplay_background", TRUE); + + // Seekbar + public static final BooleanSetting DISABLE_PRECISE_SEEKING_GESTURE = new BooleanSetting("revanced_disable_precise_seeking_gesture", TRUE); + public static final BooleanSetting SEEKBAR_TAPPING = new BooleanSetting("revanced_seekbar_tapping", TRUE); + public static final BooleanSetting SLIDE_TO_SEEK = new BooleanSetting("revanced_slide_to_seek", FALSE, true); + public static final BooleanSetting RESTORE_OLD_SEEKBAR_THUMBNAILS = new BooleanSetting("revanced_restore_old_seekbar_thumbnails", 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 SEEKBAR_CUSTOM_COLOR = new BooleanSetting("revanced_seekbar_custom_color", FALSE, true); + public static final StringSetting SEEKBAR_CUSTOM_COLOR_VALUE = new StringSetting("revanced_seekbar_custom_color_value", "#FF0000", true, parent(SEEKBAR_CUSTOM_COLOR)); + + // Misc + public static final BooleanSetting AUTO_CAPTIONS = new BooleanSetting("revanced_auto_captions", FALSE); + public static final BooleanSetting DISABLE_ZOOM_HAPTICS = new BooleanSetting("revanced_disable_zoom_haptics", TRUE); + public static final BooleanSetting EXTERNAL_BROWSER = new BooleanSetting("revanced_external_browser", TRUE, true); + public static final BooleanSetting AUTO_REPEAT = new BooleanSetting("revanced_auto_repeat", FALSE); + public static final BooleanSetting SPOOF_DEVICE_DIMENSIONS = new BooleanSetting("revanced_spoof_device_dimensions", FALSE, true, + "revanced_spoof_device_dimensions_user_dialog_message"); + public static final BooleanSetting BYPASS_URL_REDIRECTS = new BooleanSetting("revanced_bypass_url_redirects", TRUE); + public static final BooleanSetting ANNOUNCEMENTS = new BooleanSetting("revanced_announcements", TRUE); + public static final BooleanSetting SPOOF_VIDEO_STREAMS = new BooleanSetting("revanced_spoof_video_streams", TRUE, true,"revanced_spoof_video_streams_user_dialog_message"); + public static final BooleanSetting SPOOF_VIDEO_STREAMS_IOS_FORCE_AVC = new BooleanSetting("revanced_spoof_video_streams_ios_force_avc", FALSE, true, + "revanced_spoof_video_streams_ios_force_avc_user_dialog_message", new SpoofVideoStreamsPatch.ForceiOSAVCAvailability()); + public static final EnumSetting SPOOF_VIDEO_STREAMS_CLIENT_TYPE = new EnumSetting<>("revanced_spoof_video_streams_client", ClientType.ANDROID_VR, true, parent(SPOOF_VIDEO_STREAMS)); + @Deprecated + public static final StringSetting DEPRECATED_ANNOUNCEMENT_LAST_HASH = new StringSetting("revanced_announcement_last_hash", ""); + public static final IntegerSetting ANNOUNCEMENT_LAST_ID = new IntegerSetting("revanced_announcement_last_id", -1); + public static final BooleanSetting CHECK_WATCH_HISTORY_DOMAIN_NAME = new BooleanSetting("revanced_check_watch_history_domain_name", TRUE, false, false); + public static final BooleanSetting REMOVE_TRACKING_QUERY_PARAMETER = new BooleanSetting("revanced_remove_tracking_query_parameter", TRUE); + public static final IntegerSetting CHECK_ENVIRONMENT_WARNINGS_ISSUED = new IntegerSetting("revanced_check_environment_warnings_issued", 0, true, false); + + // Debugging + /** + * 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 DEBUG_PROTOBUFFER = new BooleanSetting("revanced_debug_protobuffer", FALSE, parent(BaseSettings.DEBUG)); + + // Old deprecated signature spoofing + @Deprecated public static final BooleanSetting SPOOF_SIGNATURE = new BooleanSetting("revanced_spoof_signature_verification_enabled", TRUE, true, false, + "revanced_spoof_signature_verification_enabled_user_dialog_message", null); + @Deprecated public static final BooleanSetting SPOOF_SIGNATURE_IN_FEED = new BooleanSetting("revanced_spoof_signature_in_feed_enabled", FALSE, false, false, null, + parent(SPOOF_SIGNATURE)); + @Deprecated public static final BooleanSetting SPOOF_STORYBOARD_RENDERER = new BooleanSetting("revanced_spoof_storyboard", TRUE, true, false, null, + parent(SPOOF_SIGNATURE)); + + // Swipe controls + public static final BooleanSetting SWIPE_BRIGHTNESS = new BooleanSetting("revanced_swipe_brightness", TRUE); + public static final BooleanSetting SWIPE_VOLUME = new BooleanSetting("revanced_swipe_volume", TRUE); + public static final BooleanSetting SWIPE_PRESS_TO_ENGAGE = new BooleanSetting("revanced_swipe_press_to_engage", FALSE, true, + parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME)); + public static final BooleanSetting SWIPE_HAPTIC_FEEDBACK = new BooleanSetting("revanced_swipe_haptic_feedback", TRUE, true, + parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME)); + public static final IntegerSetting SWIPE_MAGNITUDE_THRESHOLD = new IntegerSetting("revanced_swipe_threshold", 30, true, + parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME)); + public static final IntegerSetting SWIPE_OVERLAY_BACKGROUND_ALPHA = new IntegerSetting("revanced_swipe_overlay_background_alpha", 127, true, + parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME)); + public static final IntegerSetting SWIPE_OVERLAY_TEXT_SIZE = new IntegerSetting("revanced_swipe_text_overlay_size", 22, true, + parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME)); + public static final LongSetting SWIPE_OVERLAY_TIMEOUT = new LongSetting("revanced_swipe_overlay_timeout", 500L, true, + parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME)); + public static final BooleanSetting SWIPE_SAVE_AND_RESTORE_BRIGHTNESS = new BooleanSetting("revanced_swipe_save_and_restore_brightness", TRUE, true, parent(SWIPE_BRIGHTNESS)); + public static final FloatSetting SWIPE_BRIGHTNESS_VALUE = new FloatSetting("revanced_swipe_brightness_value", -1f); + public static final BooleanSetting SWIPE_LOWEST_VALUE_ENABLE_AUTO_BRIGHTNESS = new BooleanSetting("revanced_swipe_lowest_value_enable_auto_brightness", FALSE, true, parent(SWIPE_BRIGHTNESS)); + + // ReturnYoutubeDislike + public static final BooleanSetting RYD_ENABLED = new BooleanSetting("ryd_enabled", TRUE); + public static final StringSetting RYD_USER_ID = new StringSetting("ryd_user_id", "", false, false); + 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_TOAST_ON_CONNECTION_ERROR = new BooleanSetting("ryd_toast_on_connection_error", TRUE, parent(RYD_ENABLED)); + + // 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", ""); + @Deprecated + public static final StringSetting DEPRECATED_SB_UUID_OLD_MIGRATION_SETTING = new StringSetting("uuid", ""); // Delete sometime in 2024 + 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", TRUE, 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", TRUE, parent(SB_ENABLED)); + public static final StringSetting SB_API_URL = new StringSetting("sb_api_url","https://sponsor.ajay.app"); + 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 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); + + public static final StringSetting SB_CATEGORY_SPONSOR = new StringSetting("sb_sponsor", SKIP_AUTOMATICALLY_ONCE.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", MANUAL_SKIP.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", MANUAL_SKIP.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", MANUAL_SKIP.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", MANUAL_SKIP.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", IGNORE.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", IGNORE.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"); + + static { + // region Migration + + // Migrate settings from old Preference categories into replacement "revanced_prefs" category. + // This region must run before all other migration code. + + // The YT and RYD migration portion of this can be removed anytime, + // but the SB migration should remain until late 2024 or early 2025 + // because it migrates the SB private user id which cannot be recovered if lost. + + // 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); + } + } + + + // Do _not_ delete this SB private user id migration property until sometime in 2024. + // This is the only setting that cannot be reconfigured if lost, + // and more time should be given for users who rarely upgrade. + migrateOldSettingToNew(DEPRECATED_SB_UUID_OLD_MIGRATION_SETTING, SB_PRIVATE_USER_ID); + + + // Old spoof versions that no longer work reliably. + if (SpoofAppVersionPatch.isSpoofingToLessThan("17.33.00")) { + Logger.printInfo(() -> "Resetting spoof app version target"); + Settings.SPOOF_APP_VERSION_TARGET.resetToDefault(); + } + + + // Remove any previously saved announcement consumer (a random generated string). + Setting.preferences.removeKey("revanced_announcement_consumer"); + + migrateOldSettingToNew(HIDE_LOAD_MORE_BUTTON, HIDE_SHOW_MORE_BUTTON); + + migrateOldSettingToNew(HIDE_PLAYER_BUTTONS, HIDE_PLAYER_PREVIOUS_NEXT_BUTTONS); + + // endregion + } +} 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 000000000..5ca2e65dc --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/AlternativeThumbnailsAboutDeArrowPreference.java @@ -0,0 +1,35 @@ +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/ForceAVCSpoofingPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ForceAVCSpoofingPreference.java new file mode 100644 index 000000000..558175675 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ForceAVCSpoofingPreference.java @@ -0,0 +1,61 @@ +package app.revanced.extension.youtube.settings.preference; + +import static app.revanced.extension.shared.StringRef.str; +import static app.revanced.extension.youtube.patches.spoof.DeviceHardwareSupport.DEVICE_HAS_HARDWARE_DECODING_VP9; + +import android.content.Context; +import android.preference.SwitchPreference; +import android.util.AttributeSet; + +@SuppressWarnings({"unused", "deprecation"}) +public class ForceAVCSpoofingPreference extends SwitchPreference { + { + if (!DEVICE_HAS_HARDWARE_DECODING_VP9) { + setSummaryOn(str("revanced_spoof_video_streams_ios_force_avc_no_hardware_vp9_summary_on")); + } + } + + public ForceAVCSpoofingPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + public ForceAVCSpoofingPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + public ForceAVCSpoofingPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + public ForceAVCSpoofingPreference(Context context) { + super(context); + } + + private void updateUI() { + if (DEVICE_HAS_HARDWARE_DECODING_VP9) { + return; + } + + // Temporarily remove the preference key to allow changing this preference without + // causing the settings UI listeners from showing reboot dialogs by the changes made here. + String key = getKey(); + setKey(null); + + // This setting cannot be changed by the user. + super.setEnabled(false); + super.setChecked(true); + + setKey(key); + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + + updateUI(); + } + + @Override + public void setChecked(boolean checked) { + super.setChecked(checked); + + updateUI(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/HtmlPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/HtmlPreference.java new file mode 100644 index 000000000..bd9db08f5 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/HtmlPreference.java @@ -0,0 +1,35 @@ +package app.revanced.extension.youtube.settings.preference; + +import static android.text.Html.FROM_HTML_MODE_COMPACT; + +import android.content.Context; +import android.os.Build; +import android.preference.Preference; +import android.text.Html; +import android.util.AttributeSet; + +import androidx.annotation.RequiresApi; + +/** + * Allows using basic html for the summary text. + */ +@SuppressWarnings({"unused", "deprecation"}) +@RequiresApi(api = Build.VERSION_CODES.O) +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); + } +} 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 000000000..a22206f22 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedPreferenceFragment.java @@ -0,0 +1,36 @@ +package app.revanced.extension.youtube.settings.preference; + +import android.os.Build; +import android.preference.ListPreference; +import android.preference.Preference; + +import androidx.annotation.RequiresApi; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment; +import app.revanced.extension.youtube.patches.playback.speed.CustomPlaybackSpeedPatch; +import app.revanced.extension.youtube.settings.Settings; + +/** + * Preference fragment for ReVanced settings. + * + * @noinspection deprecation + */ +public class ReVancedPreferenceFragment extends AbstractPreferenceFragment { + + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + protected void initialize() { + super.initialize(); + + try { + // If the preference was included, then initialize it based on the available playback speed. + Preference defaultSpeedPreference = findPreference(Settings.PLAYBACK_SPEED_DEFAULT.key); + if (defaultSpeedPreference instanceof ListPreference) { + CustomPlaybackSpeedPatch.initializeListPreference((ListPreference) defaultSpeedPreference); + } + } catch (Exception ex) { + Logger.printException(() -> "initialize failure", ex); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedYouTubeAboutPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedYouTubeAboutPreference.java new file mode 100644 index 000000000..17b667e78 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedYouTubeAboutPreference.java @@ -0,0 +1,32 @@ +package app.revanced.extension.youtube.settings.preference; + +import android.content.Context; +import android.util.AttributeSet; + +import app.revanced.extension.shared.settings.preference.ReVancedAboutPreference; +import app.revanced.extension.youtube.ThemeHelper; + +@SuppressWarnings("unused") +public class ReVancedYouTubeAboutPreference extends ReVancedAboutPreference { + + public int getLightColor() { + return ThemeHelper.getLightThemeColor(); + } + + public int getDarkColor() { + return ThemeHelper.getDarkThemeColor(); + } + + public ReVancedYouTubeAboutPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + public ReVancedYouTubeAboutPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + public ReVancedYouTubeAboutPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + public ReVancedYouTubeAboutPreference(Context context) { + super(context); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReturnYouTubeDislikePreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReturnYouTubeDislikePreferenceFragment.java new file mode 100644 index 000000000..66bcf29e2 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReturnYouTubeDislikePreferenceFragment.java @@ -0,0 +1,237 @@ +package app.revanced.extension.youtube.settings.preference; + +import static app.revanced.extension.shared.StringRef.str; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.PreferenceCategory; +import android.preference.PreferenceFragment; +import android.preference.PreferenceManager; +import android.preference.PreferenceScreen; +import android.preference.SwitchPreference; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.youtube.patches.ReturnYouTubeDislikePatch; +import app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike; +import app.revanced.extension.youtube.returnyoutubedislike.requests.ReturnYouTubeDislikeApi; +import app.revanced.extension.youtube.settings.Settings; + +/** @noinspection deprecation*/ +public class ReturnYouTubeDislikePreferenceFragment extends PreferenceFragment { + + /** + * If dislikes are shown on Shorts. + */ + private SwitchPreference shortsPreference; + + /** + * If dislikes are shown as percentage. + */ + private SwitchPreference percentagePreference; + + /** + * If segmented like/dislike button uses smaller compact layout. + */ + private SwitchPreference compactLayoutPreference; + + /** + * If segmented like/dislike button uses smaller compact layout. + */ + private SwitchPreference toastOnRYDNotAvailable; + + private void updateUIState() { + shortsPreference.setEnabled(Settings.RYD_SHORTS.isAvailable()); + percentagePreference.setEnabled(Settings.RYD_DISLIKE_PERCENTAGE.isAvailable()); + compactLayoutPreference.setEnabled(Settings.RYD_COMPACT_LAYOUT.isAvailable()); + toastOnRYDNotAvailable.setEnabled(Settings.RYD_TOAST_ON_CONNECTION_ERROR.isAvailable()); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + try { + Activity context = getActivity(); + PreferenceManager manager = getPreferenceManager(); + manager.setSharedPreferencesName(Setting.preferences.name); + PreferenceScreen preferenceScreen = manager.createPreferenceScreen(context); + setPreferenceScreen(preferenceScreen); + + SwitchPreference enabledPreference = new SwitchPreference(context); + enabledPreference.setChecked(Settings.RYD_ENABLED.get()); + enabledPreference.setTitle(str("revanced_ryd_enable_title")); + enabledPreference.setSummaryOn(str("revanced_ryd_enable_summary_on")); + enabledPreference.setSummaryOff(str("revanced_ryd_enable_summary_off")); + enabledPreference.setOnPreferenceChangeListener((pref, newValue) -> { + final Boolean rydIsEnabled = (Boolean) newValue; + Settings.RYD_ENABLED.save(rydIsEnabled); + ReturnYouTubeDislikePatch.onRYDStatusChange(rydIsEnabled); + + updateUIState(); + return true; + }); + preferenceScreen.addPreference(enabledPreference); + + shortsPreference = new SwitchPreference(context); + shortsPreference.setChecked(Settings.RYD_SHORTS.get()); + shortsPreference.setTitle(str("revanced_ryd_shorts_title")); + 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); + shortsPreference.setSummaryOff(str("revanced_ryd_shorts_summary_off")); + shortsPreference.setOnPreferenceChangeListener((pref, newValue) -> { + Settings.RYD_SHORTS.save((Boolean) newValue); + updateUIState(); + return true; + }); + preferenceScreen.addPreference(shortsPreference); + + percentagePreference = new SwitchPreference(context); + percentagePreference.setChecked(Settings.RYD_DISLIKE_PERCENTAGE.get()); + percentagePreference.setTitle(str("revanced_ryd_dislike_percentage_title")); + percentagePreference.setSummaryOn(str("revanced_ryd_dislike_percentage_summary_on")); + percentagePreference.setSummaryOff(str("revanced_ryd_dislike_percentage_summary_off")); + percentagePreference.setOnPreferenceChangeListener((pref, newValue) -> { + Settings.RYD_DISLIKE_PERCENTAGE.save((Boolean) newValue); + ReturnYouTubeDislike.clearAllUICaches(); + updateUIState(); + return true; + }); + preferenceScreen.addPreference(percentagePreference); + + compactLayoutPreference = new SwitchPreference(context); + compactLayoutPreference.setChecked(Settings.RYD_COMPACT_LAYOUT.get()); + compactLayoutPreference.setTitle(str("revanced_ryd_compact_layout_title")); + compactLayoutPreference.setSummaryOn(str("revanced_ryd_compact_layout_summary_on")); + compactLayoutPreference.setSummaryOff(str("revanced_ryd_compact_layout_summary_off")); + compactLayoutPreference.setOnPreferenceChangeListener((pref, newValue) -> { + Settings.RYD_COMPACT_LAYOUT.save((Boolean) newValue); + ReturnYouTubeDislike.clearAllUICaches(); + updateUIState(); + return true; + }); + preferenceScreen.addPreference(compactLayoutPreference); + + toastOnRYDNotAvailable = new SwitchPreference(context); + toastOnRYDNotAvailable.setChecked(Settings.RYD_TOAST_ON_CONNECTION_ERROR.get()); + toastOnRYDNotAvailable.setTitle(str("revanced_ryd_toast_on_connection_error_title")); + toastOnRYDNotAvailable.setSummaryOn(str("revanced_ryd_toast_on_connection_error_summary_on")); + toastOnRYDNotAvailable.setSummaryOff(str("revanced_ryd_toast_on_connection_error_summary_off")); + toastOnRYDNotAvailable.setOnPreferenceChangeListener((pref, newValue) -> { + Settings.RYD_TOAST_ON_CONNECTION_ERROR.save((Boolean) newValue); + updateUIState(); + return true; + }); + preferenceScreen.addPreference(toastOnRYDNotAvailable); + + updateUIState(); + + + // About category + + PreferenceCategory aboutCategory = new PreferenceCategory(context); + aboutCategory.setTitle(str("revanced_ryd_about")); + preferenceScreen.addPreference(aboutCategory); + + // ReturnYouTubeDislike Website + + Preference aboutWebsitePreference = new Preference(context); + aboutWebsitePreference.setTitle(str("revanced_ryd_attribution_title")); + aboutWebsitePreference.setSummary(str("revanced_ryd_attribution_summary")); + aboutWebsitePreference.setOnPreferenceClickListener(pref -> { + Intent i = new Intent(Intent.ACTION_VIEW); + i.setData(Uri.parse("https://returnyoutubedislike.com")); + pref.getContext().startActivity(i); + return false; + }); + aboutCategory.addPreference(aboutWebsitePreference); + + // RYD API connection statistics + + if (BaseSettings.DEBUG.get()) { + PreferenceCategory emptyCategory = new PreferenceCategory(context); // vertical padding + preferenceScreen.addPreference(emptyCategory); + + PreferenceCategory statisticsCategory = new PreferenceCategory(context); + statisticsCategory.setTitle(str("revanced_ryd_statistics_category_title")); + preferenceScreen.addPreference(statisticsCategory); + + Preference statisticPreference; + + statisticPreference = new Preference(context); + statisticPreference.setSelectable(false); + statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallResponseTimeAverage_title")); + statisticPreference.setSummary(createMillisecondStringFromNumber(ReturnYouTubeDislikeApi.getFetchCallResponseTimeAverage())); + preferenceScreen.addPreference(statisticPreference); + + statisticPreference = new Preference(context); + statisticPreference.setSelectable(false); + statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallResponseTimeMin_title")); + statisticPreference.setSummary(createMillisecondStringFromNumber(ReturnYouTubeDislikeApi.getFetchCallResponseTimeMin())); + preferenceScreen.addPreference(statisticPreference); + + statisticPreference = new Preference(context); + statisticPreference.setSelectable(false); + statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallResponseTimeMax_title")); + statisticPreference.setSummary(createMillisecondStringFromNumber(ReturnYouTubeDislikeApi.getFetchCallResponseTimeMax())); + preferenceScreen.addPreference(statisticPreference); + + String fetchCallTimeWaitingLastSummary; + final long fetchCallTimeWaitingLast = ReturnYouTubeDislikeApi.getFetchCallResponseTimeLast(); + if (fetchCallTimeWaitingLast == ReturnYouTubeDislikeApi.FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT) { + fetchCallTimeWaitingLastSummary = str("revanced_ryd_statistics_getFetchCallResponseTimeLast_rate_limit_summary"); + } else { + fetchCallTimeWaitingLastSummary = createMillisecondStringFromNumber(fetchCallTimeWaitingLast); + } + statisticPreference = new Preference(context); + statisticPreference.setSelectable(false); + statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallResponseTimeLast_title")); + statisticPreference.setSummary(fetchCallTimeWaitingLastSummary); + preferenceScreen.addPreference(statisticPreference); + + statisticPreference = new Preference(context); + statisticPreference.setSelectable(false); + statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallCount_title")); + statisticPreference.setSummary(createSummaryText(ReturnYouTubeDislikeApi.getFetchCallCount(), + "revanced_ryd_statistics_getFetchCallCount_zero_summary", + "revanced_ryd_statistics_getFetchCallCount_non_zero_summary")); + preferenceScreen.addPreference(statisticPreference); + + statisticPreference = new Preference(context); + statisticPreference.setSelectable(false); + statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallNumberOfFailures_title")); + statisticPreference.setSummary(createSummaryText(ReturnYouTubeDislikeApi.getFetchCallNumberOfFailures(), + "revanced_ryd_statistics_getFetchCallNumberOfFailures_zero_summary", + "revanced_ryd_statistics_getFetchCallNumberOfFailures_non_zero_summary")); + preferenceScreen.addPreference(statisticPreference); + + statisticPreference = new Preference(context); + statisticPreference.setSelectable(false); + statisticPreference.setTitle(str("revanced_ryd_statistics_getNumberOfRateLimitRequestsEncountered_title")); + statisticPreference.setSummary(createSummaryText(ReturnYouTubeDislikeApi.getNumberOfRateLimitRequestsEncountered(), + "revanced_ryd_statistics_getNumberOfRateLimitRequestsEncountered_zero_summary", + "revanced_ryd_statistics_getNumberOfRateLimitRequestsEncountered_non_zero_summary")); + preferenceScreen.addPreference(statisticPreference); + } + } catch (Exception ex) { + Logger.printException(() -> "onCreate failure", ex); + } + } + + private static String createSummaryText(int value, String summaryStringZeroKey, String summaryStringOneOrMoreKey) { + if (value == 0) { + return str(summaryStringZeroKey); + } + return String.format(str(summaryStringOneOrMoreKey), value); + } + + private static String createMillisecondStringFromNumber(long number) { + return String.format(str("revanced_ryd_statistics_millisecond_text"), number); + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockPreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockPreferenceFragment.java new file mode 100644 index 000000000..9fa4a942a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockPreferenceFragment.java @@ -0,0 +1,602 @@ +package app.revanced.extension.youtube.settings.preference; + +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.os.Build; +import android.os.Bundle; +import android.preference.*; +import android.text.Html; +import android.text.InputType; +import android.util.TypedValue; +import android.widget.EditText; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.shared.settings.Setting; +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.SegmentCategoryListPreference; +import app.revanced.extension.youtube.sponsorblock.objects.UserStats; +import app.revanced.extension.youtube.sponsorblock.requests.SBRequester; +import app.revanced.extension.youtube.sponsorblock.ui.SponsorBlockViewController; + +import static android.text.Html.fromHtml; +import static app.revanced.extension.shared.StringRef.str; + +@SuppressWarnings("deprecation") +public class SponsorBlockPreferenceFragment extends PreferenceFragment { + + private SwitchPreference sbEnabled; + private SwitchPreference addNewSegment; + private SwitchPreference votingEnabled; + private SwitchPreference compactSkipButton; + private SwitchPreference autoHideSkipSegmentButton; + private SwitchPreference showSkipToast; + private SwitchPreference trackSkips; + private SwitchPreference showTimeWithoutSegments; + private SwitchPreference toastOnConnectionError; + + private EditTextPreference newSegmentStep; + private EditTextPreference minSegmentDuration; + private EditTextPreference privateUserId; + private EditTextPreference importExport; + private Preference apiUrl; + + private PreferenceCategory statsCategory; + private PreferenceCategory segmentCategory; + + private void updateUI() { + try { + final boolean enabled = Settings.SB_ENABLED.get(); + if (!enabled) { + SponsorBlockViewController.hideAll(); + SegmentPlaybackController.setCurrentVideoId(null); + } else if (!Settings.SB_CREATE_NEW_SEGMENT.get()) { + SponsorBlockViewController.hideNewSegmentLayout(); + } + // Voting and add new segment buttons automatically shows/hide themselves. + + sbEnabled.setChecked(enabled); + + addNewSegment.setChecked(Settings.SB_CREATE_NEW_SEGMENT.get()); + addNewSegment.setEnabled(enabled); + + votingEnabled.setChecked(Settings.SB_VOTING_BUTTON.get()); + votingEnabled.setEnabled(enabled); + + compactSkipButton.setChecked(Settings.SB_COMPACT_SKIP_BUTTON.get()); + compactSkipButton.setEnabled(enabled); + + autoHideSkipSegmentButton.setChecked(Settings.SB_AUTO_HIDE_SKIP_BUTTON.get()); + autoHideSkipSegmentButton.setEnabled(enabled); + + showSkipToast.setChecked(Settings.SB_TOAST_ON_SKIP.get()); + showSkipToast.setEnabled(enabled); + + toastOnConnectionError.setChecked(Settings.SB_TOAST_ON_CONNECTION_ERROR.get()); + toastOnConnectionError.setEnabled(enabled); + + trackSkips.setChecked(Settings.SB_TRACK_SKIP_COUNT.get()); + trackSkips.setEnabled(enabled); + + showTimeWithoutSegments.setChecked(Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.get()); + showTimeWithoutSegments.setEnabled(enabled); + + newSegmentStep.setText((Settings.SB_CREATE_NEW_SEGMENT_STEP.get()).toString()); + newSegmentStep.setEnabled(enabled); + + minSegmentDuration.setText((Settings.SB_SEGMENT_MIN_DURATION.get()).toString()); + minSegmentDuration.setEnabled(enabled); + + privateUserId.setText(Settings.SB_PRIVATE_USER_ID.get()); + privateUserId.setEnabled(enabled); + + // 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"); + importExport.setSummary(importExportSummary); + + apiUrl.setEnabled(enabled); + importExport.setEnabled(enabled); + segmentCategory.setEnabled(enabled); + statsCategory.setEnabled(enabled); + } catch (Exception ex) { + Logger.printException(() -> "update settings UI failure", ex); + } + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + try { + Activity context = getActivity(); + PreferenceManager manager = getPreferenceManager(); + manager.setSharedPreferencesName(Setting.preferences.name); + PreferenceScreen preferenceScreen = manager.createPreferenceScreen(context); + setPreferenceScreen(preferenceScreen); + + SponsorBlockSettings.initialize(); + + sbEnabled = new SwitchPreference(context); + sbEnabled.setTitle(str("revanced_sb_enable_sb")); + sbEnabled.setSummary(str("revanced_sb_enable_sb_sum")); + preferenceScreen.addPreference(sbEnabled); + sbEnabled.setOnPreferenceChangeListener((preference1, newValue) -> { + Settings.SB_ENABLED.save((Boolean) newValue); + updateUI(); + return true; + }); + + addAppearanceCategory(context, preferenceScreen); + + segmentCategory = new PreferenceCategory(context); + segmentCategory.setTitle(str("revanced_sb_diff_segments")); + preferenceScreen.addPreference(segmentCategory); + updateSegmentCategories(); + + addCreateSegmentCategory(context, preferenceScreen); + + addGeneralCategory(context, preferenceScreen); + + statsCategory = new PreferenceCategory(context); + statsCategory.setTitle(str("revanced_sb_stats")); + preferenceScreen.addPreference(statsCategory); + fetchAndDisplayStats(); + + addAboutCategory(context, preferenceScreen); + + updateUI(); + } catch (Exception ex) { + Logger.printException(() -> "onCreate failure", ex); + } + } + + private void addAppearanceCategory(Context context, PreferenceScreen screen) { + PreferenceCategory category = new PreferenceCategory(context); + screen.addPreference(category); + category.setTitle(str("revanced_sb_appearance_category")); + + votingEnabled = new SwitchPreference(context); + votingEnabled.setTitle(str("revanced_sb_enable_voting")); + votingEnabled.setSummaryOn(str("revanced_sb_enable_voting_sum_on")); + votingEnabled.setSummaryOff(str("revanced_sb_enable_voting_sum_off")); + category.addPreference(votingEnabled); + votingEnabled.setOnPreferenceChangeListener((preference1, newValue) -> { + Settings.SB_VOTING_BUTTON.save((Boolean) newValue); + updateUI(); + return true; + }); + + compactSkipButton = new SwitchPreference(context); + compactSkipButton.setTitle(str("revanced_sb_enable_compact_skip_button")); + compactSkipButton.setSummaryOn(str("revanced_sb_enable_compact_skip_button_sum_on")); + compactSkipButton.setSummaryOff(str("revanced_sb_enable_compact_skip_button_sum_off")); + category.addPreference(compactSkipButton); + compactSkipButton.setOnPreferenceChangeListener((preference1, newValue) -> { + Settings.SB_COMPACT_SKIP_BUTTON.save((Boolean) newValue); + updateUI(); + return true; + }); + + autoHideSkipSegmentButton = new SwitchPreference(context); + autoHideSkipSegmentButton.setTitle(str("revanced_sb_enable_auto_hide_skip_segment_button")); + autoHideSkipSegmentButton.setSummaryOn(str("revanced_sb_enable_auto_hide_skip_segment_button_sum_on")); + autoHideSkipSegmentButton.setSummaryOff(str("revanced_sb_enable_auto_hide_skip_segment_button_sum_off")); + category.addPreference(autoHideSkipSegmentButton); + autoHideSkipSegmentButton.setOnPreferenceChangeListener((preference1, newValue) -> { + Settings.SB_AUTO_HIDE_SKIP_BUTTON.save((Boolean) newValue); + updateUI(); + return true; + }); + + showSkipToast = new SwitchPreference(context); + showSkipToast.setTitle(str("revanced_sb_general_skiptoast")); + showSkipToast.setSummaryOn(str("revanced_sb_general_skiptoast_sum_on")); + showSkipToast.setSummaryOff(str("revanced_sb_general_skiptoast_sum_off")); + showSkipToast.setOnPreferenceClickListener(preference1 -> { + Utils.showToastShort(str("revanced_sb_skipped_sponsor")); + return false; + }); + showSkipToast.setOnPreferenceChangeListener((preference1, newValue) -> { + Settings.SB_TOAST_ON_SKIP.save((Boolean) newValue); + updateUI(); + return true; + }); + category.addPreference(showSkipToast); + + showTimeWithoutSegments = new SwitchPreference(context); + showTimeWithoutSegments.setTitle(str("revanced_sb_general_time_without")); + showTimeWithoutSegments.setSummaryOn(str("revanced_sb_general_time_without_sum_on")); + showTimeWithoutSegments.setSummaryOff(str("revanced_sb_general_time_without_sum_off")); + showTimeWithoutSegments.setOnPreferenceChangeListener((preference1, newValue) -> { + Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.save((Boolean) newValue); + updateUI(); + return true; + }); + category.addPreference(showTimeWithoutSegments); + } + + private void addCreateSegmentCategory(Context context, PreferenceScreen screen) { + PreferenceCategory category = new PreferenceCategory(context); + screen.addPreference(category); + category.setTitle(str("revanced_sb_create_segment_category")); + + addNewSegment = new SwitchPreference(context); + addNewSegment.setTitle(str("revanced_sb_enable_create_segment")); + addNewSegment.setSummaryOn(str("revanced_sb_enable_create_segment_sum_on")); + addNewSegment.setSummaryOff(str("revanced_sb_enable_create_segment_sum_off")); + category.addPreference(addNewSegment); + addNewSegment.setOnPreferenceChangeListener((preference1, o) -> { + Boolean newValue = (Boolean) o; + if (newValue && !Settings.SB_SEEN_GUIDELINES.get()) { + new AlertDialog.Builder(preference1.getContext()) + .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()) + .setOnDismissListener(dialog -> Settings.SB_SEEN_GUIDELINES.save(true)) + .setCancelable(false) + .show(); + } + Settings.SB_CREATE_NEW_SEGMENT.save(newValue); + updateUI(); + return true; + }); + + newSegmentStep = new EditTextPreference(context); + newSegmentStep.setTitle(str("revanced_sb_general_adjusting")); + newSegmentStep.setSummary(str("revanced_sb_general_adjusting_sum")); + newSegmentStep.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER); + newSegmentStep.setOnPreferenceChangeListener((preference1, 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; + }); + category.addPreference(newSegmentStep); + + Preference guidelinePreferences = new Preference(context); + guidelinePreferences.setTitle(str("revanced_sb_guidelines_preference_title")); + guidelinePreferences.setSummary(str("revanced_sb_guidelines_preference_sum")); + guidelinePreferences.setOnPreferenceClickListener(preference1 -> { + openGuidelines(); + return true; + }); + category.addPreference(guidelinePreferences); + } + + private void addGeneralCategory(final Context context, PreferenceScreen screen) { + PreferenceCategory category = new PreferenceCategory(context); + screen.addPreference(category); + category.setTitle(str("revanced_sb_general")); + + toastOnConnectionError = new SwitchPreference(context); + toastOnConnectionError.setTitle(str("revanced_sb_toast_on_connection_error_title")); + toastOnConnectionError.setSummaryOn(str("revanced_sb_toast_on_connection_error_summary_on")); + toastOnConnectionError.setSummaryOff(str("revanced_sb_toast_on_connection_error_summary_off")); + toastOnConnectionError.setOnPreferenceChangeListener((preference1, newValue) -> { + Settings.SB_TOAST_ON_CONNECTION_ERROR.save((Boolean) newValue); + updateUI(); + return true; + }); + category.addPreference(toastOnConnectionError); + + trackSkips = new SwitchPreference(context); + trackSkips.setTitle(str("revanced_sb_general_skipcount")); + trackSkips.setSummaryOn(str("revanced_sb_general_skipcount_sum_on")); + trackSkips.setSummaryOff(str("revanced_sb_general_skipcount_sum_off")); + trackSkips.setOnPreferenceChangeListener((preference1, newValue) -> { + Settings.SB_TRACK_SKIP_COUNT.save((Boolean) newValue); + updateUI(); + return true; + }); + category.addPreference(trackSkips); + + minSegmentDuration = new EditTextPreference(context); + minSegmentDuration.setTitle(str("revanced_sb_general_min_duration")); + minSegmentDuration.setSummary(str("revanced_sb_general_min_duration_sum")); + minSegmentDuration.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL); + minSegmentDuration.setOnPreferenceChangeListener((preference1, 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; + }); + category.addPreference(minSegmentDuration); + + privateUserId = new EditTextPreference(context); + privateUserId.setTitle(str("revanced_sb_general_uuid")); + privateUserId.setSummary(str("revanced_sb_general_uuid_sum")); + privateUserId.setOnPreferenceChangeListener((preference1, 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); + updateUI(); + fetchAndDisplayStats(); + return true; + }); + category.addPreference(privateUserId); + + apiUrl = new Preference(context); + apiUrl.setTitle(str("revanced_sb_general_api_url")); + apiUrl.setSummary(Html.fromHtml(str("revanced_sb_general_api_url_sum"))); + apiUrl.setOnPreferenceClickListener(preference1 -> { + EditText editText = new EditText(context); + editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); + editText.setText(Settings.SB_API_URL.get()); + + 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")); + } + } + }; + new AlertDialog.Builder(context) + .setTitle(apiUrl.getTitle()) + .setView(editText) + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(str("revanced_sb_reset"), urlChangeListener) + .setPositiveButton(android.R.string.ok, urlChangeListener) + .show(); + return true; + }); + category.addPreference(apiUrl); + + importExport = new EditTextPreference(context) { + protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { + Utils.setEditTextDialogTheme(builder); + + builder.setNeutralButton(str("revanced_sb_settings_copy"), (dialog, which) -> { + Utils.setClipboard(getEditText().getText().toString()); + }); + } + }; + importExport.setTitle(str("revanced_sb_settings_ie")); + // Summary is set in updateUI() + importExport.getEditText().setInputType(InputType.TYPE_CLASS_TEXT + | InputType.TYPE_TEXT_FLAG_MULTI_LINE + | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + importExport.getEditText().setAutofillHints((String) null); + } + importExport.getEditText().setTextSize(TypedValue.COMPLEX_UNIT_PT, 8); + importExport.setOnPreferenceClickListener(preference1 -> { + importExport.getEditText().setText(SponsorBlockSettings.exportDesktopSettings()); + return true; + }); + importExport.setOnPreferenceChangeListener((preference1, newValue) -> { + SponsorBlockSettings.importDesktopSettings((String) newValue); + updateSegmentCategories(); + fetchAndDisplayStats(); + updateUI(); + return true; + }); + category.addPreference(importExport); + } + + private void updateSegmentCategories() { + try { + segmentCategory.removeAll(); + + Activity activity = getActivity(); + for (SegmentCategory category : SegmentCategory.categoriesWithoutUnsubmitted()) { + segmentCategory.addPreference(new SegmentCategoryListPreference(activity, category)); + } + } catch (Exception ex) { + Logger.printException(() -> "updateSegmentCategories failure", ex); + } + } + + private void addAboutCategory(Context context, PreferenceScreen screen) { + PreferenceCategory category = new PreferenceCategory(context); + screen.addPreference(category); + category.setTitle(str("revanced_sb_about")); + + { + Preference preference = new Preference(context); + category.addPreference(preference); + preference.setTitle(str("revanced_sb_about_api")); + preference.setSummary(str("revanced_sb_about_api_sum")); + preference.setOnPreferenceClickListener(preference1 -> { + Intent i = new Intent(Intent.ACTION_VIEW); + i.setData(Uri.parse("https://sponsor.ajay.app")); + preference1.getContext().startActivity(i); + return false; + }); + } + } + + private void openGuidelines() { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse("https://wiki.sponsor.ajay.app/w/Guidelines")); + getActivity().startActivity(intent); + } + + private void fetchAndDisplayStats() { + try { + statsCategory.removeAll(); + if (!SponsorBlockSettings.userHasSBPrivateId()) { + // User has never voted or created any segments. No stats to show. + addLocalUserStats(); + return; + } + + Preference loadingPlaceholderPreference = new Preference(this.getActivity()); + 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 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. + EditTextPreference preference = new EditTextPreference(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 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/shared/NavigationBar.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/NavigationBar.java new file mode 100644 index 000000000..960df3bf0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/NavigationBar.java @@ -0,0 +1,309 @@ +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.lang.ref.WeakReference; +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.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public final class NavigationBar { + + // + // Search bar + // + + 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 PlayerType#isMaximizedOrFullscreen()}. + */ + public static boolean isSearchBarActive() { + View searchbarResults = searchBarResultsRef.get(); + return searchbarResults != null && searchbarResults.getParent() != null; + } + + // + // Navigation bar buttons + // + + /** + * 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.DEBUG.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 (BaseSettings.DEBUG.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/PlayerControlsVisibilityObserver.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerControlsVisibilityObserver.kt new file mode 100644 index 000000000..26745755d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerControlsVisibilityObserver.kt @@ -0,0 +1,84 @@ +package app.revanced.extension.youtube.shared + +import android.app.Activity +import android.view.View +import android.view.ViewGroup +import app.revanced.extension.shared.Utils +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.youtube_controls_overlay + */ + private val controlsLayoutParentId = + Utils.getResourceIdentifier(activity, "youtube_controls_overlay", "id") + + /** + * id of R.id.controls_layout + */ + private val controlsLayoutId = + Utils.getResourceIdentifier(activity, "controls_layout", "id") + + /** + * 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 +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerOverlays.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerOverlays.kt new file mode 100644 index 000000000..ec82053fa --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerOverlays.kt @@ -0,0 +1,97 @@ +package app.revanced.extension.youtube.shared + +import android.view.View +import android.view.ViewGroup +import app.revanced.extension.youtube.Event +import app.revanced.extension.youtube.swipecontrols.misc.Rectangle + +/** + * hooking class for player overlays + */ +@Suppress("MemberVisibilityCanBePrivate") +object PlayerOverlays { + + /** + * called when the overlays finished inflating + */ + val onInflate = Event() + + /** + * called when new children are added or removed from the overlay + */ + val onChildrenChange = Event() + + /** + * called when the overlay layout changes + */ + val onLayoutChange = Event() + + /** + * start listening for events on the provided view group + * + * @param overlaysLayout the overlays view group + */ + @JvmStatic + fun attach(overlaysLayout: ViewGroup) { + onInflate.invoke(overlaysLayout) + overlaysLayout.setOnHierarchyChangeListener(object : + ViewGroup.OnHierarchyChangeListener { + override fun onChildViewAdded(parent: View?, child: View?) { + if (parent is ViewGroup && child is View) { + onChildrenChange( + ChildrenChangeEventArgs( + parent, + child, + false, + ), + ) + } + } + + override fun onChildViewRemoved(parent: View?, child: View?) { + if (parent is ViewGroup && child is View) { + onChildrenChange( + ChildrenChangeEventArgs( + parent, + child, + true, + ), + ) + } + } + }) + overlaysLayout.addOnLayoutChangeListener { view, newLeft, newTop, newRight, newBottom, oldLeft, oldTop, oldRight, oldBottom -> + if (view is ViewGroup) { + onLayoutChange( + LayoutChangeEventArgs( + view, + Rectangle( + oldLeft, + oldTop, + oldRight - oldLeft, + oldBottom - oldTop, + ), + Rectangle( + newLeft, + newTop, + newRight - newLeft, + newBottom - newTop, + ), + ), + ) + } + } + } +} + +data class ChildrenChangeEventArgs( + val overlaysLayout: ViewGroup, + val childView: View, + val wasChildRemoved: Boolean, +) + +data class LayoutChangeEventArgs( + val overlaysLayout: ViewGroup, + val oldRect: Rectangle, + val newRect: Rectangle, +) 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 000000000..dc3fd8ca2 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerType.kt @@ -0,0 +1,139 @@ +package app.revanced.extension.youtube.shared + +import app.revanced.extension.shared.Logger +import app.revanced.extension.youtube.Event +import app.revanced.extension.youtube.patches.VideoInformation + +/** + * Main 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 = 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 = 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]. + * + * @see VideoInformation + */ + 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. + * @see VideoInformation + */ + 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). + * @see VideoInformation + */ + fun isNoneHiddenOrMinimized(): Boolean { + return isNoneHiddenOrSlidingMinimized() || this == WATCH_WHILE_MINIMIZED + } + + fun isMaximizedOrFullscreen(): Boolean { + return this == WATCH_WHILE_MAXIMIZED || this == WATCH_WHILE_FULLSCREEN + } +} 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 000000000..e01cb0249 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/VideoState.kt @@ -0,0 +1,51 @@ +package app.revanced.extension.youtube.shared + +import app.revanced.extension.shared.Logger +import app.revanced.extension.youtube.patches.VideoInformation + +/** + * VideoState playback state. + */ +enum class VideoState { + NEW, + PLAYING, + PAUSED, + RECOVERABLE_ERROR, + UNRECOVERABLE_ERROR, + + /** + * @see [VideoInformation.isAtEndOfVideo] + */ + ENDED, + + ; + + companion object { + + private val nameToVideoState = values().associateBy { it.name } + + @JvmStatic + fun setFromString(enumName: String) { + val state = nameToVideoState[enumName] + if (state == null) { + Logger.printException { "Unknown VideoState encountered: $enumName" } + } else if (currentVideoState != state) { + Logger.printDebug { "VideoState changed to: $state" } + currentVideoState = 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: VideoState? + get() = currentVideoState + private set(value) { + currentVideoState = value + } + + private var currentVideoState: VideoState? = null + } +} 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 000000000..3f48930e3 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SegmentPlaybackController.java @@ -0,0 +1,771 @@ +package app.revanced.extension.youtube.sponsorblock; + +import static app.revanced.extension.shared.StringRef.str; + +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.*; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.patches.VideoInformation; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.PlayerType; +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; + +/** + * 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. + */ +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; + + @Nullable + private static String currentVideoId; + @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; + + /** + * 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<>(); + + /** + * 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; + + @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. + */ + private static void clearData() { + currentVideoId = null; + 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(VideoInformation.PlaybackController ignoredPlayerController) { + 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 setCurrentVideoId(@Nullable String videoId) { + try { + if (Objects.equals(currentVideoId, videoId)) { + return; + } + clearData(); + if (videoId == null || !Settings.SB_ENABLED.get()) { + return; + } + if (PlayerType.getCurrent().isNoneOrHidden()) { + Logger.printDebug(() -> "ignoring Short"); + return; + } + if (!Utils.isNetworkConnected()) { + 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); + + 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 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() + || PlayerType.getCurrent().isNoneOrHidden() // Shorts playback. + || segments == null || segments.length == 0) { + return; + } + Logger.printDebug(() -> "setVideoTime: " + 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 * 1200); + 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 SponsorSegment lastSegmentSkipped; + private static long lastSegmentSkippedTime; + + 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); + 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 int toastNumberOfSegmentsSkipped; + @Nullable + private static SponsorSegment toastSegmentSkipped; + + 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 + */ + @SuppressWarnings("unused") + 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) { + 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 + */ + @SuppressWarnings("unused") + public static void setSponsorBarThickness(int thickness) { + sponsorBarThickness = thickness; + } + + /** + * Injection point. + */ + @SuppressWarnings("unused") + 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; + } + + private static void calculateTimeWithoutSegments() { + final long currentVideoLength = VideoInformation.getVideoLength(); + if (!Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.get() || currentVideoLength <= 0 + || segments == null || segments.length == 0) { + timeWithoutSegments = null; + return; + } + + boolean foundNonhighlightSegments = false; + long timeWithoutSegmentsValue = currentVideoLength; + + 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 highlightSegmentTimeBarScreenWidth = -1; // actual pixel width to use + 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. + */ + @SuppressWarnings("unused") + 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; + 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 000000000..0edc054c7 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockSettings.java @@ -0,0 +1,244 @@ +package app.revanced.extension.youtube.sponsorblock; + +import static app.revanced.extension.shared.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.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.youtube.settings.Settings; +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(); + } +} 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 000000000..d3f851f6e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockUtils.java @@ -0,0 +1,503 @@ +package app.revanced.extension.youtube.sponsorblock; + +import static app.revanced.extension.shared.StringRef.str; + +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.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.patches.VideoInformation; +import app.revanced.extension.youtube.settings.Settings; +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. + */ +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) { + case DialogInterface.BUTTON_NEGATIVE: + // start + newSponsorSegmentStartMillis = newSponsorSegmentDialogShownMillis; + break; + case DialogInterface.BUTTON_POSITIVE: + // end + newSponsorSegmentEndMillis = newSponsorSegmentDialogShownMillis; + break; + } + 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: + case DOWNVOTE: + SBRequester.voteForSegmentOnBackgroundThread(segment, voteOption); + break; + case CATEGORY_CHANGE: + onNewCategorySelect(segment, context); + break; + } + }) + .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 = VideoInformation.getVideoId(); + final long videoLength = VideoInformation.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_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_content", + 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); + } + } 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 = VideoInformation.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); + } + + public static String getTimeSavedString(long totalSecondsSaved) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + 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); + } + return "error"; // will never be reached. YouTube requires Android O or greater + } + + 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); + } + } + } +} 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 000000000..7cd4a44c5 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/CategoryBehaviour.java @@ -0,0 +1,122 @@ +package app.revanced.extension.youtube.sponsorblock.objects; + +import static app.revanced.extension.shared.StringRef.sf; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Objects; + +import app.revanced.extension.shared.Utils; +import app.revanced.extension.shared.StringRef; + +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++; + } + } + } + + static String[] getBehaviorKeyValues() { + if (behaviorKeyValues == null) { + createNameAndKeyArrays(); + } + return behaviorKeyValues; + } + static String[] getBehaviorKeyValuesWithoutSkipOnce() { + if (behaviorKeyValuesWithoutSkipOnce == null) { + createNameAndKeyArrays(); + } + return behaviorKeyValuesWithoutSkipOnce; + } + + static String[] getBehaviorDescriptions() { + if (behaviorDescriptions == null) { + createNameAndKeyArrays(); + } + return behaviorDescriptions; + } + 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 000000000..7518b2cc3 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategory.java @@ -0,0 +1,332 @@ +package app.revanced.extension.youtube.sponsorblock.objects; + +import static app.revanced.extension.youtube.settings.Settings.*; +import static app.revanced.extension.shared.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.Utils; +import app.revanced.extension.shared.settings.StringSetting; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.StringRef; + +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), + /** + * Unique category that is treated differently than the rest. + */ + HIGHLIGHT("poi_highlight", sf("revanced_sb_segments_highlight"), sf("revanced_sb_segments_highlight_sum"), 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_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), + UNSUBMITTED("unsubmitted", StringRef.empty, 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 + 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.IGNORE; + + 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); + } + + @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/SegmentCategoryListPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategoryListPreference.java new file mode 100644 index 000000000..a0410f098 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategoryListPreference.java @@ -0,0 +1,155 @@ +package app.revanced.extension.youtube.sponsorblock.objects; + +import static app.revanced.extension.shared.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.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.Logger; +import app.revanced.extension.shared.Utils; + +@SuppressWarnings("deprecation") +public class SegmentCategoryListPreference extends ListPreference { + private final SegmentCategory category; + private EditText mEditText; + private int mClickedDialogEntryIndex; + + public SegmentCategoryListPreference(Context context, SegmentCategory category) { + super(context); + final boolean isHighlightCategory = category == SegmentCategory.HIGHLIGHT; + this.category = Objects.requireNonNull(category); + setKey(category.keyValue); + setDefaultValue(category.behaviour.reVancedKeyValue); + setEntries(isHighlightCategory + ? CategoryBehaviour.getBehaviorDescriptionsWithoutSkipOnce() + : CategoryBehaviour.getBehaviorDescriptions()); + setEntryValues(isHighlightCategory + ? CategoryBehaviour.getBehaviorKeyValuesWithoutSkipOnce() + : CategoryBehaviour.getBehaviorKeyValues()); + setSummary(category.description.toString()); + updateTitle(); + } + + @Override + protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { + try { + Utils.setEditTextDialogTheme(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(category.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(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) -> { + onClick(dialog, DialogInterface.BUTTON_POSITIVE); + }); + builder.setNeutralButton(str("revanced_sb_reset_color"), (dialog, which) -> { + try { + category.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); + category.setBehaviour(Objects.requireNonNull(CategoryBehaviour.byReVancedKeyValue(value))); + 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")); + } + updateTitle(); + } + } catch (Exception ex) { + Logger.printException(() -> "onDialogClosed failure", ex); + } + } + + private void updateTitle() { + setTitle(category.getTitleWithColorDot()); + } +} \ No newline at end of file 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 000000000..811cb87c4 --- /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 androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import app.revanced.extension.youtube.patches.VideoInformation; +import app.revanced.extension.shared.StringRef; + +import java.util.Objects; + +import static app.revanced.extension.shared.StringRef.sf; + +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, VideoInformation.getVideoLength()).toString(); + } + + /** + * @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)) return false; + SponsorSegment other = (SponsorSegment) o; + 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 000000000..4889c7671 --- /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 000000000..7cb9122d9 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/requests/SBRequester.java @@ -0,0 +1,315 @@ +package app.revanced.extension.youtube.sponsorblock.requests; + +import static app.revanced.extension.shared.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.youtube.requests.Requester; +import app.revanced.extension.youtube.requests.Route; +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; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; + +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); + } + + // Crude debug tests to verify random features + // Could benefit from: + // 1) collection of YouTube videos with test segment times (verify client skip timing matches the video, verify seekbar draws correctly) + // 2) unit tests (verify everything else) + if (false) { + segments.clear(); + // Test auto-hide skip button: + // Button should appear only once + segments.add(new SponsorSegment(SegmentCategory.INTRO, "debug", 5000, 120000, false)); + // Button should appear only once + segments.add(new SponsorSegment(SegmentCategory.SELF_PROMO, "debug", 10000, 60000, false)); + // Button should appear only once + segments.add(new SponsorSegment(SegmentCategory.INTERACTION, "debug", 15000, 20000, false)); + // Button should appear _twice_ (at 21s and 27s) + segments.add(new SponsorSegment(SegmentCategory.SPONSOR, "debug", 21000, 30000, false)); + // Button should appear only once + segments.add(new SponsorSegment(SegmentCategory.OUTRO, "debug", 24000, 27000, false)); + + + // Test seekbar visibility: + // All three segments should be viewable on the seekbar + segments.add(new SponsorSegment(SegmentCategory.MUSIC_OFFTOPIC, "debug", 200000, 300000, false)); + segments.add(new SponsorSegment(SegmentCategory.SPONSOR, "debug", 200000, 250000, false)); + segments.add(new SponsorSegment(SegmentCategory.SELF_PROMO, "debug", 200000, 330000, false)); + } + + 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: + messageToToast = str("revanced_sb_submit_succeeded"); + break; + case 409: + messageToToast = str("revanced_sb_submit_failed_duplicate"); + break; + case 403: + messageToToast = str("revanced_sb_submit_failed_forbidden", Requester.parseErrorStringAndDisconnect(connection)); + break; + case 429: + messageToToast = str("revanced_sb_submit_failed_rate_limit"); + break; + case 400: + messageToToast = str("revanced_sb_submit_failed_invalid", Requester.parseErrorStringAndDisconnect(connection)); + break; + default: + messageToToast = str("revanced_sb_submit_failed_unknown_error", responseCode, connection.getResponseMessage()); + break; + } + 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/requests/SBRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/requests/SBRoutes.java new file mode 100644 index 000000000..fe3403e56 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/requests/SBRoutes.java @@ -0,0 +1,20 @@ +package app.revanced.extension.youtube.sponsorblock.requests; + +import static app.revanced.extension.youtube.requests.Route.Method.GET; +import static app.revanced.extension.youtube.requests.Route.Method.POST; + +import app.revanced.extension.youtube.requests.Route; + +class SBRoutes { + static final Route IS_USER_VIP = new Route(GET, "/api/isUserVIP?userID={user_id}"); + static final Route GET_SEGMENTS = new Route(GET, "/api/skipSegments?videoID={video_id}&categories={categories}"); + static final Route VIEWED_SEGMENT = new Route(POST, "/api/viewedVideoSponsorTime?UUID={segment_id}"); + static final Route GET_USER_STATS = new Route(GET, "/api/userInfo?userID={user_id}&values=[\"userID\",\"userName\",\"reputation\",\"segmentCount\",\"ignoredSegmentCount\",\"viewCount\",\"minutesSaved\"]"); + static final Route CHANGE_USERNAME = new Route(POST, "/api/setUsername?userID={user_id}&username={username}"); + 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}"); + static final Route VOTE_ON_SEGMENT_QUALITY = new Route(POST, "/api/voteOnSponsorTime?userID={user_id}&UUID={segment_id}&type={type}"); + static final Route VOTE_ON_SEGMENT_CATEGORY = new Route(POST, "/api/voteOnSponsorTime?userID={user_id}&UUID={segment_id}&category={category}"); + + private SBRoutes() { + } +} \ No newline at end of file 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 000000000..4ec6c35b7 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/CreateSegmentButtonController.java @@ -0,0 +1,111 @@ +package app.revanced.extension.youtube.sponsorblock.ui; + +import static app.revanced.extension.shared.Utils.getResourceIdentifier; + +import android.view.View; +import android.widget.ImageView; + +import java.lang.ref.WeakReference; +import java.util.Objects; + +import app.revanced.extension.youtube.patches.VideoInformation; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.videoplayer.PlayerControlButton; + +// Edit: This should be a subclass of PlayerControlButton +public class CreateSegmentButtonController { + private static WeakReference buttonReference = new WeakReference<>(null); + private static boolean isShowing; + + /** + * injection point + */ + public static void initialize(View youtubeControlsLayout) { + try { + Logger.printDebug(() -> "initializing new segment button"); + ImageView imageView = Objects.requireNonNull(Utils.getChildViewByResourceName( + youtubeControlsLayout, "revanced_sb_create_segment_button")); + imageView.setVisibility(View.GONE); + imageView.setOnClickListener(v -> SponsorBlockViewController.toggleNewSegmentLayoutVisibility()); + + buttonReference = new WeakReference<>(imageView); + } catch (Exception ex) { + Logger.printException(() -> "initialize failure", ex); + } + } + + /** + * injection point + */ + public static void changeVisibilityImmediate(boolean visible) { + if (visible) { + // Fix button flickering, by pushing this call to the back of + // the main thread and letting other layout code run first. + Utils.runOnMainThread(() -> setVisibility(true, false)); + } else { + setVisibility(false, false); + } + } + + /** + * injection point + */ + public static void changeVisibility(boolean visible, boolean animated) { + // Ignore this call, otherwise with full screen thumbnails the buttons are visible while seeking. + if (visible && !animated) return; + + setVisibility(visible, animated); + } + + private static void setVisibility(boolean visible, boolean animated) { + try { + if (isShowing == visible) return; + isShowing = visible; + + ImageView iView = buttonReference.get(); + if (iView == null) return; + + if (visible) { + iView.clearAnimation(); + if (!shouldBeShown()) { + return; + } + if (animated) { + iView.startAnimation(PlayerControlButton.getButtonFadeIn()); + } + iView.setVisibility(View.VISIBLE); + return; + } + + if (iView.getVisibility() == View.VISIBLE) { + iView.clearAnimation(); + if (animated) { + iView.startAnimation(PlayerControlButton.getButtonFadeOut()); + } + iView.setVisibility(View.GONE); + } + } catch (Exception ex) { + Logger.printException(() -> "changeVisibility failure", ex); + } + } + + private static boolean shouldBeShown() { + return Settings.SB_ENABLED.get() && Settings.SB_CREATE_NEW_SEGMENT.get() + && !VideoInformation.isAtEndOfVideo(); + } + + public static void hide() { + if (!isShowing) { + return; + } + Utils.verifyOnMainThread(); + View v = buttonReference.get(); + if (v == null) { + return; + } + v.setVisibility(View.GONE); + isShowing = 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 000000000..38e1f113c --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/NewSegmentLayout.java @@ -0,0 +1,127 @@ +package app.revanced.extension.youtube.sponsorblock.ui; + +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.youtube.patches.VideoInformation; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.sponsorblock.SponsorBlockUtils; +import app.revanced.extension.shared.Logger; + +import static app.revanced.extension.shared.Utils.getResourceDimensionPixelSize; +import static app.revanced.extension.shared.Utils.getResourceIdentifier; + +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; + + final int defaultBottomMargin; + final int ctaBottomMargin; + + 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( + getResourceIdentifier(context, "revanced_sb_new_segment", "layout"), 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" + ); + + defaultBottomMargin = getResourceDimensionPixelSize("brand_interaction_default_bottom_margin"); + ctaBottomMargin = getResourceDimensionPixelSize("brand_interaction_cta_bottom_margin"); + } + + /** + * 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(getResourceIdentifier(context, resourceIdentifierName, "id")); + + // 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 000000000..11813aa84 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SkipSponsorButton.java @@ -0,0 +1,101 @@ +package app.revanced.extension.youtube.sponsorblock.ui; + +import static app.revanced.extension.shared.Utils.getResourceColor; +import static app.revanced.extension.shared.Utils.getResourceDimension; +import static app.revanced.extension.shared.Utils.getResourceDimensionPixelSize; +import static app.revanced.extension.shared.Utils.getResourceIdentifier; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +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 static final boolean highContrast = true; + private final LinearLayout skipSponsorBtnContainer; + private final TextView skipSponsorTextView; + private final Paint background; + private final Paint border; + private SponsorSegment segment; + final int defaultBottomMargin; + final int ctaBottomMargin; + + 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(getResourceIdentifier(context, "revanced_sb_skip_sponsor_button", "layout"), this, true); // layout:skip_ad_button + setMinimumHeight(getResourceDimensionPixelSize("ad_skip_ad_button_min_height")); // dimen:ad_skip_ad_button_min_height + skipSponsorBtnContainer = Objects.requireNonNull((LinearLayout) findViewById(getResourceIdentifier(context, "revanced_sb_skip_sponsor_button_container", "id"))); // id:skip_ad_button_container + background = new Paint(); + background.setColor(getResourceColor("skip_ad_button_background_color")); // color:skip_ad_button_background_color); + background.setStyle(Paint.Style.FILL); + border = new Paint(); + border.setColor(getResourceColor("skip_ad_button_border_color")); // color:skip_ad_button_border_color); + border.setStrokeWidth(getResourceDimension("ad_skip_ad_button_border_width")); // dimen:ad_skip_ad_button_border_width); + border.setStyle(Paint.Style.STROKE); + skipSponsorTextView = Objects.requireNonNull((TextView) findViewById(getResourceIdentifier(context, "revanced_sb_skip_sponsor_button_text", "id"))); // id:skip_ad_button_text; + defaultBottomMargin = getResourceDimensionPixelSize("skip_button_default_bottom_margin"); // dimen:skip_button_default_bottom_margin + ctaBottomMargin = getResourceDimensionPixelSize("skip_button_cta_bottom_margin"); // dimen:skip_button_cta_bottom_margin + + 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); + }); + } + + @Override // android.view.ViewGroup + protected final void dispatchDraw(Canvas canvas) { + final int left = skipSponsorBtnContainer.getLeft(); + final int top = skipSponsorBtnContainer.getTop(); + final int leftPlusWidth = (left + skipSponsorBtnContainer.getWidth()); + final int topPlusHeight = (top + skipSponsorBtnContainer.getHeight()); + canvas.drawRect(left, top, leftPlusWidth, topPlusHeight, background); + if (!highContrast) { + canvas.drawLines(new float[]{ + leftPlusWidth, top, left, top, + left, top, left, topPlusHeight, + left, topPlusHeight, leftPlusWidth, topPlusHeight}, + border); + } + + super.dispatchDraw(canvas); + } + + /** + * @return true, if this button state was changed + */ + public boolean updateSkipButtonText(@NonNull SponsorSegment segment) { + this.segment = segment; + CharSequence newText = segment.getSkipButtonText(); + if (newText.equals(skipSponsorTextView.getText())) { + 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 000000000..099f0d56e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SponsorBlockViewController.java @@ -0,0 +1,224 @@ +package app.revanced.extension.youtube.sponsorblock.ui; + +import static app.revanced.extension.shared.Utils.getResourceIdentifier; + +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.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.PlayerType; +import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment; + +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; + + static { + PlayerType.getOnChange().addObserver((PlayerType type) -> { + playerTypeChanged(type); + return null; + }); + } + + 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(getResourceIdentifier("revanced_sb_inline_sponsor_overlay", "layout"), 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(layout.findViewById(getResourceIdentifier("revanced_sb_skip_highlight_button", "id")))); + skipSponsorButtonRef = new WeakReference<>( + Objects.requireNonNull(layout.findViewById(getResourceIdentifier("revanced_sb_skip_sponsor_button", "id")))); + newSegmentLayoutRef = new WeakReference<>( + Objects.requireNonNull(layout.findViewById(getResourceIdentifier("revanced_sb_new_segment_view", "id")))); + + 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, layout.defaultBottomMargin, layout.ctaBottomMargin); + } + } + private static void setSkipButtonMargins(@Nullable SkipSponsorButton button, boolean fullScreen) { + if (button != null) { + setLayoutMargins(button, fullScreen, button.defaultBottomMargin, button.ctaBottomMargin); + } + } + private static void setLayoutMargins(@NonNull View view, boolean fullScreen, + int defaultBottomMargin, int ctaBottomMargin) { + RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) view.getLayoutParams(); + if (params == null) { + Logger.printException(() -> "Unable to setNewSegmentLayoutMargins (params are null)"); + return; + } + params.bottomMargin = fullScreen ? 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.AUTO_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 000000000..bad5f2484 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/VotingButtonController.java @@ -0,0 +1,116 @@ +package app.revanced.extension.youtube.sponsorblock.ui; + +import static app.revanced.extension.shared.Utils.getResourceIdentifier; + +import android.view.View; +import android.widget.ImageView; + +import java.lang.ref.WeakReference; +import java.util.Objects; + +import app.revanced.extension.youtube.patches.VideoInformation; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.sponsorblock.SegmentPlaybackController; +import app.revanced.extension.youtube.sponsorblock.SponsorBlockUtils; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.videoplayer.PlayerControlButton; + +// Edit: This should be a subclass of PlayerControlButton +public class VotingButtonController { + private static WeakReference buttonReference = new WeakReference<>(null); + private static boolean isShowing; + + /** + * injection point + */ + public static void initialize(View youtubeControlsLayout) { + try { + Logger.printDebug(() -> "initializing voting button"); + ImageView imageView = Objects.requireNonNull(Utils.getChildViewByResourceName( + 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(() -> "initialize failure", ex); + } + } + + /** + * injection point + */ + public static void changeVisibilityImmediate(boolean visible) { + if (visible) { + // Fix button flickering, by pushing this call to the back of + // the main thread and letting other layout code run first. + Utils.runOnMainThread(() -> setVisibility(true, false)); + } else { + setVisibility(false, false); + } + } + + /** + * injection point + */ + public static void changeVisibility(boolean visible, boolean animated) { + // Ignore this call, otherwise with full screen thumbnails the buttons are visible while seeking. + if (visible && !animated) return; + + setVisibility(visible, animated); + } + + /** + * injection point + */ + private static void setVisibility(boolean visible, boolean animated) { + try { + if (isShowing == visible) return; + isShowing = visible; + + ImageView iView = buttonReference.get(); + if (iView == null) return; + + if (visible) { + iView.clearAnimation(); + if (!shouldBeShown()) { + return; + } + if (animated) { + iView.startAnimation(PlayerControlButton.getButtonFadeIn()); + } + iView.setVisibility(View.VISIBLE); + return; + } + + if (iView.getVisibility() == View.VISIBLE) { + iView.clearAnimation(); + if (animated) { + iView.startAnimation(PlayerControlButton.getButtonFadeOut()); + } + iView.setVisibility(View.GONE); + } + } catch (Exception ex) { + Logger.printException(() -> "changeVisibility failure", ex); + } + } + + private static boolean shouldBeShown() { + return Settings.SB_ENABLED.get() && Settings.SB_VOTING_BUTTON.get() + && SegmentPlaybackController.videoHasSegments() && !VideoInformation.isAtEndOfVideo(); + } + + public static void hide() { + if (!isShowing) { + return; + } + Utils.verifyOnMainThread(); + View v = buttonReference.get(); + if (v == null) { + return; + } + v.setVisibility(View.GONE); + isShowing = 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 000000000..f9850d99a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/SwipeControlsConfigurationProvider.kt @@ -0,0 +1,120 @@ +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.PlayerType + +/** + * 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.SWIPE_VOLUME.get() + + /** + * should swipe controls for volume be enabled? + */ + val enableBrightnessControl: Boolean + get() = Settings.SWIPE_BRIGHTNESS.get() + + /** + * is the video player currently in fullscreen mode? + */ + private val isFullscreenVideo: Boolean + get() = PlayerType.current == PlayerType.WATCH_WHILE_FULLSCREEN +//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.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() +//endregion + +//region overlay adjustments + + /** + * should the overlay enable haptic feedback? + */ + val shouldEnableHapticFeedback: Boolean + get() = Settings.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.SWIPE_SAVE_AND_RESTORE_BRIGHTNESS.get() + + /** + * should auto-brightness be enabled at the lowest value of the brightness gesture + */ + val shouldLowestValueEnableAutoBrightness: Boolean + get() = Settings.SWIPE_LOWEST_VALUE_ENABLE_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 +} 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 000000000..afb55d74b --- /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.Logger.printDebug +import app.revanced.extension.shared.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/extension/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) + } else { + ClassicSwipeController(this) + } + + companion object { + /** + * the currently active swipe controls host. + * the reference may be null! + */ + @JvmStatic + var currentHost: WeakReference = WeakReference(null) + private set + } +} 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 000000000..c8b1884bd --- /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 android.os.Build +import app.revanced.extension.shared.Logger.printException +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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + 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 000000000..f291bcb41 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/ScreenBrightnessController.kt @@ -0,0 +1,73 @@ +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) + } + + /** + * is the screen brightness set to device- default? + */ + val isDefaultBrightness + get() = (rawScreenBrightness == WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE) + + /** + * 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] + */ + 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 000000000..2c2edb959 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/SwipeZonesController.kt @@ -0,0 +1,144 @@ +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 +import app.revanced.extension.youtube.swipecontrols.misc.Rectangle +import app.revanced.extension.youtube.swipecontrols.misc.applyDimension +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, +) { + /** + * 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 = Utils.getResourceIdentifier(host, "player_view", "id") + + /** + * 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 eRect = effectiveSwipeRect + val zoneWidth = (eRect.width * 3) / 8 + return Rectangle( + eRect.right - zoneWidth, + eRect.top, + zoneWidth, + eRect.height, + ) + } + + /** + * the rectangle of the screen brightness control zone + */ + val brightness: Rectangle + get() { + val zoneWidth = (effectiveSwipeRect.width * 3) / 8 + 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 000000000..d2b8788df --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/VolumeKeysController.kt @@ -0,0 +1,51 @@ +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 000000000..a0d2c6db6 --- /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.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, +) : 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 = 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 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 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 000000000..12c5b3025 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/PressToSwipeController.kt @@ -0,0 +1,78 @@ +package app.revanced.extension.youtube.swipecontrols.controller.gesture + +import android.view.MotionEvent +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, +) : 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 not in swipe session or vertical + if (!isInSwipeSession || 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/core/BaseGestureController.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/BaseGestureController.kt new file mode 100644 index 000000000..fca1da9f8 --- /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, + 10, + 1, + ) { + + /** + * 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 000000000..49da1f210 --- /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 000000000..7d6fa4501 --- /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 000000000..e398696df --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/VolumeAndBrightnessScroller.kt @@ -0,0 +1,102 @@ +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: Int = 10, + brightnessDistance: Int = 1, +) : 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 000000000..8400fedaf --- /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 000000000..723834318 --- /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 000000000..67512f0f4 --- /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: Int, + 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 000000000..5e863a3c5 --- /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 000000000..409d7ad4b --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/SwipeControlsUtils.kt @@ -0,0 +1,25 @@ +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() +} 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 000000000..9972c5233 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/views/SwipeControlsOverlayLayout.kt @@ -0,0 +1,145 @@ +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.StringRef.str +import app.revanced.extension.shared.Utils +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( + Utils.getResourceIdentifier(context, name, "drawable"), + context.theme, + ).apply { + setTint(config.overlayForegroundColor) + setBounds( + 0, + 0, + width, + height, + ) + } + } + + init { + // init views + val feedbackTextViewPadding = 2.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( + feedbackTextViewPadding, + feedbackTextViewPadding, + feedbackTextViewPadding, + feedbackTextViewPadding, + ) + } + background = GradientDrawable().apply { + cornerRadius = 8f + 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("revanced_ic_sc_brightness_auto", iconHeight, iconHeight) + manualBrightnessIcon = getDrawable("revanced_ic_sc_brightness_manual", iconHeight, iconHeight) + mutedVolumeIcon = getDrawable("revanced_ic_sc_volume_mute", iconHeight, iconHeight) + normalVolumeIcon = getDrawable("revanced_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_enable_auto_brightness_overlay_text"), + autoBrightnessIcon, + ) + } else if (brightness >= 0) { + showFeedbackView("${round(brightness).toInt()}%", manualBrightnessIcon) + } + } + + override fun onEnterSwipeSession() { + if (config.shouldEnableHapticFeedback) { + @Suppress("DEPRECATION") + performHapticFeedback( + HapticFeedbackConstants.LONG_PRESS, + HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING, + ) + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/CopyVideoUrlButton.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/CopyVideoUrlButton.java new file mode 100644 index 000000000..5b877cdc6 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/CopyVideoUrlButton.java @@ -0,0 +1,54 @@ +package app.revanced.extension.youtube.videoplayer; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import app.revanced.extension.youtube.patches.CopyVideoUrlPatch; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.shared.Logger; + +@SuppressWarnings("unused") +public class CopyVideoUrlButton extends PlayerControlButton { + @Nullable + private static CopyVideoUrlButton instance; + + public CopyVideoUrlButton(ViewGroup viewGroup) { + super( + viewGroup, + "revanced_copy_video_url_button", + Settings.COPY_VIDEO_URL, + view -> CopyVideoUrlPatch.copyUrl(false), + view -> { + CopyVideoUrlPatch.copyUrl(true); + return true; + } + ); + } + + /** + * Injection point. + */ + public static void initializeButton(View view) { + try { + instance = new CopyVideoUrlButton((ViewGroup) view); + } catch (Exception ex) { + Logger.printException(() -> "initializeButton failure", ex); + } + } + + /** + * injection point + */ + public static void changeVisibilityImmediate(boolean visible) { + if (instance != null) instance.setVisibilityImmediate(visible); + } + + /** + * injection point + */ + public static void changeVisibility(boolean visible, boolean animated) { + if (instance != null) instance.setVisibility(visible, animated); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/CopyVideoUrlTimestampButton.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/CopyVideoUrlTimestampButton.java new file mode 100644 index 000000000..ebb3f518a --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/CopyVideoUrlTimestampButton.java @@ -0,0 +1,54 @@ +package app.revanced.extension.youtube.videoplayer; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import app.revanced.extension.youtube.patches.CopyVideoUrlPatch; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.shared.Logger; + +@SuppressWarnings("unused") +public class CopyVideoUrlTimestampButton extends PlayerControlButton { + @Nullable + private static CopyVideoUrlTimestampButton instance; + + public CopyVideoUrlTimestampButton(ViewGroup bottomControlsViewGroup) { + super( + bottomControlsViewGroup, + "revanced_copy_video_url_timestamp_button", + Settings.COPY_VIDEO_URL_TIMESTAMP, + view -> CopyVideoUrlPatch.copyUrl(true), + view -> { + CopyVideoUrlPatch.copyUrl(false); + return true; + } + ); + } + + /** + * Injection point. + */ + public static void initializeButton(View bottomControlsViewGroup) { + try { + instance = new CopyVideoUrlTimestampButton((ViewGroup) bottomControlsViewGroup); + } catch (Exception ex) { + Logger.printException(() -> "initializeButton failure", ex); + } + } + + /** + * injection point + */ + public static void changeVisibilityImmediate(boolean visible) { + if (instance != null) instance.setVisibilityImmediate(visible); + } + + /** + * injection point + */ + public static void changeVisibility(boolean visible, boolean animated) { + if (instance != null) instance.setVisibility(visible, animated); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/ExternalDownloadButton.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/ExternalDownloadButton.java new file mode 100644 index 000000000..bfd6d30e5 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/ExternalDownloadButton.java @@ -0,0 +1,60 @@ +package app.revanced.extension.youtube.videoplayer; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.youtube.patches.DownloadsPatch; +import app.revanced.extension.youtube.patches.VideoInformation; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class ExternalDownloadButton extends PlayerControlButton { + @Nullable + private static ExternalDownloadButton instance; + + public ExternalDownloadButton(ViewGroup viewGroup) { + super( + viewGroup, + "revanced_external_download_button", + Settings.EXTERNAL_DOWNLOADER, + ExternalDownloadButton::onDownloadClick, + null + ); + } + + /** + * Injection point. + */ + public static void initializeButton(View view) { + try { + instance = new ExternalDownloadButton((ViewGroup) view); + } catch (Exception ex) { + Logger.printException(() -> "initializeButton failure", ex); + } + } + + /** + * injection point + */ + public static void changeVisibilityImmediate(boolean visible) { + if (instance != null) instance.setVisibilityImmediate(visible); + } + + /** + * injection point + */ + public static void changeVisibility(boolean visible, boolean animated) { + if (instance != null) instance.setVisibility(visible, animated); + } + + private static void onDownloadClick(View view) { + DownloadsPatch.launchExternalDownloader( + VideoInformation.getVideoId(), + view.getContext(), + true); + } +} + diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/PlaybackSpeedDialogButton.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/PlaybackSpeedDialogButton.java new file mode 100644 index 000000000..2954eac15 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/PlaybackSpeedDialogButton.java @@ -0,0 +1,51 @@ +package app.revanced.extension.youtube.videoplayer; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import app.revanced.extension.youtube.patches.playback.speed.CustomPlaybackSpeedPatch; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.shared.Logger; + +@SuppressWarnings("unused") +public class PlaybackSpeedDialogButton extends PlayerControlButton { + @Nullable + private static PlaybackSpeedDialogButton instance; + + public PlaybackSpeedDialogButton(ViewGroup viewGroup) { + super( + viewGroup, + "revanced_playback_speed_dialog_button", + Settings.PLAYBACK_SPEED_DIALOG_BUTTON, + view -> CustomPlaybackSpeedPatch.showOldPlaybackSpeedMenu(), + null + ); + } + + /** + * Injection point. + */ + public static void initializeButton(View view) { + try { + instance = new PlaybackSpeedDialogButton((ViewGroup) view); + } catch (Exception ex) { + Logger.printException(() -> "initializeButton failure", ex); + } + } + + /** + * injection point + */ + public static void changeVisibilityImmediate(boolean visible) { + if (instance != null) instance.setVisibilityImmediate(visible); + } + + /** + * injection point + */ + public static void changeVisibility(boolean visible, boolean animated) { + if (instance != null) instance.setVisibility(visible, animated); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/PlayerControlButton.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/PlayerControlButton.java new file mode 100644 index 000000000..71e176c1f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/PlayerControlButton.java @@ -0,0 +1,117 @@ +package app.revanced.extension.youtube.videoplayer; + +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.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.shared.settings.BooleanSetting; + +public abstract class PlayerControlButton { + private static final Animation fadeIn; + private static final Animation fadeOut; + private static final Animation fadeOutImmediate; + + private final WeakReference buttonRef; + protected final BooleanSetting setting; + protected boolean isVisible; + + static { + // TODO: check if these durations are correct. + fadeIn = Utils.getResourceAnimation("fade_in"); + fadeIn.setDuration(Utils.getResourceInteger("fade_duration_fast")); + + fadeOut = Utils.getResourceAnimation("fade_out"); + fadeOut.setDuration(Utils.getResourceInteger("fade_duration_scheduled")); + + fadeOutImmediate = Utils.getResourceAnimation("abc_fade_out"); + fadeOutImmediate.setDuration(Utils.getResourceInteger("fade_duration_fast")); + } + + @NonNull + public static Animation getButtonFadeIn() { + return fadeIn; + } + + @NonNull + public static Animation getButtonFadeOut() { + return fadeOut; + } + + @NonNull + public static Animation getButtonFadeOutImmediately() { + return fadeOutImmediate; + } + + public PlayerControlButton(@NonNull ViewGroup bottomControlsViewGroup, @NonNull String imageViewButtonId, + @NonNull BooleanSetting booleanSetting, @NonNull View.OnClickListener onClickListener, + @Nullable View.OnLongClickListener longClickListener) { + Logger.printDebug(() -> "Initializing button: " + imageViewButtonId); + + ImageView imageView = Objects.requireNonNull(bottomControlsViewGroup.findViewById( + Utils.getResourceIdentifier(imageViewButtonId, "id") + )); + imageView.setVisibility(View.GONE); + + imageView.setOnClickListener(onClickListener); + if (longClickListener != null) { + imageView.setOnLongClickListener(longClickListener); + } + + setting = booleanSetting; + buttonRef = new WeakReference<>(imageView); + } + + public void setVisibilityImmediate(boolean visible) { + if (visible) { + // Fix button flickering, by pushing this call to the back of + // the main thread and letting other layout code run first. + Utils.runOnMainThread(() -> private_setVisibility(true, false)); + } else { + private_setVisibility(false, false); + } + } + + public void setVisibility(boolean visible, boolean animated) { + // Ignore this call, otherwise with full screen thumbnails the buttons are visible while seeking. + if (visible && !animated) return; + + private_setVisibility(visible, animated); + } + + private void private_setVisibility(boolean visible, boolean animated) { + try { + if (isVisible == visible) return; + isVisible = visible; + + ImageView iView = buttonRef.get(); + if (iView == null) { + return; + } + + if (visible && setting.get()) { + iView.clearAnimation(); + if (animated) { + iView.startAnimation(PlayerControlButton.getButtonFadeIn()); + } + iView.setVisibility(View.VISIBLE); + } else if (iView.getVisibility() == View.VISIBLE) { + iView.clearAnimation(); + if (animated) { + iView.startAnimation(PlayerControlButton.getButtonFadeOut()); + } + iView.setVisibility(View.GONE); + } + } catch (Exception ex) { + Logger.printException(() -> "setVisibility failure", ex); + } + } +} diff --git a/extensions/shared/stub/build.gradle.kts b/extensions/shared/stub/build.gradle.kts new file mode 100644 index 000000000..c1cc5794c --- /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 = 33 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} diff --git a/extensions/shared/stub/src/main/AndroidManifest.xml b/extensions/shared/stub/src/main/AndroidManifest.xml new file mode 100644 index 000000000..568741e54 --- /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/constraint/ConstraintLayout.java b/extensions/shared/stub/src/main/java/android/support/constraint/ConstraintLayout.java new file mode 100644 index 000000000..2861235a5 --- /dev/null +++ b/extensions/shared/stub/src/main/java/android/support/constraint/ConstraintLayout.java @@ -0,0 +1,20 @@ +package android.support.constraint; + +import android.content.Context; +import android.view.ViewGroup; + +/** + * "CompileOnly" class + * because android.support.android.support.constraint.ConstraintLayout is deprecated + * in favour of androidx.constraintlayout.widget.ConstraintLayout. + * + * This class will not be included and "replaced" by the real package's class. + */ +public class ConstraintLayout extends ViewGroup { + public ConstraintLayout(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/RecyclerView.java b/extensions/shared/stub/src/main/java/android/support/v7/widget/RecyclerView.java new file mode 100644 index 000000000..d902dbfc8 --- /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.View; + +public class RecyclerView extends View { + + public RecyclerView(Context context) { + super(context); + } + + public View getChildAt(@SuppressWarnings("unused") final int index) { + return null; + } + + public int getChildCount() { + return 0; + } +} diff --git a/extensions/shared/stub/src/main/java/com/bytedance/ies/ugc/aweme/commercialize/compliance/personalization/AdPersonalizationActivity.java b/extensions/shared/stub/src/main/java/com/bytedance/ies/ugc/aweme/commercialize/compliance/personalization/AdPersonalizationActivity.java new file mode 100644 index 000000000..ecf204d22 --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/bytedance/ies/ugc/aweme/commercialize/compliance/personalization/AdPersonalizationActivity.java @@ -0,0 +1,6 @@ +package com.bytedance.ies.ugc.aweme.commercialize.compliance.personalization; + +import android.app.Activity; + +//Dummy class +public class AdPersonalizationActivity extends Activity { } diff --git a/extensions/shared/stub/src/main/java/com/google/android/apps/youtube/app/ui/SlimMetadataScrollableButtonContainerLayout.java b/extensions/shared/stub/src/main/java/com/google/android/apps/youtube/app/ui/SlimMetadataScrollableButtonContainerLayout.java new file mode 100644 index 000000000..9b314f997 --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/google/android/apps/youtube/app/ui/SlimMetadataScrollableButtonContainerLayout.java @@ -0,0 +1,25 @@ +package com.google.android.apps.youtube.app.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.ViewGroup; + +public class SlimMetadataScrollableButtonContainerLayout extends ViewGroup { + + public SlimMetadataScrollableButtonContainerLayout(Context context) { + super(context); + } + + public SlimMetadataScrollableButtonContainerLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SlimMetadataScrollableButtonContainerLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onLayout(boolean b, int i, int i1, int i2, int i3) { + + } +} 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 000000000..f275effdb --- /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/protos/youtube/api/innertube/InnertubeContext$ClientInfo.java b/extensions/shared/stub/src/main/java/com/google/protos/youtube/api/innertube/InnertubeContext$ClientInfo.java new file mode 100644 index 000000000..f517608f2 --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/google/protos/youtube/api/innertube/InnertubeContext$ClientInfo.java @@ -0,0 +1,5 @@ +package com.google.protos.youtube.api.innertube; + +public class InnertubeContext$ClientInfo { + public int r; +} diff --git a/extensions/shared/stub/src/main/java/com/laurencedawson/reddit_sync/ui/activities/WebViewActivity.java b/extensions/shared/stub/src/main/java/com/laurencedawson/reddit_sync/ui/activities/WebViewActivity.java new file mode 100644 index 000000000..a33226a85 --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/laurencedawson/reddit_sync/ui/activities/WebViewActivity.java @@ -0,0 +1,6 @@ +package com.laurencedawson.reddit_sync.ui.activities; + +import android.app.Activity; + +public class WebViewActivity extends Activity { +} 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 000000000..f9cbb955c --- /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/com/rubenmayayo/reddit/ui/activities/WebViewActivity.java b/extensions/shared/stub/src/main/java/com/rubenmayayo/reddit/ui/activities/WebViewActivity.java new file mode 100644 index 000000000..d0c585072 --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/rubenmayayo/reddit/ui/activities/WebViewActivity.java @@ -0,0 +1,6 @@ +package com.rubenmayayo.reddit.ui.activities; + +import android.app.Activity; + +public class WebViewActivity extends Activity { +} diff --git a/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/Aweme.java b/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/Aweme.java new file mode 100644 index 000000000..e1ea9af6c --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/Aweme.java @@ -0,0 +1,36 @@ +package com.ss.android.ugc.aweme.feed.model; + +//Dummy class +public class Aweme { + public boolean isAd() { + throw new UnsupportedOperationException("Stub"); + } + + public boolean isLive() { + throw new UnsupportedOperationException("Stub"); + } + + public boolean isLiveReplay() { + throw new UnsupportedOperationException("Stub"); + } + + public boolean isWithPromotionalMusic() { + throw new UnsupportedOperationException("Stub"); + } + + public boolean getIsTikTokStory() { + throw new UnsupportedOperationException("Stub"); + } + + public boolean isImage() { + throw new UnsupportedOperationException("Stub"); + } + + public boolean isPhotoMode() { + throw new UnsupportedOperationException("Stub"); + } + + public AwemeStatistics getStatistics() { + throw new UnsupportedOperationException("Stub"); + } +} diff --git a/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/AwemeStatistics.java b/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/AwemeStatistics.java new file mode 100644 index 000000000..a9123e67c --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/AwemeStatistics.java @@ -0,0 +1,10 @@ +package com.ss.android.ugc.aweme.feed.model; + +public class AwemeStatistics { + public long getPlayCount() { + throw new UnsupportedOperationException("Stub"); + } + public long getDiggCount() { + throw new UnsupportedOperationException("Stub"); + } +} diff --git a/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/FeedItemList.java b/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/FeedItemList.java new file mode 100644 index 000000000..139159ea4 --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/FeedItemList.java @@ -0,0 +1,8 @@ +package com.ss.android.ugc.aweme.feed.model; + +import java.util.List; + +//Dummy class +public class FeedItemList { + public List items; +} diff --git a/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/TimelineObject.java b/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/TimelineObject.java new file mode 100644 index 000000000..8bb2c885d --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/TimelineObject.java @@ -0,0 +1,8 @@ +package com.tumblr.rumblr.model; + +public class TimelineObject { + public final T getData() { + throw new UnsupportedOperationException("Stub"); + } + +} diff --git a/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/TimelineObjectType.java b/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/TimelineObjectType.java new file mode 100644 index 000000000..f9b7d7abc --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/TimelineObjectType.java @@ -0,0 +1,4 @@ +package com.tumblr.rumblr.model; + +public enum TimelineObjectType { +} diff --git a/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/Timelineable.java b/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/Timelineable.java new file mode 100644 index 000000000..bf84887de --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/Timelineable.java @@ -0,0 +1,5 @@ +package com.tumblr.rumblr.model; + +public interface Timelineable { + TimelineObjectType getTimelineObjectType(); +} 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 000000000..4c02f1a40 --- /dev/null +++ b/extensions/shared/stub/src/main/java/org/chromium/net/UrlRequest.java @@ -0,0 +1,8 @@ +package org.chromium.net; + +public abstract class UrlRequest { + public abstract class Builder { + public abstract Builder addHeader(String name, String value); + public abstract UrlRequest build(); + } +} 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 000000000..8e341247d --- /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 000000000..fa0dcacd9 --- /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/extensions/shared/stub/src/main/java/tv/twitch/android/feature/settings/menu/SettingsMenuGroup.java b/extensions/shared/stub/src/main/java/tv/twitch/android/feature/settings/menu/SettingsMenuGroup.java new file mode 100644 index 000000000..72495e4b5 --- /dev/null +++ b/extensions/shared/stub/src/main/java/tv/twitch/android/feature/settings/menu/SettingsMenuGroup.java @@ -0,0 +1,14 @@ +package tv.twitch.android.feature.settings.menu; + +import java.util.List; + +// Dummy +public final class SettingsMenuGroup { + public SettingsMenuGroup(List settingsMenuItems) { + throw new UnsupportedOperationException("Stub"); + } + + public List getSettingsMenuItems() { + throw new UnsupportedOperationException("Stub"); + } +} \ No newline at end of file diff --git a/extensions/shared/stub/src/main/java/tv/twitch/android/settings/SettingsActivity.java b/extensions/shared/stub/src/main/java/tv/twitch/android/settings/SettingsActivity.java new file mode 100644 index 000000000..dcf42ab30 --- /dev/null +++ b/extensions/shared/stub/src/main/java/tv/twitch/android/settings/SettingsActivity.java @@ -0,0 +1,5 @@ +package tv.twitch.android.settings; + +import android.app.Activity; + +public class SettingsActivity extends Activity {} diff --git a/extensions/shared/stub/src/main/java/tv/twitch/android/shared/chat/util/ClickableUsernameSpan.java b/extensions/shared/stub/src/main/java/tv/twitch/android/shared/chat/util/ClickableUsernameSpan.java new file mode 100644 index 000000000..0322bdba2 --- /dev/null +++ b/extensions/shared/stub/src/main/java/tv/twitch/android/shared/chat/util/ClickableUsernameSpan.java @@ -0,0 +1,9 @@ +package tv.twitch.android.shared.chat.util; + +import android.text.style.ClickableSpan; +import android.view.View; + +public final class ClickableUsernameSpan extends ClickableSpan { + @Override + public void onClick(View widget) {} +} \ No newline at end of file diff --git a/extensions/spoof-wifi/build.gradle.kts b/extensions/spoof-wifi/build.gradle.kts new file mode 100644 index 000000000..9a2728690 --- /dev/null +++ b/extensions/spoof-wifi/build.gradle.kts @@ -0,0 +1,11 @@ +extension { + name = "extensions/all/connectivity/wifi/spoof/spoof-wifi.rve" +} + +android { + namespace = "app.revanced.extension" +} + +dependencies { + compileOnly(libs.annotation) +} diff --git a/extensions/spoof-wifi/src/main/AndroidManifest.xml b/extensions/spoof-wifi/src/main/AndroidManifest.xml new file mode 100644 index 000000000..7425a54c5 --- /dev/null +++ b/extensions/spoof-wifi/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/extensions/spoof-wifi/src/main/java/app/revanced/extension/all/connectivity/wifi/spoof/SpoofWifiPatch.java b/extensions/spoof-wifi/src/main/java/app/revanced/extension/all/connectivity/wifi/spoof/SpoofWifiPatch.java new file mode 100644 index 000000000..5f00bd730 --- /dev/null +++ b/extensions/spoof-wifi/src/main/java/app/revanced/extension/all/connectivity/wifi/spoof/SpoofWifiPatch.java @@ -0,0 +1,424 @@ +package app.revanced.extension.all.connectivity.wifi.spoof; + +import android.app.PendingIntent; +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkInfo; +import android.net.NetworkRequest; +import android.os.Build; +import android.os.Handler; + +import androidx.annotation.RequiresApi; + +/** @noinspection deprecation, unused */ +public class SpoofWifiPatch { + + // Used to check what the (real or fake) active network is (take a look at `hasTransport`). + private static ConnectivityManager CONNECTIVITY_MANAGER; + + // If Wifi is not enabled, these are types that would pretend to be Wifi for android.net.Network (lower index = higher priority). + // This does not apply to android.net.NetworkInfo, because we can pretend that Wifi is always active there. + // + // VPN should be a fallback, because Reverse Tethering uses VPN. + private static final int[] FAKE_FALLBACK_NETWORKS = { NetworkCapabilities.TRANSPORT_ETHERNET, NetworkCapabilities.TRANSPORT_VPN }; + + // In order to initialize our own ConnectivityManager, if it isn't initialized yet. + public static Object getSystemService(Context context, String name) { + Object result = context.getSystemService(name); + if (CONNECTIVITY_MANAGER == null) { + if (Context.CONNECTIVITY_SERVICE.equals(name)) { + CONNECTIVITY_MANAGER = (ConnectivityManager) result; + } else { + CONNECTIVITY_MANAGER = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + } + } + return result; + } + + // In order to initialize our own ConnectivityManager, if it isn't initialized yet. + public static Object getSystemService(Context context, Class serviceClass) { + Object result = context.getSystemService(serviceClass); + if (CONNECTIVITY_MANAGER == null) { + if (serviceClass == ConnectivityManager.class) { + CONNECTIVITY_MANAGER = (ConnectivityManager) result; + } else { + CONNECTIVITY_MANAGER = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + } + } + return result; + } + + // Simply always return Wifi as active network. + public static NetworkInfo getActiveNetworkInfo(ConnectivityManager connectivityManager) { + for (NetworkInfo networkInfo : connectivityManager.getAllNetworkInfo()) { + if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { + return networkInfo; + } + } + return connectivityManager.getActiveNetworkInfo(); + } + + // Pretend Wifi is always connected. + public static boolean isConnected(NetworkInfo networkInfo) { + if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { + return true; + } + return networkInfo.isConnected(); + } + + // Pretend Wifi is always connected. + public static boolean isConnectedOrConnecting(NetworkInfo networkInfo) { + if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { + return true; + } + return networkInfo.isConnectedOrConnecting(); + } + + // Pretend Wifi is always available. + public static boolean isAvailable(NetworkInfo networkInfo) { + if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { + return true; + } + return networkInfo.isAvailable(); + } + + // Pretend Wifi is always connected. + public static NetworkInfo.State getState(NetworkInfo networkInfo) { + if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { + return NetworkInfo.State.CONNECTED; + } + return networkInfo.getState(); + } + + // Pretend Wifi is always connected. + public static NetworkInfo.DetailedState getDetailedState(NetworkInfo networkInfo) { + if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { + return NetworkInfo.DetailedState.CONNECTED; + } + return networkInfo.getDetailedState(); + } + + // Pretend Wifi is enabled, so connection isn't metered. + public static boolean isActiveNetworkMetered(ConnectivityManager connectivityManager) { + return false; + } + + // Returns the Wifi network, if Wifi is enabled. + // Otherwise if one of our fallbacks has a connection, return them. + // And as a last resort, return the default active network. + public static Network getActiveNetwork(ConnectivityManager connectivityManager) { + Network[] prioritizedNetworks = new Network[FAKE_FALLBACK_NETWORKS.length]; + for (Network network : connectivityManager.getAllNetworks()) { + NetworkCapabilities networkCapabilities = connectivityManager.getNetworkCapabilities(network); + if (networkCapabilities == null) { + continue; + } + if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + return network; + } + if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { + for (int i = 0; i < FAKE_FALLBACK_NETWORKS.length; i++) { + int transportType = FAKE_FALLBACK_NETWORKS[i]; + if (networkCapabilities.hasTransport(transportType)) { + prioritizedNetworks[i] = network; + break; + } + } + } + } + for (Network network : prioritizedNetworks) { + if (network != null) { + return network; + } + } + return connectivityManager.getActiveNetwork(); + } + + // If the given network is a real or fake Wifi connection, return a Wifi network. + // Otherwise fallback to default implementation. + public static NetworkInfo getNetworkInfo(ConnectivityManager connectivityManager, Network network) { + NetworkCapabilities networkCapabilities = connectivityManager.getNetworkCapabilities(network); + if (networkCapabilities != null && hasTransport(networkCapabilities, NetworkCapabilities.TRANSPORT_WIFI)) { + for (NetworkInfo networkInfo : connectivityManager.getAllNetworkInfo()) { + if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { + return networkInfo; + } + } + } + return connectivityManager.getNetworkInfo(network); + } + + // If we are checking if the NetworkCapabilities use Wifi, return yes if + // - it is a real Wifi connection, + // - or the NetworkCapabilities are from a network pretending being a Wifi network. + // Otherwise fallback to default implementation. + public static boolean hasTransport(NetworkCapabilities networkCapabilities, int transportType) { + if (transportType == NetworkCapabilities.TRANSPORT_WIFI) { + if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + return true; + } + if (CONNECTIVITY_MANAGER != null) { + Network activeNetwork = getActiveNetwork(CONNECTIVITY_MANAGER); + NetworkCapabilities activeNetworkCapabilities = CONNECTIVITY_MANAGER.getNetworkCapabilities(activeNetwork); + if (activeNetworkCapabilities != null) { + for (int fallbackTransportType : FAKE_FALLBACK_NETWORKS) { + if (activeNetworkCapabilities.hasTransport(fallbackTransportType) && networkCapabilities.hasTransport(fallbackTransportType)) { + return true; + } + } + } + } + } + return networkCapabilities.hasTransport(transportType); + } + + // If the given network is a real or fake Wifi connection, pretend it has a connection (and some other things). + public static boolean hasCapability(NetworkCapabilities networkCapabilities, int capability) { + if (hasTransport(networkCapabilities, NetworkCapabilities.TRANSPORT_WIFI) && ( + capability == NetworkCapabilities.NET_CAPABILITY_INTERNET + || capability == NetworkCapabilities.NET_CAPABILITY_FOREGROUND + || capability == NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED + || capability == NetworkCapabilities.NET_CAPABILITY_NOT_METERED + || capability == NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED + || capability == NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING + || capability == NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED + || capability == NetworkCapabilities.NET_CAPABILITY_NOT_VPN + || capability == NetworkCapabilities.NET_CAPABILITY_TRUSTED + || capability == NetworkCapabilities.NET_CAPABILITY_VALIDATED)) { + return true; + } + return networkCapabilities.hasCapability(capability); + } + + // If it waits for Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + @RequiresApi(api = Build.VERSION_CODES.S) + public static void registerBestMatchingNetworkCallback(ConnectivityManager connectivityManager, NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback, Handler handler) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.of(handler), + () -> connectivityManager.registerBestMatchingNetworkCallback(request, networkCallback, handler) + ); + } + + // If it waits for Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + @RequiresApi(api = Build.VERSION_CODES.N) + public static void registerDefaultNetworkCallback(ConnectivityManager connectivityManager, ConnectivityManager.NetworkCallback networkCallback) { + Utils.networkCallback( + connectivityManager, + Utils.Option.empty(), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.empty(), + () -> connectivityManager.registerDefaultNetworkCallback(networkCallback) + ); + } + + // If it waits for Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + @RequiresApi(api = Build.VERSION_CODES.O) + public static void registerDefaultNetworkCallback(ConnectivityManager connectivityManager, ConnectivityManager.NetworkCallback networkCallback, Handler handler) { + Utils.networkCallback( + connectivityManager, + Utils.Option.empty(), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.of(handler), + () -> connectivityManager.registerDefaultNetworkCallback(networkCallback, handler) + ); + } + + // If it waits for Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + public static void registerNetworkCallback(ConnectivityManager connectivityManager, NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.empty(), + () -> connectivityManager.registerNetworkCallback(request, networkCallback) + ); + } + + // If it waits for Wifi connectivity, pretend it is fulfilled immediately. + public static void registerNetworkCallback(ConnectivityManager connectivityManager, NetworkRequest request, PendingIntent operation) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.empty(), + Utils.Option.of(operation), + Utils.Option.empty(), + () -> connectivityManager.registerNetworkCallback(request, operation) + ); + } + + // If it waits for Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + @RequiresApi(api = Build.VERSION_CODES.O) + public static void registerNetworkCallback(ConnectivityManager connectivityManager, NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback, Handler handler) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.of(handler), + () -> connectivityManager.registerNetworkCallback(request, networkCallback, handler) + ); + } + + // If it requests Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + public static void requestNetwork(ConnectivityManager connectivityManager, NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.empty(), + () -> connectivityManager.requestNetwork(request, networkCallback) + ); + } + + // If it requests Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + @RequiresApi(api = Build.VERSION_CODES.O) + public static void requestNetwork(ConnectivityManager connectivityManager, NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback, int timeoutMs) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.empty(), + () -> connectivityManager.requestNetwork(request, networkCallback, timeoutMs) + ); + } + + // If it requests Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + @RequiresApi(api = Build.VERSION_CODES.O) + public static void requestNetwork(ConnectivityManager connectivityManager, NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback, Handler handler) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.of(handler), + () -> connectivityManager.requestNetwork(request, networkCallback, handler) + ); + } + + // If it requests Wifi connectivity, pretend it is fulfilled immediately. + public static void requestNetwork(ConnectivityManager connectivityManager, NetworkRequest request, PendingIntent operation) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.empty(), + Utils.Option.of(operation), + Utils.Option.empty(), + () -> connectivityManager.requestNetwork(request, operation) + ); + } + + // If it requests Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + @RequiresApi(api = Build.VERSION_CODES.O) + public static void requestNetwork(ConnectivityManager connectivityManager, NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback, Handler handler, int timeoutMs) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.of(handler), + () -> connectivityManager.requestNetwork(request, networkCallback, handler, timeoutMs) + ); + } + + public static void unregisterNetworkCallback(ConnectivityManager connectivityManager, ConnectivityManager.NetworkCallback networkCallback) { + try { + connectivityManager.unregisterNetworkCallback(networkCallback); + } catch (IllegalArgumentException ignore) { + // ignore: NetworkCallback was not registered + } + } + + public static void unregisterNetworkCallback(ConnectivityManager connectivityManager, PendingIntent operation) { + try { + connectivityManager.unregisterNetworkCallback(operation); + } catch (IllegalArgumentException ignore) { + // ignore: PendingIntent was not registered + } + } + + private static class Utils { + private static class Option { + private final T value; + private final boolean isPresent; + + private Option(T value, boolean isPresent) { + this.value = value; + this.isPresent = isPresent; + } + + private static Option of(T value) { + return new Option<>(value, true); + } + + private static Option empty() { + return new Option<>(null, false); + } + } + + private static void networkCallback( + ConnectivityManager connectivityManager, + Option request, + Option networkCallback, + Option operation, + Option handler, + Runnable fallback + ) { + if(!request.isPresent || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && request.value != null && requestsWifiNetwork(request.value))) { + Runnable runnable = null; + if (networkCallback.isPresent && networkCallback.value != null) { + Network network = activeWifiNetwork(connectivityManager); + if (network != null) { + runnable = () -> networkCallback.value.onAvailable(network); + } + } else if (operation.isPresent && operation.value != null) { + runnable = () -> { + try { + operation.value.send(); + } catch (PendingIntent.CanceledException ignore) {} + }; + } + if (runnable != null) { + if (handler.isPresent) { + if (handler.value != null) { + handler.value.post(runnable); + return; + } + } else { + runnable.run(); + return; + } + } + } + fallback.run(); + } + + // Returns an active (maybe fake) Wifi network if there is one, otherwise null. + private static Network activeWifiNetwork(ConnectivityManager connectivityManager) { + Network network = getActiveNetwork(connectivityManager); + NetworkCapabilities networkCapabilities = connectivityManager.getNetworkCapabilities(network); + if (networkCapabilities != null && hasTransport(networkCapabilities, NetworkCapabilities.TRANSPORT_WIFI)) { + return network; + } + return null; + } + + // Whether a Wifi network with connection is requested. + @RequiresApi(api = Build.VERSION_CODES.P) + private static boolean requestsWifiNetwork(NetworkRequest request) { + return request.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + && (request.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + || request.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)); + } + } +} diff --git a/gradle.properties b/gradle.properties index 5a96ec636..ce9413cb5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,5 @@ org.gradle.parallel = true org.gradle.caching = true +android.useAndroidX=true kotlin.code.style = official version = 4.18.0-dev.6 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a4060085..abf731bd3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,18 +1,26 @@ [versions] -revanced-patcher = "19.3.1" +revanced-patcher = "20.0.2" +# Tracking https://github.com/google/smali/issues/64. #noinspection GradleDependency -smali = "3.0.5" # 3.0.7 breaks binary compatibility. Tracking https://github.com/google/smali/issues/58. -guava = "33.2.1-jre" +smali = "3.0.5" gson = "2.11.0" -binary-compatibility-validator = "0.15.1" -kotlin = "2.0.0" +# 8.3.0 causes java verifier error: https://github.com/ReVanced/revanced-patches/issues/2818. +#noinspection GradleDependency +agp = "8.2.2" +annotation = "1.9.0" +appcompat = "1.7.0" +okhttp = "5.0.0-alpha.14" +retrofit = "2.11.0" +guava = "33.2.1-jre" [libraries] -revanced-patcher = { module = "app.revanced:revanced-patcher", version.ref = "revanced-patcher" } -smali = { module = "com.android.tools.smali:smali", version.ref = "smali" } -guava = { module = "com.google.guava:guava", version.ref = "guava" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } +appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +guava = { module = "com.google.guava:guava", version.ref = "guava" } + [plugins] -binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" } -kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/patches/api/patches.api b/patches/api/patches.api new file mode 100644 index 000000000..b7b1b9b29 --- /dev/null +++ b/patches/api/patches.api @@ -0,0 +1,1556 @@ +public final class app/revanced/patches/all/misc/activity/exportall/ExportAllActivitiesPatchKt { + public static final fun getExportAllActivitiesPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/all/misc/build/BaseSpoofBuildInfoPatchKt { + public static final fun baseSpoofBuildInfoPatch (Lkotlin/jvm/functions/Function0;)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/all/misc/build/BuildInfo { + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getBoard ()Ljava/lang/String; + public final fun getBootloader ()Ljava/lang/String; + public final fun getBrand ()Ljava/lang/String; + public final fun getCpuAbi ()Ljava/lang/String; + public final fun getCpuAbi2 ()Ljava/lang/String; + public final fun getDevice ()Ljava/lang/String; + public final fun getDisplay ()Ljava/lang/String; + public final fun getFingerprint ()Ljava/lang/String; + public final fun getHardware ()Ljava/lang/String; + public final fun getHost ()Ljava/lang/String; + public final fun getId ()Ljava/lang/String; + public final fun getManufacturer ()Ljava/lang/String; + public final fun getModel ()Ljava/lang/String; + public final fun getOdmSku ()Ljava/lang/String; + public final fun getProduct ()Ljava/lang/String; + public final fun getRadio ()Ljava/lang/String; + public final fun getSerial ()Ljava/lang/String; + public final fun getSku ()Ljava/lang/String; + public final fun getSocManufacturer ()Ljava/lang/String; + public final fun getSocModel ()Ljava/lang/String; + public final fun getTags ()Ljava/lang/String; + public final fun getTime ()Ljava/lang/Long; + public final fun getType ()Ljava/lang/String; + public final fun getUser ()Ljava/lang/String; +} + +public final class app/revanced/patches/all/misc/build/SpoofBuildInfoPatchKt { + public static final fun getSpoofBuildInfoPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/all/misc/connectivity/location/hide/HideMockLocationPatchKt { + public static final fun getHideMockLocationPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/all/misc/connectivity/telephony/sim/spoof/SpoofSimCountryPatchKt { + public static final fun getSpoofSimCountryPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/all/misc/connectivity/wifi/spoof/SpoofWifiPatchKt { + public static final fun getSpoofWifiPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/all/misc/debugging/EnableAndroidDebuggingPatchKt { + public static final fun getEnableAndroidDebuggingPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/all/misc/directory/ChangeDataDirectoryLocationPatchKt { + public static final fun getChangeDataDirectoryLocationPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/all/misc/hex/HexPatchKt { + public static final fun getHexPatch ()Lapp/revanced/patcher/patch/RawResourcePatch; +} + +public final class app/revanced/patches/all/misc/interaction/gestures/PredictiveBackGesturePatchKt { + public static final fun getPredictiveBackGesturePatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/all/misc/network/OverrideCertificatePinningPatchKt { + public static final fun getOverrideCertificatePinningPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/all/misc/packagename/ChangePackageNamePatchKt { + public static field packageNameOption Lapp/revanced/patcher/patch/Option; + public static final fun getChangePackageNamePatch ()Lapp/revanced/patcher/patch/ResourcePatch; + public static final fun getPackageNameOption ()Lapp/revanced/patcher/patch/Option; + public static final fun setOrGetFallbackPackageName (Ljava/lang/String;)Ljava/lang/String; + public static final fun setPackageNameOption (Lapp/revanced/patcher/patch/Option;)V +} + +public final class app/revanced/patches/all/misc/resources/AddResourcesPatchKt { + public static final fun addResource (Ljava/lang/String;Lapp/revanced/util/resource/BaseResource;)Z + public static final fun addResources (Lapp/revanced/patcher/patch/Patch;Lkotlin/jvm/functions/Function1;)Z + public static final fun addResources (Ljava/lang/String;Ljava/lang/Iterable;)Z + public static final fun addResources (Ljava/lang/String;Ljava/lang/String;)V + public static final fun addResources (Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;)Z + public static final fun addResources (Ljava/lang/String;Ljava/util/List;)Z + public static synthetic fun addResources$default (Lapp/revanced/patcher/patch/Patch;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Z + public static synthetic fun addResources$default (Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;ILjava/lang/Object;)Z + public static final fun getAddResourcesPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/all/misc/screencapture/RemoveScreenCaptureRestrictionPatchKt { + public static final fun getRemoveScreenCaptureRestrictionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/all/misc/screenshot/RemoveScreenshotRestrictionPatchKt { + public static final fun getRemoveScreenshotRestrictionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/all/misc/shortcut/sharetargets/RemoveShareTargetsPatchKt { + public static final fun getRemoveShareTargetsPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public abstract interface class app/revanced/patches/all/misc/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/all/misc/transformation/IMethodCall$DefaultImpls { + public static fun replaceInvokeVirtualWithExtension (Lapp/revanced/patches/all/misc/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/all/misc/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/all/misc/versioncode/ChangeVersionCodePatchKt { + public static final fun getChangeVersionCodePatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/amazon/DeepLinkingPatchKt { + public static final fun getDeepLinkingPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/backdrops/misc/pro/ProUnlockPatchKt { + public static final fun getProUnlockPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/bandcamp/limitations/RemovePlayLimitsPatchKt { + public static final fun getRemovePlayLimitsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/cieid/restrictions/root/BypassRootChecksPatchKt { + public static final fun getBypassRootChecksPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/duolingo/ad/DisableAdsPatchKt { + public static final fun getDisableAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/duolingo/debug/EnableDebugMenuPatchKt { + public static final fun getEnableDebugMenuPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/facebook/ads/mainfeed/HideSponsoredStoriesPatchKt { + public static final fun getHideSponsoredStoriesPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/facebook/ads/story/HideStoryAdsPatchKt { + public static final fun getHideStoryAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/finanzonline/detection/bootloader/BootloaderDetectionPatchKt { + public static final fun getBootloaderDetectionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/finanzonline/detection/root/RootDetectionPatchKt { + public static final fun getRootDetectionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/googlenews/customtabs/EnableCustomTabsPatchKt { + public static final fun getEnableCustomTabsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/googlenews/misc/extension/ExtensionPatchKt { + public static final fun getExtensionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/googlenews/misc/gms/GmsCoreSupportPatchKt { + public static final fun getGmsCoreSupportPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/googlephotos/misc/extension/ExtensionPatchKt { + public static final fun getExtensionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/googlephotos/misc/features/SpoofBuildInfoPatchKt { + public static final fun getSpoofBuildInfoPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/googlephotos/misc/features/SpoofFeaturesPatchKt { + public static final fun getSpoofFeaturesPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/googlephotos/misc/gms/GmsCoreSupportPatchKt { + public static final fun getGmsCoreSupportPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/googlephotos/misc/preferences/RestoreHiddenBackUpWhileChargingTogglePatchKt { + public static final fun getRestoreHiddenBackUpWhileChargingTogglePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/googlerecorder/restrictions/RemoveDeviceRestrictionsKt { + public static final fun getRemoveDeviceRestrictionsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/hexeditor/ad/DisableAdsPatchKt { + public static final fun getDisableAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/iconpackstudio/misc/pro/UnlockProPatchKt { + public static final fun getUnlockProPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/idaustria/detection/root/RootDetectionPatchKt { + public static final fun getRootDetectionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/idaustria/detection/signature/SpoofSignaturePatchKt { + public static final fun getSpoofSignaturePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/inshorts/ad/InshortsAdsPatchKt { + public static final fun getHideAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/instagram/ads/HideAdsPatchKt { + public static final fun getHideAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/irplus/ad/RemoveAdsPatchKt { + public static final fun getRemoveAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/lightroom/misc/login/DisableMandatoryLoginPatchKt { + public static final fun getDisableMandatoryLoginPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/lightroom/misc/premium/UnlockPremiumPatchKt { + public static final fun getUnlockPremiumPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/memegenerator/detection/license/LicenseValidationPatchKt { + public static final fun getLicenseValidationPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/memegenerator/detection/signature/SignatureVerificationPatchKt { + public static final fun getSignatureVerificationPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/memegenerator/misc/pro/UnlockProVersionPatchKt { + public static final fun getUnlockProVersionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/messenger/inbox/HideInboxAdsPatchKt { + public static final fun getHideInboxAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/messenger/inbox/HideInboxSubtabsPatchKt { + public static final fun getHideInboxSubtabsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/messenger/inputfield/DisableSwitchingEmojiToStickerPatchKt { + public static final fun getDisableSwitchingEmojiToStickerPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/messenger/inputfield/DisableTypingIndicatorPatchKt { + public static final fun getDisableTypingIndicatorPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/mifitness/misc/locale/ForceEnglishLocalePatchKt { + public static final fun getForceEnglishLocalePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/mifitness/misc/login/FixLoginPatchKt { + public static final fun getFixLoginPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/ad/video/HideVideoAdsKt { + public static final fun getHideVideoAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/audio/exclusiveaudio/EnableExclusiveAudioPlaybackKt { + public static final fun getEnableExclusiveAudioPlaybackPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/interaction/permanentrepeat/PermanentRepeatPatchKt { + public static final fun getPermanentRepeatPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/interaction/permanentshuffle/PermanentShufflePatchKt { + public static final fun getPermanentShufflePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/layout/compactheader/HideCategoryBarKt { + public static final fun getHideCategoryBar ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/layout/premium/HideGetPremiumPatchKt { + public static final fun getHideGetPremiumPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/layout/upgradebutton/RemoveUpgradeButtonPatchKt { + public static final fun getRemoveUpgradeButtonPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/misc/androidauto/BypassCertificateChecksPatchKt { + public static final fun getBypassCertificateChecksPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +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/extension/SharedExtensionPatchKt { + public static final fun getSharedExtensionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/music/misc/gms/Constants { + public static final field INSTANCE Lapp/revanced/patches/music/misc/gms/Constants; +} + +public final class app/revanced/patches/music/misc/gms/GmsCoreSupportPatchKt { + public static final fun getGmsCoreSupportPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/myexpenses/misc/pro/UnlockProPatchKt { + public static final fun getUnlockProPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/myfitnesspal/ads/HideAdsPatchKt { + public static final fun getHideAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/netguard/broadcasts/removerestriction/RemoveBroadcastsRestrictionPatchKt { + public static final fun getRemoveBroadcastsRestrictionPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/nfctoolsse/misc/pro/UnlockProPatchKt { + public static final fun getUnlockProPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/nyx/misc/pro/UnlockProPatchKt { + public static final fun getUnlockProPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/openinghours/misc/fix/crash/FixCrashPatchKt { + public static final fun getFixCrashPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/photomath/detection/deviceid/SpoofDeviceIdPatchKt { + public static final fun getGetDeviceIdPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/photomath/detection/signature/SignatureDetectionPatchKt { + public static final fun getSignatureDetectionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/photomath/misc/annoyances/HideUpdatePopupPatchKt { + public static final fun getHideUpdatePopupPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/photomath/misc/unlock/bookpoint/EnableBookpointPatchKt { + public static final fun getEnableBookpointPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/photomath/misc/unlock/plus/UnlockPlusPatchKt { + public static final fun getUnlockPlusPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/piccomafr/misc/SpoofAndroidDeviceIdPatchKt { + public static final fun getSpoofAndroidDeviceIdPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/piccomafr/tracking/DisableTrackingPatchKt { + public static final fun getDisableTrackingPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/pixiv/ads/HideAdsPatchKt { + public static final fun getHideAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/rar/misc/annoyances/purchasereminder/HidePurchaseReminderPatchKt { + public static final fun getHidePurchaseReminderPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/ad/banner/HideBannerPatchKt { + public static final fun getHideBannerPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/reddit/ad/comments/HideCommentAdsPatchKt { + public static final fun getHideCommentAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/ad/general/HideAdsPatchKt { + public static final fun getHideAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/FixSLinksPatchKt { + public static final field RESOLVE_S_LINK_METHOD Ljava/lang/String; + public static final field SET_ACCESS_TOKEN_METHOD Ljava/lang/String; + public static final fun fixSLinksPatch (Lapp/revanced/patcher/patch/Patch;Lkotlin/jvm/functions/Function1;)Lapp/revanced/patcher/patch/BytecodePatch; + public static synthetic fun fixSLinksPatch$default (Lapp/revanced/patcher/patch/Patch;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/SpoofClientPatchKt { + public static final fun spoofClientPatch (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)Lapp/revanced/patcher/patch/BytecodePatch; + public static synthetic fun spoofClientPatch$default (Ljava/lang/String;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/baconreader/api/SpoofClientPatchKt { + public static final fun getSpoofClientPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/boostforreddit/ads/DisableAdsPatchKt { + public static final fun getDisableAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/boostforreddit/api/SpoofClientPatchKt { + public static final fun getSpoofClientPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/boostforreddit/fix/downloads/FixAudioMissingInDownloadsPatchKt { + public static final fun getFixAudioMissingInDownloadsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/boostforreddit/fix/slink/FixSLinksPatchKt { + public static final field EXTENSION_CLASS_DESCRIPTOR Ljava/lang/String; + public static final fun getFixSlinksPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/boostforreddit/misc/extension/SharedExtensionPatchKt { + public static final fun getSharedExtensionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/infinityforreddit/api/SpoofClientPatchKt { + public static final fun getSpoofClientPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/infinityforreddit/subscription/UnlockSubscriptionPatchKt { + public static final fun getUnlockSubscriptionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/joeyforreddit/ads/DisableAdsPatchKt { + public static final fun getDisableAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/joeyforreddit/api/SpoofClientPatchKt { + public static final fun getSpoofClientPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/joeyforreddit/detection/piracy/DisablePiracyDetectionPatchKt { + public static final fun getDisablePiracyDetectionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/redditisfun/api/FingerprintsKt { + public static final fun baseClientIdFingerprint (Ljava/lang/String;)Lapp/revanced/patcher/Fingerprint; +} + +public final class app/revanced/patches/reddit/customclients/redditisfun/api/SpoofClientPatchKt { + public static final fun getSpoofClientPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/relayforreddit/api/SpoofClientPatchKt { + public static final fun getSpoofClientPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/slide/api/SpoofClientPatchKt { + public static final fun getSpoofClientPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/sync/ads/DisableAdsPatchKt { + public static final fun disableAdsPatch (Lkotlin/jvm/functions/Function1;)Lapp/revanced/patcher/patch/BytecodePatch; + public static synthetic fun disableAdsPatch$default (Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/sync/detection/piracy/DisablePiracyDetectionPatchKt { + public static final fun getDisablePiracyDetectionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/sync/syncforlemmy/ads/DisableAdsPatchKt { + public static final fun getDisableAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/sync/syncforreddit/ads/DisableAdsPatchKt { + public static final fun getDisableAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/sync/syncforreddit/annoyances/startup/DisableSyncForLemmyBottomSheetPatchKt { + public static final fun getDisableSyncForLemmyBottomSheetPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/sync/syncforreddit/api/SpoofClientPatchKt { + public static final fun getSpoofClientPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/sync/syncforreddit/extension/SharedExtensionPatchKt { + public static final fun getSharedExtensionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/sync/syncforreddit/fix/slink/FixSLinksPatchKt { + public static final field EXTENSION_CLASS_DESCRIPTOR Ljava/lang/String; + public static final fun getFixSLinksPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/sync/syncforreddit/fix/user/UseUserEndpointPatchKt { + public static final fun getUseUserEndpointPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/customclients/syncforreddit/fix/video/FixVideoDownloadsPatchKt { + public static final fun getFixVideoDownloadsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/layout/disablescreenshotpopup/DisableScreenshotPopupPatchKt { + public static final fun getDisableScreenshotPopupPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/layout/premiumicon/UnlockPremiumIconPatchKt { + public static final fun getUnlockPremiumIconPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/reddit/misc/extension/ExtensionPatchKt { + public static final fun getSharedExtensionPatch ()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/serviceportalbund/detection/root/RootDetectionPatchKt { + public static final fun getRootDetectionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/misc/checks/BaseCheckEnvironmentPatchKt { + public static final fun checkEnvironmentPatch (Lapp/revanced/patcher/Fingerprint;Lapp/revanced/patcher/patch/Patch;[Ljava/lang/String;)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/misc/extension/ExtensionHook { + public final fun getFingerprint ()Lapp/revanced/patcher/Fingerprint; + public final fun invoke (Ljava/lang/String;)V +} + +public final class app/revanced/patches/shared/misc/extension/SharedExtensionPatchKt { + public static final fun extensionHook (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lapp/revanced/patches/shared/misc/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/misc/extension/ExtensionHook; + public static final fun sharedExtensionPatch ([Lapp/revanced/patches/shared/misc/extension/ExtensionHook;)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/misc/fix/verticalscroll/VerticalScrollPatchKt { + public static final fun getVerticalScrollPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/shared/misc/gms/FingerprintsKt { + public static final field GET_GMS_CORE_VENDOR_GROUP_ID_METHOD_NAME Ljava/lang/String; +} + +public final class app/revanced/patches/shared/misc/gms/GmsCoreSupportPatchKt { + public static final fun gmsCoreSupportPatch (Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patcher/Fingerprint;Ljava/util/Set;Lapp/revanced/patcher/Fingerprint;Lapp/revanced/patcher/patch/Patch;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lapp/revanced/patcher/patch/BytecodePatch; + public static synthetic fun gmsCoreSupportPatch$default (Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patcher/Fingerprint;Ljava/util/Set;Lapp/revanced/patcher/Fingerprint;Lapp/revanced/patcher/patch/Patch;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lapp/revanced/patcher/patch/BytecodePatch; + public static final fun gmsCoreSupportResourcePatch (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patcher/patch/Option;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lapp/revanced/patcher/patch/ResourcePatch; + public static synthetic fun gmsCoreSupportResourcePatch$default (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patcher/patch/Option;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/shared/misc/hex/HexPatchKt { + public static final fun hexPatch (Lkotlin/jvm/functions/Function0;)Lapp/revanced/patcher/patch/RawResourcePatch; +} + +public final class app/revanced/patches/shared/misc/hex/Replacement { + public static final field Companion Lapp/revanced/patches/shared/misc/hex/Replacement$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public final fun replacePattern ([B)V +} + +public final class app/revanced/patches/shared/misc/hex/Replacement$Companion { +} + +public final class app/revanced/patches/shared/misc/mapping/ResourceElement { + 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/misc/mapping/ResourceElement; + public static synthetic fun copy$default (Lapp/revanced/patches/shared/misc/mapping/ResourceElement;Ljava/lang/String;Ljava/lang/String;JILjava/lang/Object;)Lapp/revanced/patches/shared/misc/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/misc/mapping/ResourceMappingPatchKt { + 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/misc/settings/SettingsPatchKt { + public static final fun settingsPatch (Lkotlin/Pair;Ljava/util/Set;)Lapp/revanced/patcher/patch/ResourcePatch; + public static synthetic fun settingsPatch$default (Lkotlin/Pair;Ljava/util/Set;ILjava/lang/Object;)Lapp/revanced/patcher/patch/ResourcePatch; +} + +public abstract class app/revanced/patches/shared/misc/settings/preference/BasePreference { + public static final field Companion Lapp/revanced/patches/shared/misc/settings/preference/BasePreference$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getKey ()Ljava/lang/String; + public final fun getSummaryKey ()Ljava/lang/String; + public final fun getTag ()Ljava/lang/String; + public final fun getTitleKey ()Ljava/lang/String; + public fun hashCode ()I + public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; +} + +public final class app/revanced/patches/shared/misc/settings/preference/BasePreference$Companion { + public final fun addSummary (Lorg/w3c/dom/Element;Ljava/lang/String;Lapp/revanced/patches/shared/misc/settings/preference/SummaryType;)V + public static synthetic fun addSummary$default (Lapp/revanced/patches/shared/misc/settings/preference/BasePreference$Companion;Lorg/w3c/dom/Element;Ljava/lang/String;Lapp/revanced/patches/shared/misc/settings/preference/SummaryType;ILjava/lang/Object;)V +} + +public abstract class app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen : java/io/Closeable { + public fun ()V + public fun (Ljava/util/Set;)V + public synthetic fun (Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun close ()V + public abstract fun commit (Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreenPreference;)V +} + +public abstract class app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$BasePreferenceCollection { + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getKey ()Ljava/lang/String; + public final fun getPreferences ()Ljava/util/Set; + public final fun getTitleKey ()Ljava/lang/String; + public abstract fun transform ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreference; +} + +public class app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen : app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$BasePreferenceCollection { + public fun (Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;Ljava/util/Set;Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreenPreference$Sorting;)V + public synthetic fun (Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;Ljava/util/Set;Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreenPreference$Sorting;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun addPreferences ([Lapp/revanced/patches/shared/misc/settings/preference/BasePreference;)V + public final fun getCategories ()Ljava/util/Set; + public synthetic fun transform ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreference; + public fun transform ()Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreenPreference; +} + +public class app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen$Category : app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$BasePreferenceCollection { + public fun (Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;)V + public synthetic fun (Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun addPreferences ([Lapp/revanced/patches/shared/misc/settings/preference/BasePreference;)V + public synthetic fun transform ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreference; + public fun transform ()Lapp/revanced/patches/shared/misc/settings/preference/PreferenceCategory; +} + +public final class app/revanced/patches/shared/misc/settings/preference/InputType : java/lang/Enum { + public static final field NUMBER Lapp/revanced/patches/shared/misc/settings/preference/InputType; + public static final field TEXT Lapp/revanced/patches/shared/misc/settings/preference/InputType; + public static final field TEXT_CAP_CHARACTERS Lapp/revanced/patches/shared/misc/settings/preference/InputType; + public static final field TEXT_MULTI_LINE Lapp/revanced/patches/shared/misc/settings/preference/InputType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getType ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lapp/revanced/patches/shared/misc/settings/preference/InputType; + public static fun values ()[Lapp/revanced/patches/shared/misc/settings/preference/InputType; +} + +public final class app/revanced/patches/shared/misc/settings/preference/IntentPreference : app/revanced/patches/shared/misc/settings/preference/BasePreference { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patches/shared/misc/settings/preference/IntentPreference$Intent;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patches/shared/misc/settings/preference/IntentPreference$Intent;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getIntent ()Lapp/revanced/patches/shared/misc/settings/preference/IntentPreference$Intent; + public fun hashCode ()I + public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; +} + +public final class app/revanced/patches/shared/misc/settings/preference/IntentPreference$Intent { + public fun (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V + public final fun copy (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)Lapp/revanced/patches/shared/misc/settings/preference/IntentPreference$Intent; + public static synthetic fun copy$default (Lapp/revanced/patches/shared/misc/settings/preference/IntentPreference$Intent;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lapp/revanced/patches/shared/misc/settings/preference/IntentPreference$Intent; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class app/revanced/patches/shared/misc/settings/preference/ListPreference : app/revanced/patches/shared/misc/settings/preference/BasePreference { + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/util/resource/ArrayResource;Lapp/revanced/util/resource/ArrayResource;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/util/resource/ArrayResource;Lapp/revanced/util/resource/ArrayResource;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getEntries ()Lapp/revanced/util/resource/ArrayResource; + public final fun getEntriesKey ()Ljava/lang/String; + public final fun getEntryValues ()Lapp/revanced/util/resource/ArrayResource; + public final fun getEntryValuesKey ()Ljava/lang/String; + public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; +} + +public final class app/revanced/patches/shared/misc/settings/preference/NonInteractivePreference : app/revanced/patches/shared/misc/settings/preference/BasePreference { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getSelectable ()Z + public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; +} + +public class app/revanced/patches/shared/misc/settings/preference/PreferenceCategory : app/revanced/patches/shared/misc/settings/preference/BasePreference { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getPreferences ()Ljava/util/Set; + public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; +} + +public class app/revanced/patches/shared/misc/settings/preference/PreferenceScreenPreference : app/revanced/patches/shared/misc/settings/preference/BasePreference { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreenPreference$Sorting;Ljava/lang/String;Ljava/util/Set;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreenPreference$Sorting;Ljava/lang/String;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getPreferences ()Ljava/util/Set; + public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; +} + +public final class app/revanced/patches/shared/misc/settings/preference/PreferenceScreenPreference$Sorting : java/lang/Enum { + public static final field BY_KEY Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreenPreference$Sorting; + public static final field BY_TITLE Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreenPreference$Sorting; + public static final field UNSORTED Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreenPreference$Sorting; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getKeySuffix ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreenPreference$Sorting; + public static fun values ()[Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreenPreference$Sorting; +} + +public final class app/revanced/patches/shared/misc/settings/preference/SummaryType : java/lang/Enum { + public static final field DEFAULT Lapp/revanced/patches/shared/misc/settings/preference/SummaryType; + public static final field OFF Lapp/revanced/patches/shared/misc/settings/preference/SummaryType; + public static final field ON Lapp/revanced/patches/shared/misc/settings/preference/SummaryType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getType ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lapp/revanced/patches/shared/misc/settings/preference/SummaryType; + public static fun values ()[Lapp/revanced/patches/shared/misc/settings/preference/SummaryType; +} + +public final class app/revanced/patches/shared/misc/settings/preference/SwitchPreference : app/revanced/patches/shared/misc/settings/preference/BasePreference { + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getSummaryOffKey ()Ljava/lang/String; + public final fun getSummaryOnKey ()Ljava/lang/String; + public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; +} + +public final class app/revanced/patches/shared/misc/settings/preference/TextPreference : app/revanced/patches/shared/misc/settings/preference/BasePreference { + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patches/shared/misc/settings/preference/InputType;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patches/shared/misc/settings/preference/InputType;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getInputType ()Lapp/revanced/patches/shared/misc/settings/preference/InputType; + public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; +} + +public final class app/revanced/patches/solidexplorer2/functionality/filesize/RemoveFileSizeLimitPatchKt { + public static final fun getRemoveFileSizeLimitPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/songpal/badge/BadgeTabPatchKt { + public static final fun getBadgeTabPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/songpal/badge/RemoveNotificationBadgePatchKt { + public static final fun getRemoveNotificationBadgePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/soundcloud/ad/HideAdsPatchKt { + public static final fun getHideAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/soundcloud/analytics/DisableTelemetryPatchKt { + public static final fun getDisableTelemetryPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/soundcloud/offlinesync/EnableOfflineSyncPatchKt { + public static final fun getEnableOfflineSync ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/spotify/layout/theme/CustomThemePatchKt { + public static final fun getCustomThemePatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/spotify/lite/ondemand/OnDemandPatchKt { + public static final fun getOnDemandPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/spotify/navbar/PremiumNavbarTabPatchKt { + public static final fun getPremiumNavbarTabPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/stocard/layout/HideOffersTabPatchKt { + public static final fun getHideOffersTabPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/stocard/layout/HideStoryBubblesPatchKt { + public static final fun getHideStoryBubblesPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/strava/subscription/UnlockSubscriptionPatchKt { + public static final fun getUnlockSubscriptionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/strava/upselling/DisableSubscriptionSuggestionsPatchKt { + public static final fun getDisableSubscriptionSuggestionsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/swissid/integritycheck/RemoveGooglePlayIntegrityCheckPatchKt { + public static final fun getRemoveGooglePlayIntegrityCheckPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/ticktick/misc/themeunlock/UnlockThemePatchKt { + public static final fun getUnlockProPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tiktok/feedfilter/FeedFilterPatchKt { + public static final fun getFeedFilterPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tiktok/interaction/cleardisplay/RememberClearDisplayPatchKt { + public static final fun getRememberClearDisplayPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tiktok/interaction/downloads/DownloadsPatchKt { + public static final fun getDownloadsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tiktok/interaction/seekbar/ShowSeekbarPatchKt { + public static final fun getShowSeekbarPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tiktok/interaction/speed/PlaybackSpeedPatchKt { + public static final fun getPlaybackSpeedPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tiktok/misc/extension/ExtensionPatchKt { + public static final fun getSharedExtensionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tiktok/misc/login/disablerequirement/DisableLoginRequirementPatchKt { + public static final fun getDisableLoginRequirementPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tiktok/misc/login/fixgoogle/FixGoogleLoginPatchKt { + public static final fun getFixGoogleLoginPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tiktok/misc/settings/SettingsPatchKt { + public static final fun getSettingsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tiktok/misc/spoof/sim/SpoofSimPatchKt { + public static final fun getSpoofSimPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/trakt/UnlockProPatchKt { + public static final fun getUnlockProPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tudortmund/lockscreen/ShowOnLockscreenPatchKt { + public static final fun getShowOnLockscreenPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tudortmund/misc/extension/ExtensionPatchKt { + public static final fun getSharedExtensionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tumblr/ads/DisableDashboardAdsKt { + public static final fun getDisableDashboardAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tumblr/annoyances/adfree/DisableAdFreeBannerPatchKt { + public static final fun getDisableAdFreeBannerPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tumblr/annoyances/inappupdate/DisableInAppUpdatePatchKt { + public static final fun getDisableInAppUpdatePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tumblr/annoyances/notifications/DisableBlogNotificationReminderPatchKt { + public static final fun getDisableBlogNotificationReminderPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tumblr/annoyances/popups/DisableGiftMessagePopupPatchKt { + public static final fun getDisableGiftMessagePopupPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tumblr/featureflags/OverrideFeatureFlagsPatchKt { + public static final fun getOverrideFeatureFlagsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tumblr/fixes/FixOldVersionsPatchKt { + public static final fun getFixOldVersionsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tumblr/live/DisableTumblrLivePatchKt { + public static final fun getDisableTumblrLivePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tumblr/misc/extension/ExtensionPatchKt { + public static final fun getSharedExtensionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/tumblr/timelinefilter/FilterTimelineObjectsPatchKt { + public static field addTimelineObjectTypeFilter Lkotlin/jvm/functions/Function1; + public static final fun getAddTimelineObjectTypeFilter ()Lkotlin/jvm/functions/Function1; + public static final fun getFilterTimelineObjectsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; + public static final fun setAddTimelineObjectTypeFilter (Lkotlin/jvm/functions/Function1;)V +} + +public final class app/revanced/patches/twitch/ad/audio/AudioAdsPatchKt { + public static final fun getAudioAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitch/ad/embedded/EmbeddedAdsPatchKt { + public static final fun getEmbeddedAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitch/ad/shared/util/AdPatchKt { + public static final fun adPatch (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function3;)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitch/ad/shared/util/ReturnMethod { + public static final field Companion Lapp/revanced/patches/twitch/ad/shared/util/ReturnMethod$Companion; + public fun (CLjava/lang/String;)V + public final fun getReturnType ()C + public final fun getValue ()Ljava/lang/String; +} + +public final class app/revanced/patches/twitch/ad/shared/util/ReturnMethod$Companion { + public final fun getDefault ()Lapp/revanced/patches/twitch/ad/shared/util/ReturnMethod; +} + +public final class app/revanced/patches/twitch/ad/video/VideoAdsPatchKt { + public static final fun getVideoAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitch/chat/antidelete/ShowDeletedMessagesPatchKt { + public static final fun getShowDeletedMessagesPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitch/chat/autoclaim/AutoClaimChannelPointsPatchKt { + public static final fun getAutoClaimChannelPointsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitch/debug/DebugModePatchKt { + public static final fun getDebugModePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitch/misc/extension/SharedExtensionPatchKt { + public static final fun getSharedExtensionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitch/misc/settings/SettingsPatchKt { + public static final fun addSettingPreference (Lapp/revanced/patches/shared/misc/settings/preference/BasePreference;)V + public static final fun getSettingsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitter/interaction/downloads/UnlockDownloadsPatchKt { + public static final fun getUnlockDownloadsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitter/layout/viewcount/HideViewCountPatchKt { + public static final fun getHideViewCountPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitter/misc/dynamiccolor/DynamicColorPatchKt { + public static final fun getDynamicColorPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/twitter/misc/extension/ExtensionPatchKt { + public static final fun getSharedExtensionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitter/misc/hook/HideAdsHookPatchKt { + public static final fun getHideAdsHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitter/misc/hook/HideRecommendedUsersPatchKt { + public static final fun getHideRecommendedUsersPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitter/misc/hook/HookPatchKt { + public static final fun hookPatch (Ljava/lang/String;Ljava/lang/String;)Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitter/misc/hook/json/JsonHook { + public fun (Lapp/revanced/patcher/patch/BytecodePatchContext;Ljava/lang/String;)V +} + +public final class app/revanced/patches/twitter/misc/hook/json/JsonHookPatchHook : java/io/Closeable { + public fun (Lapp/revanced/patcher/Fingerprint;)V + public final fun addHook (Lapp/revanced/patches/twitter/misc/hook/json/JsonHook;)V + public fun close ()V +} + +public final class app/revanced/patches/twitter/misc/hook/json/JsonHookPatchKt { + public static final fun getJsonHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitter/misc/links/ChangeLinkSharingDomainPatchKt { + public static final fun getChangeLinkSharingDomainPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitter/misc/links/OpenLinksWithAppChooserPatchKt { + public static final fun getOpenLinksWithAppChooserPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/twitter/misc/links/SanitizeSharingLinksPatchKt { + public static final fun getSanitizeSharingLinksPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/vsco/misc/pro/UnlockProPatchKt { + public static final fun getUnlockProPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/warnwetter/misc/firebasegetcert/FirebaseGetCertPatchKt { + public static final fun getFirebaseGetCertPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/warnwetter/misc/promocode/PromoCodeUnlockPatchKt { + public static final fun getPromoCodeUnlockPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/windyapp/misc/unlockpro/UnlockProPatchKt { + public static final fun getUnlockProPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/ad/general/HideAdsPatchKt { + public static final fun getHideAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/ad/getpremium/HideGetPremiumPatchKt { + public static final fun getHideGetPremiumPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/ad/video/VideoAdsPatchKt { + public static final fun getVideoAdsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/interaction/copyvideourl/CopyVideoUrlPatchKt { + public static final fun getCopyVideoUrlPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/interaction/dialog/RemoveViewerDiscretionDialogPatchKt { + public static final fun getRemoveViewerDiscretionDialogPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/interaction/downloads/DownloadsPatchKt { + public static final fun getDownloadsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/interaction/seekbar/DisablePreciseSeekingGesturePatchKt { + public static final fun getDisablePreciseSeekingGesturePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/interaction/seekbar/EnableSeekbarTappingPatchKt { + public static final fun getEnableSeekbarTappingPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/interaction/seekbar/EnableSlideToSeekPatchKt { + public static final fun getEnableSlideToSeekPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/interaction/swipecontrols/SwipeControlsPatchKt { + public static final fun getSwipeControlsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/autocaptions/AutoCaptionsPatchKt { + public static final fun getAutoCaptionsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/branding/CustomBrandingPatchKt { + public static final fun getCustomBrandingPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/layout/branding/header/ChangeHeaderPatchKt { + public static final fun getChangeHeaderPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/layout/buttons/action/HideButtonsPatchKt { + public static final fun getHideButtonsPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/layout/buttons/navigation/NavigationButtonsPatchKt { + public static final fun getNavigationButtonsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/buttons/overlay/HidePlayerOverlayButtonsPatchKt { + public static final fun getHidePlayerOverlayButtonsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/hide/breakingnews/BreakingNewsPatchKt { + public static final fun getBreakingNewsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/hide/endscreencards/HideEndscreenCardsPatchKt { + public static final fun getHideEndscreenCardsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/hide/fullscreenambientmode/DisableFullscreenAmbientModePatchKt { + public static final fun getDisableFullscreenAmbientModePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/hide/general/HideLayoutComponentsPatchKt { + public static final fun getAlbumCardId ()J + public static final fun getBarContainerHeightId ()J + public static final fun getCrowdfundingBoxId ()J + public static final fun getExpandButtonDownId ()J + public static final fun getFabButtonId ()J + public static final fun getFilterBarHeightId ()J + public static final fun getHideLayoutComponentsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; + public static final fun getRelatedChipCloudMarginId ()J + public static final fun getYouTubeLogo ()J +} + +public final class app/revanced/patches/youtube/layout/hide/infocards/HideInfoCardsPatchKt { + public static final fun getHideInfoCardsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/hide/player/flyoutmenupanel/HidePlayerFlyoutMenuPatchKt { + public static final fun getHidePlayerFlyoutMenuPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/hide/rollingnumber/DisableRollingNumberAnimationPatchKt { + public static final fun getDisableRollingNumberAnimationPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/hide/seekbar/HideSeekbarPatchKt { + public static final fun getHideSeekbarPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/hide/shorts/HideShortsComponentsPatchKt { + public static final fun getHideShortsComponentsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/hide/suggestedvideoendscreen/DisableSuggestedVideoEndScreenPatchKt { + public static final fun getDisableSuggestedVideoEndScreenPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/hide/time/HideTimestampPatchKt { + public static final fun getHideTimestampPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/miniplayer/FingerprintsKt { + public static final field ANIMATION_INTERPOLATION_FEATURE_KEY J + public static final field DOUBLE_TAP_ENABLED_FEATURE_KEY_LITERAL J + public static final field DRAG_DROP_ENABLED_FEATURE_KEY_LITERAL J + public static final field DROP_SHADOW_FEATURE_KEY J + public static final field INITIAL_SIZE_FEATURE_KEY_LITERAL J + public static final field MODERN_FEATURE_FLAGS_ENABLED_KEY_LITERAL J + public static final field MODERN_MINIPLAYER_ENABLED_OLD_TARGETS_FEATURE_KEY J + public static final field ROUNDED_CORNERS_FEATURE_KEY J +} + +public final class app/revanced/patches/youtube/layout/miniplayer/MiniplayerPatchKt { + public static final fun getFloatyBarButtonTopMargin ()J + public static final fun getMiniplayerMaxSize ()J + public static final fun getMiniplayerPatch ()Lapp/revanced/patcher/patch/BytecodePatch; + 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 getPlayerOverlays ()J + public static final fun getScrimOverlay ()J + public static final fun getYtOutlinePictureInPictureWhite24 ()J + public static final fun getYtOutlineXWhite24 ()J +} + +public final class app/revanced/patches/youtube/layout/panels/popup/PlayerPopupPanelsPatchKt { + public static final fun getPlayerPopupPanelsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/player/background/PlayerControlsBackgroundPatchKt { + public static final fun getPlayerControlsBackgroundPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/layout/player/overlay/CustomPlayerOverlayOpacityPatchKt { + public static final fun getCustomPlayerOverlayOpacityPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/returnyoutubedislike/ReturnYouTubeDislikePatchKt { + public static final fun getReturnYouTubeDislikePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/returnyoutubedislike/Vote : java/lang/Enum { + public static final field DISLIKE Lapp/revanced/patches/youtube/layout/returnyoutubedislike/Vote; + public static final field LIKE Lapp/revanced/patches/youtube/layout/returnyoutubedislike/Vote; + public static final field REMOVE_LIKE Lapp/revanced/patches/youtube/layout/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/layout/returnyoutubedislike/Vote; + public static fun values ()[Lapp/revanced/patches/youtube/layout/returnyoutubedislike/Vote; +} + +public final class app/revanced/patches/youtube/layout/searchbar/WideSearchbarPatchKt { + public static final fun getWideSearchbarPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/seekbar/FingerprintsKt { + public static final field PLAYER_SEEKBAR_GRADIENT_FEATURE_FLAG J +} + +public final class app/revanced/patches/youtube/layout/seekbar/RestoreOldSeekbarThumbnailsPatchKt { + public static final fun getRestoreOldSeekbarThumbnailsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/seekbar/SeekbarColorPatchKt { + public static final fun getSeekbarColorPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/shortsautoplay/ShortsAutoplayPatchKt { + public static final fun getShortsAutoplayPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/sponsorblock/SponsorBlockPatchKt { + public static final fun getSponsorBlockPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/spoofappversion/SpoofAppVersionPatchKt { + public static final fun getSpoofAppVersionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/startpage/ChangeStartPagePatchKt { + public static final fun getChangeStartPagePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/startupshortsreset/DisableResumingShortsOnStartupPatchKt { + public static final fun getDisableResumingShortsOnStartupPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/startupshortsreset/FingerprintsKt { + public static final fun indexOfOptionalInstruction (Lcom/android/tools/smali/dexlib2/iface/Method;)I +} + +public final class app/revanced/patches/youtube/layout/tablet/EnableTabletLayoutPatchKt { + public static final field EXTENSION_CLASS_DESCRIPTOR Ljava/lang/String; + public static final fun getEnableTabletLayoutPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/theme/LithoColorHookPatchKt { + public static final fun getLithoColorHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; + public static final fun getLithoColorOverrideHook ()Lkotlin/jvm/functions/Function2; +} + +public final class app/revanced/patches/youtube/layout/theme/ThemePatchKt { + public static final fun getThemePatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/thumbnails/AlternativeThumbnailsPatchKt { + public static final fun getAlternativeThumbnailsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/layout/thumbnails/BypassImageRegionRestrictionsPatchKt { + public static final fun getBypassImageRegionRestrictionsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/announcements/AnnouncementsPatchKt { + public static final fun getAnnouncementsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/autorepeat/AutoRepeatPatchKt { + public static final fun getAutoRepeatPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +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/check/CheckEnvironmentPatchKt { + public static final fun getCheckEnvironmentPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/debugging/EnableDebuggingPatchKt { + public static final fun getEnableDebuggingPatch ()Lapp/revanced/patcher/patch/ResourcePatch; +} + +public final class app/revanced/patches/youtube/misc/dimensions/spoof/SpoofDeviceDimensionsPatchKt { + public static final fun getSpoofDeviceDimensionsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/dns/CheckWatchHistoryDomainNameResolutionPatchKt { + public static final fun getCheckWatchHistoryDomainNameResolutionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/extension/SharedExtensionPatchKt { + public static final fun getSharedExtensionPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/fix/cairo/FingerprintsKt { + public static final field CAIRO_CONFIG_LITERAL_VALUE J +} + +public final class app/revanced/patches/youtube/misc/fix/playback/SpoofVideoStreamsPatchKt { + public static final fun getSpoofVideoStreamsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/fix/playback/UserAgentClientSpoofPatchKt { + public static final fun getUserAgentClientSpoofPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/gms/GmsCoreSupportPatchKt { + public static final fun getGmsCoreSupportPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/imageurlhook/CronetImageUrlHookKt { + public static final fun addImageUrlErrorCallbackHook (Ljava/lang/String;)V + public static final fun addImageUrlHook (Ljava/lang/String;Z)V + public static synthetic fun addImageUrlHook$default (Ljava/lang/String;ZILjava/lang/Object;)V + public static final fun addImageUrlSuccessCallbackHook (Ljava/lang/String;)V + public static final fun getCronetImageUrlHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/links/BypassURLRedirectsPatchKt { + public static final fun getBypassURLRedirectsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/links/OpenLinksExternallyPatchKt { + public static final fun getOpenLinksExternallyPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/litho/filter/LithoFilterPatchKt { + public static final fun getAddLithoFilter ()Lkotlin/jvm/functions/Function1; + public static final fun getLithoFilterPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/navigation/NavigationBarHookPatchKt { + public static field hookNavigationButtonCreated Lkotlin/jvm/functions/Function1; + 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/misc/playercontrols/PlayerControlsPatchKt { + public static final fun getAddBottomControl ()Lkotlin/jvm/functions/Function1; + public static final fun getPlayerControlsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; + public static final fun getPlayerControlsResourcePatch ()Lapp/revanced/patcher/patch/ResourcePatch; + public static final fun initializeBottomControl (Ljava/lang/String;)V + public static final fun injectVisibilityCheckCall (Ljava/lang/String;)V +} + +public final class app/revanced/patches/youtube/misc/playertype/PlayerTypeHookPatchKt { + public static final fun getPlayerTypeHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/playservice/VersionCheckPatchKt { + public static final fun getVersionCheckPatch ()Lapp/revanced/patcher/patch/ResourcePatch; + public static final fun is_19_03_or_greater ()Z + public static final fun is_19_04_or_greater ()Z + public static final fun is_19_16_or_greater ()Z + public static final fun is_19_17_or_greater ()Z + public static final fun is_19_18_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_26_or_greater ()Z + public static final fun is_19_29_or_greater ()Z + public static final fun is_19_32_or_greater ()Z + public static final fun is_19_33_or_greater ()Z + public static final fun is_19_34_or_greater ()Z + public static final fun is_19_36_or_greater ()Z + public static final fun is_19_41_or_greater ()Z + public static final fun is_19_43_or_greater ()Z +} + +public final class app/revanced/patches/youtube/misc/privacy/RemoveTrackingQueryParameterPatchKt { + public static final fun getRemoveTrackingQueryParameterPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/recyclerviewtree/hook/RecyclerViewTreeHookPatchKt { + public static final fun getAddRecyclerViewTreeHook ()Lkotlin/jvm/functions/Function1; + public static final fun getRecyclerViewTreeHookPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/misc/settings/PreferenceScreen : app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen { + public static final field INSTANCE Lapp/revanced/patches/youtube/misc/settings/PreferenceScreen; + public fun commit (Lapp/revanced/patches/shared/misc/settings/preference/PreferenceScreenPreference;)V + public final fun getADS ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; + public final fun getALTERNATIVE_THUMBNAILS ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; + public final fun getFEED ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; + public final fun getGENERAL_LAYOUT ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; + public final fun getMISC ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; + public final fun getPLAYER ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; + public final fun getSEEKBAR ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; + public final fun getSHORTS ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; + public final fun getSWIPE_CONTROLS ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; + public final fun getVIDEO ()Lapp/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen$Screen; +} + +public final class app/revanced/patches/youtube/misc/settings/SettingsPatchKt { + public static final fun addSettingPreference (Lapp/revanced/patches/shared/misc/settings/preference/BasePreference;)V + public static final fun getSettingsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; + public static final fun newIntent (Ljava/lang/String;)Lapp/revanced/patches/shared/misc/settings/preference/IntentPreference$Intent; +} + +public final class app/revanced/patches/youtube/misc/zoomhaptics/ZoomHapticsPatchKt { + public static final fun getZoomHapticsPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/shared/FingerprintsKt { + public static final fun getRollingNumberTextViewAnimationUpdateFingerprint ()Lapp/revanced/patcher/Fingerprint; +} + +public final class app/revanced/patches/youtube/video/information/VideoInformationPatchKt { + public static final fun getSetPlaybackSpeedClassFieldReference ()Ljava/lang/String; + public static final fun getSetPlaybackSpeedContainerClassFieldReference ()Ljava/lang/String; + public static final fun getSetPlaybackSpeedMethodReference ()Ljava/lang/String; + public static final fun getVideoInformationPatch ()Lapp/revanced/patcher/patch/BytecodePatch; + public static final fun userSelectedPlaybackSpeedHook (Ljava/lang/String;Ljava/lang/String;)V + public static final fun videoTimeHook (Ljava/lang/String;Ljava/lang/String;)V +} + +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$ProtoBufferParameter : app/revanced/patches/youtube/video/playerresponse/Hook { + public fun (Ljava/lang/String;)V +} + +public final class app/revanced/patches/youtube/video/playerresponse/Hook$ProtoBufferParameterBeforeVideoId : 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/quality/RememberVideoQualityPatchKt { + public static final fun getRememberVideoQualityPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/video/speed/PlaybackSpeedPatchKt { + public static final fun getPlaybackSpeedPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/video/speed/button/PlaybackSpeedButtonPatchKt { + public static final fun getPlaybackSpeedButtonPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/youtube/video/speed/custom/CustomPlaybackSpeedPatchKt { + public static final fun getSpeedUnavailableId ()J +} + +public final class app/revanced/patches/youtube/video/videoid/VideoIdPatchKt { + public static final fun getVideoIdPatch ()Lapp/revanced/patcher/patch/BytecodePatch; + public static final fun hookBackgroundPlayVideoId (Ljava/lang/String;)V + public static final fun hookPlayerResponseVideoId (Ljava/lang/String;)V + public static final fun hookVideoId (Ljava/lang/String;)V +} + +public final class app/revanced/patches/youtube/video/videoqualitymenu/RestoreOldVideoQualityMenuPatchKt { + public static final fun getRestoreOldVideoQualityMenuPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/patches/yuka/misc/unlockpremium/UnlockPremiumPatchKt { + public static final fun getUnlockPremiumPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + +public final class app/revanced/util/BytecodeUtilsKt { + public static final fun applyMatch (Lapp/revanced/patcher/Fingerprint;Lapp/revanced/patcher/patch/BytecodePatchContext;Lapp/revanced/patcher/Match;)Lapp/revanced/patcher/Match; + public static final fun containsWideLiteralInstructionValue (Lcom/android/tools/smali/dexlib2/iface/Method;J)Z + 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 findOpcodeIndicesReversed (Lcom/android/tools/smali/dexlib2/iface/Method;Lcom/android/tools/smali/dexlib2/Opcode;)Ljava/util/List; + public static final fun findOpcodeIndicesReversed (Lcom/android/tools/smali/dexlib2/iface/Method;Lkotlin/jvm/functions/Function1;)Ljava/util/List; + public static final fun forEachLiteralValueInstruction (Lapp/revanced/patcher/patch/BytecodePatchContext;JLkotlin/jvm/functions/Function2;)V + public static final fun getException (Lapp/revanced/patcher/Fingerprint;)Lapp/revanced/patcher/patch/PatchException; + public static final fun getMatchOrThrow (Lapp/revanced/patcher/Fingerprint;)Lapp/revanced/patcher/Match; + 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 final fun indexOfFirstInstruction (Lcom/android/tools/smali/dexlib2/iface/Method;Lkotlin/jvm/functions/Function1;)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;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 indexOfFirstWideLiteralInstructionValue (Lcom/android/tools/smali/dexlib2/iface/Method;J)I + public static final fun indexOfFirstWideLiteralInstructionValueOrThrow (Lcom/android/tools/smali/dexlib2/iface/Method;J)I + public static final fun indexOfFirstWideLiteralInstructionValueReversed (Lcom/android/tools/smali/dexlib2/iface/Method;J)I + public static final fun indexOfFirstWideLiteralInstructionValueReversedOrThrow (Lcom/android/tools/smali/dexlib2/iface/Method;J)I + public static final fun indexOfIdResource (Lcom/android/tools/smali/dexlib2/iface/Method;Ljava/lang/String;)I + public static final fun indexOfIdResourceOrThrow (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 literal (Lapp/revanced/patcher/FingerprintBuilder;Lkotlin/jvm/functions/Function0;)V + public static final fun returnEarly (Lapp/revanced/patcher/Fingerprint;Z)V + public static final fun returnEarly (Ljava/lang/Iterable;Z)V + public static final fun returnEarly (Ljava/util/List;Z)V + public static synthetic fun returnEarly$default (Lapp/revanced/patcher/Fingerprint;ZILjava/lang/Object;)V + public static synthetic fun returnEarly$default (Ljava/lang/Iterable;ZILjava/lang/Object;)V + public static synthetic fun returnEarly$default (Ljava/util/List;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 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 asSequence (Lorg/w3c/dom/NodeList;)Lkotlin/sequences/Sequence; + public static final fun childElementsSequence (Lorg/w3c/dom/Node;)Lkotlin/sequences/Sequence; + public static final fun copyResources (Lapp/revanced/patcher/patch/ResourcePatchContext;Ljava/lang/String;[Lapp/revanced/util/ResourceGroup;)V + 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 insertFirst (Lorg/w3c/dom/Node;Lorg/w3c/dom/Node;)V + public static final fun iterateXmlNodeChildren (Lapp/revanced/patcher/patch/ResourcePatchContext;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V +} + +public final class app/revanced/util/resource/ArrayResource : app/revanced/util/resource/BaseResource { + public static final field Companion Lapp/revanced/util/resource/ArrayResource$Companion; + public fun (Ljava/lang/String;Ljava/util/List;)V + public final fun getItems ()Ljava/util/List; + public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; +} + +public final class app/revanced/util/resource/ArrayResource$Companion { + public final fun fromNode (Lorg/w3c/dom/Node;)Lapp/revanced/util/resource/ArrayResource; +} + +public abstract class app/revanced/util/resource/BaseResource { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public final fun getTag ()Ljava/lang/String; + public fun hashCode ()I + public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; + public static synthetic fun serialize$default (Lapp/revanced/util/resource/BaseResource;Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/w3c/dom/Element; +} + +public final class app/revanced/util/resource/StringResource : app/revanced/util/resource/BaseResource { + public static final field Companion Lapp/revanced/util/resource/StringResource$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;Z)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getFormatted ()Z + public final fun getValue ()Ljava/lang/String; + public fun serialize (Lorg/w3c/dom/Document;Lkotlin/jvm/functions/Function1;)Lorg/w3c/dom/Element; +} + +public final class app/revanced/util/resource/StringResource$Companion { + public final fun fromNode (Lorg/w3c/dom/Node;)Lapp/revanced/util/resource/StringResource; +} + diff --git a/patches/build.gradle.kts b/patches/build.gradle.kts new file mode 100644 index 000000000..dab82da23 --- /dev/null +++ b/patches/build.gradle.kts @@ -0,0 +1,35 @@ +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) + // Required due to smali, or build fails. Can be removed once smali is bumped. + implementation(libs.guava) + // Android API stubs defined here. + compileOnly(project(":patches:stub")) +} + +publishing { + repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/revanced/revanced-patches") + credentials { + username = System.getenv("GITHUB_ACTOR") + password = System.getenv("GITHUB_TOKEN") + } + } + } +} diff --git a/src/main/kotlin/app/revanced/patches/all/activity/exportall/ExportAllActivitiesPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/activity/exportall/ExportAllActivitiesPatch.kt similarity index 63% rename from src/main/kotlin/app/revanced/patches/all/activity/exportall/ExportAllActivitiesPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/all/misc/activity/exportall/ExportAllActivitiesPatch.kt index ccb324694..35c7b24f7 100644 --- a/src/main/kotlin/app/revanced/patches/all/activity/exportall/ExportAllActivitiesPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/activity/exportall/ExportAllActivitiesPatch.kt @@ -1,27 +1,22 @@ -package app.revanced.patches.all.activity.exportall +package app.revanced.patches.all.misc.activity.exportall -import app.revanced.patcher.data.ResourceContext -import app.revanced.patcher.patch.ResourcePatch -import app.revanced.patcher.patch.annotation.Patch +import app.revanced.patcher.patch.resourcePatch -@Patch( +@Suppress("unused") +val exportAllActivitiesPatch = resourcePatch( name = "Export all activities", description = "Makes all app activities exportable.", use = false, -) -@Suppress("unused") -object ExportAllActivitiesPatch : ResourcePatch() { - private const val EXPORTED_FLAG = "android:exported" - - override fun execute(context: ResourceContext) { - context.xmlEditor["AndroidManifest.xml"].use { editor -> - val document = editor.file +) { + execute { context -> + val exportedFlag = "android:exported" + context.document["AndroidManifest.xml"].use { document -> val activities = document.getElementsByTagName("activity") for (i in 0..activities.length) { activities.item(i)?.apply { - val exportedAttribute = attributes.getNamedItem(EXPORTED_FLAG) + val exportedAttribute = attributes.getNamedItem(exportedFlag) if (exportedAttribute != null) { if (exportedAttribute.nodeValue != "true") { @@ -31,7 +26,7 @@ object ExportAllActivitiesPatch : ResourcePatch() { // Reason why the attribute is added in the case it does not exist: // https://github.com/revanced/revanced-patches/pull/1751/files#r1141481604 else { - document.createAttribute(EXPORTED_FLAG) + document.createAttribute(exportedFlag) .apply { value = "true" } .let(attributes::setNamedItem) } diff --git a/patches/src/main/kotlin/app/revanced/patches/all/misc/build/BaseSpoofBuildInfoPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/build/BaseSpoofBuildInfoPatch.kt new file mode 100644 index 000000000..434b97e27 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/build/BaseSpoofBuildInfoPatch.kt @@ -0,0 +1,92 @@ +package app.revanced.patches.all.misc.build + +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.transformation.transformInstructionsPatch +import app.revanced.util.getReference +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +private const val BUILD_CLASS_DESCRIPTOR = "Landroid/os/Build;" + +class BuildInfo( + // The build information supported32BitAbis, supported64BitAbis, and supportedAbis are not supported for now, + // because initializing an array in transform is a bit more complex. + val board: String? = null, + val bootloader: String? = null, + val brand: String? = null, + val cpuAbi: String? = null, + val cpuAbi2: String? = null, + val device: String? = null, + val display: String? = null, + val fingerprint: String? = null, + val hardware: String? = null, + val host: String? = null, + val id: String? = null, + val manufacturer: String? = null, + val model: String? = null, + val odmSku: String? = null, + val product: String? = null, + val radio: String? = null, + val serial: String? = null, + val sku: String? = null, + val socManufacturer: String? = null, + val socModel: String? = null, + val tags: String? = null, + val time: Long? = null, + val type: String? = null, + val user: String? = null, +) + +fun baseSpoofBuildInfoPatch(buildInfoSupplier: () -> BuildInfo) = bytecodePatch { + // Lazy, so that patch options above are initialized before they are accessed. + val replacements by lazy { + with(buildInfoSupplier()) { + buildMap { + if (board != null) put("BOARD", "const-string" to "\"$board\"") + if (bootloader != null) put("BOOTLOADER", "const-string" to "\"$bootloader\"") + if (brand != null) put("BRAND", "const-string" to "\"$brand\"") + if (cpuAbi != null) put("CPU_ABI", "const-string" to "\"$cpuAbi\"") + if (cpuAbi2 != null) put("CPU_ABI2", "const-string" to "\"$cpuAbi2\"") + if (device != null) put("DEVICE", "const-string" to "\"$device\"") + if (display != null) put("DISPLAY", "const-string" to "\"$display\"") + if (fingerprint != null) put("FINGERPRINT", "const-string" to "\"$fingerprint\"") + if (hardware != null) put("HARDWARE", "const-string" to "\"$hardware\"") + if (host != null) put("HOST", "const-string" to "\"$host\"") + if (id != null) put("ID", "const-string" to "\"$id\"") + if (manufacturer != null) put("MANUFACTURER", "const-string" to "\"$manufacturer\"") + if (model != null) put("MODEL", "const-string" to "\"$model\"") + if (odmSku != null) put("ODM_SKU", "const-string" to "\"$odmSku\"") + if (product != null) put("PRODUCT", "const-string" to "\"$product\"") + if (radio != null) put("RADIO", "const-string" to "\"$radio\"") + if (serial != null) put("SERIAL", "const-string" to "\"$serial\"") + if (sku != null) put("SKU", "const-string" to "\"$sku\"") + if (socManufacturer != null) put("SOC_MANUFACTURER", "const-string" to "\"$socManufacturer\"") + if (socModel != null) put("SOC_MODEL", "const-string" to "\"$socModel\"") + if (tags != null) put("TAGS", "const-string" to "\"$tags\"") + if (time != null) put("TIME", "const-wide" to "$time") + if (type != null) put("TYPE", "const-string" to "\"$type\"") + if (user != null) put("USER", "const-string" to "\"$user\"") + } + } + } + + dependsOn( + transformInstructionsPatch( + filterMap = filterMap@{ _, _, instruction, instructionIndex -> + val reference = instruction.getReference() ?: return@filterMap null + if (reference.definingClass != BUILD_CLASS_DESCRIPTOR) return@filterMap null + + return@filterMap replacements[reference.name]?.let { instructionIndex to it } + }, + transform = { mutableMethod, entry -> + val (index, replacement) = entry + val (opcode, operand) = replacement + val register = mutableMethod.getInstruction(index).registerA + + mutableMethod.replaceInstruction(index, "$opcode v$register, $operand") + }, + ), + ) +} diff --git a/src/main/kotlin/app/revanced/patches/all/misc/build/SpoofBuildInfoPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/build/SpoofBuildInfoPatch.kt similarity index 57% rename from src/main/kotlin/app/revanced/patches/all/misc/build/SpoofBuildInfoPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/all/misc/build/SpoofBuildInfoPatch.kt index 994ddd455..7cfd385c6 100644 --- a/src/main/kotlin/app/revanced/patches/all/misc/build/SpoofBuildInfoPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/build/SpoofBuildInfoPatch.kt @@ -1,183 +1,214 @@ package app.revanced.patches.all.misc.build -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patcher.patch.options.PatchOption.PatchExtensions.longPatchOption -import app.revanced.patcher.patch.options.PatchOption.PatchExtensions.stringPatchOption +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.longOption +import app.revanced.patcher.patch.stringOption -@Patch( +@Suppress("unused") +val spoofBuildInfoPatch = bytecodePatch( name = "Spoof build info", description = "Spoof the information about the current build.", - use = false -) -@Suppress("unused") -class SpoofBuildInfoPatch : BaseSpoofBuildInfoPatch() { - override val board by stringPatchOption( + use = false, +) { + val board by stringOption( key = "board", default = null, title = "Board", - description = "The name of the underlying board, like \"goldfish\"." + description = "The name of the underlying board, like \"goldfish\".", ) - override val bootloader by stringPatchOption( + val bootloader by stringOption( key = "bootloader", default = null, title = "Bootloader", - description = "The system bootloader version number." + description = "The system bootloader version number.", ) - override val brand by stringPatchOption( + val brand by stringOption( key = "brand", default = null, title = "Brand", - description = "The consumer-visible brand with which the product/hardware will be associated, if any." + description = "The consumer-visible brand with which the product/hardware will be associated, if any.", ) - override val cpuAbi by stringPatchOption( + val cpuAbi by stringOption( key = "cpu-abi", default = null, title = "CPU ABI", - description = "This field was deprecated in API level 21. Use SUPPORTED_ABIS instead." + description = "This field was deprecated in API level 21. Use SUPPORTED_ABIS instead.", ) - override val cpuAbi2 by stringPatchOption( + val cpuAbi2 by stringOption( key = "cpu-abi-2", default = null, title = "CPU ABI 2", - description = "This field was deprecated in API level 21. Use SUPPORTED_ABIS instead." + description = "This field was deprecated in API level 21. Use SUPPORTED_ABIS instead.", ) - override val device by stringPatchOption( + val device by stringOption( key = "device", default = null, title = "Device", - description = "The name of the industrial design." + description = "The name of the industrial design.", ) - override val display by stringPatchOption( + val display by stringOption( key = "display", default = null, title = "Display", - description = "A build ID string meant for displaying to the user." + description = "A build ID string meant for displaying to the user.", ) - override val fingerprint by stringPatchOption( + val fingerprint by stringOption( key = "fingerprint", default = null, title = "Fingerprint", - description = "A string that uniquely identifies this build." + description = "A string that uniquely identifies this build.", ) - override val hardware by stringPatchOption( + val hardware by stringOption( key = "hardware", default = null, title = "Hardware", - description = "The name of the hardware (from the kernel command line or /proc)." + description = "The name of the hardware (from the kernel command line or /proc).", ) - override val host by stringPatchOption( + val host by stringOption( key = "host", default = null, title = "Host", - description = "The host." + description = "The host.", ) - override val id by stringPatchOption( + val id by stringOption( key = "id", default = null, title = "ID", - description = "Either a changelist number, or a label like \"M4-rc20\"." + description = "Either a changelist number, or a label like \"M4-rc20\".", ) - override val manufacturer by stringPatchOption( + val manufacturer by stringOption( key = "manufacturer", default = null, title = "Manufacturer", - description = "The manufacturer of the product/hardware." + description = "The manufacturer of the product/hardware.", ) - override val model by stringPatchOption( + val model by stringOption( key = "model", default = null, title = "Model", - description = "The end-user-visible name for the end product." + description = "The end-user-visible name for the end product.", ) - override val odmSku by stringPatchOption( + val odmSku by stringOption( key = "odm-sku", default = null, title = "ODM SKU", - description = "The SKU of the device as set by the original design manufacturer (ODM)." + description = "The SKU of the device as set by the original design manufacturer (ODM).", ) - override val product by stringPatchOption( + val product by stringOption( key = "product", default = null, title = "Product", - description = "The name of the overall product." + description = "The name of the overall product.", ) - override val radio by stringPatchOption( + val radio by stringOption( key = "radio", default = null, title = "Radio", description = "This field was deprecated in API level 15. " + - "The radio firmware version is frequently not available when this class is initialized, " + - "leading to a blank or \"unknown\" value for this string. Use getRadioVersion() instead." + "The radio firmware version is frequently not available when this class is initialized, " + + "leading to a blank or \"unknown\" value for this string. Use getRadioVersion() instead.", ) - override val serial by stringPatchOption( + val serial by stringOption( key = "serial", default = null, title = "Serial", - description = "This field was deprecated in API level 26. Use getSerial() instead." + description = "This field was deprecated in API level 26. Use getSerial() instead.", ) - override val sku by stringPatchOption( + val sku by stringOption( key = "sku", default = null, title = "SKU", - description = "The SKU of the hardware (from the kernel command line)." + description = "The SKU of the hardware (from the kernel command line).", ) - override val socManufacturer by stringPatchOption( + val socManufacturer by stringOption( key = "soc-manufacturer", default = null, title = "SOC Manufacturer", - description = "The manufacturer of the device's primary system-on-chip." + description = "The manufacturer of the device's primary system-on-chip.", ) - override val socModel by stringPatchOption( + val socModel by stringOption( key = "soc-model", default = null, title = "SOC Model", - description = "The model name of the device's primary system-on-chip." + description = "The model name of the device's primary system-on-chip.", ) - override val tags by stringPatchOption( + val tags by stringOption( key = "tags", default = null, title = "Tags", - description = "Comma-separated tags describing the build, like \"unsigned,debug\"." + description = "Comma-separated tags describing the build, like \"unsigned,debug\".", ) - override val time by longPatchOption( + val time by longOption( key = "time", default = null, title = "Time", - description = "The time at which the build was produced, given in milliseconds since the UNIX epoch." + description = "The time at which the build was produced, given in milliseconds since the UNIX epoch.", ) - override val type by stringPatchOption( + val type by stringOption( key = "type", default = null, title = "Type", - description = "The type of build, like \"user\" or \"eng\"." + description = "The type of build, like \"user\" or \"eng\".", ) - override val user by stringPatchOption( + val user by stringOption( key = "user", default = null, title = "User", - description = "The user." + description = "The user.", ) -} \ No newline at end of file + + dependsOn( + baseSpoofBuildInfoPatch { + BuildInfo( + board, + bootloader, + brand, + cpuAbi, + cpuAbi2, + device, + display, + fingerprint, + hardware, + host, + id, + manufacturer, + model, + odmSku, + product, + radio, + serial, + sku, + socManufacturer, + socModel, + tags, + time, + type, + user, + ) + }, + + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/all/misc/connectivity/location/hide/HideMockLocationPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/connectivity/location/hide/HideMockLocationPatch.kt new file mode 100644 index 000000000..b17eea94d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/connectivity/location/hide/HideMockLocationPatch.kt @@ -0,0 +1,50 @@ +@file:Suppress("unused") + +package app.revanced.patches.all.misc.connectivity.location.hide + +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.transformation.IMethodCall +import app.revanced.patches.all.misc.transformation.fromMethodReference +import app.revanced.patches.all.misc.transformation.transformInstructionsPatch +import app.revanced.util.getReference +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +@Suppress("unused") +val hideMockLocationPatch = bytecodePatch( + name = "Hide mock location", + description = "Prevents the app from knowing the device location is being mocked by a third party app.", + use = false, +) { + dependsOn( + transformInstructionsPatch( + filterMap = filter@{ _, _, instruction, instructionIndex -> + val reference = instruction.getReference() ?: return@filter null + if (fromMethodReference(reference) == null) return@filter null + + instruction to instructionIndex + }, + transform = { method, entry -> + val (instruction, index) = entry + instruction as FiveRegisterInstruction + + // Replace return value with a constant `false` boolean. + method.replaceInstruction( + index + 1, + "const/4 v${instruction.registerC}, 0x0", + ) + }, + ), + ) +} + +private enum class MethodCall( + override val definedClassName: String, + override val methodName: String, + override val methodParams: Array, + override val returnType: String, +) : IMethodCall { + IsMock("Landroid/location/Location;", "isMock", emptyArray(), "Z"), + IsFromMockProvider("Landroid/location/Location;", "isFromMockProvider", emptyArray(), "Z"), +} diff --git a/patches/src/main/kotlin/app/revanced/patches/all/misc/connectivity/telephony/sim/spoof/SpoofSimCountryPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/connectivity/telephony/sim/spoof/SpoofSimCountryPatch.kt new file mode 100644 index 000000000..b50ccfc88 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/connectivity/telephony/sim/spoof/SpoofSimCountryPatch.kt @@ -0,0 +1,105 @@ +package app.revanced.patches.all.misc.connectivity.telephony.sim.spoof + +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.stringOption +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patches.all.misc.transformation.transformInstructionsPatch +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.immutable.reference.ImmutableMethodReference +import com.android.tools.smali.dexlib2.util.MethodUtil +import java.util.* + +@Suppress("unused") +val spoofSimCountryPatch = bytecodePatch( + name = "Spoof SIM country", + description = "Spoofs country information returned by the SIM card provider.", + use = false, +) { + val countries = Locale.getISOCountries().associateBy { Locale("", it).displayCountry } + + fun isoCountryPatchOption( + key: String, + title: String, + ) = stringOption( + key, + null, + countries, + title, + "ISO-3166-1 alpha-2 country code equivalent for the SIM provider's country code.", + false, + validator = { it: String? -> it == null || it.uppercase() in countries.values }, + ) + + val networkCountryIso by isoCountryPatchOption( + "networkCountryIso", + "Network ISO Country Code", + ) + + val simCountryIso by isoCountryPatchOption( + "simCountryIso", + "Sim ISO Country Code", + ) + + dependsOn( + transformInstructionsPatch( + filterMap = { _, _, instruction, instructionIndex -> + if (instruction !is ReferenceInstruction) return@transformInstructionsPatch null + + val reference = instruction.reference as? MethodReference ?: return@transformInstructionsPatch null + + val match = MethodCall.entries.firstOrNull { search -> + MethodUtil.methodSignaturesMatch(reference, search.reference) + } ?: return@transformInstructionsPatch null + + val iso = when (match) { + MethodCall.NetworkCountryIso -> networkCountryIso + MethodCall.SimCountryIso -> simCountryIso + }?.lowercase() + + iso?.let { instructionIndex to it } + }, + transform = { mutableMethod, entry: Pair -> + transformMethodCall(entry, mutableMethod) + }, + ), + ) +} + +private fun transformMethodCall( + entry: Pair, + mutableMethod: MutableMethod, +) { + val (instructionIndex, methodCallValue) = entry + + val register = mutableMethod.getInstruction(instructionIndex + 1).registerA + + mutableMethod.replaceInstruction( + instructionIndex + 1, + "const-string v$register, \"$methodCallValue\"", + ) +} + +private enum class MethodCall( + val reference: MethodReference, +) { + NetworkCountryIso( + ImmutableMethodReference( + "Landroid/telephony/TelephonyManager;", + "getNetworkCountryIso", + emptyList(), + "Ljava/lang/String;", + ), + ), + SimCountryIso( + ImmutableMethodReference( + "Landroid/telephony/TelephonyManager;", + "getSimCountryIso", + emptyList(), + "Ljava/lang/String;", + ), + ), +} diff --git a/patches/src/main/kotlin/app/revanced/patches/all/misc/connectivity/wifi/spoof/SpoofWifiPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/connectivity/wifi/spoof/SpoofWifiPatch.kt new file mode 100644 index 000000000..3e086f95c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/connectivity/wifi/spoof/SpoofWifiPatch.kt @@ -0,0 +1,224 @@ +package app.revanced.patches.all.misc.connectivity.wifi.spoof + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.transformation.IMethodCall +import app.revanced.patches.all.misc.transformation.filterMapInstruction35c +import app.revanced.patches.all.misc.transformation.transformInstructionsPatch + +internal const val EXTENSION_CLASS_DESCRIPTOR_PREFIX = "Lapp/revanced/extension/all/connectivity/wifi/spoof/SpoofWifiPatch" + +internal const val EXTENSION_CLASS_DESCRIPTOR = "$EXTENSION_CLASS_DESCRIPTOR_PREFIX;" + +@Suppress("unused") +val spoofWifiPatch = bytecodePatch( + name = "Spoof Wi-Fi connection", + description = "Spoofs an existing Wi-Fi connection.", + use = false, +) { + extendWith("extensions/all/connectivity/wifi/spoof/spoof-wifi.rve") + + dependsOn( + transformInstructionsPatch( + filterMap = { classDef, _, instruction, instructionIndex -> + filterMapInstruction35c( + EXTENSION_CLASS_DESCRIPTOR_PREFIX, + classDef, + instruction, + instructionIndex, + ) + }, + transform = { method, entry -> + val (methodType, instruction, instructionIndex) = entry + methodType.replaceInvokeVirtualWithExtension( + EXTENSION_CLASS_DESCRIPTOR, + method, + instruction, + instructionIndex, + ) + }, + ), + ) +} + +// Information about method calls we want to replace +@Suppress("unused") +private enum class MethodCall( + override val definedClassName: String, + override val methodName: String, + override val methodParams: Array, + override val returnType: String, +) : IMethodCall { + GetSystemService1( + "Landroid/content/Context;", + "getSystemService", + arrayOf("Ljava/lang/String;"), + "Ljava/lang/Object;", + ), + GetSystemService2( + "Landroid/content/Context;", + "getSystemService", + arrayOf("Ljava/lang/Class;"), + "Ljava/lang/Object;", + ), + GetActiveNetworkInfo( + "Landroid/net/ConnectivityManager;", + "getActiveNetworkInfo", + arrayOf(), + "Landroid/net/NetworkInfo;", + ), + IsConnected( + "Landroid/net/NetworkInfo;", + "isConnected", + arrayOf(), + "Z", + ), + IsConnectedOrConnecting( + "Landroid/net/NetworkInfo;", + "isConnectedOrConnecting", + arrayOf(), + "Z", + ), + IsAvailable( + "Landroid/net/NetworkInfo;", + "isAvailable", + arrayOf(), + "Z", + ), + GetState( + "Landroid/net/NetworkInfo;", + "getState", + arrayOf(), + "Landroid/net/NetworkInfo\$State;", + ), + GetDetailedState( + "Landroid/net/NetworkInfo;", + "getDetailedState", + arrayOf(), + "Landroid/net/NetworkInfo\$DetailedState;", + ), + IsActiveNetworkMetered( + "Landroid/net/ConnectivityManager;", + "isActiveNetworkMetered", + arrayOf(), + "Z", + ), + GetActiveNetwork( + "Landroid/net/ConnectivityManager;", + "getActiveNetwork", + arrayOf(), + "Landroid/net/Network;", + ), + GetNetworkInfo( + "Landroid/net/ConnectivityManager;", + "getNetworkInfo", + arrayOf("Landroid/net/Network;"), + "Landroid/net/NetworkInfo;", + ), + HasTransport( + "Landroid/net/NetworkCapabilities;", + "hasTransport", + arrayOf("I"), + "Z", + ), + HasCapability( + "Landroid/net/NetworkCapabilities;", + "hasCapability", + arrayOf("I"), + "Z", + ), + RegisterBestMatchingNetworkCallback( + "Landroid/net/ConnectivityManager;", + "registerBestMatchingNetworkCallback", + arrayOf( + "Landroid/net/NetworkRequest;", + "Landroid/net/ConnectivityManager\$NetworkCallback;", + "Landroid/os/Handler;", + ), + "V", + ), + RegisterDefaultNetworkCallback1( + "Landroid/net/ConnectivityManager;", + "registerDefaultNetworkCallback", + arrayOf("Landroid/net/ConnectivityManager\$NetworkCallback;"), + "V", + ), + RegisterDefaultNetworkCallback2( + "Landroid/net/ConnectivityManager;", + "registerDefaultNetworkCallback", + arrayOf("Landroid/net/ConnectivityManager\$NetworkCallback;", "Landroid/os/Handler;"), + "V", + ), + RegisterNetworkCallback1( + "Landroid/net/ConnectivityManager;", + "registerNetworkCallback", + arrayOf("Landroid/net/NetworkRequest;", "Landroid/net/ConnectivityManager\$NetworkCallback;"), + "V", + ), + RegisterNetworkCallback2( + "Landroid/net/ConnectivityManager;", + "registerNetworkCallback", + arrayOf("Landroid/net/NetworkRequest;", "Landroid/app/PendingIntent;"), + "V", + ), + RegisterNetworkCallback3( + "Landroid/net/ConnectivityManager;", + "registerNetworkCallback", + arrayOf( + "Landroid/net/NetworkRequest;", + "Landroid/net/ConnectivityManager\$NetworkCallback;", + "Landroid/os/Handler;", + ), + "V", + ), + RequestNetwork1( + "Landroid/net/ConnectivityManager;", + "requestNetwork", + arrayOf("Landroid/net/NetworkRequest;", "Landroid/net/ConnectivityManager\$NetworkCallback;"), + "V", + ), + RequestNetwork2( + "Landroid/net/ConnectivityManager;", + "requestNetwork", + arrayOf("Landroid/net/NetworkRequest;", "Landroid/net/ConnectivityManager\$NetworkCallback;", "I"), + "V", + ), + RequestNetwork3( + "Landroid/net/ConnectivityManager;", + "requestNetwork", + arrayOf( + "Landroid/net/NetworkRequest;", + "Landroid/net/ConnectivityManager\$NetworkCallback;", + "Landroid/os/Handler;", + ), + "V", + ), + RequestNetwork4( + "Landroid/net/ConnectivityManager;", + "requestNetwork", + arrayOf("Landroid/net/NetworkRequest;", "Landroid/app/PendingIntent;"), + "V", + ), + RequestNetwork5( + "Landroid/net/ConnectivityManager;", + "requestNetwork", + arrayOf( + "Landroid/net/NetworkRequest;", + "Landroid/net/ConnectivityManager\$NetworkCallback;", + "Landroid/os/Handler;", + "I", + ), + "V", + ), + UnregisterNetworkCallback1( + "Landroid/net/ConnectivityManager;", + "unregisterNetworkCallback", + arrayOf("Landroid/net/ConnectivityManager\$NetworkCallback;"), + "V", + ), + UnregisterNetworkCallback2( + "Landroid/net/ConnectivityManager;", + "unregisterNetworkCallback", + arrayOf("Landroid/app/PendingIntent;"), + "V", + ), +} diff --git a/src/main/kotlin/app/revanced/patches/all/misc/debugging/EnableAndroidDebuggingPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/debugging/EnableAndroidDebuggingPatch.kt similarity index 58% rename from src/main/kotlin/app/revanced/patches/all/misc/debugging/EnableAndroidDebuggingPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/all/misc/debugging/EnableAndroidDebuggingPatch.kt index 4e3dedd32..97320c664 100644 --- a/src/main/kotlin/app/revanced/patches/all/misc/debugging/EnableAndroidDebuggingPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/debugging/EnableAndroidDebuggingPatch.kt @@ -1,21 +1,16 @@ package app.revanced.patches.all.misc.debugging -import app.revanced.patcher.data.ResourceContext -import app.revanced.patcher.patch.ResourcePatch -import app.revanced.patcher.patch.annotation.Patch +import app.revanced.patcher.patch.resourcePatch import org.w3c.dom.Element -@Patch( +@Suppress("unused") +val enableAndroidDebuggingPatch = resourcePatch( name = "Enable Android debugging", description = "Enables Android debugging capabilities. This can slow down the app.", use = false, -) -@Suppress("unused") -object EnableAndroidDebuggingPatch : ResourcePatch() { - override fun execute(context: ResourceContext) { - context.xmlEditor["AndroidManifest.xml"].use { editor -> - val document = editor.file - +) { + execute { context -> + context.document["AndroidManifest.xml"].use { document -> val applicationNode = document .getElementsByTagName("application") diff --git a/patches/src/main/kotlin/app/revanced/patches/all/misc/directory/ChangeDataDirectoryLocationPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/directory/ChangeDataDirectoryLocationPatch.kt new file mode 100644 index 000000000..7256d1edb --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/directory/ChangeDataDirectoryLocationPatch.kt @@ -0,0 +1,58 @@ +package app.revanced.patches.all.misc.directory + +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.transformation.transformInstructionsPatch +import app.revanced.util.getReference +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.immutable.reference.ImmutableMethodReference +import com.android.tools.smali.dexlib2.util.MethodUtil + +@Suppress("unused") +val changeDataDirectoryLocationPatch = bytecodePatch( + name = "Change data directory location", + description = "Changes the data directory in the application from " + + "the app internal storage directory to /sdcard/android/data accessible by root-less devices." + + "Using this patch can cause unexpected issues with some apps.", + use = false, +) { + dependsOn( + transformInstructionsPatch( + filterMap = filter@{ _, _, instruction, instructionIndex -> + val reference = instruction.getReference() ?: return@filter null + + if (!MethodUtil.methodSignaturesMatch(reference, MethodCall.GetDir.reference)) { + return@filter null + } + + return@filter instructionIndex + }, + transform = { method, index -> + val getDirInstruction = method.getInstruction(index) + val contextRegister = getDirInstruction.registerC + val dataRegister = getDirInstruction.registerD + + method.replaceInstruction( + index, + "invoke-virtual { v$contextRegister, v$dataRegister }, " + + "Landroid/content/Context;->getExternalFilesDir(Ljava/lang/String;)Ljava/io/File;", + ) + }, + ), + ) +} + +private enum class MethodCall( + val reference: MethodReference, +) { + GetDir( + ImmutableMethodReference( + "Landroid/content/Context;", + "getDir", + listOf("Ljava/lang/String;", "I"), + "Ljava/io/File;", + ), + ), +} diff --git a/src/main/kotlin/app/revanced/patches/all/misc/hex/HexPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/hex/HexPatch.kt similarity index 54% rename from src/main/kotlin/app/revanced/patches/all/misc/hex/HexPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/all/misc/hex/HexPatch.kt index 7aaee12b4..a8bb80517 100644 --- a/src/main/kotlin/app/revanced/patches/all/misc/hex/HexPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/hex/HexPatch.kt @@ -1,23 +1,22 @@ package app.revanced.patches.all.misc.hex import app.revanced.patcher.patch.PatchException -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patcher.patch.options.PatchOption.PatchExtensions.registerNewPatchOption -import app.revanced.patches.shared.misc.hex.BaseHexPatch +import app.revanced.patcher.patch.rawResourcePatch +import app.revanced.patcher.patch.stringsOption +import app.revanced.patches.shared.misc.hex.Replacement +import app.revanced.patches.shared.misc.hex.hexPatch import app.revanced.util.Utils.trimIndentMultiline -import app.revanced.patcher.patch.Patch as PatchClass -@Patch( +@Suppress("unused") +val hexPatch = rawResourcePatch( name = "Hex", description = "Replaces a hexadecimal patterns of bytes of files in an APK.", use = false, -) -@Suppress("unused") -class HexPatch : BaseHexPatch() { +) { // TODO: Instead of stringArrayOption, use a custom option type to work around // https://github.com/ReVanced/revanced-library/issues/48. // Replace the custom option type with a stringArrayOption once the issue is resolved. - private val replacementsOption by registerNewPatchOption, List>( + val replacements by stringsOption( key = "replacements", title = "Replacements", description = """ @@ -34,22 +33,24 @@ class HexPatch : BaseHexPatch() { 'aa 01 02 FF|00 00 00 00|path/to/file' """.trimIndentMultiline(), required = true, - valueType = "StringArray", ) - override val replacements - get() = replacementsOption!!.map { from -> - val (pattern, replacementPattern, targetFilePath) = try { - from.split("|", limit = 3) - } catch (e: Exception) { - throw PatchException( - "Invalid input: $from.\n" + - "Every pattern must be followed by a pipe ('|'), " + - "the replacement pattern, another pipe ('|'), " + - "and the path to the file to make the changes in relative to the APK root. ", - ) - } + dependsOn( + hexPatch { + replacements!!.map { from -> + val (pattern, replacementPattern, targetFilePath) = try { + from.split("|", limit = 3) + } catch (e: Exception) { + throw PatchException( + "Invalid input: $from.\n" + + "Every pattern must be followed by a pipe ('|'), " + + "the replacement pattern, another pipe ('|'), " + + "and the path to the file to make the changes in relative to the APK root. ", + ) + } - Replacement(pattern, replacementPattern, targetFilePath) - } + Replacement(pattern, replacementPattern, targetFilePath) + }.toSet() + }, + ) } diff --git a/patches/src/main/kotlin/app/revanced/patches/all/misc/interaction/gestures/PredictiveBackGesturePatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/interaction/gestures/PredictiveBackGesturePatch.kt new file mode 100644 index 000000000..abf7f002c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/interaction/gestures/PredictiveBackGesturePatch.kt @@ -0,0 +1,24 @@ +package app.revanced.patches.all.misc.interaction.gestures + +import app.revanced.patcher.patch.resourcePatch + +@Suppress("unused") +val predictiveBackGesturePatch = resourcePatch( + name = "Predictive back gesture", + description = "Enables the predictive back gesture introduced on Android 13.", + use = false, +) { + execute { context -> + val flag = "android:enableOnBackInvokedCallback" + + context.document["AndroidManifest.xml"].use { document -> + with(document.getElementsByTagName("application").item(0)) { + if (attributes.getNamedItem(flag) != null) return@with + + document.createAttribute(flag) + .apply { value = "true" } + .let(attributes::setNamedItem) + } + } + } +} diff --git a/src/main/kotlin/app/revanced/patches/all/misc/network/OverrideCertificatePinningPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/network/OverrideCertificatePinningPatch.kt similarity index 81% rename from src/main/kotlin/app/revanced/patches/all/misc/network/OverrideCertificatePinningPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/all/misc/network/OverrideCertificatePinningPatch.kt index dc300df4a..26a3be2b0 100644 --- a/src/main/kotlin/app/revanced/patches/all/misc/network/OverrideCertificatePinningPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/network/OverrideCertificatePinningPatch.kt @@ -1,28 +1,24 @@ package app.revanced.patches.all.misc.network -import app.revanced.patcher.data.ResourceContext -import app.revanced.patcher.patch.ResourcePatch -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patches.all.misc.debugging.EnableAndroidDebuggingPatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.all.misc.debugging.enableAndroidDebuggingPatch import app.revanced.util.Utils.trimIndentMultiline import org.w3c.dom.Element import java.io.File -@Patch( +@Suppress("unused") +val overrideCertificatePinningPatch = resourcePatch( name = "Override certificate pinning", description = "Overrides certificate pinning, allowing to inspect traffic via a proxy.", - dependencies = [EnableAndroidDebuggingPatch::class], use = false, -) -@Suppress("unused") -object OverrideCertificatePinningPatch : ResourcePatch() { - override fun execute(context: ResourceContext) { +) { + dependsOn(enableAndroidDebuggingPatch) + + execute { context -> val resXmlDirectory = context.get("res/xml") // Add android:networkSecurityConfig="@xml/network_security_config" and the "networkSecurityConfig" attribute if it does not exist. - context.xmlEditor["AndroidManifest.xml"].use { editor -> - val document = editor.file - + context.document["AndroidManifest.xml"].use { document -> val applicationNode = document.getElementsByTagName("application").item(0) as Element if (!applicationNode.hasAttribute("networkSecurityConfig")) { @@ -58,4 +54,4 @@ object OverrideCertificatePinningPatch : ResourcePatch() { ) } } -} +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/all/misc/packagename/ChangePackageNamePatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/packagename/ChangePackageNamePatch.kt new file mode 100644 index 000000000..9565e8381 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/packagename/ChangePackageNamePatch.kt @@ -0,0 +1,62 @@ +package app.revanced.patches.all.misc.packagename + +import app.revanced.patcher.patch.Option +import app.revanced.patcher.patch.OptionException +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption +import org.w3c.dom.Element + +lateinit var packageNameOption: Option + +/** + * Set the package name to use. + * If this is called multiple times, the first call will set the package name. + * + * @param fallbackPackageName The package name to use if the user has not already specified a package name. + * @return The package name that was set. + * @throws OptionException.ValueValidationException If the package name is invalid. + */ +fun setOrGetFallbackPackageName(fallbackPackageName: String): String { + val packageName = packageNameOption.value!! + + return if (packageName == packageNameOption.default) { + fallbackPackageName.also { packageNameOption.value = it } + } else { + packageName + } +} + +@Suppress("unused") +val changePackageNamePatch = resourcePatch( + name = "Change package name", + description = "Appends \".revanced\" to the package name by default. Changing the package name of the app can lead to unexpected issues.", + use = false, +) { + packageNameOption = stringOption( + key = "packageName", + default = "Default", + values = mapOf("Default" to "Default"), + title = "Package name", + description = "The name of the package to rename the app to.", + required = true, + ) { + it == "Default" || it!!.matches(Regex("^[a-z]\\w*(\\.[a-z]\\w*)+\$")) + } + + finalize { context -> + context.document["AndroidManifest.xml"].use { document -> + + val replacementPackageName = packageNameOption.value + + val manifest = document.getElementsByTagName("manifest").item(0) as Element + manifest.setAttribute( + "package", + if (replacementPackageName != packageNameOption.default) { + replacementPackageName + } else { + "${manifest.getAttribute("package")}.revanced" + }, + ) + } + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/all/misc/resources/AddResourcesPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/resources/AddResourcesPatch.kt new file mode 100644 index 000000000..9c13b7034 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/resources/AddResourcesPatch.kt @@ -0,0 +1,397 @@ +package app.revanced.patches.all.misc.resources + +import app.revanced.patcher.patch.Patch +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.util.Document +import app.revanced.util.* +import app.revanced.util.resource.ArrayResource +import app.revanced.util.resource.BaseResource +import app.revanced.util.resource.StringResource +import org.w3c.dom.Node + +/** + * An identifier of an app. For example, `youtube`. + */ +private typealias AppId = String + +/** + * An identifier of a patch. For example, `ad.general.HideAdsPatch`. + */ +private typealias PatchId = String + +/** + * A set of resources of a patch. + */ +private typealias PatchResources = MutableSet + +/** + * A map of resources belonging to a patch. + */ +private typealias AppResources = MutableMap + +/** + * A map of resources belonging to an app. + */ +private typealias Resources = MutableMap + +/** + * The value of a resource. + * For example, `values` or `values-de`. + */ +private typealias Value = String + +/** + * A set of resources mapped by their value. + */ +private typealias MutableResources = MutableMap> + +/** + * A map of all resources associated by their value staged by [addResourcesPatch]. + */ +private lateinit var stagedResources: Map + +/** + * A map of all resources added to the app by [addResourcesPatch]. + */ +private val resources: MutableResources = mutableMapOf() + +/** + * Map of Crowdin locales to Android resource locale names. + * + * Fixme: Instead this patch should detect what locale regions are present in both patches and the target app, + * and automatically merge into the appropriate existing target file. + * So if a target app has only 'es', then the Crowdin file of 'es-rES' should merge into that. + * But if a target app has specific regions (such as 'pt-rBR'), + * then the Crowdin region specific file should merged into that. + */ +private val locales = mapOf( + "af-rZA" to "af", + "am-rET" to "am", + "ar-rSA" to "ar", + "as-rIN" to "as", + "az-rAZ" to "az", + "be-rBY" to "be", + "bg-rBG" to "bg", + "bn-rBD" to "bn", + "bs-rBA" to "bs", + "ca-rES" to "ca", + "cs-rCZ" to "cs", + "da-rDK" to "da", + "de-rDE" to "de", + "el-rGR" to "el", + "es-rES" to "es", + "et-rEE" to "et", + "eu-rES" to "eu", + "fa-rIR" to "fa", + "fi-rFI" to "fi", + "fil-rPH" to "tl", + "fr-rFR" to "fr", + "ga-rIE" to "ga", + "gl-rES" to "gl", + "gu-rIN" to "gu", + "hi-rIN" to "hi", + "hr-rHR" to "hr", + "hu-rHU" to "hu", + "hy-rAM" to "hy", + "in-rID" to "in", + "is-rIS" to "is", + "it-rIT" to "it", + "iw-rIL" to "iw", + "ja-rJP" to "ja", + "ka-rGE" to "ka", + "kk-rKZ" to "kk", + "km-rKH" to "km", + "kn-rIN" to "kn", + "ko-rKR" to "ko", + "ky-rKG" to "ky", + "lo-rLA" to "lo", + "lt-rLT" to "lt", + "lv-rLV" to "lv", + "mk-rMK" to "mk", + "ml-rIN" to "ml", + "mn-rMN" to "mn", + "mr-rIN" to "mr", + "ms-rMY" to "ms", + "my-rMM" to "my", + "nb-rNO" to "nb", + "ne-rIN" to "ne", + "nl-rNL" to "nl", + "or-rIN" to "or", + "pa-rIN" to "pa", + "pl-rPL" to "pl", + "pt-rBR" to "pt-rBR", + "pt-rPT" to "pt-rPT", + "ro-rRO" to "ro", + "ru-rRU" to "ru", + "si-rLK" to "si", + "sk-rSK" to "sk", + "sl-rSI" to "sl", + "sq-rAL" to "sq", + "sr-rCS" to "b+sr+Latn", + "sr-rSP" to "sr", + "sv-rSE" to "sv", + "sw-rKE" to "sw", + "ta-rIN" to "ta", + "te-rIN" to "te", + "th-rTH" to "th", + "tl-rPH" to "tl", + "tr-rTR" to "tr", + "uk-rUA" to "uk", + "ur-rIN" to "ur", + "uz-rUZ" to "uz", + "vi-rVN" to "vi", + "zh-rCN" to "zh-rCN", + "zh-rTW" to "zh-rTW", + "zu-rZA" to "zu", +) + +/** + * Adds a [BaseResource] to the map using [MutableMap.getOrPut]. + * + * @param value The value of the resource. For example, `values` or `values-de`. + * @param resource The resource to add. + * + * @return True if the resource was added, false if it already existed. + */ +fun addResource( + value: Value, + resource: BaseResource, +) = resources.getOrPut(value, ::mutableSetOf).add(resource) + +/** + * Adds a list of [BaseResource]s to the map using [MutableMap.getOrPut]. + * + * @param value The value of the resource. For example, `values` or `values-de`. + * @param resources The resources to add. + * + * @return True if the resources were added, false if they already existed. + */ +fun addResources( + value: Value, + resources: Iterable, +) = app.revanced.patches.all.misc.resources.resources.getOrPut(value, ::mutableSetOf).addAll(resources) + +/** + * Adds a [StringResource]. + * + * @param name The name of the string resource. + * @param value The value of the string resource. + * @param formatted Whether the string resource is formatted. Defaults to `true`. + * @param resourceValue The value of the resource. For example, `values` or `values-de`. + * + * @return True if the resource was added, false if it already existed. + */ +fun addResources( + name: String, + value: String, + formatted: Boolean = true, + resourceValue: Value = "values", +) = addResource(resourceValue, StringResource(name, value, formatted)) + +/** + * Adds an [ArrayResource]. + * + * @param name The name of the array resource. + * @param items The items of the array resource. + * + * @return True if the resource was added, false if it already existed. + */ +fun addResources( + name: String, + items: List, +) = addResource("values", ArrayResource(name, items)) + +/** + * Puts all resources of any [Value] staged in [stagedResources] for the [Patch] to [addResources]. + * + * @param patch The [Patch] of the patch to stage resources for. + * @param parseIds A function that parses a set of [PatchId] each mapped to an [AppId] from the given [Patch]. + * This is used to access the resources in [addResources] to stage them in [stagedResources]. + * The default implementation assumes that the [Patch] has a name and declares packages it is compatible with. + * + * @return True if any resources were added, false if none were added. + * + * @see addResourcesPatch + */ +fun addResources( + patch: Patch<*>, + parseIds: (Patch<*>) -> Map> = { + val patchId = patch.name ?: throw PatchException("Patch has no name") + val packages = patch.compatiblePackages ?: throw PatchException("Patch has no compatible packages") + + buildMap> { + packages.forEach { (appId, _) -> + getOrPut(appId) { mutableSetOf() }.add(patchId) + } + } + }, +): Boolean { + var result = false + + // Stage resources for the given patch to addResourcesPatch associated with their value. + parseIds(patch).forEach { (appId, patchIds) -> + patchIds.forEach { patchId -> + stagedResources.forEach { (value, resources) -> + resources[appId]?.get(patchId)?.let { patchResources -> + if (addResources(value, patchResources)) result = true + } + } + } + } + + return result +} + +/** + * Puts all resources for the given [appId] and [patchId] staged in [addResources] to [addResourcesPatch]. + * + * + * @return True if any resources were added, false if none were added. + * + * @see addResourcesPatch + */ +fun addResources( + appId: AppId, + patchId: String, +) = stagedResources.forEach { (value, resources) -> + resources[appId]?.get(patchId)?.let { patchResources -> + addResources(value, patchResources) + } +} + +val addResourcesPatch = resourcePatch( + description = "Add resources such as strings or arrays to the app.", +) { + /* + The strategy of this patch is to stage resources present in `/resources/addresources`. + These resources are organized by their respective value and patch. + + On addResourcesPatch#execute, all resources are staged in a temporary map. + After that, other patches that depend on addResourcesPatch can call + addResourcesPatch#invoke(Patch) to stage resources belonging to that patch + from the temporary map to addResourcesPatch. + + After all patches that depend on addResourcesPatch have been executed, + addResourcesPatch#finalize is finally called to add all staged resources to the app. + */ + execute { context -> + stagedResources = buildMap { + /** + * Puts resources under `/resources/addresources//.xml` into the map. + * + * @param sourceValue The source value of the resource. For example, `values` or `values-de-rDE`. + * @param destValue The destination value of the resource. For example, 'values' or 'values-de'. + * @param resourceKind The kind of the resource. For example, `strings` or `arrays`. + * @param transform A function that transforms the [Node]s from the XML files to a [BaseResource]. + */ + fun addResources( + sourceValue: Value, + destValue: Value = sourceValue, + resourceKind: String, + transform: (Node) -> BaseResource, + ) { + inputStreamFromBundledResource( + "addresources", + "$sourceValue/$resourceKind.xml", + )?.let { stream -> + // Add the resources associated with the given value to the map, + // instead of overwriting it. + // This covers the example case such as adding strings and arrays of the same value. + getOrPut(destValue, ::mutableMapOf).apply { + context.document[stream].use { document -> + document.getElementsByTagName("app").asSequence().forEach { app -> + val appId = app.attributes.getNamedItem("id").textContent + + getOrPut(appId, ::mutableMapOf).apply { + app.forEachChildElement { patch -> + val patchId = patch.attributes.getNamedItem("id").textContent + + getOrPut(patchId, ::mutableSetOf).apply { + patch.forEachChildElement { resourceNode -> + val resource = transform(resourceNode) + + add(resource) + } + } + } + } + } + } + } + } + } + + // Stage all resources to a temporary map. + // Staged resources consumed by addResourcesPatch#invoke(Patch) + // are later used in addResourcesPatch#finalize. + try { + val addStringResources = { source: Value, dest: Value -> + addResources(source, dest, "strings", StringResource::fromNode) + } + locales.forEach { (source, dest) -> addStringResources("values-$source", "values-$dest") } + addStringResources("values", "values") + + addResources("values", "values", "arrays", ArrayResource::fromNode) + } catch (e: Exception) { + throw PatchException("Failed to read resources", e) + } + } + } + + /** + * Adds all resources staged in [addResourcesPatch] to the app. + * This is called after all patches that depend on [addResourcesPatch] have been executed. + */ + finalize { context -> + operator fun MutableMap>.invoke( + value: Value, + resource: BaseResource, + ) { + // TODO: Fix open-closed principle violation by modifying BaseResource#serialize so that it accepts + // a Value and the map of documents. It will then get or put the document suitable for its resource type + // to serialize itself to it. + val resourceFileName = + when (resource) { + is StringResource -> "strings" + is ArrayResource -> "arrays" + else -> throw NotImplementedError("Unsupported resource type") + } + + getOrPut(resourceFileName) { + val targetFile = + context["res/$value/$resourceFileName.xml"].also { + it.parentFile?.mkdirs() + + if (it.createNewFile()) { + it.writeText("\n\n") + } + } + + context.document[targetFile.path].let { document -> + + // Save the target node here as well + // in order to avoid having to call document.getNode("resources") + // but also save the document so that it can be closed later. + document to document.getNode("resources") + } + }.let { (_, targetNode) -> + targetNode.addResource(resource) { invoke(value, it) } + } + } + + resources.forEach { (value, resources) -> + // A map of document associated by their kind (e.g. strings, arrays). + // Each document is accompanied by the target node to which resources are added. + // A map is used because Map#getOrPut allows opening a new document for the duration of a resource value. + // This is done to prevent having to open the files for every resource that is added. + // Instead, it is cached once and reused for resources of the same value. + // This map is later accessed to close all documents for the current resource value. + val documents = mutableMapOf>() + + resources.forEach { resource -> documents(value, resource) } + + documents.values.forEach { (document, _) -> document.close() } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/all/misc/screencapture/RemoveScreenCaptureRestrictionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/screencapture/RemoveScreenCaptureRestrictionPatch.kt new file mode 100644 index 000000000..3633541e7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/screencapture/RemoveScreenCaptureRestrictionPatch.kt @@ -0,0 +1,83 @@ +package app.revanced.patches.all.misc.screencapture + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.all.misc.transformation.IMethodCall +import app.revanced.patches.all.misc.transformation.filterMapInstruction35c +import app.revanced.patches.all.misc.transformation.transformInstructionsPatch +import org.w3c.dom.Element + +private val removeCaptureRestrictionResourcePatch = resourcePatch( + description = "Sets allowAudioPlaybackCapture in manifest to true.", +) { + execute { context -> + context.document["AndroidManifest.xml"].use { document -> + // Get the application node. + val applicationNode = + document + .getElementsByTagName("application") + .item(0) as Element + + // Set allowAudioPlaybackCapture attribute to true. + applicationNode.setAttribute("android:allowAudioPlaybackCapture", "true") + } + } +} + +private const val EXTENSION_CLASS_DESCRIPTOR_PREFIX = + "Lapp/revanced/extension/all/screencapture/removerestriction/RemoveScreencaptureRestrictionPatch" +private const val EXTENSION_CLASS_DESCRIPTOR = "$EXTENSION_CLASS_DESCRIPTOR_PREFIX;" + +@Suppress("unused") +val removeScreenCaptureRestrictionPatch = bytecodePatch( + name = "Remove screen capture restriction", + description = "Removes the restriction of capturing audio from apps that normally wouldn't allow it.", + use = false, +) { + extendWith("extensions/all/screencapture/remove-screen-capture-restriction.rve") + + dependsOn( + removeCaptureRestrictionResourcePatch, + transformInstructionsPatch( + filterMap = { classDef, _, instruction, instructionIndex -> + filterMapInstruction35c( + EXTENSION_CLASS_DESCRIPTOR_PREFIX, + classDef, + instruction, + instructionIndex, + ) + }, + transform = { mutableMethod, entry -> + val (methodType, instruction, instructionIndex) = entry + methodType.replaceInvokeVirtualWithExtension( + EXTENSION_CLASS_DESCRIPTOR, + mutableMethod, + instruction, + instructionIndex, + ) + }, + ), + ) +} + +// Information about method calls we want to replace +@Suppress("unused") +private enum class MethodCall( + override val definedClassName: String, + override val methodName: String, + override val methodParams: Array, + override val returnType: String, +) : IMethodCall { + SetAllowedCapturePolicySingle( + "Landroid/media/AudioAttributes\$Builder;", + "setAllowedCapturePolicy", + arrayOf("I"), + "Landroid/media/AudioAttributes\$Builder;", + ), + SetAllowedCapturePolicyGlobal( + "Landroid/media/AudioManager;", + "setAllowedCapturePolicy", + arrayOf("I"), + "V", + ), +} diff --git a/patches/src/main/kotlin/app/revanced/patches/all/misc/screenshot/RemoveScreenshotRestrictionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/screenshot/RemoveScreenshotRestrictionPatch.kt new file mode 100644 index 000000000..af689f5ed --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/screenshot/RemoveScreenshotRestrictionPatch.kt @@ -0,0 +1,97 @@ +package app.revanced.patches.all.misc.screenshot + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.transformation.IMethodCall +import app.revanced.patches.all.misc.transformation.filterMapInstruction35c +import app.revanced.patches.all.misc.transformation.transformInstructionsPatch +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction22c +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +private const val EXTENSION_CLASS_DESCRIPTOR_PREFIX = + "Lapp/revanced/extension/all/screenshot/removerestriction/RemoveScreenshotRestrictionPatch" +private const val EXTENSION_CLASS_DESCRIPTOR = "$EXTENSION_CLASS_DESCRIPTOR_PREFIX;" + +@Suppress("unused") +val removeScreenshotRestrictionPatch = bytecodePatch( + name = "Remove screenshot restriction", + description = "Removes the restriction of taking screenshots in apps that normally wouldn't allow it.", + use = false, +) { + extendWith("extensions/all/screenshot/remove-screenshot-restriction.rve") + + dependsOn( + // Remove the restriction of taking screenshots. + transformInstructionsPatch( + filterMap = { classDef, _, instruction, instructionIndex -> + filterMapInstruction35c( + EXTENSION_CLASS_DESCRIPTOR_PREFIX, + classDef, + instruction, + instructionIndex, + ) + }, + transform = { mutableMethod, entry -> + val (methodType, instruction, instructionIndex) = entry + methodType.replaceInvokeVirtualWithExtension( + EXTENSION_CLASS_DESCRIPTOR, + mutableMethod, + instruction, + instructionIndex, + ) + }, + ), + // Modify layout params. + transformInstructionsPatch( + filterMap = { _, _, instruction, instructionIndex -> + if (instruction.opcode != Opcode.IPUT) { + return@transformInstructionsPatch null + } + + val instruction22c = instruction as Instruction22c + val fieldReference = instruction22c.reference as FieldReference + + if (fieldReference.definingClass != "Landroid/view/WindowManager\$LayoutParams;" || + fieldReference.name != "flags" || + fieldReference.type != "I" + ) { + return@transformInstructionsPatch null + } + + Pair(instruction22c, instructionIndex) + }, + transform = { mutableMethod, entry -> + val (instruction, index) = entry + val register = instruction.registerA + + mutableMethod.addInstructions( + index, + "and-int/lit16 v$register, v$register, -0x2001", + ) + }, + ), + ) +} + +// Information about method calls we want to replace +@Suppress("unused") +private enum class MethodCall( + override val definedClassName: String, + override val methodName: String, + override val methodParams: Array, + override val returnType: String, +) : IMethodCall { + AddFlags( + "Landroid/view/Window;", + "addFlags", + arrayOf("I"), + "V", + ), + SetFlags( + "Landroid/view/Window;", + "setFlags", + arrayOf("I", "I"), + "V", + ), +} diff --git a/src/main/kotlin/app/revanced/patches/all/shortcut/sharetargets/RemoveShareTargetsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/shortcut/sharetargets/RemoveShareTargetsPatch.kt similarity index 63% rename from src/main/kotlin/app/revanced/patches/all/shortcut/sharetargets/RemoveShareTargetsPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/all/misc/shortcut/sharetargets/RemoveShareTargetsPatch.kt index 6e7fbb936..c2e39dc24 100644 --- a/src/main/kotlin/app/revanced/patches/all/shortcut/sharetargets/RemoveShareTargetsPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/shortcut/sharetargets/RemoveShareTargetsPatch.kt @@ -1,26 +1,23 @@ -package app.revanced.patches.all.shortcut.sharetargets +package app.revanced.patches.all.misc.shortcut.sharetargets -import app.revanced.patcher.data.ResourceContext -import app.revanced.patcher.patch.ResourcePatch -import app.revanced.patcher.patch.annotation.Patch +import app.revanced.patcher.patch.resourcePatch import app.revanced.util.asSequence import app.revanced.util.getNode import org.w3c.dom.Element import java.io.FileNotFoundException import java.util.logging.Logger -@Patch( +@Suppress("unused") +val removeShareTargetsPatch = resourcePatch( name = "Remove share targets", description = "Removes share targets like directly sharing to a frequent contact.", use = false, -) -@Suppress("unused") -object RemoveShareTargetsPatch : ResourcePatch() { - override fun execute(context: ResourceContext) { +) { + execute { context -> try { context.document["res/xml/shortcuts.xml"] } catch (_: FileNotFoundException) { - return Logger.getLogger(this::class.java.name).warning("The app has no shortcuts") + return@execute Logger.getLogger(this::class.java.name).warning("The app has no shortcuts") }.use { document -> val rootNode = document.getNode("shortcuts") as? Element ?: return@use diff --git a/src/main/kotlin/app/revanced/patches/all/misc/transformation/MethodCall.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/transformation/MethodCall.kt similarity index 67% rename from src/main/kotlin/app/revanced/patches/all/misc/transformation/MethodCall.kt rename to patches/src/main/kotlin/app/revanced/patches/all/misc/transformation/MethodCall.kt index 5736ff247..0c39436d1 100644 --- a/src/main/kotlin/app/revanced/patches/all/misc/transformation/MethodCall.kt +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/transformation/MethodCall.kt @@ -18,8 +18,8 @@ interface IMethodCall { /** * Replaces an invoke-virtual instruction with an invoke-static instruction, - * which calls a static replacement method in the respective integrations class. - * The method definition in the integrations class is expected to be the same, + * 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. * @@ -27,56 +27,58 @@ interface IMethodCall { * * original method: Window#setFlags(int, int) * - * replacement method: Integrations#setFlags(Window, int, int) + * replacement method: Extension#setFlags(Window, int, int) */ - fun replaceInvokeVirtualWithIntegrations( + fun replaceInvokeVirtualWithExtension( definingClassDescriptor: String, method: MutableMethod, instruction: Instruction35c, - instructionIndex: Int + instructionIndex: Int, ) { val registers = arrayOf( instruction.registerC, instruction.registerD, instruction.registerE, instruction.registerF, - instruction.registerG + 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}." + "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 replacementMethodDefinition = - "${methodName}(${definedClassName}${methodParams.joinToString(separator = "")})${returnType}" + 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}->${replacementMethodDefinition}" + "invoke-static { $args }, $definingClassDescriptor->$replacementMethod", ) } } -inline fun fromMethodReference(methodReference: MethodReference) +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 + search.definedClassName == methodReference.definingClass && + search.methodName == methodReference.name && + methodReference.parameterTypes.toTypedArray().contentEquals(search.methodParams) && + search.returnType == methodReference.returnType } inline fun filterMapInstruction35c( - integrationsClassDescriptorPrefix: String, + extensionClassDescriptorPrefix: String, classDef: ClassDef, instruction: Instruction, - instructionIndex: Int + instructionIndex: Int, ): Instruction35cInfo? where E : Enum, E : IMethodCall { - if (classDef.type.startsWith(integrationsClassDescriptorPrefix)) { + if (classDef.startsWith(extensionClassDescriptorPrefix)) { // avoid infinite recursion return null } diff --git a/src/main/kotlin/app/revanced/patches/all/misc/transformation/BaseTransformInstructionsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/transformation/TransformInstructionsPatch.kt similarity index 79% rename from src/main/kotlin/app/revanced/patches/all/misc/transformation/BaseTransformInstructionsPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/all/misc/transformation/TransformInstructionsPatch.kt index e651c4d63..48d9ffa58 100644 --- a/src/main/kotlin/app/revanced/patches/all/misc/transformation/BaseTransformInstructionsPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/transformation/TransformInstructionsPatch.kt @@ -1,24 +1,16 @@ package app.revanced.patches.all.misc.transformation -import app.revanced.patcher.data.BytecodeContext -import app.revanced.patcher.patch.BytecodePatch +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 -@Suppress("MemberVisibilityCanBePrivate") -abstract class BaseTransformInstructionsPatch : BytecodePatch(emptySet()) { - abstract fun filterMap( - classDef: ClassDef, - method: Method, - instruction: Instruction, - instructionIndex: Int, - ): T? - - abstract fun transform(mutableMethod: MutableMethod, entry: T) - +fun transformInstructionsPatch( + filterMap: (ClassDef, Method, Instruction, Int) -> T?, + transform: (MutableMethod, T) -> Unit, +) = bytecodePatch { // Returns the patch indices as a Sequence, which will execute lazily. fun findPatchIndices(classDef: ClassDef, method: Method): Sequence? { return method.implementation?.instructions?.asSequence()?.withIndex()?.mapNotNull { (index, instruction) -> @@ -26,7 +18,7 @@ abstract class BaseTransformInstructionsPatch : BytecodePatch(emptySet()) { } } - override fun execute(context: BytecodeContext) { + execute { context -> // Find all methods to patch buildMap { context.classes.forEach { classDef -> diff --git a/src/main/kotlin/app/revanced/patches/all/misc/versioncode/ChangeVersionCodePatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/versioncode/ChangeVersionCodePatch.kt similarity index 69% rename from src/main/kotlin/app/revanced/patches/all/misc/versioncode/ChangeVersionCodePatch.kt rename to patches/src/main/kotlin/app/revanced/patches/all/misc/versioncode/ChangeVersionCodePatch.kt index 5170332db..b8bfb4432 100644 --- a/src/main/kotlin/app/revanced/patches/all/misc/versioncode/ChangeVersionCodePatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/versioncode/ChangeVersionCodePatch.kt @@ -1,22 +1,19 @@ package app.revanced.patches.all.misc.versioncode -import app.revanced.patcher.data.ResourceContext -import app.revanced.patcher.patch.ResourcePatch -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patcher.patch.options.PatchOption.PatchExtensions.intPatchOption +import app.revanced.patcher.patch.intOption +import app.revanced.patcher.patch.resourcePatch import app.revanced.util.getNode import org.w3c.dom.Element -@Patch( +@Suppress("unused") +val changeVersionCodePatch = resourcePatch( name = "Change version code", description = "Changes the version code of the app. By default the highest version code is set. " + "This allows older versions of an app to be installed " + "if their version code is set to the same or a higher value and can stop app stores to update the app.", use = false, -) -@Suppress("unused") -object ChangeVersionCodePatch : ResourcePatch() { - private val versionCode by intPatchOption( +) { + val versionCode by intOption( key = "versionCode", default = Int.MAX_VALUE, values = mapOf( @@ -26,11 +23,9 @@ object ChangeVersionCodePatch : ResourcePatch() { title = "Version code", description = "The version code to use", required = true, - ) { - it!! >= 1 - } + ) { versionCode -> versionCode!! >= 1 } - override fun execute(context: ResourceContext) { + execute { context -> context.document["AndroidManifest.xml"].use { document -> val manifestElement = document.getNode("manifest") as Element manifestElement.setAttribute("android:versionCode", "$versionCode") diff --git a/patches/src/main/kotlin/app/revanced/patches/amazon/DeepLinkingPatch.kt b/patches/src/main/kotlin/app/revanced/patches/amazon/DeepLinkingPatch.kt new file mode 100644 index 000000000..5a726b791 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/amazon/DeepLinkingPatch.kt @@ -0,0 +1,24 @@ +package app.revanced.patches.amazon + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val deepLinkingPatch = bytecodePatch( + name = "Always allow deep-linking", + description = "Open Amazon links, even if the app is not set to handle Amazon links.", +) { + compatibleWith("com.amazon.mShop.android.shopping") + + val deepLinkingMatch by deepLinkingFingerprint() + + execute { + deepLinkingMatch.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/amazon/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/amazon/Fingerprints.kt new file mode 100644 index 000000000..1339149f4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/amazon/Fingerprints.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.amazon + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val deepLinkingFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE) + returns("Z") + parameters("L") + strings("https://www.", "android.intent.action.VIEW") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/backdrops/misc/pro/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/backdrops/misc/pro/Fingerprints.kt new file mode 100644 index 000000000..bbe71e8c7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/backdrops/misc/pro/Fingerprints.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.backdrops.misc.pro + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.Opcode + +internal val proUnlockFingerprint = fingerprint { + opcodes( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT, + Opcode.IF_EQZ, + ) + custom { method, _ -> + method.name == "lambda\$existPurchase\$0" && + method.definingClass == "Lcom/backdrops/wallpapers/data/local/DatabaseHandlerIAB;" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/backdrops/misc/pro/ProUnlockPatch.kt b/patches/src/main/kotlin/app/revanced/patches/backdrops/misc/pro/ProUnlockPatch.kt new file mode 100644 index 000000000..144f165de --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/backdrops/misc/pro/ProUnlockPatch.kt @@ -0,0 +1,27 @@ +package app.revanced.patches.backdrops.misc.pro + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +@Suppress("unused") +val proUnlockPatch = bytecodePatch( + name = "Pro unlock", +) { + compatibleWith("com.backdrops.wallpapers") + + val proUnlockMatch by proUnlockFingerprint() + + execute { + val registerIndex = proUnlockMatch.patternMatch!!.endIndex - 1 + + proUnlockMatch.mutableMethod.apply { + val register = getInstruction(registerIndex).registerA + addInstruction( + proUnlockMatch.patternMatch!!.endIndex, + "const/4 v$register, 0x1", + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/bandcamp/limitations/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/bandcamp/limitations/Fingerprints.kt new file mode 100644 index 000000000..96bbab5cc --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/bandcamp/limitations/Fingerprints.kt @@ -0,0 +1,7 @@ +package app.revanced.patches.bandcamp.limitations + +import app.revanced.patcher.fingerprint + +internal val handlePlaybackLimitsFingerprint = fingerprint { + strings("play limits processing track", "found play_count") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/bandcamp/limitations/RemovePlayLimitsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/bandcamp/limitations/RemovePlayLimitsPatch.kt new file mode 100644 index 000000000..88302ef4c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/bandcamp/limitations/RemovePlayLimitsPatch.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.bandcamp.limitations + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val removePlayLimitsPatch = bytecodePatch( + name = "Remove play limits", + description = "Disables purchase nagging and playback limits of not purchased tracks.", +) { + compatibleWith("com.bandcamp.android") + + val handlePlaybackLimitsMatch by handlePlaybackLimitsFingerprint() + + execute { + handlePlaybackLimitsMatch.mutableMethod.addInstructions(0, "return-void") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/cieid/restrictions/root/BypassRootChecksPatch.kt b/patches/src/main/kotlin/app/revanced/patches/cieid/restrictions/root/BypassRootChecksPatch.kt new file mode 100644 index 000000000..a0a79f6db --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/cieid/restrictions/root/BypassRootChecksPatch.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.cieid.restrictions.root + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val bypassRootChecksPatch = bytecodePatch( + name = "Bypass root checks", + description = "Removes the restriction to use the app with root permissions or on a custom ROM.", +) { + compatibleWith("it.ipzs.cieid") + + val checkRootMatch by checkRootFingerprint() + + execute { + checkRootMatch.mutableMethod.addInstruction(1, "return-void") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/cieid/restrictions/root/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/cieid/restrictions/root/Fingerprints.kt new file mode 100644 index 000000000..387b0a0be --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/cieid/restrictions/root/Fingerprints.kt @@ -0,0 +1,9 @@ +package app.revanced.patches.cieid.restrictions.root + +import app.revanced.patcher.fingerprint + +internal val checkRootFingerprint = fingerprint { + custom { method, _ -> + method.name == "onResume" && method.definingClass == "Lit/ipzs/cieid/BaseActivity;" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/duolingo/ad/DisableAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/duolingo/ad/DisableAdsPatch.kt new file mode 100644 index 000000000..fa9eaf009 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/duolingo/ad/DisableAdsPatch.kt @@ -0,0 +1,34 @@ +package app.revanced.patches.duolingo.ad + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction + +@Suppress("unused") +val disableAdsPatch = bytecodePatch( + "Disable ads", +) { + compatibleWith("com.duolingo") + + val initializeMonetizationDebugSettingsMatch by initializeMonetizationDebugSettingsFingerprint() + + execute { + // Couple approaches to remove ads exist: + // + // MonetizationDebugSettings has a boolean value for "disableAds". + // OnboardingState has a getter to check if the user has any "adFreeSessions". + // SharedPreferences has a debug boolean value with key "disable_ads", which maps to "DebugCategory.DISABLE_ADS". + // + // MonetizationDebugSettings seems to be the most general setting to work fine. + initializeMonetizationDebugSettingsMatch.mutableMethod.apply { + val insertIndex = initializeMonetizationDebugSettingsMatch.patternMatch!!.startIndex + val register = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, + "const/4 v$register, 0x1", + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/duolingo/ad/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/duolingo/ad/Fingerprints.kt new file mode 100644 index 000000000..59b0644d9 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/duolingo/ad/Fingerprints.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.duolingo.ad + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val initializeMonetizationDebugSettingsFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + returns("V") + parameters( + "Z", // disableAds + "Z", // useDebugBilling + "Z", // showManageSubscriptions + "Z", // alwaysShowSuperAds + "Lcom/duolingo/debug/FamilyQuestOverride;", + ) + opcodes(Opcode.IPUT_BOOLEAN) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/duolingo/debug/EnableDebugMenuPatch.kt b/patches/src/main/kotlin/app/revanced/patches/duolingo/debug/EnableDebugMenuPatch.kt new file mode 100644 index 000000000..609b03cf1 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/duolingo/debug/EnableDebugMenuPatch.kt @@ -0,0 +1,28 @@ +package app.revanced.patches.duolingo.debug + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction + +@Suppress("unused") +val enableDebugMenuPatch = bytecodePatch( + name = "Enable debug menu", + use = false, +) { + compatibleWith("com.duolingo"("5.158.4")) + + val initializeBuildConfigProviderMatch by initializeBuildConfigProviderFingerprint() + + execute { + initializeBuildConfigProviderMatch.mutableMethod.apply { + val insertIndex = initializeBuildConfigProviderMatch.patternMatch!!.startIndex + val register = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, + "const/4 v$register, 0x1", + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/duolingo/debug/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/duolingo/debug/Fingerprints.kt new file mode 100644 index 000000000..543e40b43 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/duolingo/debug/Fingerprints.kt @@ -0,0 +1,19 @@ +package app.revanced.patches.duolingo.debug + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +/** + * The `BuildConfigProvider` class has two booleans: + * + * - `isChina`: (usually) compares "play" with "china"...except for builds in China + * - `isDebug`: compares "release" with "debug" <-- we want to force this to `true` + */ + +internal val initializeBuildConfigProviderFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + returns("V") + opcodes(Opcode.IPUT_BOOLEAN) + strings("debug", "release", "china") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/facebook/ads/mainfeed/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/facebook/ads/mainfeed/Fingerprints.kt new file mode 100644 index 000000000..7c732378f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/facebook/ads/mainfeed/Fingerprints.kt @@ -0,0 +1,47 @@ +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val baseModelMapperFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Lcom/facebook/graphql/modelutil/BaseModelWithTree;") + parameters("Ljava/lang/Class", "I", "I") + opcodes( + Opcode.SGET_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CONST_4, + Opcode.IF_EQ, + ) +} + +internal val getSponsoredDataModelTemplateFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("L") + parameters() + opcodes( + Opcode.CONST, + Opcode.CONST, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.RETURN_OBJECT, + ) + custom { _, classDef -> + classDef.type == "Lcom/facebook/graphql/model/GraphQLFBMultiAdsFeedUnit;" + } +} + +internal val getStoryVisibilityFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("Ljava/lang/String;") + opcodes( + Opcode.INSTANCE_OF, + Opcode.IF_NEZ, + Opcode.INSTANCE_OF, + Opcode.IF_NEZ, + Opcode.INSTANCE_OF, + Opcode.IF_NEZ, + Opcode.CONST, + ) + strings("This should not be called for base class object") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/facebook/ads/mainfeed/HideSponsoredStoriesPatch.kt b/patches/src/main/kotlin/app/revanced/patches/facebook/ads/mainfeed/HideSponsoredStoriesPatch.kt new file mode 100644 index 000000000..0c201f9fc --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/facebook/ads/mainfeed/HideSponsoredStoriesPatch.kt @@ -0,0 +1,90 @@ +package app.revanced.patches.facebook.ads.mainfeed + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable +import baseModelMapperFingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.builder.MutableMethodImplementation +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction31i +import com.android.tools.smali.dexlib2.immutable.ImmutableMethod +import com.android.tools.smali.dexlib2.immutable.ImmutableMethodParameter +import getSponsoredDataModelTemplateFingerprint +import getStoryVisibilityFingerprint + +@Suppress("unused") +val hideSponsoredStoriesPatch = bytecodePatch( + name = "Hide 'Sponsored Stories'", +) { + compatibleWith("com.facebook.katana") + + val getStoryVisibilityMatch by getStoryVisibilityFingerprint() + val getSponsoredDataModelTemplateMatch by getSponsoredDataModelTemplateFingerprint() + val baseModelMapperMatch by baseModelMapperFingerprint() + + execute { + val sponsoredDataModelTemplateMethod = getSponsoredDataModelTemplateMatch.method + val baseModelMapperMethod = baseModelMapperMatch.method + val baseModelWithTreeType = baseModelMapperMethod.returnType + + val graphQlStoryClassDescriptor = "Lcom/facebook/graphql/model/GraphQLStory;" + + // The "SponsoredDataModelTemplate" methods has the ids in its body to extract sponsored data + // from GraphQL models, but targets the wrong derived type of "BaseModelWithTree". Since those ids + // could change in future version, we need to extract them and call the base implementation directly. + val getSponsoredDataHelperMethod = ImmutableMethod( + getStoryVisibilityMatch.classDef.type, + "getSponsoredData", + listOf(ImmutableMethodParameter(graphQlStoryClassDescriptor, null, null)), + baseModelWithTreeType, + AccessFlags.PRIVATE.value or AccessFlags.STATIC.value, + null, + null, + MutableMethodImplementation(4), + ).toMutable().apply { + // Extract the ids of the original method. These ids seem to correspond to model types for + // GraphQL data structure. They are then fed to a method of BaseModelWithTree that populate + // and cast the requested GraphQL subtype. The Ids are found in the two first "CONST" instructions. + val constInstructions = sponsoredDataModelTemplateMethod.implementation!!.instructions + .asSequence() + .filterIsInstance() + .take(2) + .toList() + + val storyTypeId = constInstructions[0].narrowLiteral + val sponsoredDataTypeId = constInstructions[1].narrowLiteral + + addInstructions( + """ + const-class v2, $baseModelWithTreeType + const v1, $storyTypeId + const v0, $sponsoredDataTypeId + invoke-virtual {p0, v2, v1, v0}, $baseModelMapperMethod + move-result-object v0 + check-cast v0, $baseModelWithTreeType + return-object v0 + """, + ) + } + + getStoryVisibilityMatch.mutableClass.methods.add(getSponsoredDataHelperMethod) + + // Check if the parameter type is GraphQLStory and if sponsoredDataModelGetter returns a non-null value. + // If so, hide the story by setting the visibility to StoryVisibility.GONE. + getStoryVisibilityMatch.mutableMethod.addInstructionsWithLabels( + getStoryVisibilityMatch.patternMatch!!.startIndex, + """ + instance-of v0, p0, $graphQlStoryClassDescriptor + if-eqz v0, :resume_normal + invoke-static {p0}, $getSponsoredDataHelperMethod + move-result-object v0 + if-eqz v0, :resume_normal + const-string v0, "GONE" + return-object v0 + :resume_normal + nop + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/facebook/ads/story/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/facebook/ads/story/Fingerprints.kt new file mode 100644 index 000000000..293d7ee82 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/facebook/ads/story/Fingerprints.kt @@ -0,0 +1,24 @@ +package app.revanced.patches.facebook.ads.story + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.iface.value.StringEncodedValue + +internal val adsInsertionFingerprint = fieldFingerprint( + fieldValue = "AdBucketDataSourceUtil\$attemptAdsInsertion\$1", +) + +internal val fetchMoreAdsFingerprint = fieldFingerprint( + fieldValue = "AdBucketDataSourceUtil\$attemptFetchMoreAds\$1", +) + +internal fun fieldFingerprint(fieldValue: String) = fingerprint { + returns("V") + parameters() + custom { method, classDef -> + method.name == "run" && + classDef.fields.any any@{ field -> + if (field.name != "__redex_internal_original_name") return@any false + (field.initialValue as? StringEncodedValue)?.value == fieldValue + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/facebook/ads/story/HideStoryAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/facebook/ads/story/HideStoryAdsPatch.kt new file mode 100644 index 000000000..7699b7be1 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/facebook/ads/story/HideStoryAdsPatch.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.facebook.ads.story + +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val hideStoryAdsPatch = bytecodePatch( + name = "Hide story ads", + description = "Hides the ads in the Facebook app stories.", +) { + compatibleWith("com.facebook.katana") + + val fetchMoreAdsMatch by fetchMoreAdsFingerprint() + val adsInsertionMatch by adsInsertionFingerprint() + + execute { + setOf(fetchMoreAdsMatch, adsInsertionMatch).forEach { match -> + match.mutableMethod.replaceInstruction(0, "return-void") + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/finanzonline/detection/bootloader/BootloaderDetectionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/finanzonline/detection/bootloader/BootloaderDetectionPatch.kt new file mode 100644 index 000000000..4bdeaf4a5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/finanzonline/detection/bootloader/BootloaderDetectionPatch.kt @@ -0,0 +1,27 @@ +package app.revanced.patches.finanzonline.detection.bootloader + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val bootloaderDetectionPatch = bytecodePatch( + name = "Remove bootloader detection", + description = "Removes the check for an unlocked bootloader.", +) { + compatibleWith("at.gv.bmf.bmf2go") + + val createKeyMatch by createKeyFingerprint() + val bootStateMatch by bootStateFingerprint() + + execute { + setOf(createKeyMatch, bootStateMatch).forEach { match -> + match.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + } + } +} diff --git a/src/main/kotlin/app/revanced/patches/finanzonline/detection/bootloader/fingerprints/BootStateFingerprint.kt b/patches/src/main/kotlin/app/revanced/patches/finanzonline/detection/bootloader/Fingerprints.kt similarity index 57% rename from src/main/kotlin/app/revanced/patches/finanzonline/detection/bootloader/fingerprints/BootStateFingerprint.kt rename to patches/src/main/kotlin/app/revanced/patches/finanzonline/detection/bootloader/Fingerprints.kt index f76ab78aa..4c8ee1c54 100644 --- a/src/main/kotlin/app/revanced/patches/finanzonline/detection/bootloader/fingerprints/BootStateFingerprint.kt +++ b/patches/src/main/kotlin/app/revanced/patches/finanzonline/detection/bootloader/Fingerprints.kt @@ -1,14 +1,14 @@ -package app.revanced.patches.finanzonline.detection.bootloader.fingerprints +package app.revanced.patches.finanzonline.detection.bootloader -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint // Located @ at.gv.bmf.bmf2go.taxequalization.tools.utils.AttestationHelper#isBootStateOk (3.0.1) -internal object BootStateFingerprint : MethodFingerprint( - "Z", - accessFlags = AccessFlags.PUBLIC.value, - opcodes = listOf( +internal val bootStateFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC) + returns("Z") + opcodes( Opcode.INVOKE_DIRECT, Opcode.MOVE_RESULT_OBJECT, Opcode.CONST_4, @@ -27,4 +27,11 @@ internal object BootStateFingerprint : MethodFingerprint( Opcode.MOVE, Opcode.RETURN ) -) +} + +// Located @ at.gv.bmf.bmf2go.taxequalization.tools.utils.AttestationHelper#createKey (3.0.1) +internal val createKeyFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC) + returns("Z") + strings("attestation", "SHA-256", "random", "EC", "AndroidKeyStore") +} \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/finanzonline/detection/root/fingerprints/RootDetectionFingerprint.kt b/patches/src/main/kotlin/app/revanced/patches/finanzonline/detection/root/Fingerprints.kt similarity index 53% rename from src/main/kotlin/app/revanced/patches/finanzonline/detection/root/fingerprints/RootDetectionFingerprint.kt rename to patches/src/main/kotlin/app/revanced/patches/finanzonline/detection/root/Fingerprints.kt index 50c7f76a8..fec7f1249 100644 --- a/src/main/kotlin/app/revanced/patches/finanzonline/detection/root/fingerprints/RootDetectionFingerprint.kt +++ b/patches/src/main/kotlin/app/revanced/patches/finanzonline/detection/root/Fingerprints.kt @@ -1,16 +1,15 @@ -package app.revanced.patches.finanzonline.detection.root.fingerprints +package app.revanced.patches.finanzonline.detection.root -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 +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint // Located @ at.gv.bmf.bmf2go.taxequalization.tools.utils.RootDetection#isRooted (3.0.1) -internal object RootDetectionFingerprint : MethodFingerprint( - "L", - accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, - parameters = listOf("L"), - opcodes = listOf( +internal val rootDetectionFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("L") + parameters("L") + opcodes( Opcode.NEW_INSTANCE, Opcode.INVOKE_DIRECT, Opcode.INVOKE_VIRTUAL, @@ -19,4 +18,4 @@ internal object RootDetectionFingerprint : MethodFingerprint( Opcode.MOVE_RESULT_OBJECT, Opcode.RETURN_OBJECT ) -) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/finanzonline/detection/root/RootDetectionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/finanzonline/detection/root/RootDetectionPatch.kt new file mode 100644 index 000000000..9144bf749 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/finanzonline/detection/root/RootDetectionPatch.kt @@ -0,0 +1,24 @@ +package app.revanced.patches.finanzonline.detection.root + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val rootDetectionPatch = bytecodePatch( + name = "Remove root detection", + description = "Removes the check for root permissions.", +) { + compatibleWith("at.gv.bmf.bmf2go") + + val rootDetectionMatch by rootDetectionFingerprint() + + execute { + rootDetectionMatch.mutableMethod.addInstructions( + 0, + """ + sget-object v0, Ljava/lang/Boolean;->FALSE:Ljava/lang/Boolean; + return-object v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/googlenews/customtabs/EnableCustomTabsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/googlenews/customtabs/EnableCustomTabsPatch.kt new file mode 100644 index 000000000..81cbb8c58 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/googlenews/customtabs/EnableCustomTabsPatch.kt @@ -0,0 +1,25 @@ +package app.revanced.patches.googlenews.customtabs + +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +@Suppress("unused") +val enableCustomTabsPatch = bytecodePatch( + name = "Enable CustomTabs", + description = "Enables CustomTabs to open articles in your default browser.", +) { + compatibleWith("com.google.android.apps.magazines") + + val launchCustomTabMatch by launchCustomTabFingerprint() + + execute { + launchCustomTabMatch.mutableMethod.apply { + val checkIndex = launchCustomTabMatch.patternMatch!!.endIndex + 1 + val register = getInstruction(checkIndex).registerA + + replaceInstruction(checkIndex, "const/4 v$register, 0x1") + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/googlenews/customtabs/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/googlenews/customtabs/Fingerprints.kt new file mode 100644 index 000000000..8880c010e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/googlenews/customtabs/Fingerprints.kt @@ -0,0 +1,17 @@ +package app.revanced.patches.googlenews.customtabs + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val launchCustomTabFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + opcodes( + Opcode.IPUT_OBJECT, + Opcode.CONST_4, + Opcode.IPUT, + Opcode.CONST_4, + Opcode.IPUT_BOOLEAN, + ) + custom { _, classDef -> classDef.endsWith("CustomTabsArticleLauncher;") } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/googlenews/misc/extension/ExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/googlenews/misc/extension/ExtensionPatch.kt new file mode 100644 index 000000000..281872662 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/googlenews/misc/extension/ExtensionPatch.kt @@ -0,0 +1,6 @@ +package app.revanced.patches.googlenews.misc.extension + +import app.revanced.patches.googlenews.misc.extension.hooks.startActivityInitHook +import app.revanced.patches.shared.misc.extension.sharedExtensionPatch + +val extensionPatch = sharedExtensionPatch(startActivityInitHook) diff --git a/src/main/kotlin/app/revanced/patches/googlenews/misc/integrations/fingerprints/StartActivityInitFingerprint.kt b/patches/src/main/kotlin/app/revanced/patches/googlenews/misc/extension/hooks/StartActivityInitHook.kt similarity index 71% rename from src/main/kotlin/app/revanced/patches/googlenews/misc/integrations/fingerprints/StartActivityInitFingerprint.kt rename to patches/src/main/kotlin/app/revanced/patches/googlenews/misc/extension/hooks/StartActivityInitHook.kt index b71636793..558b24eba 100644 --- a/src/main/kotlin/app/revanced/patches/googlenews/misc/integrations/fingerprints/StartActivityInitFingerprint.kt +++ b/patches/src/main/kotlin/app/revanced/patches/googlenews/misc/extension/hooks/StartActivityInitHook.kt @@ -1,26 +1,15 @@ -package app.revanced.patches.googlenews.misc.integrations.fingerprints +package app.revanced.patches.googlenews.misc.extension.hooks -import app.revanced.patches.googlenews.misc.integrations.fingerprints.StartActivityInitFingerprint.getApplicationContextIndex -import app.revanced.patches.shared.misc.integrations.BaseIntegrationsPatch.IntegrationsFingerprint +import app.revanced.patches.shared.misc.extension.extensionHook 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 -internal object StartActivityInitFingerprint : IntegrationsFingerprint( - opcodes = listOf( - Opcode.INVOKE_STATIC, - Opcode.MOVE_RESULT, - Opcode.CONST_4, - Opcode.IF_EQZ, - Opcode.CONST, - Opcode.INVOKE_VIRTUAL, - Opcode.IPUT_OBJECT, - Opcode.IPUT_BOOLEAN, - Opcode.INVOKE_VIRTUAL, // Calls startActivity.getApplicationContext(). - Opcode.MOVE_RESULT_OBJECT, - ), +private var getApplicationContextIndex = -1 + +internal val startActivityInitHook = extensionHook( insertIndexResolver = { method -> getApplicationContextIndex = method.indexOfFirstInstructionOrThrow { getReference()?.name == "getApplicationContext" @@ -33,9 +22,20 @@ internal object StartActivityInitFingerprint : IntegrationsFingerprint( as OneRegisterInstruction moveResultInstruction.registerA }, - customFingerprint = { methodDef, classDef -> - methodDef.name == "onCreate" && classDef.endsWith("/StartActivity;") - }, ) { - private var getApplicationContextIndex = -1 + opcodes( + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT, + Opcode.CONST_4, + Opcode.IF_EQZ, + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.IPUT_OBJECT, + Opcode.IPUT_BOOLEAN, + Opcode.INVOKE_VIRTUAL, // Calls startActivity.getApplicationContext(). + Opcode.MOVE_RESULT_OBJECT, + ) + custom { methodDef, classDef -> + methodDef.name == "onCreate" && classDef.endsWith("/StartActivity;") + } } diff --git a/src/main/kotlin/app/revanced/patches/googlenews/misc/gms/Constants.kt b/patches/src/main/kotlin/app/revanced/patches/googlenews/misc/gms/Constants.kt similarity index 100% rename from src/main/kotlin/app/revanced/patches/googlenews/misc/gms/Constants.kt rename to patches/src/main/kotlin/app/revanced/patches/googlenews/misc/gms/Constants.kt diff --git a/patches/src/main/kotlin/app/revanced/patches/googlenews/misc/gms/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/googlenews/misc/gms/Fingerprints.kt new file mode 100644 index 000000000..6ddeb3e07 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/googlenews/misc/gms/Fingerprints.kt @@ -0,0 +1,9 @@ +package app.revanced.patches.googlenews.misc.gms + +import app.revanced.patcher.fingerprint + +internal val magazinesActivityOnCreateFingerprint = fingerprint { + custom { methodDef, classDef -> + methodDef.name == "onCreate" && classDef.endsWith("/StartActivity;") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/googlenews/misc/gms/GmsCoreSupportPatch.kt b/patches/src/main/kotlin/app/revanced/patches/googlenews/misc/gms/GmsCoreSupportPatch.kt new file mode 100644 index 000000000..d03ce31e7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/googlenews/misc/gms/GmsCoreSupportPatch.kt @@ -0,0 +1,30 @@ +package app.revanced.patches.googlenews.misc.gms + +import app.revanced.patcher.patch.Option +import app.revanced.patches.googlenews.misc.extension.extensionPatch +import app.revanced.patches.googlenews.misc.gms.Constants.MAGAZINES_PACKAGE_NAME +import app.revanced.patches.googlenews.misc.gms.Constants.REVANCED_MAGAZINES_PACKAGE_NAME +import app.revanced.patches.shared.misc.gms.gmsCoreSupportPatch +import app.revanced.patches.shared.misc.gms.gmsCoreSupportResourcePatch + +@Suppress("unused") +val gmsCoreSupportPatch = gmsCoreSupportPatch( + fromPackageName = MAGAZINES_PACKAGE_NAME, + toPackageName = REVANCED_MAGAZINES_PACKAGE_NAME, + mainActivityOnCreateFingerprint = magazinesActivityOnCreateFingerprint, + extensionPatch = extensionPatch, + gmsCoreSupportResourcePatchFactory = ::gmsCoreSupportResourcePatch, +) { + // Remove version constraint, + // once https://github.com/ReVanced/revanced-patches/pull/3111#issuecomment-2240877277 is resolved. + compatibleWith(MAGAZINES_PACKAGE_NAME("5.108.0.644447823")) +} + +private fun gmsCoreSupportResourcePatch( + gmsCoreVendorGroupIdOption: Option, +) = gmsCoreSupportResourcePatch( + fromPackageName = MAGAZINES_PACKAGE_NAME, + toPackageName = REVANCED_MAGAZINES_PACKAGE_NAME, + spoofedPackageSignature = "24bb24c05e47e0aefa68a58a766179d9b613a666", + gmsCoreVendorGroupIdOption = gmsCoreVendorGroupIdOption, +) diff --git a/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/extension/ExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/extension/ExtensionPatch.kt new file mode 100644 index 000000000..406d28fe4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/extension/ExtensionPatch.kt @@ -0,0 +1,5 @@ +package app.revanced.patches.googlephotos.misc.extension + +import app.revanced.patches.shared.misc.extension.sharedExtensionPatch + +val extensionPatch = sharedExtensionPatch(homeActivityInitHook) diff --git a/src/main/kotlin/app/revanced/patches/googlephotos/misc/integrations/fingerprints/HomeActivityInitFingerprint.kt b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/extension/Fingerprints.kt similarity index 69% rename from src/main/kotlin/app/revanced/patches/googlephotos/misc/integrations/fingerprints/HomeActivityInitFingerprint.kt rename to patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/extension/Fingerprints.kt index 1fb7bf440..84e6d5876 100644 --- a/src/main/kotlin/app/revanced/patches/googlephotos/misc/integrations/fingerprints/HomeActivityInitFingerprint.kt +++ b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/extension/Fingerprints.kt @@ -1,22 +1,15 @@ -package app.revanced.patches.googlephotos.misc.integrations.fingerprints +package app.revanced.patches.googlephotos.misc.extension -import app.revanced.patches.googlephotos.misc.integrations.fingerprints.HomeActivityInitFingerprint.getApplicationContextIndex -import app.revanced.patches.shared.misc.integrations.BaseIntegrationsPatch.IntegrationsFingerprint +import app.revanced.patches.shared.misc.extension.extensionHook 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 -internal object HomeActivityInitFingerprint : IntegrationsFingerprint( - opcodes = listOf( - Opcode.CONST_STRING, - Opcode.INVOKE_STATIC, - Opcode.MOVE_RESULT_OBJECT, - Opcode.IF_NEZ, - Opcode.INVOKE_VIRTUAL, // Calls getApplicationContext(). - Opcode.MOVE_RESULT_OBJECT, - ), +private var getApplicationContextIndex = -1 + +internal val homeActivityInitHook = extensionHook( insertIndexResolver = { method -> getApplicationContextIndex = method.indexOfFirstInstructionOrThrow { getReference()?.name == "getApplicationContext" @@ -29,9 +22,16 @@ internal object HomeActivityInitFingerprint : IntegrationsFingerprint( as OneRegisterInstruction moveResultInstruction.registerA }, - customFingerprint = { methodDef, classDef -> - methodDef.name == "onCreate" && classDef.endsWith("/HomeActivity;") - }, ) { - private var getApplicationContextIndex = -1 + opcodes( + Opcode.CONST_STRING, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IF_NEZ, + Opcode.INVOKE_VIRTUAL, // Calls getApplicationContext(). + Opcode.MOVE_RESULT_OBJECT, + ) + custom { methodDef, classDef -> + methodDef.name == "onCreate" && classDef.endsWith("/HomeActivity;") + } } diff --git a/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/features/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/features/Fingerprints.kt new file mode 100644 index 000000000..95f2a3dba --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/features/Fingerprints.kt @@ -0,0 +1,7 @@ +package app.revanced.patches.googlephotos.misc.features + +import app.revanced.patcher.fingerprint + +internal val initializeFeaturesEnumFingerprint = fingerprint { + strings("com.google.android.apps.photos.NEXUS_PRELOAD") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/features/SpoofBuildInfoPatch.kt b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/features/SpoofBuildInfoPatch.kt new file mode 100644 index 000000000..fa7ff3c14 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/features/SpoofBuildInfoPatch.kt @@ -0,0 +1,17 @@ +package app.revanced.patches.googlephotos.misc.features + +import app.revanced.patches.all.misc.build.BuildInfo +import app.revanced.patches.all.misc.build.baseSpoofBuildInfoPatch + +// Spoof build info to Google Pixel XL. +@Suppress("unused") +val spoofBuildInfoPatch = baseSpoofBuildInfoPatch { + BuildInfo( + brand = "google", + manufacturer = "Google", + device = "marlin", + product = "marlin", + model = "Pixel XL", + fingerprint = "google/marlin/marlin:10/QP1A.191005.007.A3/5972272:user/release-keys", + ) +} diff --git a/src/main/kotlin/app/revanced/patches/googlephotos/features/SpoofFeaturesPatch.kt b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/features/SpoofFeaturesPatch.kt similarity index 53% rename from src/main/kotlin/app/revanced/patches/googlephotos/features/SpoofFeaturesPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/features/SpoofFeaturesPatch.kt index 040012d45..f0aaad890 100644 --- a/src/main/kotlin/app/revanced/patches/googlephotos/features/SpoofFeaturesPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/features/SpoofFeaturesPatch.kt @@ -1,30 +1,26 @@ -package app.revanced.patches.googlephotos.features +package app.revanced.patches.googlephotos.misc.features -import app.revanced.patcher.data.BytecodeContext -import app.revanced.patcher.extensions.InstructionExtensions.getInstructions +import app.revanced.patcher.extensions.InstructionExtensions.instructions import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction -import app.revanced.patcher.patch.BytecodePatch -import app.revanced.patcher.patch.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patcher.patch.options.PatchOption.PatchExtensions.stringArrayPatchOption -import app.revanced.patches.googlephotos.features.fingerprints.InitializeFeaturesEnumFingerprint +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.stringsOption import app.revanced.util.getReference -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.StringReference -@Patch( +@Suppress("unused") +val spoofFeaturesPatch = bytecodePatch( name = "Spoof features", description = "Spoofs the device to enable Google Pixel exclusive features, including unlimited storage.", - dependencies = [SpoofBuildInfoPatch::class], - compatiblePackages = [CompatiblePackage("com.google.android.apps.photos")], -) -@Suppress("unused") -object SpoofFeaturesPatch : BytecodePatch(setOf(InitializeFeaturesEnumFingerprint)) { - private val featuresToEnable by stringArrayPatchOption( - "featuresToEnable", - arrayOf( +) { + compatibleWith("com.google.android.apps.photos") + + dependsOn(spoofBuildInfoPatch) + + val featuresToEnable by stringsOption( + key = "featuresToEnable", + default = listOf( "com.google.android.apps.photos.NEXUS_PRELOAD", "com.google.android.apps.photos.nexus_preload", ), @@ -33,9 +29,9 @@ object SpoofFeaturesPatch : BytecodePatch(setOf(InitializeFeaturesEnumFingerprin required = true, ) - private val featuresToDisable by stringArrayPatchOption( - "featuresToDisable", - arrayOf( + val featuresToDisable by stringsOption( + key = "featuresToDisable", + default = listOf( "com.google.android.apps.photos.PIXEL_2017_PRELOAD", "com.google.android.apps.photos.PIXEL_2018_PRELOAD", "com.google.android.apps.photos.PIXEL_2019_MIDYEAR_PRELOAD", @@ -58,29 +54,32 @@ object SpoofFeaturesPatch : BytecodePatch(setOf(InitializeFeaturesEnumFingerprin required = true, ) - override fun execute(context: BytecodeContext) { + val initializeFeaturesEnumMatch by initializeFeaturesEnumFingerprint() + + execute { + @Suppress("NAME_SHADOWING") val featuresToEnable = featuresToEnable!!.toSet() + + @Suppress("NAME_SHADOWING") val featuresToDisable = featuresToDisable!!.toSet() - InitializeFeaturesEnumFingerprint.resultOrThrow().let { result -> - result.mutableMethod.apply { - getInstructions().filter { it.opcode == Opcode.CONST_STRING }.forEach { - val feature = it.getReference()!!.string + initializeFeaturesEnumMatch.mutableMethod.apply { + instructions.filter { it.opcode == Opcode.CONST_STRING }.forEach { + val feature = it.getReference()!!.string - val spoofedFeature = when (feature) { - in featuresToEnable -> "android.hardware.wifi" - in featuresToDisable -> "dummy" - else -> return@forEach - } - - val constStringIndex = it.location.index - val constStringRegister = (it as OneRegisterInstruction).registerA - - replaceInstruction( - constStringIndex, - "const-string v$constStringRegister, \"$spoofedFeature\"", - ) + val spoofedFeature = when (feature) { + in featuresToEnable -> "android.hardware.wifi" + in featuresToDisable -> "dummy" + else -> return@forEach } + + val constStringIndex = it.location.index + val constStringRegister = (it as OneRegisterInstruction).registerA + + replaceInstruction( + constStringIndex, + "const-string v$constStringRegister, \"$spoofedFeature\"", + ) } } } diff --git a/src/main/kotlin/app/revanced/patches/googlephotos/misc/gms/Constants.kt b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/gms/Constants.kt similarity index 100% rename from src/main/kotlin/app/revanced/patches/googlephotos/misc/gms/Constants.kt rename to patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/gms/Constants.kt diff --git a/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/gms/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/gms/Fingerprints.kt new file mode 100644 index 000000000..f47c1a3d9 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/gms/Fingerprints.kt @@ -0,0 +1,9 @@ +package app.revanced.patches.googlephotos.misc.gms + +import app.revanced.patcher.fingerprint + +internal val homeActivityOnCreateFingerprint = fingerprint { + custom { methodDef, classDef -> + methodDef.name == "onCreate" && classDef.endsWith("/HomeActivity;") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/gms/GmsCoreSupportPatch.kt b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/gms/GmsCoreSupportPatch.kt new file mode 100644 index 000000000..3ed14a29d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/gms/GmsCoreSupportPatch.kt @@ -0,0 +1,27 @@ +package app.revanced.patches.googlephotos.misc.gms + +import app.revanced.patcher.patch.Option +import app.revanced.patches.googlephotos.misc.extension.extensionPatch +import app.revanced.patches.googlephotos.misc.gms.Constants.PHOTOS_PACKAGE_NAME +import app.revanced.patches.googlephotos.misc.gms.Constants.REVANCED_PHOTOS_PACKAGE_NAME +import app.revanced.patches.shared.misc.gms.gmsCoreSupportPatch + +@Suppress("unused") +val gmsCoreSupportPatch = gmsCoreSupportPatch( + fromPackageName = PHOTOS_PACKAGE_NAME, + toPackageName = REVANCED_PHOTOS_PACKAGE_NAME, + mainActivityOnCreateFingerprint = homeActivityOnCreateFingerprint, + extensionPatch = extensionPatch, + gmsCoreSupportResourcePatchFactory = ::gmsCoreSupportResourcePatch, +) { + compatibleWith(PHOTOS_PACKAGE_NAME) +} + +private fun gmsCoreSupportResourcePatch( + gmsCoreVendorGroupIdOption: Option, +) = app.revanced.patches.shared.misc.gms.gmsCoreSupportResourcePatch( + fromPackageName = PHOTOS_PACKAGE_NAME, + toPackageName = REVANCED_PHOTOS_PACKAGE_NAME, + spoofedPackageSignature = "24bb24c05e47e0aefa68a58a766179d9b613a600", + gmsCoreVendorGroupIdOption = gmsCoreVendorGroupIdOption, +) diff --git a/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/preferences/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/preferences/Fingerprints.kt new file mode 100644 index 000000000..54c20a7f8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/preferences/Fingerprints.kt @@ -0,0 +1,8 @@ +package app.revanced.patches.googlephotos.misc.preferences + +import app.revanced.patcher.fingerprint + +internal val backupPreferencesFingerprint = fingerprint { + returns("Lcom/google/android/apps/photos/backup/data/BackupPreferences;") + strings("backup_prefs_had_backup_only_when_charging_enabled") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/preferences/RestoreHiddenBackUpWhileChargingTogglePatch.kt b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/preferences/RestoreHiddenBackUpWhileChargingTogglePatch.kt new file mode 100644 index 000000000..91693d047 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/googlephotos/misc/preferences/RestoreHiddenBackUpWhileChargingTogglePatch.kt @@ -0,0 +1,27 @@ +package app.revanced.patches.googlephotos.misc.preferences + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +@Suppress("unused") +val restoreHiddenBackUpWhileChargingTogglePatch = bytecodePatch( + name = "Restore hidden 'Back up while charging' toggle", + description = "Restores a hidden toggle to only run backups when the device is charging.", +) { + compatibleWith("com.google.android.apps.photos") + + val backupPreferencesMatch by backupPreferencesFingerprint() + + execute { + // Patches 'backup_prefs_had_backup_only_when_charging_enabled' to always be true. + val chargingPrefStringIndex = backupPreferencesMatch.stringMatches!!.first().index + backupPreferencesMatch.mutableMethod.apply { + // Get the register of move-result. + val resultRegister = getInstruction(chargingPrefStringIndex + 2).registerA + // Insert const after move-result to override register as true. + addInstruction(chargingPrefStringIndex + 3, "const/4 v$resultRegister, 0x1") + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/googlerecorder/restrictions/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/googlerecorder/restrictions/Fingerprints.kt new file mode 100644 index 000000000..62e1e5f16 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/googlerecorder/restrictions/Fingerprints.kt @@ -0,0 +1,12 @@ +package app.revanced.patches.googlerecorder.restrictions + +import app.revanced.patcher.fingerprint + +internal val onApplicationCreateFingerprint = fingerprint { + strings("com.google.android.feature.PIXEL_2017_EXPERIENCE") + custom { method, classDef -> + if (method.name != "onCreate") return@custom false + + classDef.endsWith("RecorderApplication;") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/googlerecorder/restrictions/RemoveDeviceRestrictions.kt b/patches/src/main/kotlin/app/revanced/patches/googlerecorder/restrictions/RemoveDeviceRestrictions.kt new file mode 100644 index 000000000..2c5a0aec1 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/googlerecorder/restrictions/RemoveDeviceRestrictions.kt @@ -0,0 +1,31 @@ +package app.revanced.patches.googlerecorder.restrictions + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.removeInstructions +import app.revanced.patcher.patch.bytecodePatch +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +@Suppress("unused") +val removeDeviceRestrictionsPatch = bytecodePatch( + name = "Remove device restrictions", + description = "Removes restrictions from using the app on any device. Requires mounting patched app over original.", +) { + compatibleWith("com.google.android.apps.recorder") + + val onApplicationCreateMatch by onApplicationCreateFingerprint() + + execute { + val featureStringIndex = onApplicationCreateMatch.stringMatches!!.first().index + + onApplicationCreateMatch.mutableMethod.apply { + // Remove check for device restrictions. + removeInstructions(featureStringIndex - 2, 5) + + val featureAvailableRegister = getInstruction(featureStringIndex).registerA + + // Override "isPixelDevice()" to return true. + addInstruction(featureStringIndex, "const/4 v$featureAvailableRegister, 0x1") + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/hexeditor/ad/DisableAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/hexeditor/ad/DisableAdsPatch.kt new file mode 100644 index 000000000..ae269f817 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/hexeditor/ad/DisableAdsPatch.kt @@ -0,0 +1,23 @@ +package app.revanced.patches.hexeditor.ad + +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val disableAdsPatch = bytecodePatch( + name = "Disable ads", +) { + compatibleWith("com.myprog.hexedit") + + val primaryAdsMatch by primaryAdsFingerprint() + + execute { + primaryAdsMatch.mutableMethod.replaceInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/hexeditor/ad/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/hexeditor/ad/Fingerprints.kt new file mode 100644 index 000000000..2fa2c5b85 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/hexeditor/ad/Fingerprints.kt @@ -0,0 +1,9 @@ +package app.revanced.patches.hexeditor.ad + +import app.revanced.patcher.fingerprint + +internal val primaryAdsFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("PreferencesHelper;") && method.name == "isAdsDisabled" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/iconpackstudio/misc/pro/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/iconpackstudio/misc/pro/Fingerprints.kt new file mode 100644 index 000000000..84db55457 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/iconpackstudio/misc/pro/Fingerprints.kt @@ -0,0 +1,8 @@ +package app.revanced.patches.iconpackstudio.misc.pro + +import app.revanced.patcher.fingerprint + +internal val checkProFingerprint = fingerprint { + returns("Z") + custom { _, classDef -> classDef.endsWith("IPSPurchaseRepository;") } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/iconpackstudio/misc/pro/UnlockProPatch.kt b/patches/src/main/kotlin/app/revanced/patches/iconpackstudio/misc/pro/UnlockProPatch.kt new file mode 100644 index 000000000..daea6581c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/iconpackstudio/misc/pro/UnlockProPatch.kt @@ -0,0 +1,23 @@ +package app.revanced.patches.iconpackstudio.misc.pro + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val unlockProPatch = bytecodePatch( + name = "Unlock pro", +) { + compatibleWith("ginlemon.iconpackstudio"("2.2 build 016")) + + val checkProMatch by checkProFingerprint() + + execute { + checkProMatch.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/idaustria/detection/root/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/idaustria/detection/root/Fingerprints.kt new file mode 100644 index 000000000..38f814f6d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/idaustria/detection/root/Fingerprints.kt @@ -0,0 +1,31 @@ +package app.revanced.patches.idaustria.detection.root + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val attestationSupportedCheckFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC) + returns("V") + custom { method, classDef -> + method.name == "attestationSupportCheck" && + classDef.endsWith("/DeviceIntegrityCheck;") + } +} + +internal val bootloaderCheckFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC) + returns("Z") + custom { method, classDef -> + method.name == "bootloaderCheck" && + classDef.endsWith("/DeviceIntegrityCheck;") + } +} + +internal val rootCheckFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC) + returns("V") + custom { method, classDef -> + method.name == "rootCheck" && + classDef.endsWith("/DeviceIntegrityCheck;") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/idaustria/detection/root/RootDetectionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/idaustria/detection/root/RootDetectionPatch.kt new file mode 100644 index 000000000..9440e20d3 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/idaustria/detection/root/RootDetectionPatch.kt @@ -0,0 +1,20 @@ +package app.revanced.patches.idaustria.detection.root + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.util.returnEarly + +@Suppress("unused") +val rootDetectionPatch = bytecodePatch( + name = "Remove root detection", + description = "Removes the check for root permissions and unlocked bootloader.", +) { + compatibleWith("at.gv.oe.app") + + attestationSupportedCheckFingerprint() + bootloaderCheckFingerprint() + rootCheckFingerprint() + + execute { + setOf(attestationSupportedCheckFingerprint, bootloaderCheckFingerprint, rootCheckFingerprint).returnEarly(true) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/idaustria/detection/signature/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/idaustria/detection/signature/Fingerprints.kt new file mode 100644 index 000000000..61cd9605f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/idaustria/detection/signature/Fingerprints.kt @@ -0,0 +1,13 @@ +package app.revanced.patches.idaustria.detection.signature + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val spoofSignatureFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE) + returns("L") + parameters("L") + custom { method, classDef -> + classDef.endsWith("/SL2Step1Task;") && method.name == "getPubKey" + } +} diff --git a/src/main/kotlin/app/revanced/patches/idaustria/detection/signature/SpoofSignaturePatch.kt b/patches/src/main/kotlin/app/revanced/patches/idaustria/detection/signature/SpoofSignaturePatch.kt similarity index 65% rename from src/main/kotlin/app/revanced/patches/idaustria/detection/signature/SpoofSignaturePatch.kt rename to patches/src/main/kotlin/app/revanced/patches/idaustria/detection/signature/SpoofSignaturePatch.kt index ae427b744..12295f90c 100644 --- a/src/main/kotlin/app/revanced/patches/idaustria/detection/signature/SpoofSignaturePatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/idaustria/detection/signature/SpoofSignaturePatch.kt @@ -1,23 +1,20 @@ package app.revanced.patches.idaustria.detection.signature -import app.revanced.patcher.data.BytecodeContext import app.revanced.patcher.extensions.InstructionExtensions.addInstructions -import app.revanced.patcher.patch.BytecodePatch -import app.revanced.patcher.patch.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patches.idaustria.detection.signature.fingerprints.SpoofSignatureFingerprint +import app.revanced.patcher.patch.bytecodePatch -@Patch( +@Suppress("unused") +val spoofSignaturePatch = bytecodePatch( name = "Spoof signature", description = "Spoofs the signature of the app.", - compatiblePackages = [CompatiblePackage("at.gv.oe.app")] -) -@Suppress("unused") -object SpoofSignaturePatch : BytecodePatch( - setOf(SpoofSignatureFingerprint) ) { - private const val EXPECTED_SIGNATURE = - "OpenSSLRSAPublicKey{modulus=ac3e6fd6050aa7e0d6010ae58190404cd89a56935b44f6fee" + + compatibleWith("at.gv.oe.app") + + val spoofSignatureMatch by spoofSignatureFingerprint() + + execute { + val expectedSignature = + "OpenSSLRSAPublicKey{modulus=ac3e6fd6050aa7e0d6010ae58190404cd89a56935b44f6fee" + "067c149768320026e10b24799a1339e414605e448e3f264444a327b9ae292be2b62ad567dd1800dbed4a88f718a33dc6db6b" + "f5178aa41aa0efff8a3409f5ca95dbfccd92c7b4298966df806ea7a0204a00f0e745f6d9f13bdf24f3df715d7b62c1600906" + "15de1c8a956b9286764985a3b3c060963c435fb9481a5543aaf0671fc2dba6c5c2b17d1ef1d85137f14dc9bbdf3490288087" + @@ -29,13 +26,12 @@ object SpoofSignaturePatch : BytecodePatch( "77ef1be61b2c01ebdabddcbf53cc4b6fd9a3c445606ee77b3758162c80ad8f8137b3c6864e92db904807dcb2be9d7717dd21" + "bf42c121d620ddfb7914f7a95c713d9e1c1b7bdb4a03d618e40cf7e9e235c0b5687e03b7ab3,publicExponent=10001}" - override fun execute(context: BytecodeContext) { - SpoofSignatureFingerprint.result!!.mutableMethod.addInstructions( + spoofSignatureMatch.mutableMethod.addInstructions( 0, """ - const-string v0, "$EXPECTED_SIGNATURE" + const-string v0, "$expectedSignature" return-object v0 - """ + """, ) } } diff --git a/patches/src/main/kotlin/app/revanced/patches/inshorts/ad/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/inshorts/ad/Fingerprints.kt new file mode 100644 index 000000000..573bd72e3 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/inshorts/ad/Fingerprints.kt @@ -0,0 +1,8 @@ +package app.revanced.patches.inshorts.ad + +import app.revanced.patcher.fingerprint + +internal val inshortsAdsFingerprint = fingerprint { + returns("V") + strings("GoogleAdLoader", "exception in requestAd") +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/inshorts/ad/InshortsAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/inshorts/ad/InshortsAdsPatch.kt new file mode 100644 index 000000000..3fff6a270 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/inshorts/ad/InshortsAdsPatch.kt @@ -0,0 +1,22 @@ +package app.revanced.patches.inshorts.ad + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val hideAdsPatch = bytecodePatch( + name = "Hide ads", +) { + compatibleWith("com.nis.app") + + val inshortsAdsMatch by inshortsAdsFingerprint() + + execute { + inshortsAdsMatch.mutableMethod.addInstruction( + 0, + """ + return-void + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/instagram/ads/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/instagram/ads/Fingerprints.kt new file mode 100644 index 000000000..65d052729 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/instagram/ads/Fingerprints.kt @@ -0,0 +1,14 @@ +package app.revanced.patches.instagram.ads + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val adInjectorFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE) + returns("Z") + parameters("L", "L") + strings( + "SponsoredContentController.insertItem", + "SponsoredContentController::Delivery", + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/instagram/ads/HideAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/instagram/ads/HideAdsPatch.kt new file mode 100644 index 000000000..1291c4212 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/instagram/ads/HideAdsPatch.kt @@ -0,0 +1,25 @@ +package app.revanced.patches.instagram.ads + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val hideAdsPatch = bytecodePatch( + name = "Hide ads", + description = "Hides ads in stories, discover, profile, etc. " + + "An ad can still appear once when refreshing the home feed.", +) { + compatibleWith("com.instagram.android") + + val adInjectorMatch by adInjectorFingerprint() + + execute { + adInjectorMatch.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x0 + return v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/irplus/ad/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/irplus/ad/Fingerprints.kt new file mode 100644 index 000000000..30242b8d9 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/irplus/ad/Fingerprints.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.irplus.ad + +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val irplusAdsFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + returns("V") + parameters("L", "Z") + strings("TAGGED") +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/irplus/ad/RemoveAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/irplus/ad/RemoveAdsPatch.kt new file mode 100644 index 000000000..a0a6e0170 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/irplus/ad/RemoveAdsPatch.kt @@ -0,0 +1,19 @@ +package app.revanced.patches.irplus.ad + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val removeAdsPatch = bytecodePatch( + name = "Remove ads", +) { + compatibleWith("net.binarymode.android.irplus") + + val irplusAdsMatch by irplusAdsFingerprint() + + execute { + // By overwriting the second parameter of the method, + // the view which holds the advertisement is removed. + irplusAdsMatch.mutableMethod.addInstruction(0, "const/4 p2, 0x0") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/lightroom/misc/login/DisableMandatoryLoginPatch.kt b/patches/src/main/kotlin/app/revanced/patches/lightroom/misc/login/DisableMandatoryLoginPatch.kt new file mode 100644 index 000000000..79d41368e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/lightroom/misc/login/DisableMandatoryLoginPatch.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.lightroom.misc.login + +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val disableMandatoryLoginPatch = bytecodePatch( + name = "Disable mandatory login", +) { + compatibleWith("com.adobe.lrmobile") + + val isLoggedInMatch by isLoggedInFingerprint() + + execute { + isLoggedInMatch.mutableMethod.apply { + val index = implementation!!.instructions.lastIndex - 1 + // Set isLoggedIn = true. + replaceInstruction(index, "const/4 v0, 0x1") + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/lightroom/misc/login/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/lightroom/misc/login/Fingerprints.kt new file mode 100644 index 000000000..6345541e1 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/lightroom/misc/login/Fingerprints.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.lightroom.misc.login + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val isLoggedInFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC, AccessFlags.FINAL) + returns("Z") + opcodes( + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.SGET_OBJECT, + Opcode.IF_NE, + Opcode.CONST_4, + Opcode.GOTO + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/lightroom/misc/premium/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/lightroom/misc/premium/Fingerprints.kt new file mode 100644 index 000000000..5a00dc68c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/lightroom/misc/premium/Fingerprints.kt @@ -0,0 +1,17 @@ +package app.revanced.patches.lightroom.misc.premium + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val hasPurchasedFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.FINAL) + returns("Z") + opcodes( + Opcode.SGET_OBJECT, + Opcode.CONST_4, + Opcode.CONST_4, + Opcode.CONST_4, + ) + strings("isPurchaseDoneRecently = true, access platform profile present? = ") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/lightroom/misc/premium/UnlockPremiumPatch.kt b/patches/src/main/kotlin/app/revanced/patches/lightroom/misc/premium/UnlockPremiumPatch.kt new file mode 100644 index 000000000..730c523d4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/lightroom/misc/premium/UnlockPremiumPatch.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.lightroom.misc.premium + +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val unlockPremiumPatch = bytecodePatch( + name = "Unlock premium", +) { + compatibleWith("com.adobe.lrmobile") + + val hasPurchasedMatch by hasPurchasedFingerprint() + + execute { + // Set hasPremium = true. + hasPurchasedMatch.mutableMethod.replaceInstruction(2, "const/4 v2, 0x1") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/memegenerator/detection/license/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/memegenerator/detection/license/Fingerprints.kt new file mode 100644 index 000000000..0dfbf5cda --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/memegenerator/detection/license/Fingerprints.kt @@ -0,0 +1,23 @@ +package app.revanced.patches.memegenerator.detection.license + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val licenseValidationFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("Z") + parameters("Landroid/content/Context;") + opcodes( + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_WIDE, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_WIDE, + Opcode.CMP_LONG, + Opcode.IF_GEZ, + 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/memegenerator/detection/license/LicenseValidationPatch.kt b/patches/src/main/kotlin/app/revanced/patches/memegenerator/detection/license/LicenseValidationPatch.kt new file mode 100644 index 000000000..6d49ef6a9 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/memegenerator/detection/license/LicenseValidationPatch.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.memegenerator.detection.license + +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val licenseValidationPatch = bytecodePatch( + description = "Disables Firebase license validation.", +) { + val licenseValidationMatch by licenseValidationFingerprint() + + execute { + licenseValidationMatch.mutableMethod.replaceInstructions( + 0, + """ + const/4 p0, 0x1 + return p0 + """, + ) + } +} diff --git a/src/main/kotlin/app/revanced/patches/memegenerator/detection/signature/fingerprints/VerifySignatureFingerprint.kt b/patches/src/main/kotlin/app/revanced/patches/memegenerator/detection/signature/Fingerprints.kt similarity index 54% rename from src/main/kotlin/app/revanced/patches/memegenerator/detection/signature/fingerprints/VerifySignatureFingerprint.kt rename to patches/src/main/kotlin/app/revanced/patches/memegenerator/detection/signature/Fingerprints.kt index b77fd051f..75912318b 100644 --- a/src/main/kotlin/app/revanced/patches/memegenerator/detection/signature/fingerprints/VerifySignatureFingerprint.kt +++ b/patches/src/main/kotlin/app/revanced/patches/memegenerator/detection/signature/Fingerprints.kt @@ -1,17 +1,14 @@ -package app.revanced.patches.memegenerator.detection.signature.fingerprints +package app.revanced.patches.memegenerator.detection.signature -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.annotation.FuzzyPatternScanMethod -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint -@FuzzyPatternScanMethod(2) -internal object VerifySignatureFingerprint : MethodFingerprint( - returnType = "Z", - accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC, - parameters = listOf("Landroid/app/Activity;"), - opcodes = listOf( +internal val verifySignatureFingerprint = fingerprint(fuzzyPatternScanThreshold = 2) { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("Z") + parameters("Landroid/app/Activity;") + opcodes( Opcode.SGET_OBJECT, Opcode.IF_NEZ, Opcode.INVOKE_STATIC, @@ -31,5 +28,5 @@ internal object VerifySignatureFingerprint : MethodFingerprint( Opcode.CONST_4, Opcode.RETURN, Opcode.ADD_INT_LIT8 - ), -) \ No newline at end of file + ) +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/memegenerator/detection/signature/SignatureVerificationPatch.kt b/patches/src/main/kotlin/app/revanced/patches/memegenerator/detection/signature/SignatureVerificationPatch.kt new file mode 100644 index 000000000..6637b57e7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/memegenerator/detection/signature/SignatureVerificationPatch.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.memegenerator.detection.signature + +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val signatureVerificationPatch = bytecodePatch( + description = "Disables detection of incorrect signature.", +) { + val verifySignatureMatch by verifySignatureFingerprint() + + execute { + verifySignatureMatch.mutableMethod.replaceInstructions( + 0, + """ + const/4 p0, 0x1 + return p0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/memegenerator/misc/pro/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/memegenerator/misc/pro/Fingerprints.kt new file mode 100644 index 000000000..1f16bb10e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/memegenerator/misc/pro/Fingerprints.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.memegenerator.misc.pro + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val isFreeVersionFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("Ljava/lang/Boolean;") + parameters("Landroid/content/Context;") + opcodes( + Opcode.SGET, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CONST_STRING, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_EQZ + ) + strings("free") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/memegenerator/misc/pro/UnlockProVersionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/memegenerator/misc/pro/UnlockProVersionPatch.kt new file mode 100644 index 000000000..fa475fbbc --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/memegenerator/misc/pro/UnlockProVersionPatch.kt @@ -0,0 +1,27 @@ +package app.revanced.patches.memegenerator.misc.pro + +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.memegenerator.detection.license.licenseValidationPatch +import app.revanced.patches.memegenerator.detection.signature.signatureVerificationPatch + +@Suppress("unused") +val unlockProVersionPatch = bytecodePatch( + name = "Unlock pro", +) { + dependsOn(signatureVerificationPatch, licenseValidationPatch) + + compatibleWith("com.zombodroid.MemeGenerator"("4.6364", "4.6370", "4.6375", "4.6377")) + + val isFreeVersionMatch by isFreeVersionFingerprint() + + execute { + isFreeVersionMatch.mutableMethod.replaceInstructions( + 0, + """ + sget-object p0, Ljava/lang/Boolean;->FALSE:Ljava/lang/Boolean; + return-object p0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/messenger/inbox/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/messenger/inbox/Fingerprints.kt new file mode 100644 index 000000000..a2fa6329c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/messenger/inbox/Fingerprints.kt @@ -0,0 +1,36 @@ +package app.revanced.patches.messenger.inbox + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.value.StringEncodedValue + +internal val createInboxSubTabsFingerprint = fingerprint { + returns("V") + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + opcodes( + Opcode.CONST_4, + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN_VOID, + ) + custom { method, classDef -> + method.name == "run" && + classDef.fields.any any@{ field -> + if (field.name != "__redex_internal_original_name") return@any false + (field.initialValue as? StringEncodedValue)?.value == "InboxSubtabsItemSupplierImplementation\$onSubscribe\$1" + } + } +} + +internal val loadInboxAdsFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("V") + strings( + "ads_load_begin", + "inbox_ads_fetch_start", + ) + custom { method, _ -> + method.definingClass == "Lcom/facebook/messaging/business/inboxads/plugins/inboxads/itemsupplier/" + + "InboxAdsItemSupplierImplementation;" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/messenger/inbox/HideInboxAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/messenger/inbox/HideInboxAdsPatch.kt new file mode 100644 index 000000000..ca13b71c8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/messenger/inbox/HideInboxAdsPatch.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.messenger.inbox + +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val hideInboxAdsPatch = bytecodePatch( + name = "Hide inbox ads", + description = "Hides ads in inbox.", +) { + compatibleWith("com.facebook.orca") + + val loadInboxAdsMatch by loadInboxAdsFingerprint() + + execute { + loadInboxAdsMatch.mutableMethod.replaceInstruction(0, "return-void") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/messenger/inbox/HideInboxSubtabsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/messenger/inbox/HideInboxSubtabsPatch.kt new file mode 100644 index 000000000..24d3f8192 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/messenger/inbox/HideInboxSubtabsPatch.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.messenger.inbox + +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val hideInboxSubtabsPatch = bytecodePatch( + name = "Hide inbox subtabs", + description = "Hides Home and Channels tabs between active now tray and chats.", +) { + compatibleWith("com.facebook.orca") + + val createInboxSubTabsMatch by createInboxSubTabsFingerprint() + + execute { + createInboxSubTabsMatch.mutableMethod.replaceInstruction(2, "const/4 v0, 0x0") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/messenger/inputfield/DisableSwitchingEmojiToStickerPatch.kt b/patches/src/main/kotlin/app/revanced/patches/messenger/inputfield/DisableSwitchingEmojiToStickerPatch.kt new file mode 100644 index 000000000..3d4e223f6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/messenger/inputfield/DisableSwitchingEmojiToStickerPatch.kt @@ -0,0 +1,26 @@ +package app.revanced.patches.messenger.inputfield + +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +@Suppress("unused") +val disableSwitchingEmojiToStickerPatch = bytecodePatch( + name = "Disable switching emoji to sticker", + description = "Disables switching from emoji to sticker search mode in message input field.", +) { + compatibleWith("com.facebook.orca"("439.0.0.29.119")) + + val switchMessangeInputEmojiButtonMatch by switchMessangeInputEmojiButtonFingerprint() + + execute { + val setStringIndex = switchMessangeInputEmojiButtonMatch.patternMatch!!.startIndex + 2 + + switchMessangeInputEmojiButtonMatch.mutableMethod.apply { + val targetRegister = getInstruction(setStringIndex).registerA + + replaceInstruction(setStringIndex, "const-string v$targetRegister, \"expression\"") + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/messenger/inputfield/DisableTypingIndicatorPatch.kt b/patches/src/main/kotlin/app/revanced/patches/messenger/inputfield/DisableTypingIndicatorPatch.kt new file mode 100644 index 000000000..651491ca6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/messenger/inputfield/DisableTypingIndicatorPatch.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.messenger.inputfield + +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val disableTypingIndicatorPatch = bytecodePatch( + name = "Disable typing indicator", + description = "Disables the indicator while typing a message.", +) { + compatibleWith("com.facebook.orca") + + val sendTypingIndicatorMatch by sendTypingIndicatorFingerprint() + + execute { + sendTypingIndicatorMatch.mutableMethod.replaceInstruction(0, "return-void") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/messenger/inputfield/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/messenger/inputfield/Fingerprints.kt new file mode 100644 index 000000000..0a1d181d2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/messenger/inputfield/Fingerprints.kt @@ -0,0 +1,31 @@ +package app.revanced.patches.messenger.inputfield + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.dexbacked.value.DexBackedStringEncodedValue + +internal val sendTypingIndicatorFingerprint = fingerprint { + returns("V") + parameters() + custom { method, classDef -> + method.name == "run" && + classDef.fields.any { + it.name == "__redex_internal_original_name" && + (it.initialValue as? DexBackedStringEncodedValue)?.value == "ConversationTypingContext\$sendActiveStateRunnable\$1" + } + } +} + +internal val switchMessangeInputEmojiButtonFingerprint = fingerprint { + returns("V") + parameters("L", "Z") + opcodes( + Opcode.IGET_OBJECT, + Opcode.IF_EQZ, + Opcode.CONST_STRING, + Opcode.GOTO, + Opcode.CONST_STRING, + Opcode.GOTO, + ) + strings("afterTextChanged", "expression_search") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/mifitness/misc/locale/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/mifitness/misc/locale/Fingerprints.kt new file mode 100644 index 000000000..14105f762 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/mifitness/misc/locale/Fingerprints.kt @@ -0,0 +1,12 @@ +package app.revanced.patches.mifitness.misc.locale + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.Opcode + +internal val syncBluetoothLanguageFingerprint = fingerprint { + opcodes(Opcode.MOVE_RESULT_OBJECT) + custom { method, _ -> + method.name == "syncBluetoothLanguage" && + method.definingClass == "Lcom/xiaomi/fitness/devicesettings/DeviceSettingsSyncer;" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/mifitness/misc/locale/ForceEnglishLocalePatch.kt b/patches/src/main/kotlin/app/revanced/patches/mifitness/misc/locale/ForceEnglishLocalePatch.kt new file mode 100644 index 000000000..bb038f26b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/mifitness/misc/locale/ForceEnglishLocalePatch.kt @@ -0,0 +1,33 @@ +package app.revanced.patches.mifitness.misc.locale + +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.mifitness.misc.login.fixLoginPatch +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +@Suppress("unused") +val forceEnglishLocalePatch = bytecodePatch( + name = "Force English locale", + description = "Forces wearable devices to use the English locale.", +) { + compatibleWith("com.xiaomi.wearable") + + dependsOn(fixLoginPatch) + + val syncBluetoothLanguageMatch by syncBluetoothLanguageFingerprint() + + execute { + val resolvePhoneLocaleInstruction = syncBluetoothLanguageMatch.patternMatch!!.startIndex + + syncBluetoothLanguageMatch.mutableMethod.apply { + val registerIndexToUpdate = + getInstruction(resolvePhoneLocaleInstruction).registerA + + replaceInstruction( + resolvePhoneLocaleInstruction, + "const-string v$registerIndexToUpdate, \"en_gb\"", + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/mifitness/misc/login/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/mifitness/misc/login/Fingerprints.kt new file mode 100644 index 000000000..e3eee2499 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/mifitness/misc/login/Fingerprints.kt @@ -0,0 +1,12 @@ +package app.revanced.patches.mifitness.misc.login + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val xiaomiAccountManagerConstructorFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.CONSTRUCTOR) + parameters("Landroid/content/Context;", "Z") + custom { method, _ -> + method.definingClass == "Lcom/xiaomi/passport/accountmanager/XiaomiAccountManager;" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/mifitness/misc/login/FixLoginPatch.kt b/patches/src/main/kotlin/app/revanced/patches/mifitness/misc/login/FixLoginPatch.kt new file mode 100644 index 000000000..27c18eae0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/mifitness/misc/login/FixLoginPatch.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.mifitness.misc.login + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val fixLoginPatch = bytecodePatch( + name = "Fix login", + description = "Fixes login for uncertified Mi Fitness app", +) { + compatibleWith("com.xiaomi.wearable") + + val xiaomiAccountManagerConstructorMatch by xiaomiAccountManagerConstructorFingerprint() + + execute { + xiaomiAccountManagerConstructorMatch.mutableMethod.addInstruction(0, "const/16 p2, 0x0") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/ad/video/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/ad/video/Fingerprints.kt new file mode 100644 index 000000000..6ce0519ad --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/ad/video/Fingerprints.kt @@ -0,0 +1,13 @@ +package app.revanced.patches.music.ad.video + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.Opcode + +internal val showVideoAdsParentFingerprint = fingerprint { + opcodes( + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.IGET_OBJECT, + ) + strings("maybeRegenerateCpnAndStatsClient called unexpectedly, but no error.") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/ad/video/HideVideoAds.kt b/patches/src/main/kotlin/app/revanced/patches/music/ad/video/HideVideoAds.kt new file mode 100644 index 000000000..b39b2822f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/ad/video/HideVideoAds.kt @@ -0,0 +1,22 @@ +package app.revanced.patches.music.ad.video + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val hideVideoAdsPatch = bytecodePatch( + name = "Hide music video ads", + description = "Hides ads that appear while listening to or streaming music videos, podcasts, or songs.", +) { + compatibleWith("com.google.android.apps.youtube.music") + + val showVideoAdsParentMatch by showVideoAdsParentFingerprint() + + execute { context -> + val showVideoAdsMethod = context + .navigate(showVideoAdsParentMatch.mutableMethod) + .at(showVideoAdsParentMatch.patternMatch!!.startIndex + 1).mutable() + + showVideoAdsMethod.addInstruction(0, "const/4 p1, 0x0") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/audio/exclusiveaudio/EnableExclusiveAudioPlayback.kt b/patches/src/main/kotlin/app/revanced/patches/music/audio/exclusiveaudio/EnableExclusiveAudioPlayback.kt new file mode 100644 index 000000000..6d09557c8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/audio/exclusiveaudio/EnableExclusiveAudioPlayback.kt @@ -0,0 +1,26 @@ +package app.revanced.patches.music.audio.exclusiveaudio + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val enableExclusiveAudioPlaybackPatch = bytecodePatch( + name = "Enable exclusive audio playback", + description = "Enables the option to play audio without video.", +) { + compatibleWith("com.google.android.apps.youtube.music") + + val allowExclusiveAudioPlaybackMatch by allowExclusiveAudioPlaybackFingerprint() + + execute { + allowExclusiveAudioPlaybackMatch.mutableMethod.apply { + addInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + } + } +} diff --git a/src/main/kotlin/app/revanced/patches/music/audio/exclusiveaudio/fingerprints/AllowExclusiveAudioPlaybackFingerprint.kt b/patches/src/main/kotlin/app/revanced/patches/music/audio/exclusiveaudio/Fingerprints.kt similarity index 55% rename from src/main/kotlin/app/revanced/patches/music/audio/exclusiveaudio/fingerprints/AllowExclusiveAudioPlaybackFingerprint.kt rename to patches/src/main/kotlin/app/revanced/patches/music/audio/exclusiveaudio/Fingerprints.kt index c86f79427..02a978f0f 100644 --- a/src/main/kotlin/app/revanced/patches/music/audio/exclusiveaudio/fingerprints/AllowExclusiveAudioPlaybackFingerprint.kt +++ b/patches/src/main/kotlin/app/revanced/patches/music/audio/exclusiveaudio/Fingerprints.kt @@ -1,15 +1,14 @@ -package app.revanced.patches.music.audio.exclusiveaudio.fingerprints +package app.revanced.patches.music.audio.exclusiveaudio -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 +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint -internal object AllowExclusiveAudioPlaybackFingerprint: MethodFingerprint( - "Z", - AccessFlags.PUBLIC or AccessFlags.FINAL, - listOf(), - listOf( +internal val allowExclusiveAudioPlaybackFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Z") + parameters() + opcodes( Opcode.INVOKE_VIRTUAL, Opcode.MOVE_RESULT_OBJECT, Opcode.CHECK_CAST, @@ -22,4 +21,4 @@ internal object AllowExclusiveAudioPlaybackFingerprint: MethodFingerprint( Opcode.MOVE_RESULT, Opcode.RETURN ) -) \ No newline at end of file +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/interaction/permanentrepeat/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/interaction/permanentrepeat/Fingerprints.kt new file mode 100644 index 000000000..13820d29d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/interaction/permanentrepeat/Fingerprints.kt @@ -0,0 +1,20 @@ +package app.revanced.patches.music.interaction.permanentrepeat + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val repeatTrackFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("L", "L") + opcodes( + Opcode.IGET_OBJECT, + Opcode.IGET_OBJECT, + Opcode.SGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_NEZ + ) + strings("w_st") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/interaction/permanentrepeat/PermanentRepeatPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/interaction/permanentrepeat/PermanentRepeatPatch.kt new file mode 100644 index 000000000..31c3be9d6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/interaction/permanentrepeat/PermanentRepeatPatch.kt @@ -0,0 +1,31 @@ +package app.revanced.patches.music.interaction.permanentrepeat + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.instructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import org.stringtemplate.v4.compiler.Bytecode.instructions + +@Suppress("unused") +val permanentRepeatPatch = bytecodePatch( + name = "Permanent repeat", + description = "Permanently remember your repeating preference even if the playlist ends or another track is played.", + use = false, +) { + compatibleWith("com.google.android.apps.youtube.music") + + val repeatTrackMatch by repeatTrackFingerprint() + + execute { + val startIndex = repeatTrackMatch.patternMatch!!.endIndex + val repeatIndex = startIndex + 1 + + repeatTrackMatch.mutableMethod.apply { + addInstructionsWithLabels( + startIndex, + "goto :repeat", + ExternalLabel("repeat", instructions[repeatIndex]), + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/interaction/permanentshuffle/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/interaction/permanentshuffle/Fingerprints.kt new file mode 100644 index 000000000..f2169744f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/interaction/permanentshuffle/Fingerprints.kt @@ -0,0 +1,19 @@ +package app.revanced.patches.music.interaction.permanentshuffle + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val disableShuffleFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters() + opcodes( + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.SGET_OBJECT, + Opcode.IPUT_OBJECT, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL + ) +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/interaction/permanentshuffle/PermanentShufflePatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/interaction/permanentshuffle/PermanentShufflePatch.kt new file mode 100644 index 000000000..f4d754216 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/interaction/permanentshuffle/PermanentShufflePatch.kt @@ -0,0 +1,28 @@ +package app.revanced.patches.music.interaction.permanentshuffle + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val permanentShufflePatch = bytecodePatch( + name = "Permanent shuffle", + description = "Permanently remember your shuffle preference " + + "even if the playlist ends or another track is played.", + use = false, +) { + compatibleWith( + "com.google.android.apps.youtube.music"( + "6.45.54", + "6.51.53", + "7.01.53", + "7.02.52", + "7.03.52", + ), + ) + + val disableShuffleMatch by disableShuffleFingerprint() + + execute { + disableShuffleMatch.mutableMethod.addInstruction(0, "return-void") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/layout/compactheader/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/layout/compactheader/Fingerprints.kt new file mode 100644 index 000000000..d7f0f03de --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/layout/compactheader/Fingerprints.kt @@ -0,0 +1,22 @@ +package app.revanced.patches.music.layout.compactheader + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val constructCategoryBarFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + returns("V") + parameters("Landroid/content/Context;", "L", "L", "L") + opcodes( + Opcode.IPUT_OBJECT, + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IPUT_OBJECT, + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.NEW_INSTANCE, + Opcode.INVOKE_DIRECT, + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/layout/compactheader/HideCategoryBar.kt b/patches/src/main/kotlin/app/revanced/patches/music/layout/compactheader/HideCategoryBar.kt new file mode 100644 index 000000000..0dc7e02e4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/layout/compactheader/HideCategoryBar.kt @@ -0,0 +1,32 @@ +package app.revanced.patches.music.layout.compactheader + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +@Suppress("unused") +val hideCategoryBar = bytecodePatch( + name = "Hide category bar", + description = "Hides the category bar at the top of the homepage.", + use = false, +) { + compatibleWith("com.google.android.apps.youtube.music") + + val constructCategoryBarMatch by constructCategoryBarFingerprint() + + execute { + constructCategoryBarMatch.mutableMethod.apply { + val insertIndex = constructCategoryBarMatch.patternMatch!!.startIndex + val register = getInstruction(insertIndex - 1).registerA + + addInstructions( + insertIndex, + """ + const/16 v2, 0x8 + invoke-virtual {v$register, v2}, Landroid/view/View;->setVisibility(I)V + """, + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/layout/premium/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/layout/premium/Fingerprints.kt new file mode 100644 index 000000000..29558ab4b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/layout/premium/Fingerprints.kt @@ -0,0 +1,31 @@ +package app.revanced.patches.music.layout.premium + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val hideGetPremiumFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters() + opcodes( + Opcode.IF_NEZ, + Opcode.CONST_16, + Opcode.INVOKE_VIRTUAL, + ) + strings("FEmusic_history", "FEmusic_offline") +} + +internal val membershipSettingsFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Ljava/lang/CharSequence;") + opcodes( + Opcode.IGET_OBJECT, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/layout/premium/HideGetPremiumPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/layout/premium/HideGetPremiumPatch.kt new file mode 100644 index 000000000..08711e1fb --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/layout/premium/HideGetPremiumPatch.kt @@ -0,0 +1,48 @@ +package app.revanced.patches.music.layout.premium + +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 com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction + +@Suppress("unused") +val hideGetPremiumPatch = bytecodePatch( + name = "Hide 'Get Music Premium' label", + description = "Hides the \"Get Music Premium\" label from the account menu and settings.", +) { + compatibleWith("com.google.android.apps.youtube.music") + + val hideGetPremiumMatch by hideGetPremiumFingerprint() + val membershipSettingsMatch by membershipSettingsFingerprint() + + execute { + hideGetPremiumMatch.mutableMethod.apply { + val insertIndex = hideGetPremiumMatch.patternMatch!!.endIndex + + val setVisibilityInstruction = getInstruction(insertIndex) + val getPremiumViewRegister = setVisibilityInstruction.registerC + val visibilityRegister = setVisibilityInstruction.registerD + + replaceInstruction( + insertIndex, + "const/16 v$visibilityRegister, 0x8", + ) + + addInstruction( + insertIndex + 1, + "invoke-virtual {v$getPremiumViewRegister, v$visibilityRegister}, " + + "Landroid/view/View;->setVisibility(I)V", + ) + } + + membershipSettingsMatch.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x0 + return-object v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/layout/upgradebutton/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/layout/upgradebutton/Fingerprints.kt new file mode 100644 index 000000000..f3c96dc44 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/layout/upgradebutton/Fingerprints.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.music.layout.upgradebutton + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val pivotBarConstructorFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + returns("V") + parameters("L", "Z") + opcodes( + Opcode.CHECK_CAST, + Opcode.INVOKE_INTERFACE, + Opcode.GOTO, + Opcode.IPUT_OBJECT, + Opcode.RETURN_VOID + ) +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/music/layout/upgradebutton/RemoveUpgradeButtonPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/layout/upgradebutton/RemoveUpgradeButtonPatch.kt new file mode 100644 index 000000000..01167b6e4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/layout/upgradebutton/RemoveUpgradeButtonPatch.kt @@ -0,0 +1,77 @@ +package app.revanced.patches.music.layout.upgradebutton + +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.extensions.newLabel +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.toInstructions +import app.revanced.util.getReference +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.builder.instruction.BuilderInstruction22t +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +@Suppress("unused") +val removeUpgradeButtonPatch = bytecodePatch( + name = "Remove upgrade button", + description = "Removes the upgrade tab from the pivot bar.", +) { + compatibleWith("com.google.android.apps.youtube.music") + + val pivotBarConstructorMatch by pivotBarConstructorFingerprint() + + execute { + pivotBarConstructorMatch.mutableMethod.apply { + val pivotBarElementFieldReference = + getInstruction(pivotBarConstructorMatch.patternMatch!!.endIndex - 1) + .getReference() + + val register = getInstruction(0).registerC + + // First compile all the needed instructions. + val instructionList = """ + invoke-interface { v0 }, Ljava/util/List;->size()I + move-result v1 + const/4 v2, 0x4 + invoke-interface {v0, v2}, Ljava/util/List;->remove(I)Ljava/lang/Object; + iput-object v0, v$register, $pivotBarElementFieldReference + """.toInstructions().toMutableList() + + val endIndex = pivotBarConstructorMatch.patternMatch!!.endIndex + + // Replace the instruction to retain the label at given index. + replaceInstruction( + endIndex - 1, + instructionList[0], // invoke-interface. + ) + // Do not forget to remove this instruction since we added it already. + instructionList.removeFirst() + + val exitInstruction = instructionList.last() // iput-object + addInstruction( + endIndex, + exitInstruction, + ) + // Do not forget to remove this instruction since we added it already. + instructionList.removeLast() + + // Add the necessary if statement to remove the upgrade tab button in case it exists. + instructionList.add( + 2, // if-le. + BuilderInstruction22t( + Opcode.IF_LE, + 1, + 2, + newLabel(endIndex), + ), + ) + + addInstructions( + endIndex, + instructionList, + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/androidauto/BypassCertificateChecksPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/androidauto/BypassCertificateChecksPatch.kt new file mode 100644 index 000000000..b499cdbc7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/androidauto/BypassCertificateChecksPatch.kt @@ -0,0 +1,24 @@ +package app.revanced.patches.music.misc.androidauto + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val bypassCertificateChecksPatch = bytecodePatch( + name = "Bypass certificate checks", + description = "Bypasses certificate checks which prevent YouTube Music from working on Android Auto.", +) { + compatibleWith("com.google.android.apps.youtube.music") + + val checkCertificateMatch by checkCertificateFingerprint() + + execute { + checkCertificateMatch.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/androidauto/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/androidauto/Fingerprints.kt new file mode 100644 index 000000000..957f055b6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/androidauto/Fingerprints.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.music.misc.androidauto + +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val checkCertificateFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Z") + parameters("Ljava/lang/String;") + strings("X509", "Failed to get certificate.") +} \ No newline at end of file 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 000000000..7e791af12 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/backgroundplayback/BackgroundPlaybackPatch.kt @@ -0,0 +1,31 @@ +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.patch.bytecodePatch + +@Suppress("unused") +val backgroundPlaybackPatch = bytecodePatch( + name = "Remove background playback restrictions", + description = "Removes restrictions on background playback, including playing kids videos in the background.", +) { + compatibleWith("com.google.android.apps.youtube.music") + + val kidsBackgroundPlaybackPolicyControllerMatch by kidsBackgroundPlaybackPolicyControllerFingerprint() + val backgroundPlaybackDisableMatch by backgroundPlaybackDisableFingerprint() + + execute { + kidsBackgroundPlaybackPolicyControllerMatch.mutableMethod.addInstruction( + 0, + "return-void", + ) + + backgroundPlaybackDisableMatch.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + } +} 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 000000000..e1cf24e1a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/backgroundplayback/Fingerprints.kt @@ -0,0 +1,42 @@ +package app.revanced.patches.music.misc.backgroundplayback + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val backgroundPlaybackDisableFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("Z") + parameters("L") + opcodes( + Opcode.CONST_4, + Opcode.IF_EQZ, + Opcode.IGET, + Opcode.AND_INT_LIT16, + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, + Opcode.IF_NEZ, + Opcode.SGET_OBJECT, + Opcode.IGET, + ) +} + +internal val kidsBackgroundPlaybackPolicyControllerFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("I", "L", "Z") + opcodes( + 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, + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/extension/SharedExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/extension/SharedExtensionPatch.kt new file mode 100644 index 000000000..e6c1c69fe --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/extension/SharedExtensionPatch.kt @@ -0,0 +1,6 @@ +package app.revanced.patches.music.misc.extension + +import app.revanced.patches.music.misc.extension.hooks.applicationInitHook +import app.revanced.patches.shared.misc.extension.sharedExtensionPatch + +val sharedExtensionPatch = sharedExtensionPatch(applicationInitHook) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/extension/hooks/ApplicationInitHook.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/extension/hooks/ApplicationInitHook.kt new file mode 100644 index 000000000..1e1a43f9a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/extension/hooks/ApplicationInitHook.kt @@ -0,0 +1,10 @@ +package app.revanced.patches.music.misc.extension.hooks + +import app.revanced.patches.shared.misc.extension.extensionHook + +internal val applicationInitHook = extensionHook { + returns("V") + parameters() + strings("activity") + custom { method, _ -> method.name == "onCreate" } +} diff --git a/src/main/kotlin/app/revanced/patches/music/misc/gms/Constants.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/gms/Constants.kt similarity index 100% rename from src/main/kotlin/app/revanced/patches/music/misc/gms/Constants.kt rename to patches/src/main/kotlin/app/revanced/patches/music/misc/gms/Constants.kt diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/gms/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/gms/Fingerprints.kt new file mode 100644 index 000000000..7131e143d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/gms/Fingerprints.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.music.misc.gms + +import app.revanced.patcher.fingerprint + +internal val musicActivityOnCreateFingerprint = fingerprint { + returns("V") + parameters("Landroid/os/Bundle;") + custom { method, classDef -> + method.name == "onCreate" && classDef.endsWith("/MusicActivity;") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/gms/GmsCoreSupportPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/gms/GmsCoreSupportPatch.kt new file mode 100644 index 000000000..e38ca6015 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/gms/GmsCoreSupportPatch.kt @@ -0,0 +1,33 @@ +package app.revanced.patches.music.misc.gms + +import app.revanced.patcher.patch.Option +import app.revanced.patches.music.misc.extension.sharedExtensionPatch +import app.revanced.patches.music.misc.gms.Constants.MUSIC_PACKAGE_NAME +import app.revanced.patches.music.misc.gms.Constants.REVANCED_MUSIC_PACKAGE_NAME +import app.revanced.patches.shared.castContextFetchFingerprint +import app.revanced.patches.shared.misc.gms.gmsCoreSupportPatch +import app.revanced.patches.shared.primeMethodFingerprint + +@Suppress("unused") +val gmsCoreSupportPatch = gmsCoreSupportPatch( + fromPackageName = MUSIC_PACKAGE_NAME, + toPackageName = REVANCED_MUSIC_PACKAGE_NAME, + primeMethodFingerprint = primeMethodFingerprint, + earlyReturnFingerprints = setOf( + castContextFetchFingerprint, + ), + mainActivityOnCreateFingerprint = musicActivityOnCreateFingerprint, + extensionPatch = sharedExtensionPatch, + gmsCoreSupportResourcePatchFactory = ::gmsCoreSupportResourcePatch, +) { + compatibleWith(MUSIC_PACKAGE_NAME) +} + +private fun gmsCoreSupportResourcePatch( + gmsCoreVendorGroupIdOption: Option, +) = app.revanced.patches.shared.misc.gms.gmsCoreSupportResourcePatch( + fromPackageName = MUSIC_PACKAGE_NAME, + toPackageName = REVANCED_MUSIC_PACKAGE_NAME, + gmsCoreVendorGroupIdOption = gmsCoreVendorGroupIdOption, + spoofedPackageSignature = "afb0fed5eeaebdd86f56a97742f4b6b33ef59875", +) diff --git a/src/main/kotlin/app/revanced/patches/reddit/customclients/infinityforreddit/api/fingerprints/AbstractClientIdFingerprint.kt b/patches/src/main/kotlin/app/revanced/patches/music/premium/backgroundplay/BackgroundPlayPatch.kt similarity index 100% rename from src/main/kotlin/app/revanced/patches/reddit/customclients/infinityforreddit/api/fingerprints/AbstractClientIdFingerprint.kt rename to patches/src/main/kotlin/app/revanced/patches/music/premium/backgroundplay/BackgroundPlayPatch.kt diff --git a/patches/src/main/kotlin/app/revanced/patches/myexpenses/misc/pro/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/myexpenses/misc/pro/Fingerprints.kt new file mode 100644 index 000000000..6bc4c21e5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/myexpenses/misc/pro/Fingerprints.kt @@ -0,0 +1,8 @@ +package app.revanced.patches.myexpenses.misc.pro + +import app.revanced.patcher.fingerprint + +internal val isEnabledFingerprint = fingerprint { + returns("Z") + strings("feature", "feature.licenceStatus") +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/myexpenses/misc/pro/UnlockProPatch.kt b/patches/src/main/kotlin/app/revanced/patches/myexpenses/misc/pro/UnlockProPatch.kt new file mode 100644 index 000000000..6f3fd4a1b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/myexpenses/misc/pro/UnlockProPatch.kt @@ -0,0 +1,23 @@ +package app.revanced.patches.myexpenses.misc.pro + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val unlockProPatch = bytecodePatch( + name = "Unlock pro", +) { + compatibleWith("org.totschnig.myexpenses") + + val isEnabledMatch by isEnabledFingerprint() + + execute { + isEnabledMatch.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/myfitnesspal/ads/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/myfitnesspal/ads/Fingerprints.kt new file mode 100644 index 000000000..160e2db27 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/myfitnesspal/ads/Fingerprints.kt @@ -0,0 +1,19 @@ +package app.revanced.patches.myfitnesspal.ads + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val isPremiumUseCaseImplFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC) + custom { method, classDef -> + classDef.endsWith("IsPremiumUseCaseImpl;") && method.name == "doWork" + } +} + +internal val mainActivityNavigateToNativePremiumUpsellFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.FINAL) + returns("V") + custom { method, classDef -> + classDef.endsWith("MainActivity;") && method.name == "navigateToNativePremiumUpsell" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/myfitnesspal/ads/HideAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/myfitnesspal/ads/HideAdsPatch.kt new file mode 100644 index 000000000..47be5c2fd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/myfitnesspal/ads/HideAdsPatch.kt @@ -0,0 +1,33 @@ +package app.revanced.patches.myfitnesspal.ads + +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val hideAdsPatch = bytecodePatch( + name = "Hide ads", + description = "Hides most of the ads across the app.", +) { + compatibleWith("com.myfitnesspal.android") + + val isPremiumUseCaseImplMatch by isPremiumUseCaseImplFingerprint() + val mainActivityNavigateToNativePremiumUpsellMatch by mainActivityNavigateToNativePremiumUpsellFingerprint() + + execute { + // Overwrite the premium status specifically for ads. + isPremiumUseCaseImplMatch.mutableMethod.replaceInstructions( + 0, + """ + sget-object v0, Ljava/lang/Boolean;->TRUE:Ljava/lang/Boolean; + return-object v0 + """, + ) + + // Prevent the premium upsell dialog from showing when the main activity is launched. + // In other places that are premium-only the dialog will still show. + mainActivityNavigateToNativePremiumUpsellMatch.mutableMethod.replaceInstructions( + 0, + "return-void", + ) + } +} diff --git a/src/main/kotlin/app/revanced/patches/netguard/broadcasts/removerestriction/RemoveBroadcastsRestrictionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/netguard/broadcasts/removerestriction/RemoveBroadcastsRestrictionPatch.kt similarity index 62% rename from src/main/kotlin/app/revanced/patches/netguard/broadcasts/removerestriction/RemoveBroadcastsRestrictionPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/netguard/broadcasts/removerestriction/RemoveBroadcastsRestrictionPatch.kt index 70346ec2c..c747f426e 100644 --- a/src/main/kotlin/app/revanced/patches/netguard/broadcasts/removerestriction/RemoveBroadcastsRestrictionPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/netguard/broadcasts/removerestriction/RemoveBroadcastsRestrictionPatch.kt @@ -1,23 +1,17 @@ package app.revanced.patches.netguard.broadcasts.removerestriction -import app.revanced.patcher.data.ResourceContext -import app.revanced.patcher.patch.ResourcePatch -import app.revanced.patcher.patch.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch +import app.revanced.patcher.patch.resourcePatch import org.w3c.dom.Element -@Patch( +@Suppress("unused") +val removeBroadcastsRestrictionPatch = resourcePatch( name = "Remove broadcasts restriction", description = "Enables starting/stopping NetGuard via broadcasts.", - compatiblePackages = [CompatiblePackage("eu.faircode.netguard")], - use = false, -) -@Suppress("unused") -object RemoveBroadcastsRestrictionPatch : ResourcePatch() { - override fun execute(context: ResourceContext) { - context.xmlEditor["AndroidManifest.xml"].use { editor -> - val document = editor.file +) { + compatibleWith("eu.faircode.netguard") + execute { context -> + context.document["AndroidManifest.xml"].use { document -> val applicationNode = document .getElementsByTagName("application") diff --git a/patches/src/main/kotlin/app/revanced/patches/nfctoolsse/misc/pro/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/nfctoolsse/misc/pro/Fingerprints.kt new file mode 100644 index 000000000..f4f5d48c0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/nfctoolsse/misc/pro/Fingerprints.kt @@ -0,0 +1,10 @@ +package app.revanced.patches.nfctoolsse.misc.pro + +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val isLicenseRegisteredFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC) + returns("Z") + strings("kLicenseCheck") +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/nfctoolsse/misc/pro/UnlockProPatch.kt b/patches/src/main/kotlin/app/revanced/patches/nfctoolsse/misc/pro/UnlockProPatch.kt new file mode 100644 index 000000000..c44f91116 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/nfctoolsse/misc/pro/UnlockProPatch.kt @@ -0,0 +1,23 @@ +package app.revanced.patches.nfctoolsse.misc.pro + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val unlockProPatch = bytecodePatch( + name = "Unlock pro", +) { + compatibleWith("com.wakdev.apps.nfctools.se") + + val isLicenseRegisteredMatch by isLicenseRegisteredFingerprint() + + execute { + isLicenseRegisteredMatch.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/nyx/misc/pro/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/nyx/misc/pro/Fingerprints.kt new file mode 100644 index 000000000..e2bffa451 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/nyx/misc/pro/Fingerprints.kt @@ -0,0 +1,9 @@ +package app.revanced.patches.nyx.misc.pro + +import app.revanced.patcher.fingerprint + +internal val checkProFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("BillingManager;") && method.name == "isProVersion" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/nyx/misc/pro/UnlockProPatch.kt b/patches/src/main/kotlin/app/revanced/patches/nyx/misc/pro/UnlockProPatch.kt new file mode 100644 index 000000000..a8a1f3a18 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/nyx/misc/pro/UnlockProPatch.kt @@ -0,0 +1,23 @@ +package app.revanced.patches.nyx.misc.pro + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val unlockProPatch = bytecodePatch( + name = "Unlock pro", +) { + compatibleWith("com.awedea.nyx") + + val checkProMatch by checkProFingerprint() + + execute { + checkProMatch.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/openinghours/misc/fix/crash/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/openinghours/misc/fix/crash/Fingerprints.kt new file mode 100644 index 000000000..69463c510 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/openinghours/misc/fix/crash/Fingerprints.kt @@ -0,0 +1,12 @@ +package app.revanced.patches.openinghours.misc.fix.crash + +import app.revanced.patcher.fingerprint + +internal val setPlaceFingerprint = fingerprint { + returns("V") + parameters("Lde/simon/openinghours/models/Place;") + custom { method, _ -> + method.name == "setPlace" && + method.definingClass == "Lde/simon/openinghours/views/custom/PlaceCard;" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/openinghours/misc/fix/crash/FixCrashPatch.kt b/patches/src/main/kotlin/app/revanced/patches/openinghours/misc/fix/crash/FixCrashPatch.kt new file mode 100644 index 000000000..a24ba416f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/openinghours/misc/fix/crash/FixCrashPatch.kt @@ -0,0 +1,107 @@ +package app.revanced.patches.openinghours.misc.fix.crash + +import app.revanced.patcher.extensions.InstructionExtensions.instructions +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.extensions.newLabel +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.util.getReference +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.builder.instruction.BuilderInstruction21t +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.reference.MethodReference + +@Suppress("unused") +val fixCrashPatch = bytecodePatch( + name = "Fix crash", +) { + compatibleWith("de.simon.openinghours"("1.0")) + + val setPlaceMatch by setPlaceFingerprint() + + execute { + val indexedInstructions = setPlaceMatch.mutableMethod.instructions.withIndex().toList() + + /** + * This function replaces all `checkNotNull` instructions in the integer interval + * from [startIndex] to [endIndex], both inclusive. In place of the `checkNotNull` + * instruction an if-null check is inserted. If the if-null check yields that + * the value is indeed null, we jump to a newly created label at `endIndex + 1`. + */ + fun avoidNullPointerException(startIndex: Int, endIndex: Int) { + val continueLabel = setPlaceMatch.mutableMethod.newLabel(endIndex + 1) + + for (index in startIndex..endIndex) { + val instruction = indexedInstructions[index].value + + if (!instruction.isCheckNotNullInstruction) { + continue + } + + val checkNotNullInstruction = instruction as FiveRegisterInstruction + val originalRegister = checkNotNullInstruction.registerC + + setPlaceMatch.mutableMethod.replaceInstruction( + index, + BuilderInstruction21t( + Opcode.IF_EQZ, + originalRegister, + continueLabel, + ), + ) + } + } + + val getOpeningHoursIndex = getIndicesOfInvoke( + indexedInstructions, + "Lde/simon/openinghours/models/Place;", + "getOpeningHours", + ) + + val setWeekDayTextIndex = getIndexOfInvoke( + indexedInstructions, + "Lde/simon/openinghours/views/custom/PlaceCard;", + "setWeekDayText", + ) + + val startCalculateStatusIndex = getIndexOfInvoke( + indexedInstructions, + "Lde/simon/openinghours/views/custom/PlaceCard;", + "startCalculateStatus", + ) + + // Replace the Intrinsics;->checkNotNull instructions with a null check + // and jump to our newly created label if it returns true. + // This avoids the NullPointerExceptions. + avoidNullPointerException(getOpeningHoursIndex[1], startCalculateStatusIndex) + avoidNullPointerException(getOpeningHoursIndex[0], setWeekDayTextIndex) + } +} + +private fun isInvokeInstruction(instruction: Instruction, className: String, methodName: String): Boolean { + val methodRef = instruction.getReference() ?: return false + return methodRef.definingClass == className && methodRef.name == methodName +} + +private fun getIndicesOfInvoke( + instructions: List>, + className: String, + methodName: String, +): List = instructions.mapNotNull { (index, instruction) -> + if (isInvokeInstruction(instruction, className, methodName)) { + index + } else { + null + } +} + +private fun getIndexOfInvoke( + instructions: List>, + className: String, + methodName: String, +): Int = instructions.first { (_, instruction) -> + isInvokeInstruction(instruction, className, methodName) +}.index + +private val Instruction.isCheckNotNullInstruction + get() = isInvokeInstruction(this, "Lkotlin/jvm/internal/Intrinsics;", "checkNotNull") diff --git a/src/main/kotlin/app/revanced/patches/photomath/detection/deviceid/fingerprints/GetDeviceIdFingerprint.kt b/patches/src/main/kotlin/app/revanced/patches/photomath/detection/deviceid/Fingerprints.kt similarity index 55% rename from src/main/kotlin/app/revanced/patches/photomath/detection/deviceid/fingerprints/GetDeviceIdFingerprint.kt rename to patches/src/main/kotlin/app/revanced/patches/photomath/detection/deviceid/Fingerprints.kt index fe01d7b59..90c0bbb91 100644 --- a/src/main/kotlin/app/revanced/patches/photomath/detection/deviceid/fingerprints/GetDeviceIdFingerprint.kt +++ b/patches/src/main/kotlin/app/revanced/patches/photomath/detection/deviceid/Fingerprints.kt @@ -1,11 +1,12 @@ -package app.revanced.patches.photomath.detection.deviceid.fingerprints +package app.revanced.patches.photomath.detection.deviceid -import app.revanced.patcher.fingerprint.MethodFingerprint import com.android.tools.smali.dexlib2.Opcode +import app.revanced.patcher.fingerprint -internal object GetDeviceIdFingerprint : MethodFingerprint( - returnType = "Ljava/lang/String;", - opcodes = listOf( +internal val getDeviceIdFingerprint = fingerprint { + returns("Ljava/lang/String;") + parameters() + opcodes( Opcode.SGET_OBJECT, Opcode.IGET_OBJECT, Opcode.INVOKE_STATIC, @@ -16,6 +17,5 @@ internal object GetDeviceIdFingerprint : MethodFingerprint( Opcode.INVOKE_VIRTUAL, Opcode.MOVE_RESULT_OBJECT, Opcode.INVOKE_VIRTUAL, - ), - parameters = listOf() -) \ No newline at end of file + ) +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/photomath/detection/deviceid/SpoofDeviceIdPatch.kt b/patches/src/main/kotlin/app/revanced/patches/photomath/detection/deviceid/SpoofDeviceIdPatch.kt new file mode 100644 index 000000000..152ee8edd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/photomath/detection/deviceid/SpoofDeviceIdPatch.kt @@ -0,0 +1,28 @@ +package app.revanced.patches.photomath.detection.deviceid + +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.photomath.detection.signature.signatureDetectionPatch +import kotlin.random.Random + +@Suppress("unused") +val getDeviceIdPatch = bytecodePatch( + name = "Spoof device ID", + description = "Spoofs device ID to mitigate manual bans by developers.", +) { + dependsOn(signatureDetectionPatch) + + compatibleWith("com.microblink.photomath"("8.37.0")) + + val getDeviceIdMatch by getDeviceIdFingerprint() + + execute { + getDeviceIdMatch.mutableMethod.replaceInstructions( + 0, + """ + const-string v0, "${Random.nextLong().toString(16)}" + return-object v0 + """, + ) + } +} diff --git a/src/main/kotlin/app/revanced/patches/photomath/detection/signature/fingerprints/CheckSignatureFingerprint.kt b/patches/src/main/kotlin/app/revanced/patches/photomath/detection/signature/Fingerprints.kt similarity index 52% rename from src/main/kotlin/app/revanced/patches/photomath/detection/signature/fingerprints/CheckSignatureFingerprint.kt rename to patches/src/main/kotlin/app/revanced/patches/photomath/detection/signature/Fingerprints.kt index cd784ab20..5d7a20783 100644 --- a/src/main/kotlin/app/revanced/patches/photomath/detection/signature/fingerprints/CheckSignatureFingerprint.kt +++ b/patches/src/main/kotlin/app/revanced/patches/photomath/detection/signature/Fingerprints.kt @@ -1,13 +1,10 @@ -package app.revanced.patches.photomath.detection.signature.fingerprints +package app.revanced.patches.photomath.detection.signature -import app.revanced.patcher.fingerprint.MethodFingerprint import com.android.tools.smali.dexlib2.Opcode +import app.revanced.patcher.fingerprint -internal object CheckSignatureFingerprint : MethodFingerprint( - strings = listOf( - "signatures", - ), - opcodes = listOf( +internal val checkSignatureFingerprint = fingerprint { + opcodes( Opcode.CONST_STRING, Opcode.INVOKE_STATIC, Opcode.INVOKE_STATIC, @@ -17,4 +14,5 @@ internal object CheckSignatureFingerprint : MethodFingerprint( Opcode.INVOKE_STATIC, Opcode.MOVE_RESULT, ) -) + strings("signatures") +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/photomath/detection/signature/SignatureDetectionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/photomath/detection/signature/SignatureDetectionPatch.kt new file mode 100644 index 000000000..5a2a7c5da --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/photomath/detection/signature/SignatureDetectionPatch.kt @@ -0,0 +1,25 @@ +package app.revanced.patches.photomath.detection.signature + +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +@Suppress("unused") +val signatureDetectionPatch = bytecodePatch( + description = "Disables detection of incorrect signature.", +) { + val checkSignatureMatch by checkSignatureFingerprint() + + execute { + val signatureCheckInstruction = checkSignatureMatch.mutableMethod.getInstruction( + checkSignatureMatch.patternMatch!!.endIndex, + ) + val checkRegister = (signatureCheckInstruction as OneRegisterInstruction).registerA + + checkSignatureMatch.mutableMethod.replaceInstruction( + signatureCheckInstruction.location.index, + "const/4 v$checkRegister, 0x1", + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/photomath/misc/annoyances/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/photomath/misc/annoyances/Fingerprints.kt new file mode 100644 index 000000000..301f2f9a5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/photomath/misc/annoyances/Fingerprints.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.photomath.misc.annoyances + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val hideUpdatePopupFingerprint = fingerprint { + accessFlags(AccessFlags.FINAL, AccessFlags.PUBLIC) + returns("V") + opcodes( + Opcode.CONST_HIGH16, + Opcode.INVOKE_VIRTUAL, // ViewPropertyAnimator.alpha(1.0f) + Opcode.MOVE_RESULT_OBJECT, + Opcode.CONST_WIDE_16, + Opcode.INVOKE_VIRTUAL, // ViewPropertyAnimator.setDuration(1000L) + ) + custom { method, _ -> + // The popup is shown only in the main activity + method.definingClass == "Lcom/microblink/photomath/main/activity/MainActivity;" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/photomath/misc/annoyances/HideUpdatePopupPatch.kt b/patches/src/main/kotlin/app/revanced/patches/photomath/misc/annoyances/HideUpdatePopupPatch.kt new file mode 100644 index 000000000..00d0caa18 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/photomath/misc/annoyances/HideUpdatePopupPatch.kt @@ -0,0 +1,24 @@ +package app.revanced.patches.photomath.misc.annoyances + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.photomath.detection.signature.signatureDetectionPatch + +@Suppress("unused") +val hideUpdatePopupPatch = bytecodePatch( + name = "Hide update popup", + description = "Prevents the update popup from showing up.", +) { + dependsOn(signatureDetectionPatch) + + compatibleWith("com.microblink.photomath"("8.32.0")) + + val hideUpdatePopupMatch by hideUpdatePopupFingerprint() + + execute { + hideUpdatePopupMatch.mutableMethod.addInstructions( + 2, // Insert after the null check. + "return-void", + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/photomath/misc/unlock/bookpoint/EnableBookpointPatch.kt b/patches/src/main/kotlin/app/revanced/patches/photomath/misc/unlock/bookpoint/EnableBookpointPatch.kt new file mode 100644 index 000000000..adf0d7bd8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/photomath/misc/unlock/bookpoint/EnableBookpointPatch.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.photomath.misc.unlock.bookpoint + +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val enableBookpointPatch = bytecodePatch( + description = "Enables textbook access", +) { + val isBookpointEnabledMatch by isBookpointEnabledFingerprint() + + execute { + isBookpointEnabledMatch.mutableMethod.replaceInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/photomath/misc/unlock/bookpoint/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/photomath/misc/unlock/bookpoint/Fingerprints.kt new file mode 100644 index 000000000..6722f4223 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/photomath/misc/unlock/bookpoint/Fingerprints.kt @@ -0,0 +1,16 @@ +package app.revanced.patches.photomath.misc.unlock.bookpoint + +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val isBookpointEnabledFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Z") + parameters() + strings( + "NoGeoData", + "NoCountryInGeo", + "RemoteConfig", + "GeoRCMismatch" + ) +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/photomath/misc/unlock/plus/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/photomath/misc/unlock/plus/Fingerprints.kt new file mode 100644 index 000000000..f6c282cbd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/photomath/misc/unlock/plus/Fingerprints.kt @@ -0,0 +1,13 @@ +package app.revanced.patches.photomath.misc.unlock.plus + +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val isPlusUnlockedFingerprint = fingerprint{ + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Z") + strings("genius") + custom { _, classDef -> + classDef.endsWith("/User;") + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/photomath/misc/unlock/plus/UnlockPlusPatch.kt b/patches/src/main/kotlin/app/revanced/patches/photomath/misc/unlock/plus/UnlockPlusPatch.kt new file mode 100644 index 000000000..f62533e0c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/photomath/misc/unlock/plus/UnlockPlusPatch.kt @@ -0,0 +1,27 @@ +package app.revanced.patches.photomath.misc.unlock.plus + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.photomath.detection.signature.signatureDetectionPatch +import app.revanced.patches.photomath.misc.unlock.bookpoint.enableBookpointPatch + +@Suppress("unused") +val unlockPlusPatch = bytecodePatch( + name = "Unlock plus", +) { + dependsOn(signatureDetectionPatch, enableBookpointPatch) + + compatibleWith("com.microblink.photomath"("8.37.0")) + + val isPlusUnlockedMatch by isPlusUnlockedFingerprint() + + execute { + isPlusUnlockedMatch.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/piccomafr/misc/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/piccomafr/misc/Fingerprints.kt new file mode 100644 index 000000000..8c2d579ef --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/piccomafr/misc/Fingerprints.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.piccomafr.misc + +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val getAndroidIdFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Ljava/lang/String;") + parameters("Landroid/content/Context;") + strings("context", "android_id") +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/piccomafr/misc/SpoofAndroidDeviceIdPatch.kt b/patches/src/main/kotlin/app/revanced/patches/piccomafr/misc/SpoofAndroidDeviceIdPatch.kt new file mode 100644 index 000000000..6d9e4dc5c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/piccomafr/misc/SpoofAndroidDeviceIdPatch.kt @@ -0,0 +1,52 @@ +package app.revanced.patches.piccomafr.misc + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.stringOption + +@Suppress("unused") +val spoofAndroidDeviceIdPatch = bytecodePatch( + name = "Spoof Android device ID", + description = "Spoofs the Android device ID used by the app for account authentication." + + "This can be used to copy the account to another device.", + use = false, +) { + compatibleWith( + "com.piccomaeurope.fr"( + "6.4.0", + "6.4.1", + "6.4.2", + "6.4.3", + "6.4.4", + "6.4.5", + "6.5.0", + "6.5.1", + "6.5.2", + "6.5.3", + "6.5.4", + "6.6.0", + "6.6.1", + "6.6.2", + ), + ) + + val getAndroidIDMatch by getAndroidIdFingerprint() + + val androidDeviceId by stringOption( + key = "android-device-id", + default = "0011223344556677", + title = "Android device ID", + description = "The Android device ID to spoof to.", + required = true, + ) { it!!.matches("[A-Fa-f0-9]{16}".toRegex()) } + + execute { + getAndroidIDMatch.mutableMethod.addInstructions( + 0, + """ + const-string v0, "$androidDeviceId" + return-object v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/piccomafr/tracking/DisableTrackingPatch.kt b/patches/src/main/kotlin/app/revanced/patches/piccomafr/tracking/DisableTrackingPatch.kt new file mode 100644 index 000000000..cf4ba8b08 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/piccomafr/tracking/DisableTrackingPatch.kt @@ -0,0 +1,71 @@ +package app.revanced.patches.piccomafr.tracking + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.instructions +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.util.getReference +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 + +@Suppress("unused") +val disableTrackingPatch = bytecodePatch( + name = "Disable tracking", + description = "Disables tracking by replacing tracking URLs with example.com.", +) { + compatibleWith( + "com.piccomaeurope.fr"( + "6.4.0", + "6.4.1", + "6.4.2", + "6.4.3", + "6.4.4", + "6.4.5", + "6.5.0", + "6.5.1", + "6.5.2", + "6.5.3", + "6.5.4", + "6.6.0", + "6.6.1", + "6.6.2", + ), + ) + + val facebookSDKMatch by facebookSDKFingerprint() + val firebaseInstallMatch by firebaseInstallFingerprint() + val appMeasurementMatch by appMeasurementFingerprint() + + execute { + facebookSDKMatch.mutableMethod.apply { + instructions.filter { instruction -> + instruction.opcode == Opcode.CONST_STRING + }.forEach { instruction -> + instruction as OneRegisterInstruction + + replaceInstruction( + instruction.location.index, + "const-string v${instruction.registerA}, \"example.com\"", + ) + } + } + + firebaseInstallMatch.mutableMethod.apply { + instructions.filter { + it.opcode == Opcode.CONST_STRING + }.filter { + it.getReference()?.string == "firebaseinstallations.googleapis.com" + }.forEach { instruction -> + instruction as OneRegisterInstruction + + replaceInstruction( + instruction.location.index, + "const-string v${instruction.registerA}, \"example.com\"", + ) + } + } + + appMeasurementMatch.mutableMethod.addInstruction(0, "return-void") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/piccomafr/tracking/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/piccomafr/tracking/Fingerprints.kt new file mode 100644 index 000000000..794f21bcb --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/piccomafr/tracking/Fingerprints.kt @@ -0,0 +1,24 @@ +package app.revanced.patches.piccomafr.tracking + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val appMeasurementFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.FINAL) + returns("V") + strings("config/app/", "Fetching remote configuration") +} + +internal val facebookSDKFingerprint = fingerprint { + accessFlags(AccessFlags.STATIC, AccessFlags.CONSTRUCTOR) + returns("V") + strings("instagram.com", "facebook.com") +} + +internal val firebaseInstallFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE) + strings( + "https://%s/%s/%s", + "firebaseinstallations.googleapis.com", + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/pixiv/ads/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/pixiv/ads/Fingerprints.kt new file mode 100644 index 000000000..3e2addaa0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/pixiv/ads/Fingerprints.kt @@ -0,0 +1,12 @@ +package app.revanced.patches.pixiv.ads + +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val shouldShowAdsFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Z") + custom { methodDef, classDef -> + classDef.type.endsWith("AdUtils;") && methodDef.name == "shouldShowAds" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/pixiv/ads/HideAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/pixiv/ads/HideAdsPatch.kt new file mode 100644 index 000000000..584d2f48a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/pixiv/ads/HideAdsPatch.kt @@ -0,0 +1,23 @@ +package app.revanced.patches.pixiv.ads + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val hideAdsPatch = bytecodePatch( + name = "Hide ads", +) { + compatibleWith("jp.pxv.android") + + val shouldShowAdsMatch by shouldShowAdsFingerprint() + + execute { + shouldShowAdsMatch.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x0 + return v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/rar/misc/annoyances/purchasereminder/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/rar/misc/annoyances/purchasereminder/Fingerprints.kt new file mode 100644 index 000000000..a4d2c9e22 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/rar/misc/annoyances/purchasereminder/Fingerprints.kt @@ -0,0 +1,12 @@ +package app.revanced.patches.rar.misc.annoyances.purchasereminder + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val showReminderFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("V") + custom { method, _ -> + method.definingClass.endsWith("AdsNotify;") && method.name == "show" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/rar/misc/annoyances/purchasereminder/HidePurchaseReminderPatch.kt b/patches/src/main/kotlin/app/revanced/patches/rar/misc/annoyances/purchasereminder/HidePurchaseReminderPatch.kt new file mode 100644 index 000000000..32957d5bd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/rar/misc/annoyances/purchasereminder/HidePurchaseReminderPatch.kt @@ -0,0 +1,19 @@ +package app.revanced.patches.rar.misc.annoyances.purchasereminder + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val hidePurchaseReminderPatch = bytecodePatch( + name = "Hide purchase reminder", + description = "Hides the popup that reminds you to purchase the app.", + +) { + compatibleWith("com.rarlab.rar") + + val showReminderMatch by showReminderFingerprint() + + execute { + showReminderMatch.mutableMethod.addInstruction(0, "return-void") + } +} diff --git a/src/main/kotlin/app/revanced/patches/reddit/ad/banner/HideBannerPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/ad/banner/HideBannerPatch.kt similarity index 65% rename from src/main/kotlin/app/revanced/patches/reddit/ad/banner/HideBannerPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/reddit/ad/banner/HideBannerPatch.kt index 311803195..f3f6d6669 100644 --- a/src/main/kotlin/app/revanced/patches/reddit/ad/banner/HideBannerPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/ad/banner/HideBannerPatch.kt @@ -1,20 +1,18 @@ package app.revanced.patches.reddit.ad.banner -import app.revanced.patcher.data.ResourceContext -import app.revanced.patcher.patch.ResourcePatch -import app.revanced.patcher.patch.annotation.Patch +import app.revanced.patcher.patch.resourcePatch // Note that for now, this patch and anything using it will only work on // Reddit 2024.17.0 or older. Newer versions will crash during patching. // See https://github.com/ReVanced/revanced-patches/issues/3099 -@Patch(description = "Hides banner ads from comments on subreddits.") -object HideBannerPatch : ResourcePatch() { - private const val RESOURCE_FILE_PATH = "res/layout/merge_listheader_link_detail.xml" - - override fun execute(context: ResourceContext) { - context.xmlEditor[RESOURCE_FILE_PATH].use { editor -> - val document = editor.file +@Suppress("unused") +val hideBannerPatch = resourcePatch( + description = "Hides banner ads from comments on subreddits.", +) { + execute { context -> + val resourceFilePath = "res/layout/merge_listheader_link_detail.xml" + context.document[resourceFilePath].use { document -> document.getElementsByTagName("merge").item(0).childNodes.apply { val attributes = arrayOf("height", "width") diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/ad/comments/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/ad/comments/Fingerprints.kt new file mode 100644 index 000000000..c99df5707 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/ad/comments/Fingerprints.kt @@ -0,0 +1,14 @@ +package app.revanced.patches.reddit.ad.comments + +import app.revanced.patcher.fingerprint + +internal val hideCommentAdsFingerprint = fingerprint { + strings( + "link", + // CommentPageRepository is not returning a link object + "is not returning a link object" + ) + custom { _, classDef -> + classDef.sourceFile == "PostDetailPresenter.kt" + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/ad/comments/HideCommentAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/ad/comments/HideCommentAdsPatch.kt new file mode 100644 index 000000000..c556e76a2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/ad/comments/HideCommentAdsPatch.kt @@ -0,0 +1,22 @@ +package app.revanced.patches.reddit.ad.comments + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val hideCommentAdsPatch = bytecodePatch( + description = "Removes ads in the comments.", +) { + val hideCommentAdsMatch by hideCommentAdsFingerprint() + + execute { + hideCommentAdsMatch.mutableMethod.addInstructions( + 0, + """ + new-instance v0, Ljava/lang/Object; + invoke-direct {v0}, Ljava/lang/Object;->()V + return-object v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/ad/general/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/ad/general/Fingerprints.kt new file mode 100644 index 000000000..e7dd78912 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/ad/general/Fingerprints.kt @@ -0,0 +1,17 @@ +package app.revanced.patches.reddit.ad.general + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.Opcode + +internal val adPostFingerprint = fingerprint { + returns("V") + // "children" are present throughout multiple versions + strings("children") + custom { _, classDef -> classDef.endsWith("Listing;") } +} + +internal val newAdPostFingerprint = fingerprint { + opcodes(Opcode.INVOKE_VIRTUAL) + strings("chain", "feedElement") + custom { _, classDef -> classDef.sourceFile == "AdElementConverter.kt" } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/ad/general/HideAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/ad/general/HideAdsPatch.kt new file mode 100644 index 000000000..fae110042 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/ad/general/HideAdsPatch.kt @@ -0,0 +1,81 @@ +package app.revanced.patches.reddit.ad.general + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.reddit.ad.banner.hideBannerPatch +import app.revanced.patches.reddit.ad.comments.hideCommentAdsPatch +import app.revanced.patches.reddit.misc.extension.sharedExtensionPatch +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction22c +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +@Suppress("unused") +val hideAdsPatch = bytecodePatch( + name = "Hide ads", +) { + dependsOn(hideBannerPatch, hideCommentAdsPatch, sharedExtensionPatch) + + // Note that for now, this patch and anything using it will only work on + // Reddit 2024.17.0 or older. Newer versions will crash during patching. + // See https://github.com/ReVanced/revanced-patches/issues/3099 + // and https://github.com/iBotPeaches/Apktool/issues/3534. + // This constraint is necessary due to dependency on hideBannerPatch. + compatibleWith("com.reddit.frontpage"("2024.17.0")) + + val adPostMatch by adPostFingerprint() + val newAdPostMatch by newAdPostFingerprint() + + execute { + // region Filter promoted ads (does not work in popular or latest feed) + + val filterMethodDescriptor = + "Lapp/revanced/extension/reddit/patches/FilterPromotedLinksPatch;" + + "->filterChildren(Ljava/lang/Iterable;)Ljava/util/List;" + + adPostMatch.mutableMethod.apply { + val setPostsListChildren = implementation!!.instructions.first { instruction -> + if (instruction.opcode != Opcode.IPUT_OBJECT) return@first false + + val reference = (instruction as ReferenceInstruction).reference as FieldReference + reference.name == "children" + } + + val castedInstruction = setPostsListChildren as Instruction22c + val itemsRegister = castedInstruction.registerA + val listInstanceRegister = castedInstruction.registerB + + // postsList.children = filterChildren(postListItems) + removeInstruction(setPostsListChildren.location.index) + addInstructions( + setPostsListChildren.location.index, + """ + invoke-static {v$itemsRegister}, $filterMethodDescriptor + move-result-object v0 + iput-object v0, v$listInstanceRegister, ${castedInstruction.reference} + """, + ) + } + + // endregion + + // region Remove ads from popular and latest feed + + // 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. + val index = newAdPostMatch.method.implementation!!.instructions.indexOfFirst { + if (it.opcode != Opcode.INVOKE_VIRTUAL) return@indexOfFirst false + + val reference = (it as ReferenceInstruction).reference as MethodReference + + reference.name == "add" && reference.definingClass == "Ljava/util/ArrayList;" + } + + newAdPostMatch.mutableMethod.removeInstruction(index) + } + + // endregion +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/FixSLinksPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/FixSLinksPatch.kt new file mode 100644 index 000000000..ccc0346a6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/FixSLinksPatch.kt @@ -0,0 +1,17 @@ +package app.revanced.patches.reddit.customclients + +import app.revanced.patcher.patch.BytecodePatchBuilder +import app.revanced.patcher.patch.Patch +import app.revanced.patcher.patch.bytecodePatch + +const val RESOLVE_S_LINK_METHOD = "patchResolveSLink(Ljava/lang/String;)Z" +const val SET_ACCESS_TOKEN_METHOD = "patchSetAccessToken(Ljava/lang/String;)V" + +fun fixSLinksPatch( + extensionPatch: Patch<*>, + block: BytecodePatchBuilder.() -> Unit = {}, +) = bytecodePatch(name = "Fix /s/ links") { + dependsOn(extensionPatch) + + block() +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/SpoofClientPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/SpoofClientPatch.kt new file mode 100644 index 000000000..b21b36a0d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/SpoofClientPatch.kt @@ -0,0 +1,34 @@ +package app.revanced.patches.reddit.customclients + +import app.revanced.patcher.patch.BytecodePatchBuilder +import app.revanced.patcher.patch.Option +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.stringOption + +/** + * Base class for patches that spoof the Reddit client. + * + * @param redirectUri The redirect URI of the Reddit OAuth client. + * @param block The patch block. It is called with the client ID option. + */ +fun spoofClientPatch( + redirectUri: String, + block: BytecodePatchBuilder.(Option) -> Unit = {}, +) = bytecodePatch( + name = "Spoof client", + description = "Restores functionality of the app by using custom client ID.", +) { + block( + stringOption( + "client-id", + null, + null, + "OAuth client ID", + "The Reddit OAuth client ID. " + + "You can get your client ID from https://www.reddit.com/prefs/apps. " + + "The application type has to be \"Installed app\" " + + "and the redirect URI has to be set to \"$redirectUri\".", + true, + ), + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/baconreader/api/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/baconreader/api/Fingerprints.kt new file mode 100644 index 000000000..bb87c2114 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/baconreader/api/Fingerprints.kt @@ -0,0 +1,19 @@ +package app.revanced.patches.reddit.customclients.baconreader.api + +import app.revanced.patcher.fingerprint + +internal val getAuthorizationUrlFingerprint = fingerprint { + strings("client_id=zACVn0dSFGdWqQ") +} +internal val getClientIdFingerprint = fingerprint { + strings("client_id=zACVn0dSFGdWqQ") + custom { method, classDef -> + if (!classDef.endsWith("RedditOAuth;")) return@custom false + + method.name == "getAuthorizeUrl" + } +} + +internal val requestTokenFingerprint = fingerprint { + strings("zACVn0dSFGdWqQ", "kDm2tYpu9DqyWFFyPlNcXGEni4k") // App ID and secret. +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/baconreader/api/SpoofClientPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/baconreader/api/SpoofClientPatch.kt new file mode 100644 index 000000000..649dffc16 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/baconreader/api/SpoofClientPatch.kt @@ -0,0 +1,40 @@ +package app.revanced.patches.reddit.customclients.baconreader.api + +import app.revanced.patcher.Match +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patches.reddit.customclients.spoofClientPatch +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +@Suppress("unused") +val spoofClientPatch = spoofClientPatch(redirectUri = "http://baconreader.com/auth") { clientIdOption -> + compatibleWith( + "com.onelouder.baconreader", + "com.onelouder.baconreader.premium", + ) + + val getAuthorizationUrlMatch by getAuthorizationUrlFingerprint() + val requestTokenMatch by requestTokenFingerprint() + + val clientId by clientIdOption + + execute { + fun Match.patch(replacementString: String) { + val clientIdIndex = stringMatches!!.first().index + + mutableMethod.apply { + val clientIdRegister = getInstruction(clientIdIndex).registerA + replaceInstruction( + clientIdIndex, + "const-string v$clientIdRegister, \"$replacementString\"", + ) + } + } + + // Patch client id in authorization url. + getAuthorizationUrlMatch.patch("client_id=$clientId") + + // Patch client id for access token request. + requestTokenMatch.patch(clientId!!) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/ads/DisableAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/ads/DisableAdsPatch.kt new file mode 100644 index 000000000..fefe25160 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/ads/DisableAdsPatch.kt @@ -0,0 +1,20 @@ +package app.revanced.patches.reddit.customclients.boostforreddit.ads + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val disableAdsPatch = bytecodePatch( + name = "Disable ads", +) { + compatibleWith("com.rubenmayayo.reddit") + + val maxMediationMatch by maxMediationFingerprint() + val admobMediationMatch by admobMediationFingerprint() + + execute { + arrayOf(maxMediationMatch, admobMediationMatch).forEach { + it.mutableMethod.addInstructions(0, "return-void") + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/ads/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/ads/Fingerprints.kt new file mode 100644 index 000000000..618e2f145 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/ads/Fingerprints.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.reddit.customclients.boostforreddit.ads + +import app.revanced.patcher.fingerprint + +internal val maxMediationFingerprint = fingerprint { + strings("MaxMediation: Attempting to initialize SDK") +} + +internal val admobMediationFingerprint = fingerprint { + strings("AdmobMediation: Attempting to initialize SDK") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/api/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/api/Fingerprints.kt new file mode 100644 index 000000000..cc06fd396 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/api/Fingerprints.kt @@ -0,0 +1,15 @@ +package app.revanced.patches.reddit.customclients.boostforreddit.api + +import app.revanced.patcher.fingerprint + +internal val buildUserAgentFingerprint = fingerprint { + strings("%s:%s:%s (by /u/%s)") +} + +internal val getClientIdFingerprint = fingerprint { + custom { method, classDef -> + if (!classDef.endsWith("Credentials;")) return@custom false + + method.name == "getClientId" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/api/SpoofClientPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/api/SpoofClientPatch.kt new file mode 100644 index 000000000..fb4df92f2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/api/SpoofClientPatch.kt @@ -0,0 +1,41 @@ +package app.revanced.patches.reddit.customclients.boostforreddit.api + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patches.reddit.customclients.spoofClientPatch + +@Suppress("unused") +val spoofClientPatch = spoofClientPatch(redirectUri = "http://rubenmayayo.com") { clientIdOption -> + compatibleWith("com.rubenmayayo.reddit") + + val getClientIdMatch by getClientIdFingerprint() + val buildUserAgentMatch by buildUserAgentFingerprint() + + val clientId by clientIdOption + + execute { + // region Patch client id. + + getClientIdMatch.mutableMethod.addInstructions( + 0, + """ + const-string v0, "$clientId" + return-object v0 + """, + ) + + // endregion + + // region Patch user agent. + + // Use a random number as the platform in the user agent string. + val platformName = (0..100000).random() + val platformParameter = 0 + + buildUserAgentMatch.mutableMethod.addInstructions( + 0, + "const-string p$platformParameter, \"$platformName\"", + ) + + // endregion + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/fix/downloads/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/fix/downloads/Fingerprints.kt new file mode 100644 index 000000000..a2b1530b8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/fix/downloads/Fingerprints.kt @@ -0,0 +1,7 @@ +package app.revanced.patches.reddit.customclients.boostforreddit.fix.downloads + +import app.revanced.patcher.fingerprint + +internal val downloadAudioFingerprint = fingerprint { + strings("/DASH_audio.mp4", "/audio") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/fix/downloads/FixAudioMissingInDownloadsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/fix/downloads/FixAudioMissingInDownloadsPatch.kt new file mode 100644 index 000000000..31975f44b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/fix/downloads/FixAudioMissingInDownloadsPatch.kt @@ -0,0 +1,32 @@ +package app.revanced.patches.reddit.customclients.boostforreddit.fix.downloads + +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +@Suppress("unused") +val fixAudioMissingInDownloadsPatch = bytecodePatch( + name = "Fix missing audio in video downloads", + description = "Fixes audio missing in videos downloaded from v.redd.it.", +) { + compatibleWith("com.rubenmayayo.reddit") + + val downloadAudioMatch by downloadAudioFingerprint() + + execute { + val endpointReplacements = mapOf( + "/DASH_audio.mp4" to "/DASH_AUDIO_128.mp4", + "/audio" to "/DASH_AUDIO_64.mp4", + ) + + downloadAudioMatch.stringMatches!!.forEach { match -> + downloadAudioMatch.mutableMethod.apply { + val replacement = endpointReplacements[match.string] + val register = getInstruction(match.index).registerA + + replaceInstruction(match.index, "const-string v$register, \"$replacement\"") + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/fix/slink/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/fix/slink/Fingerprints.kt new file mode 100644 index 000000000..665dba5a4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/fix/slink/Fingerprints.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.reddit.customclients.boostforreddit.fix.slink + +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val getOAuthAccessTokenFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC) + returns("Ljava/lang/String") + strings("access_token") + custom { method, _ -> method.definingClass == "Lnet/dean/jraw/http/oauth/OAuthData;" } +} + +internal val handleNavigationFingerprint = fingerprint { + strings( + "android.intent.action.SEARCH", + "subscription", + "sort", + "period", + "boostforreddit.com/themes", + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/fix/slink/FixSLinksPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/fix/slink/FixSLinksPatch.kt new file mode 100644 index 000000000..b35a85320 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/fix/slink/FixSLinksPatch.kt @@ -0,0 +1,53 @@ +package app.revanced.patches.reddit.customclients.boostforreddit.fix.slink + +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.util.smali.ExternalLabel +import app.revanced.patches.reddit.customclients.RESOLVE_S_LINK_METHOD +import app.revanced.patches.reddit.customclients.SET_ACCESS_TOKEN_METHOD +import app.revanced.patches.reddit.customclients.boostforreddit.misc.extension.sharedExtensionPatch +import app.revanced.patches.reddit.customclients.fixSLinksPatch + +const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/boostforreddit/FixSLinksPatch;" + +@Suppress("unused") +val fixSlinksPatch = fixSLinksPatch( + extensionPatch = sharedExtensionPatch, +) { + compatibleWith("com.rubenmayayo.reddit") + + val handleNavigationMatch by handleNavigationFingerprint() + val setAccessTokenMatch by getOAuthAccessTokenFingerprint() + + execute { + // region Patch navigation handler. + + handleNavigationMatch.mutableMethod.apply { + val urlRegister = "p1" + val tempRegister = "v1" + + addInstructionsWithLabels( + 0, + """ + invoke-static { $urlRegister }, $EXTENSION_CLASS_DESCRIPTOR->$RESOLVE_S_LINK_METHOD + move-result $tempRegister + if-eqz $tempRegister, :continue + return $tempRegister + """, + ExternalLabel("continue", getInstruction(0)), + ) + } + + // endregion + + // region Patch set access token. + + setAccessTokenMatch.mutableMethod.addInstruction( + 3, + "invoke-static { v0 }, $EXTENSION_CLASS_DESCRIPTOR->$SET_ACCESS_TOKEN_METHOD", + ) + + // endregion + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/misc/extension/SharedExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/misc/extension/SharedExtensionPatch.kt new file mode 100644 index 000000000..3d92d142b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/misc/extension/SharedExtensionPatch.kt @@ -0,0 +1,6 @@ +package app.revanced.patches.reddit.customclients.boostforreddit.misc.extension + +import app.revanced.patches.reddit.customclients.boostforreddit.misc.extension.hooks.initHook +import app.revanced.patches.shared.misc.extension.sharedExtensionPatch + +val sharedExtensionPatch = sharedExtensionPatch(initHook) diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/misc/extension/hooks/InitHook.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/misc/extension/hooks/InitHook.kt new file mode 100644 index 000000000..1e7e4e2e8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/boostforreddit/misc/extension/hooks/InitHook.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.reddit.customclients.boostforreddit.misc.extension.hooks + +import app.revanced.patches.shared.misc.extension.extensionHook + +internal val initHook = extensionHook( + insertIndexResolver = { 1 }, +) { + custom { method, _ -> + method.definingClass == "Lcom/rubenmayayo/reddit/MyApplication;" && method.name == "onCreate" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/infinityforreddit/api/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/infinityforreddit/api/Fingerprints.kt new file mode 100644 index 000000000..4bce1362c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/infinityforreddit/api/Fingerprints.kt @@ -0,0 +1,7 @@ +package app.revanced.patches.reddit.customclients.infinityforreddit.api + +import app.revanced.patcher.fingerprint + +internal val apiUtilsFingerprint = fingerprint { + strings("native-lib") +} \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/reddit/customclients/infinityforreddit/api/SpoofClientPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/infinityforreddit/api/SpoofClientPatch.kt similarity index 60% rename from src/main/kotlin/app/revanced/patches/reddit/customclients/infinityforreddit/api/SpoofClientPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/reddit/customclients/infinityforreddit/api/SpoofClientPatch.kt index 0565bfefa..3ec7d8c69 100644 --- a/src/main/kotlin/app/revanced/patches/reddit/customclients/infinityforreddit/api/SpoofClientPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/infinityforreddit/api/SpoofClientPatch.kt @@ -1,24 +1,22 @@ package app.revanced.patches.reddit.customclients.infinityforreddit.api -import app.revanced.patcher.data.BytecodeContext -import app.revanced.patcher.extensions.or -import app.revanced.patcher.fingerprint.MethodFingerprintResult import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable import app.revanced.patcher.util.smali.toInstructions -import app.revanced.patches.reddit.customclients.BaseSpoofClientPatch -import app.revanced.patches.reddit.customclients.infinityforreddit.api.fingerprints.APIUtilsFingerprint +import app.revanced.patches.reddit.customclients.spoofClientPatch import com.android.tools.smali.dexlib2.AccessFlags import com.android.tools.smali.dexlib2.immutable.ImmutableMethod import com.android.tools.smali.dexlib2.immutable.ImmutableMethodImplementation @Suppress("unused") -object SpoofClientPatch : BaseSpoofClientPatch( - redirectUri = "infinity://localhost", - clientIdFingerprints = setOf(APIUtilsFingerprint), - compatiblePackages = setOf(CompatiblePackage("ml.docilealligator.infinityforreddit")) -) { - override fun Set.patchClientId(context: BytecodeContext) { - first().mutableClass.methods.apply { +val spoofClientPatch = spoofClientPatch(redirectUri = "infinity://localhost") { clientIdOption -> + compatibleWith("ml.docilealligator.infinityforreddit") + + val apiUtilsMatch by apiUtilsFingerprint() + + val clientId by clientIdOption + + execute { + apiUtilsMatch.mutableClass.methods.apply { val getClientIdMethod = single { it.name == "getId" }.also(::remove) val newGetClientIdMethod = ImmutableMethod( @@ -26,7 +24,7 @@ object SpoofClientPatch : BaseSpoofClientPatch( getClientIdMethod.name, null, getClientIdMethod.returnType, - AccessFlags.PUBLIC or AccessFlags.STATIC, + AccessFlags.PUBLIC.value or AccessFlags.STATIC.value, null, null, ImmutableMethodImplementation( diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/infinityforreddit/subscription/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/infinityforreddit/subscription/Fingerprints.kt new file mode 100644 index 000000000..36fe06279 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/infinityforreddit/subscription/Fingerprints.kt @@ -0,0 +1,15 @@ +package app.revanced.patches.reddit.customclients.infinityforreddit.subscription + +import app.revanced.patcher.fingerprint +import app.revanced.util.literal + +internal val billingClientOnServiceConnectedFingerprint = fingerprint { + strings("Billing service connected") +} + +internal val startSubscriptionActivityFingerprint = fingerprint { + literal { + // Intent start flag only used in the subscription activity + 0x10008000 + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/infinityforreddit/subscription/UnlockSubscriptionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/infinityforreddit/subscription/UnlockSubscriptionPatch.kt new file mode 100644 index 000000000..1c427c967 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/infinityforreddit/subscription/UnlockSubscriptionPatch.kt @@ -0,0 +1,22 @@ +package app.revanced.patches.reddit.customclients.infinityforreddit.subscription + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.reddit.customclients.infinityforreddit.api.spoofClientPatch +import app.revanced.util.returnEarly + +@Suppress("unused") +val unlockSubscriptionPatch = bytecodePatch( + name = "Unlock subscription", + description = "Unlocks the subscription feature but requires a custom client ID.", +) { + dependsOn(spoofClientPatch) + + compatibleWith("ml.docilealligator.infinityforreddit") + + startSubscriptionActivityFingerprint() + billingClientOnServiceConnectedFingerprint() + + execute { + setOf(startSubscriptionActivityFingerprint, billingClientOnServiceConnectedFingerprint).returnEarly() + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/ads/DisableAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/ads/DisableAdsPatch.kt new file mode 100644 index 000000000..c6498ee00 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/ads/DisableAdsPatch.kt @@ -0,0 +1,26 @@ +package app.revanced.patches.reddit.customclients.joeyforreddit.ads + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.reddit.customclients.joeyforreddit.detection.piracy.disablePiracyDetectionPatch + +@Suppress("unused") +val disableAdsPatch = bytecodePatch( + name = "Disable ads", +) { + dependsOn(disablePiracyDetectionPatch) + + compatibleWith("o.o.joey") + + val isAdFreeUserMatch by isAdFreeUserFingerprint() + + execute { + isAdFreeUserMatch.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/ads/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/ads/Fingerprints.kt new file mode 100644 index 000000000..465faf120 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/ads/Fingerprints.kt @@ -0,0 +1,10 @@ +package app.revanced.patches.reddit.customclients.joeyforreddit.ads + +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val isAdFreeUserFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC) + returns("Z") + strings("AD_FREE_USER") +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/api/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/api/Fingerprints.kt new file mode 100644 index 000000000..e6c591748 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/api/Fingerprints.kt @@ -0,0 +1,28 @@ +package app.revanced.patches.reddit.customclients.joeyforreddit.api + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val authUtilityUserAgentFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("Ljava/lang/String;") + opcodes(Opcode.APUT_OBJECT) + custom { method, classDef -> + classDef.sourceFile == "AuthUtility.java" + } +} + +internal val getClientIdFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("L") + opcodes( + Opcode.CONST, // R.string.valuable_cid + Opcode.INVOKE_STATIC, // StringMaster.decrypt + Opcode.MOVE_RESULT_OBJECT, + Opcode.RETURN_OBJECT + ) + custom { _, classDef -> + classDef.sourceFile == "AuthUtility.java" + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/api/SpoofClientPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/api/SpoofClientPatch.kt new file mode 100644 index 000000000..ad3fc0cb1 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/api/SpoofClientPatch.kt @@ -0,0 +1,52 @@ +package app.revanced.patches.reddit.customclients.joeyforreddit.api + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstructions +import app.revanced.patches.reddit.customclients.joeyforreddit.detection.piracy.disablePiracyDetectionPatch +import app.revanced.patches.reddit.customclients.spoofClientPatch + +@Suppress("unused") +val spoofClientPatch = spoofClientPatch(redirectUri = "https://127.0.0.1:65023/authorize_callback") { clientIdOption -> + dependsOn(disablePiracyDetectionPatch) + + compatibleWith( + "o.o.joey", + "o.o.joey.pro", + "o.o.joey.dev", + ) + + val getClientIdMatch by getClientIdFingerprint() + val authUtilityUserAgentMatch by authUtilityUserAgentFingerprint() + + val clientId by clientIdOption + + execute { + // region Patch client id. + + getClientIdMatch.mutableMethod.addInstructions( + 0, + """ + const-string v0, "$clientId" + return-object v0 + """, + ) + + // endregion + + // region Patch user agent. + + // Use a random user agent. + val randomName = (0..100000).random() + val userAgent = "$randomName:app.revanced.$randomName:v1.0.0 (by /u/revanced)" + + authUtilityUserAgentMatch.mutableMethod.replaceInstructions( + 0, + """ + const-string v0, "$userAgent" + return-object v0 + """, + ) + + // endregion + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/detection/piracy/DisablePiracyDetectionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/detection/piracy/DisablePiracyDetectionPatch.kt new file mode 100644 index 000000000..32a676f9d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/detection/piracy/DisablePiracyDetectionPatch.kt @@ -0,0 +1,13 @@ +package app.revanced.patches.reddit.customclients.joeyforreddit.detection.piracy + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val disablePiracyDetectionPatch = bytecodePatch { + val piracyDetectionMatch by piracyDetectionFingerprint() + + execute { + piracyDetectionMatch.mutableMethod.addInstruction(0, "return-void") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/detection/piracy/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/detection/piracy/Fingerprints.kt new file mode 100644 index 000000000..76343a530 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/joeyforreddit/detection/piracy/Fingerprints.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.reddit.customclients.joeyforreddit.detection.piracy + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val piracyDetectionFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.STATIC) + returns("V") + opcodes( + Opcode.NEW_INSTANCE, + Opcode.CONST_16, + Opcode.CONST_WIDE_16, + Opcode.INVOKE_DIRECT, + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN_VOID + ) + custom { _, classDef -> + classDef.endsWith("ProcessLifeCyleListener;") + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/redditisfun/api/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/redditisfun/api/Fingerprints.kt new file mode 100644 index 000000000..14d0f4766 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/redditisfun/api/Fingerprints.kt @@ -0,0 +1,31 @@ +package app.revanced.patches.reddit.customclients.redditisfun.api + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +fun baseClientIdFingerprint(string: String) = fingerprint { + strings("yyOCBp.RHJhDKd", string) +} + +internal val basicAuthorizationFingerprint = baseClientIdFingerprint( + string = "fJOxVwBUyo*=f:.patchClientId(context: BytecodeContext) { +val spoofClientPatch = spoofClientPatch(redirectUri = "redditisfun://auth") { clientIdOption -> + compatibleWith( + "com.andrewshu.android.reddit", + "com.andrewshu.android.redditdonation", + ) + + val buildAuthorizationStringMatch by buildAuthorizationStringFingerprint() + val basicAuthorizationMatch by basicAuthorizationFingerprint() + val getUserAgentMatch by getUserAgentFingerprint() + + val clientId by clientIdOption + + execute { + // region Patch client id. + /** * Replaces a one register instruction with a const-string instruction * at the index returned by [getReplacementIndex]. * * @param string The string to replace the instruction with. * @param getReplacementIndex A function that returns the index of the instruction to replace - * using the [StringMatch] list from the [MethodFingerprintResult]. + * using the [Match.StringMatch] list from the [Match]. */ - fun MethodFingerprintResult.replaceWith( + fun Match.replaceWith( string: String, - getReplacementIndex: List.() -> Int, + getReplacementIndex: List.() -> Int, ) = mutableMethod.apply { - val replacementIndex = scanResult.stringsScanResult!!.matches.getReplacementIndex() + val replacementIndex = stringMatches!!.getReplacementIndex() val clientIdRegister = getInstruction(replacementIndex).registerA replaceInstruction(replacementIndex, "const-string v$clientIdRegister, \"$string\"") } // Patch OAuth authorization. - first().replaceWith(clientId!!) { first().index + 4 } + buildAuthorizationStringMatch.replaceWith(clientId!!) { first().index + 4 } // Path basic authorization. - last().replaceWith("$clientId:") { last().index + 7 } - } + basicAuthorizationMatch.replaceWith("$clientId:") { last().index + 7 } + + // endregion + + // region Patch user agent. - override fun Set.patchUserAgent(context: BytecodeContext) { // Use a random user agent. val randomName = (0..100000).random() val userAgent = "$randomName:app.revanced.$randomName:v1.0.0 (by /u/revanced)" - first().mutableMethod.addInstructions( + getUserAgentMatch.mutableMethod.addInstructions( 0, """ const-string v0, "$userAgent" return-object v0 """, ) - } - override fun Set.patchMiscellaneous(context: BytecodeContext) { + // endregion + + // region Patch miscellaneous. + // Reddit messed up and does not append a redirect uri to the authorization url to old.reddit.com/login. // Replace old.reddit.com with ssl.reddit.com to fix this. - BuildAuthorizationStringFingerprint.result!!.mutableMethod.apply { + buildAuthorizationStringMatch.mutableMethod.apply { val index = indexOfFirstInstructionOrThrow { getReference()?.contains("old.reddit.com") == true } @@ -78,5 +83,7 @@ object SpoofClientPatch : BaseSpoofClientPatch( "const-string v$targetRegister, \"https://ssl.reddit.com/api/v1/authorize.compact\"", ) } + + // endregion } } diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/relayforreddit/api/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/relayforreddit/api/Fingerprints.kt new file mode 100644 index 000000000..263602f73 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/relayforreddit/api/Fingerprints.kt @@ -0,0 +1,26 @@ +package app.revanced.patches.reddit.customclients.relayforreddit.api + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.Opcode + +internal fun baseClientIdFingerprint(string: String) = fingerprint { + strings("dj-xCIZQYiLbEg", string) +} + +internal val getLoggedInBearerTokenFingerprint = baseClientIdFingerprint("authorization_code") + +internal val getLoggedOutBearerTokenFingerprint = baseClientIdFingerprint("https://oauth.reddit.com/grants/installed_client") + +internal val getRefreshTokenFingerprint = baseClientIdFingerprint("refresh_token") + +internal val loginActivityClientIdFingerprint = baseClientIdFingerprint("&duration=permanent") + +internal val redditCheckDisableAPIFingerprint = fingerprint { + opcodes(Opcode.IF_EQZ) + strings("Reddit Disabled") +} + +internal val setRemoteConfigFingerprint = fingerprint { + parameters("Lcom/google/firebase/remoteconfig/FirebaseRemoteConfig;") + strings("reddit_oauth_url") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/relayforreddit/api/SpoofClientPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/relayforreddit/api/SpoofClientPatch.kt new file mode 100644 index 000000000..3313c7ff7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/relayforreddit/api/SpoofClientPatch.kt @@ -0,0 +1,65 @@ +package app.revanced.patches.reddit.customclients.relayforreddit.api + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patches.reddit.customclients.spoofClientPatch +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.builder.instruction.BuilderInstruction10t +import com.android.tools.smali.dexlib2.builder.instruction.BuilderInstruction21t +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +@Suppress("unused") +val spoofClientPatch = spoofClientPatch(redirectUri = "dbrady://relay") { + compatibleWith( + "free.reddit.news", + "reddit.news", + ) + + val loginActivityClientIdMatch by loginActivityClientIdFingerprint() + val getLoggedInBearerTokenMatch by getLoggedInBearerTokenFingerprint() + val getLoggedOutBearerTokenMatch by getLoggedOutBearerTokenFingerprint() + val getRefreshTokenMatch by getRefreshTokenFingerprint() + val setRemoteConfigMatch by setRemoteConfigFingerprint() + val redditCheckDisableAPIMatch by redditCheckDisableAPIFingerprint() + + val clientId by it + + execute { + // region Patch client id. + + setOf( + loginActivityClientIdMatch, + getLoggedInBearerTokenMatch, + getLoggedOutBearerTokenMatch, + getRefreshTokenMatch, + ).forEach { match -> + val clientIdIndex = match.stringMatches!!.first().index + match.mutableMethod.apply { + val clientIdRegister = getInstruction(clientIdIndex).registerA + + match.mutableMethod.replaceInstruction( + clientIdIndex, + "const-string v$clientIdRegister, \"$clientId\"", + ) + } + } + + // endregion + + // region Patch miscellaneous. + + // Do not load remote config which disables OAuth login remotely. + setRemoteConfigMatch.mutableMethod.addInstructions(0, "return-void") + + // Prevent OAuth login being disabled remotely. + val checkIsOAuthRequestIndex = redditCheckDisableAPIMatch.patternMatch!!.startIndex + + redditCheckDisableAPIMatch.mutableMethod.apply { + val returnNextChain = getInstruction(checkIsOAuthRequestIndex).target + replaceInstruction(checkIsOAuthRequestIndex, BuilderInstruction10t(Opcode.GOTO, returnNextChain)) + } + + // endregion + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/slide/api/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/slide/api/Fingerprints.kt new file mode 100644 index 000000000..4ff8be461 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/slide/api/Fingerprints.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.reddit.customclients.slide.api + +import app.revanced.patcher.fingerprint + +internal val getClientIdFingerprint = fingerprint { + custom { method, classDef -> + if (!classDef.endsWith("Credentials;")) return@custom false + + method.name == "getClientId" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/slide/api/SpoofClientPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/slide/api/SpoofClientPatch.kt new file mode 100644 index 000000000..bfd687096 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/slide/api/SpoofClientPatch.kt @@ -0,0 +1,23 @@ +package app.revanced.patches.reddit.customclients.slide.api + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patches.reddit.customclients.spoofClientPatch + +@Suppress("unused") +val spoofClientPatch = spoofClientPatch(redirectUri = "http://www.ccrama.me") { clientIdOption -> + compatibleWith("me.ccrama.redditslide") + + val getClientIdMatch by getClientIdFingerprint() + + val clientId by clientIdOption + + execute { + getClientIdMatch.mutableMethod.addInstructions( + 0, + """ + const-string v0, "$clientId" + return-object v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/ads/DisableAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/ads/DisableAdsPatch.kt new file mode 100644 index 000000000..d9b5b9fd4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/ads/DisableAdsPatch.kt @@ -0,0 +1,17 @@ +package app.revanced.patches.reddit.customclients.sync.ads + +import app.revanced.patcher.patch.BytecodePatchBuilder +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.util.returnEarly + +fun disableAdsPatch(block: BytecodePatchBuilder.() -> Unit = {}) = bytecodePatch( + name = "Disable ads", +) { + isAdsEnabledFingerprint() + + execute { + isAdsEnabledFingerprint.returnEarly() + } + + block() +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/ads/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/ads/Fingerprints.kt new file mode 100644 index 000000000..e055493bd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/ads/Fingerprints.kt @@ -0,0 +1,10 @@ +package app.revanced.patches.reddit.customclients.sync.ads + +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val isAdsEnabledFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("Z") + strings("SyncIapHelper") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/detection/piracy/DisablePiracyDetectionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/detection/piracy/DisablePiracyDetectionPatch.kt new file mode 100644 index 000000000..e87e2e7b4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/detection/piracy/DisablePiracyDetectionPatch.kt @@ -0,0 +1,17 @@ +package app.revanced.patches.reddit.customclients.sync.detection.piracy + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val disablePiracyDetectionPatch = bytecodePatch( + description = "Disables detection of modified versions.", +) { + val piracyDetectionMatch by piracyDetectionFingerprint() + + execute { + // Do not throw an error if the fingerprint is not resolved. + // This is fine because new versions of the target app do not need this patch. + piracyDetectionMatch.mutableMethod.addInstruction(0, "return-void") + } +} diff --git a/src/main/kotlin/app/revanced/patches/reddit/customclients/syncforreddit/detection/piracy/fingerprints/PiracyDetectionFingerprint.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/detection/piracy/Fingerprints.kt similarity index 57% rename from src/main/kotlin/app/revanced/patches/reddit/customclients/syncforreddit/detection/piracy/fingerprints/PiracyDetectionFingerprint.kt rename to patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/detection/piracy/Fingerprints.kt index f7cbc8771..7dc955912 100644 --- a/src/main/kotlin/app/revanced/patches/reddit/customclients/syncforreddit/detection/piracy/fingerprints/PiracyDetectionFingerprint.kt +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/detection/piracy/Fingerprints.kt @@ -1,22 +1,21 @@ -package app.revanced.patches.reddit.customclients.syncforreddit.detection.piracy.fingerprints +package app.revanced.patches.reddit.customclients.sync.detection.piracy -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 import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint -internal object PiracyDetectionFingerprint : MethodFingerprint( - returnType = "V", - accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL, - opcodes = listOf( +internal val piracyDetectionFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.FINAL) + returns("V") + opcodes( Opcode.NEW_INSTANCE, Opcode.INVOKE_DIRECT, Opcode.NEW_INSTANCE, Opcode.INVOKE_DIRECT, - Opcode.INVOKE_VIRTUAL - ), - customFingerprint = { method, _ -> + Opcode.INVOKE_VIRTUAL, + ) + custom { method, _ -> method.implementation?.instructions?.any { if (it.opcode != Opcode.NEW_INSTANCE) return@any false @@ -25,4 +24,4 @@ internal object PiracyDetectionFingerprint : MethodFingerprint( reference.toString() == "Lcom/github/javiersantos/piracychecker/PiracyChecker;" } ?: false } -) \ No newline at end of file +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforlemmy/ads/DisableAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforlemmy/ads/DisableAdsPatch.kt new file mode 100644 index 000000000..ca74997aa --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforlemmy/ads/DisableAdsPatch.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.reddit.customclients.sync.syncforlemmy.ads + +import app.revanced.patches.reddit.customclients.sync.ads.disableAdsPatch +import app.revanced.patches.reddit.customclients.sync.detection.piracy.disablePiracyDetectionPatch + +@Suppress("unused") +val disableAdsPatch = disableAdsPatch { + dependsOn(disablePiracyDetectionPatch) + + compatibleWith("com.laurencedawson.reddit_sync") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/ads/DisableAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/ads/DisableAdsPatch.kt new file mode 100644 index 000000000..e50158cdd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/ads/DisableAdsPatch.kt @@ -0,0 +1,8 @@ +package app.revanced.patches.reddit.customclients.sync.syncforreddit.ads + +import app.revanced.patches.reddit.customclients.sync.ads.disableAdsPatch + +@Suppress("unused") +val disableAdsPatch = disableAdsPatch { + compatibleWith("io.syncapps.lemmy_sync") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/annoyances/startup/DisableSyncForLemmyBottomSheetPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/annoyances/startup/DisableSyncForLemmyBottomSheetPatch.kt new file mode 100644 index 000000000..85d51573c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/annoyances/startup/DisableSyncForLemmyBottomSheetPatch.kt @@ -0,0 +1,26 @@ +package app.revanced.patches.reddit.customclients.sync.syncforreddit.annoyances.startup + +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val disableSyncForLemmyBottomSheetPatch = bytecodePatch( + name = "Disable Sync for Lemmy bottom sheet", + description = "Disables the bottom sheet at the startup that asks you to signup to \"Sync for Lemmy\".", +) { + compatibleWith( + "com.laurencedawson.reddit_sync"("v23.06.30-13:39"), + "com.laurencedawson.reddit_sync.pro"(), // Version unknown. + "com.laurencedawson.reddit_sync.dev"(), // Version unknown. + ) + + val mainActivityOnCreateMatch by mainActivityOnCreateFingerprint() + + execute { + mainActivityOnCreateMatch.mutableMethod.apply { + val showBottomSheetIndex = implementation!!.instructions.lastIndex - 1 + + removeInstruction(showBottomSheetIndex) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/annoyances/startup/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/annoyances/startup/Fingerprints.kt new file mode 100644 index 000000000..21c788a89 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/annoyances/startup/Fingerprints.kt @@ -0,0 +1,9 @@ +package app.revanced.patches.reddit.customclients.sync.syncforreddit.annoyances.startup + +import app.revanced.patcher.fingerprint + +internal val mainActivityOnCreateFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("MainActivity;") && method.name == "onCreate" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/api/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/api/Fingerprints.kt new file mode 100644 index 000000000..c7902b1f4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/api/Fingerprints.kt @@ -0,0 +1,19 @@ +package app.revanced.patches.reddit.customclients.sync.syncforreddit.api + +import app.revanced.patcher.fingerprint + +internal val getAuthorizationStringFingerprint = fingerprint { + strings("authorize.compact?client_id") +} + +internal val getBearerTokenFingerprint = fingerprint { + strings("Basic") +} + +internal val getUserAgentFingerprint = fingerprint { + strings("android:com.laurencedawson.reddit_sync") +} + +internal val imgurImageAPIFingerprint = fingerprint { + strings("https://imgur-apiv3.p.rapidapi.com/3/image") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/api/SpoofClientPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/api/SpoofClientPatch.kt new file mode 100644 index 000000000..1d9aaa5a8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/api/SpoofClientPatch.kt @@ -0,0 +1,94 @@ +package app.revanced.patches.reddit.customclients.sync.syncforreddit.api + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patches.reddit.customclients.spoofClientPatch +import app.revanced.patches.reddit.customclients.sync.detection.piracy.disablePiracyDetectionPatch +import app.revanced.util.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.reference.StringReference +import java.util.* + +@Suppress("unused") +val spoofClientPatch = spoofClientPatch( + redirectUri = "http://redditsync/auth", +) { clientIdOption -> + dependsOn(disablePiracyDetectionPatch) + + compatibleWith( + "com.laurencedawson.reddit_sync", + "com.laurencedawson.reddit_sync.pro", + "com.laurencedawson.reddit_sync.dev", + ) + + val imgurImageAPIMatch by imgurImageAPIFingerprint() + val getAuthorizationStringMatch by getAuthorizationStringFingerprint() + val getUserAgentMatch by getUserAgentFingerprint() + + val clientId by clientIdOption + + execute { context -> + // region Patch client id. + + getBearerTokenFingerprint.apply { + match(context, getAuthorizationStringMatch.classDef) + }.matchOrThrow.mutableMethod.apply { + val auth = Base64.getEncoder().encodeToString("$clientId:".toByteArray(Charsets.UTF_8)) + addInstructions( + 0, + """ + const-string v0, "Basic $auth" + return-object v0 + """, + ) + val occurrenceIndex = + getAuthorizationStringMatch.stringMatches!!.first().index + + getAuthorizationStringMatch.mutableMethod.apply { + val authorizationStringInstruction = getInstruction(occurrenceIndex) + val targetRegister = (authorizationStringInstruction as OneRegisterInstruction).registerA + val reference = authorizationStringInstruction.reference as StringReference + + val newAuthorizationUrl = reference.string.replace( + "client_id=.*?&".toRegex(), + "client_id=$clientId&", + ) + + replaceInstruction( + occurrenceIndex, + "const-string v$targetRegister, \"$newAuthorizationUrl\"", + ) + } + } + + // endregion + + // region Patch user agent. + + // Use a random user agent. + val randomName = (0..100000).random() + val userAgent = "$randomName:app.revanced.$randomName:v1.0.0 (by /u/revanced)" + + imgurImageAPIMatch.mutableMethod.replaceInstruction( + 0, + """ + const-string v0, "$userAgent" + return-object v0 + """, + ) + + // endregion + + // region Patch Imgur API URL. + + val apiUrlIndex = getUserAgentMatch.stringMatches!!.first().index + getUserAgentMatch.mutableMethod.replaceInstruction( + apiUrlIndex, + "const-string v1, \"https://api.imgur.com/3/image\"", + ) + + // endregion + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/extension/SharedExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/extension/SharedExtensionPatch.kt new file mode 100644 index 000000000..67f02676f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/extension/SharedExtensionPatch.kt @@ -0,0 +1,6 @@ +package app.revanced.patches.reddit.customclients.sync.syncforreddit.extension + +import app.revanced.patches.reddit.customclients.sync.syncforreddit.extension.hooks.initHook +import app.revanced.patches.shared.misc.extension.sharedExtensionPatch + +val sharedExtensionPatch = sharedExtensionPatch(initHook) diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/extension/hooks/InitHook.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/extension/hooks/InitHook.kt new file mode 100644 index 000000000..00244b4df --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/extension/hooks/InitHook.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.reddit.customclients.sync.syncforreddit.extension.hooks + +import app.revanced.patches.shared.misc.extension.extensionHook + +internal val initHook = extensionHook( + insertIndexResolver = { 1 }, // Insert after call to super class. +) { + custom { method, classDef -> + method.name == "onCreate" && classDef.type == "Lcom/laurencedawson/reddit_sync/RedditApplication;" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/fix/slink/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/fix/slink/Fingerprints.kt new file mode 100644 index 000000000..f7287fcc3 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/fix/slink/Fingerprints.kt @@ -0,0 +1,13 @@ +package app.revanced.patches.reddit.customclients.sync.syncforreddit.fix.slink + +import app.revanced.patcher.fingerprint + +internal val linkHelperOpenLinkFingerprint = fingerprint { + strings("Link title: ") +} + +internal val setAuthorizationHeaderFingerprint = fingerprint { + returns("Ljava/util/HashMap;") + strings("Authorization", "bearer ") + custom { method, _ -> method.definingClass == "Lcom/laurencedawson/reddit_sync/singleton/a;" } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/fix/slink/FixSLinksPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/fix/slink/FixSLinksPatch.kt new file mode 100644 index 000000000..80da3f6bc --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/fix/slink/FixSLinksPatch.kt @@ -0,0 +1,57 @@ +package app.revanced.patches.reddit.customclients.sync.syncforreddit.fix.slink + +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.util.smali.ExternalLabel +import app.revanced.patches.reddit.customclients.RESOLVE_S_LINK_METHOD +import app.revanced.patches.reddit.customclients.SET_ACCESS_TOKEN_METHOD +import app.revanced.patches.reddit.customclients.fixSLinksPatch +import app.revanced.patches.reddit.customclients.sync.syncforreddit.extension.sharedExtensionPatch + +const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/syncforreddit/FixSLinksPatch;" + +@Suppress("unused") +val fixSLinksPatch = fixSLinksPatch( + extensionPatch = sharedExtensionPatch, +) { + compatibleWith( + "com.laurencedawson.reddit_sync", + "com.laurencedawson.reddit_sync.pro", + "com.laurencedawson.reddit_sync.dev", + ) + + val handleNavigationMatch by linkHelperOpenLinkFingerprint() + val setAccessTokenMatch by setAuthorizationHeaderFingerprint() + + execute { + // region Patch navigation handler. + + handleNavigationMatch.mutableMethod.apply { + val urlRegister = "p3" + val tempRegister = "v2" + + addInstructionsWithLabels( + 0, + """ + invoke-static { $urlRegister }, $EXTENSION_CLASS_DESCRIPTOR->$RESOLVE_S_LINK_METHOD + move-result $tempRegister + if-eqz $tempRegister, :continue + return $tempRegister + """, + ExternalLabel("continue", getInstruction(0)), + ) + } + + // endregion + + // region Patch set access token. + + setAccessTokenMatch.mutableMethod.addInstruction( + 0, + "invoke-static { p0 }, $EXTENSION_CLASS_DESCRIPTOR->$SET_ACCESS_TOKEN_METHOD", + ) + + // endregion + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/fix/user/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/fix/user/Fingerprints.kt new file mode 100644 index 000000000..4bac74de7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/fix/user/Fingerprints.kt @@ -0,0 +1,36 @@ +package app.revanced.patches.reddit.customclients.sync.syncforreddit.fix.user + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal fun userEndpointFingerprint(source: String, accessFlags: Set? = null) = fingerprint { + strings("u/") + custom { _, classDef -> classDef.sourceFile == source } + accessFlags(*accessFlags?.toTypedArray() ?: return@fingerprint) +} + +internal val oAuthFriendRequestFingerprint = userEndpointFingerprint( + "OAuthFriendRequest.java", +) + +internal val oAuthUnfriendRequestFingerprint = userEndpointFingerprint( + "OAuthUnfriendRequest.java", +) + +internal val oAuthUserIdRequestFingerprint = userEndpointFingerprint( + "OAuthUserIdRequest.java", +) + +internal val oAuthUserInfoRequestFingerprint = userEndpointFingerprint( + "OAuthUserInfoRequest.java", +) + +internal val oAuthSubredditInfoRequestConstructorFingerprint = userEndpointFingerprint( + "OAuthSubredditInfoRequest.java", + setOf(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR), +) + +internal val oAuthSubredditInfoRequestHelperFingerprint = userEndpointFingerprint( + "OAuthSubredditInfoRequest.java", + setOf(AccessFlags.PRIVATE, AccessFlags.STATIC), +) diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/fix/user/UseUserEndpointPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/fix/user/UseUserEndpointPatch.kt new file mode 100644 index 000000000..a53007045 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/sync/syncforreddit/fix/user/UseUserEndpointPatch.kt @@ -0,0 +1,51 @@ +package app.revanced.patches.reddit.customclients.sync.syncforreddit.fix.user + +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.util.getReference +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.StringReference + +@Suppress("unused") +val useUserEndpointPatch = bytecodePatch( + name = "Use /user/ endpoint", + description = "Replaces the deprecated endpoint for viewing user profiles /u with /user, that used to fix a bug.", + use = false, + +) { + compatibleWith( + "com.laurencedawson.reddit_sync", + "com.laurencedawson.reddit_sync.pro", + "com.laurencedawson.reddit_sync.dev", + ) + + val oAuthFriendRequestMatch by oAuthFriendRequestFingerprint() + val oAuthSubredditInfoRequestConstructorMatch by oAuthSubredditInfoRequestConstructorFingerprint() + val oAuthSubredditInfoRequestHelperMatch by oAuthSubredditInfoRequestHelperFingerprint() + val oAuthUnfriendRequestMatch by oAuthUnfriendRequestFingerprint() + val oAuthUserIdRequestMatch by oAuthUserIdRequestFingerprint() + val oAuthUserInfoRequestMatch by oAuthUserInfoRequestFingerprint() + + execute { + arrayOf( + oAuthFriendRequestMatch, + oAuthSubredditInfoRequestConstructorMatch, + oAuthSubredditInfoRequestHelperMatch, + oAuthUnfriendRequestMatch, + oAuthUserIdRequestMatch, + oAuthUserInfoRequestMatch, + ).map { it.stringMatches!!.first().index to it.mutableMethod }.forEach { (userPathStringIndex, method) -> + val userPathStringInstruction = method.getInstruction(userPathStringIndex) + + val userPathStringRegister = userPathStringInstruction.registerA + val fixedUserPathString = userPathStringInstruction.getReference()!! + .string.replace("u/", "user/") + + method.replaceInstruction( + userPathStringIndex, + "const-string v$userPathStringRegister, \"${fixedUserPathString}\"", + ) + } + } +} diff --git a/src/main/kotlin/app/revanced/patches/reddit/customclients/syncforreddit/fix/video/fingerprints/ParseRedditVideoNetworkResponseFingerprint.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/syncforreddit/fix/video/Fingerprints.kt similarity index 53% rename from src/main/kotlin/app/revanced/patches/reddit/customclients/syncforreddit/fix/video/fingerprints/ParseRedditVideoNetworkResponseFingerprint.kt rename to patches/src/main/kotlin/app/revanced/patches/reddit/customclients/syncforreddit/fix/video/Fingerprints.kt index 1a06cd54d..3123563ee 100644 --- a/src/main/kotlin/app/revanced/patches/reddit/customclients/syncforreddit/fix/video/fingerprints/ParseRedditVideoNetworkResponseFingerprint.kt +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/syncforreddit/fix/video/Fingerprints.kt @@ -1,16 +1,16 @@ -package app.revanced.patches.reddit.customclients.syncforreddit.fix.video.fingerprints +package app.revanced.patches.reddit.customclients.syncforreddit.fix.video -import app.revanced.patcher.fingerprint.MethodFingerprint +import app.revanced.patcher.fingerprint import com.android.tools.smali.dexlib2.Opcode -internal object ParseRedditVideoNetworkResponseFingerprint : MethodFingerprint( - opcodes = listOf( +internal val parseRedditVideoNetworkResponseFingerprint = fingerprint { + opcodes( Opcode.NEW_INSTANCE, Opcode.IGET_OBJECT, Opcode.INVOKE_DIRECT, - Opcode.CONST_WIDE_32 - ), - customFingerprint = { methodDef, classDef -> + Opcode.CONST_WIDE_32, + ) + custom { methodDef, classDef -> classDef.sourceFile == "RedditVideoRequest.java" && methodDef.name == "parseNetworkResponse" } -) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/syncforreddit/fix/video/FixVideoDownloadsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/syncforreddit/fix/video/FixVideoDownloadsPatch.kt new file mode 100644 index 000000000..a43e13fe6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/customclients/syncforreddit/fix/video/FixVideoDownloadsPatch.kt @@ -0,0 +1,57 @@ +package app.revanced.patches.reddit.customclients.syncforreddit.fix.video + +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.customclients.sync.syncforreddit.extension.sharedExtensionPatch +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/syncforreddit/FixRedditVideoDownloadPatch;" +private const val GET_LINKS_METHOD = "getLinks([B)[Ljava/lang/String;" + +@Suppress("unused") +val fixVideoDownloadsPatch = bytecodePatch( + name = "Fix video downloads", + description = "Fixes a bug in Sync's MPD parser resulting in only the audio-track being saved.", +) { + dependsOn(sharedExtensionPatch) + + compatibleWith( + "com.laurencedawson.reddit_sync", + "com.laurencedawson.reddit_sync.pro", + "com.laurencedawson.reddit_sync.dev", + ) + + val parseRedditVideoNetworkResponseMatch by parseRedditVideoNetworkResponseFingerprint() + + execute { + val scanResult = parseRedditVideoNetworkResponseMatch.patternMatch!! + val newInstanceIndex = scanResult.startIndex + val invokeDirectIndex = scanResult.endIndex - 1 + + val buildResponseInstruction = parseRedditVideoNetworkResponseMatch.mutableMethod.getInstruction(invokeDirectIndex) + + parseRedditVideoNetworkResponseMatch.mutableMethod.addInstructions( + newInstanceIndex + 1, + """ + # Get byte array from response. + iget-object v2, p1, Lcom/android/volley/NetworkResponse;->data:[B + + # Parse the videoUrl and audioUrl from the byte array. + invoke-static { v2 }, $EXTENSION_CLASS_DESCRIPTOR->$GET_LINKS_METHOD + move-result-object v2 + + # Get videoUrl (Index 0). + const/4 v5, 0x0 + aget-object v${buildResponseInstruction.registerE}, v2, v5 + + # Get audioUrl (Index 1). + const/4 v6, 0x1 + aget-object v${buildResponseInstruction.registerF}, v2, v6 + + # Register E and F are used to build the response. + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/disablescreenshotpopup/DisableScreenshotPopupPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/disablescreenshotpopup/DisableScreenshotPopupPatch.kt new file mode 100644 index 000000000..116205a34 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/disablescreenshotpopup/DisableScreenshotPopupPatch.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.reddit.layout.disablescreenshotpopup + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val disableScreenshotPopupPatch = bytecodePatch( + name = "Disable screenshot popup", + description = "Disables the popup that shows up when taking a screenshot.", +) { + compatibleWith("com.reddit.frontpage") + + val disableScreenshotPopupMatch by disableScreenshotPopupFingerprint() + + execute { + disableScreenshotPopupMatch.mutableMethod.addInstruction(0, "return-void") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/disablescreenshotpopup/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/disablescreenshotpopup/Fingerprints.kt new file mode 100644 index 000000000..09fe16247 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/disablescreenshotpopup/Fingerprints.kt @@ -0,0 +1,15 @@ +package app.revanced.patches.reddit.layout.disablescreenshotpopup + +import app.revanced.patcher.fingerprint + +internal val disableScreenshotPopupFingerprint = fingerprint { + returns("V") + parameters("Landroidx/compose/runtime/", "I") + custom { method, classDef -> + if (!classDef.endsWith("\$ScreenshotTakenBannerKt\$lambda-1\$1;")) { + return@custom false + } + + method.name == "invoke" + } +} 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 000000000..2eac1cbe2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/premiumicon/Fingerprints.kt @@ -0,0 +1,10 @@ +package app.revanced.patches.reddit.layout.premiumicon + +import app.revanced.patcher.fingerprint + +internal val hasPremiumIconAccessFingerprint = fingerprint { + returns("Z") + custom { method, classDef -> + classDef.endsWith("MyAccount;") && method.name == "isPremiumSubscriber" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/layout/premiumicon/UnlockPremiumIconPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/premiumicon/UnlockPremiumIconPatch.kt new file mode 100644 index 000000000..8a3a0f810 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/layout/premiumicon/UnlockPremiumIconPatch.kt @@ -0,0 +1,24 @@ +package app.revanced.patches.reddit.layout.premiumicon + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val unlockPremiumIconPatch = bytecodePatch( + name = "Unlock premium Reddit icons", + description = "Unlocks the premium Reddit icons.", +) { + compatibleWith("com.reddit.frontpage") + + val hasPremiumIconAccessMatch by hasPremiumIconAccessFingerprint() + + execute { + hasPremiumIconAccessMatch.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/reddit/misc/extension/ExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/reddit/misc/extension/ExtensionPatch.kt new file mode 100644 index 000000000..db449a93e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/misc/extension/ExtensionPatch.kt @@ -0,0 +1,5 @@ +package app.revanced.patches.reddit.misc.extension + +import app.revanced.patches.shared.misc.extension.sharedExtensionPatch + +val sharedExtensionPatch = sharedExtensionPatch() 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 000000000..3381fd2bb --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/misc/tracking/url/Fingerprints.kt @@ -0,0 +1,9 @@ +package app.revanced.patches.reddit.misc.tracking.url + +import app.revanced.patcher.fingerprint + +internal val shareLinkFormatterFingerprint = fingerprint { + custom { _, classDef -> + classDef.startsWith("Lcom/reddit/sharing/") && classDef.sourceFile == "UrlUtil.kt" + } +} \ No newline at end of file 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 000000000..fb14ca80c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/reddit/misc/tracking/url/SanitizeUrlQueryPatch.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.reddit.misc.tracking.url + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val sanitizeUrlQueryPatch = bytecodePatch( + name = "Sanitize sharing links", + description = "Removes (tracking) query parameters from the URLs when sharing links.", +) { + compatibleWith("com.reddit.frontpage") + + val shareLinkFormatterMatch by shareLinkFormatterFingerprint() + + execute { + shareLinkFormatterMatch.mutableMethod.addInstructions( + 0, + "return-object p0", + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/serviceportalbund/detection/root/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/serviceportalbund/detection/root/Fingerprints.kt new file mode 100644 index 000000000..f7efe3103 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/serviceportalbund/detection/root/Fingerprints.kt @@ -0,0 +1,12 @@ +package app.revanced.patches.serviceportalbund.detection.root + +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val rootDetectionFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC) + returns("V") + custom { _, classDef -> + classDef.endsWith("/DeviceIntegrityCheck;") + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/serviceportalbund/detection/root/RootDetectionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/serviceportalbund/detection/root/RootDetectionPatch.kt new file mode 100644 index 000000000..40723ca27 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/serviceportalbund/detection/root/RootDetectionPatch.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.serviceportalbund.detection.root + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val rootDetectionPatch = bytecodePatch( + name = "Remove root detection", + description = "Removes the check for root permissions and unlocked bootloader.", +) { + compatibleWith("at.gv.bka.serviceportal") + + val rootDetectionMatch by rootDetectionFingerprint() + + execute { + rootDetectionMatch.mutableMethod.addInstruction(0, "return-void") + } +} 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 000000000..df927dd4a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/Fingerprints.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.shared + +import app.revanced.patcher.fingerprint + +internal val castContextFetchFingerprint = fingerprint { + strings("Error fetching CastContext.") +} + +internal val primeMethodFingerprint = fingerprint { + strings("com.google.android.GoogleCamera", "com.android.vending") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/misc/checks/BaseCheckEnvironmentPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/checks/BaseCheckEnvironmentPatch.kt new file mode 100644 index 000000000..8c3066c91 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/misc/checks/BaseCheckEnvironmentPatch.kt @@ -0,0 +1,111 @@ +package app.revanced.patches.shared.misc.checks + +import android.os.Build.* +import app.revanced.patcher.Fingerprint +import app.revanced.patcher.Match +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.Patch +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.encodedValue.MutableEncodedValue +import app.revanced.patcher.util.proxy.mutableTypes.encodedValue.MutableLongEncodedValue +import app.revanced.patcher.util.proxy.mutableTypes.encodedValue.MutableStringEncodedValue +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import com.android.tools.smali.dexlib2.immutable.value.ImmutableLongEncodedValue +import com.android.tools.smali.dexlib2.immutable.value.ImmutableStringEncodedValue +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/shared/checks/CheckEnvironmentPatch;" + +fun checkEnvironmentPatch( + mainActivityOnCreateFingerprint: Fingerprint, + extensionPatch: Patch<*>, + vararg compatiblePackages: String, +) = bytecodePatch( + description = "Checks, if the application was patched by, otherwise warns the user.", +) { + compatibleWith(*compatiblePackages) + + dependsOn( + extensionPatch, + addResourcesPatch, + ) + + val patchInfoMatch by patchInfoFingerprint() + val patchInfoBuildMatch by patchInfoBuildFingerprint() + val mainActivityOnCreateMatch by mainActivityOnCreateFingerprint() + + execute { + addResources("shared", "misc.checks.checkEnvironmentPatch") + + fun setPatchInfo() { + patchInfoMatch.setClassFields( + "PATCH_TIME" to System.currentTimeMillis().encoded, + ) + + fun setBuildInfo() { + patchInfoBuildMatch.setClassFields( + "PATCH_BOARD" to BOARD.encodedAndHashed, + "PATCH_BOOTLOADER" to BOOTLOADER.encodedAndHashed, + "PATCH_BRAND" to BRAND.encodedAndHashed, + "PATCH_CPU_ABI" to CPU_ABI.encodedAndHashed, + "PATCH_CPU_ABI2" to CPU_ABI2.encodedAndHashed, + "PATCH_DEVICE" to DEVICE.encodedAndHashed, + "PATCH_DISPLAY" to DISPLAY.encodedAndHashed, + "PATCH_FINGERPRINT" to FINGERPRINT.encodedAndHashed, + "PATCH_HARDWARE" to HARDWARE.encodedAndHashed, + "PATCH_HOST" to HOST.encodedAndHashed, + "PATCH_ID" to ID.encodedAndHashed, + "PATCH_MANUFACTURER" to MANUFACTURER.encodedAndHashed, + "PATCH_MODEL" to MODEL.encodedAndHashed, + "PATCH_PRODUCT" to PRODUCT.encodedAndHashed, + "PATCH_RADIO" to RADIO.encodedAndHashed, + "PATCH_TAGS" to TAGS.encodedAndHashed, + "PATCH_TYPE" to TYPE.encodedAndHashed, + "PATCH_USER" to USER.encodedAndHashed, + ) + } + + try { + Class.forName("android.os.Build") + // This only works on Android, + // because it uses Android APIs. + setBuildInfo() + } catch (_: ClassNotFoundException) { + } + } + + fun invokeCheck() = mainActivityOnCreateMatch.mutableMethod?.addInstructions( + 0, + "invoke-static/range { p0 .. p0 },$EXTENSION_CLASS_DESCRIPTOR->check(Landroid/app/Activity;)V", + ) + + setPatchInfo() + invokeCheck() + } +} + +@OptIn(ExperimentalEncodingApi::class) +private val String.encodedAndHashed + get() = MutableStringEncodedValue( + ImmutableStringEncodedValue( + Base64.encode( + MessageDigest.getInstance("SHA-1") + .digest(this.toByteArray(StandardCharsets.UTF_8)), + ), + ), + ) + +private val Long.encoded get() = MutableLongEncodedValue(ImmutableLongEncodedValue(this)) + +private fun Match.setClassFields(vararg fieldNameValues: Pair) { + val fieldNameValueMap = mapOf(*fieldNameValues) + + mutableClass.fields.forEach { field -> + field.initialValue = fieldNameValueMap[field.name] ?: return@forEach + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/misc/checks/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/checks/Fingerprints.kt new file mode 100644 index 000000000..0eabd2f54 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/misc/checks/Fingerprints.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.shared.misc.checks + +import app.revanced.patcher.fingerprint + +internal val patchInfoFingerprint = fingerprint { + custom { _, classDef -> classDef.type == "Lapp/revanced/extension/shared/checks/PatchInfo;" } +} + +internal val patchInfoBuildFingerprint = fingerprint { + custom { _, classDef -> classDef.type == "Lapp/revanced/extension/shared/checks/PatchInfo\$Build;" } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/misc/extension/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/extension/Fingerprints.kt new file mode 100644 index 000000000..58cc5082f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/misc/extension/Fingerprints.kt @@ -0,0 +1,13 @@ +package app.revanced.patches.shared.misc.extension + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val revancedUtilsPatchesVersionFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("Ljava/lang/String;") + parameters() + custom { method, _ -> + method.name == "getPatchesReleaseVersion" && method.definingClass == EXTENSION_CLASS_DESCRIPTOR + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/misc/extension/SharedExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/extension/SharedExtensionPatch.kt new file mode 100644 index 000000000..3d7a1e444 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/misc/extension/SharedExtensionPatch.kt @@ -0,0 +1,100 @@ +package app.revanced.patches.shared.misc.extension + +import app.revanced.patcher.Fingerprint +import app.revanced.patcher.FingerprintBuilder +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.fingerprint +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.util.exception +import com.android.tools.smali.dexlib2.iface.Method +import java.net.URLDecoder +import java.util.jar.JarFile + +internal const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/shared/Utils;" + +fun sharedExtensionPatch( + vararg hooks: ExtensionHook, +) = bytecodePatch { + extendWith("extensions/shared.rve") + + val revancedUtilsPatchesVersionMatch by revancedUtilsPatchesVersionFingerprint() + hooks.forEach { it.fingerprint() } + + execute { context -> + if (context.classBy { EXTENSION_CLASS_DESCRIPTOR in it.type } == null) { + throw PatchException( + "Shared extension has not been merged yet. This patch can not succeed without merging it.", + ) + } + + hooks.forEach { hook -> hook(EXTENSION_CLASS_DESCRIPTOR) } + + // Modify Utils method to include the patches release version. + revancedUtilsPatchesVersionMatch.mutableMethod.apply { + /** + * @return The file path for the jar this classfile is contained inside. + */ + fun getCurrentJarFilePath(): String { + val className = object {}::class.java.enclosingClass.name.replace('.', '/') + ".class" + val classUrl = object {}::class.java.classLoader.getResource(className) + if (classUrl != null) { + val urlString = classUrl.toString() + + if (urlString.startsWith("jar:file:")) { + val end = urlString.lastIndexOf('!') + + return URLDecoder.decode(urlString.substring("jar:file:".length, end), "UTF-8") + } + } + throw IllegalStateException("Not running from inside a JAR file.") + } + + /** + * @return The value for the manifest entry, + * or "Unknown" if the entry does not exist or is blank. + */ + @Suppress("SameParameterValue") + fun getPatchesManifestEntry(attributeKey: String) = JarFile(getCurrentJarFilePath()).use { jarFile -> + jarFile.manifest.mainAttributes.entries.firstOrNull { it.key.toString() == attributeKey }?.value?.toString() + ?: "Unknown" + } + + val manifestValue = getPatchesManifestEntry("Version") + + addInstructions( + 0, + """ + const-string v0, "$manifestValue" + return-object v0 + """, + ) + } + } +} + +class ExtensionHook internal constructor( + val fingerprint: Fingerprint, + private val insertIndexResolver: ((Method) -> Int), + private val contextRegisterResolver: (Method) -> Int, +) { + operator fun invoke(extensionClassDescriptor: String) { + fingerprint.match?.mutableMethod?.let { method -> + val insertIndex = insertIndexResolver(method) + val contextRegister = contextRegisterResolver(method) + + method.addInstruction( + insertIndex, + "invoke-static/range { v$contextRegister .. v$contextRegister }, " + + "$extensionClassDescriptor->setContext(Landroid/content/Context;)V", + ) + } ?: throw fingerprint.exception + } +} + +fun extensionHook( + insertIndexResolver: ((Method) -> Int) = { 0 }, + contextRegisterResolver: (Method) -> Int = { it.implementation!!.registerCount - 1 }, + fingerprintBuilderBlock: FingerprintBuilder.() -> Unit, +) = ExtensionHook(fingerprint(block = fingerprintBuilderBlock), insertIndexResolver, contextRegisterResolver) diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/misc/fix/verticalscroll/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/fix/verticalscroll/Fingerprints.kt new file mode 100644 index 000000000..80d041ada --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/misc/fix/verticalscroll/Fingerprints.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.shared.misc.fix.verticalscroll + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val canScrollVerticallyFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Z") + parameters() + opcodes( + Opcode.MOVE_RESULT, + Opcode.RETURN, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT + ) + custom { _, classDef -> classDef.endsWith("SwipeRefreshLayout;") } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/misc/fix/verticalscroll/VerticalScrollPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/fix/verticalscroll/VerticalScrollPatch.kt new file mode 100644 index 000000000..2ebb6a234 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/misc/fix/verticalscroll/VerticalScrollPatch.kt @@ -0,0 +1,26 @@ +package app.revanced.patches.shared.misc.fix.verticalscroll + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +@Suppress("unused") +val verticalScrollPatch = bytecodePatch( + description = "Fixes issues with refreshing the feed when the first component is of type EmptyComponent.", +) { + val canScrollVerticallyMatch by canScrollVerticallyFingerprint() + + execute { + canScrollVerticallyMatch.mutableMethod.apply { + val moveResultIndex = canScrollVerticallyMatch.patternMatch!!.endIndex + val moveResultRegister = getInstruction(moveResultIndex).registerA + + val insertIndex = moveResultIndex + 1 + addInstruction( + insertIndex, + "const/4 v$moveResultRegister, 0x0", + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/misc/gms/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/gms/Fingerprints.kt new file mode 100644 index 000000000..b5f613d54 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/misc/gms/Fingerprints.kt @@ -0,0 +1,30 @@ +package app.revanced.patches.shared.misc.gms + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +const val GET_GMS_CORE_VENDOR_GROUP_ID_METHOD_NAME = "getGmsCoreVendorGroupId" + +internal val gmsCoreSupportFingerprint = fingerprint { + custom { _, classDef -> + classDef.endsWith("GmsCoreSupport;") + } +} + +internal val googlePlayUtilityFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("I") + parameters("L", "I") + strings( + "This should never happen.", + "MetadataValueReader", + "com.google.android.gms", + ) +} + +internal val serviceCheckFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("V") + parameters("L", "I") + strings("Google Play Services not available") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/misc/gms/GmsCoreSupportPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/gms/GmsCoreSupportPatch.kt new file mode 100644 index 000000000..c338385bf --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/misc/gms/GmsCoreSupportPatch.kt @@ -0,0 +1,614 @@ +package app.revanced.patches.shared.misc.gms + +import app.revanced.patcher.Fingerprint +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.instructions +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.* +import app.revanced.patches.all.misc.packagename.changePackageNamePatch +import app.revanced.patches.all.misc.packagename.setOrGetFallbackPackageName +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.gms.Constants.ACTIONS +import app.revanced.patches.shared.misc.gms.Constants.AUTHORITIES +import app.revanced.patches.shared.misc.gms.Constants.PERMISSIONS +import app.revanced.util.exception +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.returnEarly +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.builder.instruction.BuilderInstruction21c +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +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 PACKAGE_NAME_REGEX_PATTERN = "^[a-z]\\w*(\\.[a-z]\\w*)+\$" + +/** + * 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 toPackageName The package name to fall back to if no custom package name is specified in patch options. + * @param primeMethodFingerprint The fingerprint of the "prime" method that needs to be patched. + * @param earlyReturnFingerprints The fingerprints of methods that need to be returned early. + * @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, + toPackageName: String, + primeMethodFingerprint: Fingerprint? = null, + earlyReturnFingerprints: Set = setOf(), + mainActivityOnCreateFingerprint: Fingerprint, + extensionPatch: Patch<*>, + gmsCoreSupportResourcePatchFactory: (gmsCoreVendorGroupIdOption: Option) -> Patch<*>, + executeBlock: Patch.(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)) } + + dependsOn( + changePackageNamePatch, + gmsCoreSupportResourcePatchFactory(gmsCoreVendorGroupIdOption), + extensionPatch, + ) + + val gmsCoreVendorGroupId by gmsCoreVendorGroupIdOption + + val gmsCoreSupportMatch by gmsCoreSupportFingerprint() + val mainActivityOnCreateMatch by mainActivityOnCreateFingerprint() + primeMethodFingerprint?.invoke() + googlePlayUtilityFingerprint() + serviceCheckFingerprint() + earlyReturnFingerprints.forEach { it() } + + execute { context -> + fun transformStringReferences(transform: (str: String) -> String?) = context.classes.forEach { + val mutableClass by lazy { + context.proxy(it).mutableClass + } + + it.methods.forEach classLoop@{ method -> + val implementation = method.implementation ?: return@classLoop + + val mutableMethod by lazy { + mutableClass.methods.first { MethodUtil.methodSignaturesMatch(it, 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(packageName: String) { + primeMethodFingerprint!!.match?.mutableMethod?.apply { + var register = 2 + + val index = instructions.indexOfFirst { + if (it.getReference()?.string != fromPackageName) return@indexOfFirst false + + register = (it as OneRegisterInstruction).registerA + return@indexOfFirst true + } + + replaceInstruction(index, "const-string v$register, \"$packageName\"") + } ?: throw primeMethodFingerprint.exception + } + + // endregion + + val packageName = setOrGetFallbackPackageName(toPackageName) + + // 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 + } + + // Specific method that needs to be patched. + primeMethodFingerprint?.let { transformPrimeMethod(packageName) } + + // Return these methods early to prevent the app from crashing. + earlyReturnFingerprints.returnEarly() + serviceCheckFingerprint.returnEarly() + + // Google Play Utility is not present in all apps, so we need to check if it's present. + if (googlePlayUtilityFingerprint.match != null) { + googlePlayUtilityFingerprint.returnEarly() + } + + // Verify GmsCore is installed and whitelisted for power optimizations and background usage. + mainActivityOnCreateMatch.mutableMethod.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. + addInstructions( + if (setContextIndex < 0) 0 else setContextIndex + 1, + "invoke-static/range { p0 .. p0 }, Lapp/revanced/extension/shared/GmsCoreSupport;->" + + "checkGmsCore(Landroid/app/Activity;)V", + ) + } + + // Change the vendor of GmsCore in the extension. + gmsCoreSupportMatch.mutableClass.methods + .single { it.name == GET_GMS_CORE_VENDOR_GROUP_ID_METHOD_NAME } + .replaceInstruction(0, "const-string v0, \"$gmsCoreVendorGroupId\"") + + executeBlock(context) + } + + 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( + "com.google.android.c2dm.permission.RECEIVE", + "com.google.android.c2dm.permission.SEND", + "com.google.android.gms.auth.api.phone.permission.SEND", + "com.google.android.gms.permission.AD_ID", + "com.google.android.gms.permission.AD_ID_NOTIFICATION", + "com.google.android.gms.permission.CAR_FUEL", + "com.google.android.gms.permission.CAR_INFORMATION", + "com.google.android.gms.permission.CAR_MILEAGE", + "com.google.android.gms.permission.CAR_SPEED", + "com.google.android.gms.permission.CAR_VENDOR_EXTENSION", + "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", + "com.google.android.gtalkservice.permission.GTALK_SERVICE", + "com.google.android.providers.gsf.permission.READ_GSERVICES", + ) + + /** + * All intent actions. + */ + val ACTIONS = setOf( + "com.google.android.c2dm.intent.RECEIVE", + "com.google.android.c2dm.intent.REGISTER", + "com.google.android.c2dm.intent.REGISTRATION", + "com.google.android.c2dm.intent.UNREGISTER", + "com.google.android.contextmanager.service.ContextManagerService.START", + "com.google.android.gcm.intent.SEND", + "com.google.android.gms.accounts.ACCOUNT_SERVICE", + "com.google.android.gms.accountsettings.ACCOUNT_PREFERENCES_SETTINGS", + "com.google.android.gms.accountsettings.action.BROWSE_SETTINGS", + "com.google.android.gms.accountsettings.action.VIEW_SETTINGS", + "com.google.android.gms.accountsettings.MY_ACCOUNT", + "com.google.android.gms.accountsettings.PRIVACY_SETTINGS", + "com.google.android.gms.accountsettings.SECURITY_SETTINGS", + "com.google.android.gms.ads.gservice.START", + "com.google.android.gms.ads.identifier.service.EVENT_ATTESTATION", + "com.google.android.gms.ads.service.CACHE", + "com.google.android.gms.ads.service.CONSENT_LOOKUP", + "com.google.android.gms.ads.service.HTTP", + "com.google.android.gms.analytics.service.START", + "com.google.android.gms.app.settings.GoogleSettingsLink", + "com.google.android.gms.appstate.service.START", + "com.google.android.gms.appusage.service.START", + "com.google.android.gms.asterism.service.START", + "com.google.android.gms.audiomodem.service.AudioModemService.START", + "com.google.android.gms.audit.service.START", + "com.google.android.gms.auth.account.authapi.START", + "com.google.android.gms.auth.account.authenticator.auto.service.START", + "com.google.android.gms.auth.account.authenticator.chromeos.START", + "com.google.android.gms.auth.account.authenticator.tv.service.START", + "com.google.android.gms.auth.account.data.service.START", + "com.google.android.gms.auth.api.credentials.PICKER", + "com.google.android.gms.auth.api.credentials.service.START", + "com.google.android.gms.auth.api.identity.service.authorization.START", + "com.google.android.gms.auth.api.identity.service.credentialsaving.START", + "com.google.android.gms.auth.api.identity.service.signin.START", + "com.google.android.gms.auth.api.phone.service.InternalService.START", + "com.google.android.gms.auth.api.signin.service.START", + "com.google.android.gms.auth.be.appcert.AppCertService", + "com.google.android.gms.auth.blockstore.service.START", + "com.google.android.gms.auth.config.service.START", + "com.google.android.gms.auth.cryptauth.cryptauthservice.START", + "com.google.android.gms.auth.GOOGLE_SIGN_IN", + "com.google.android.gms.auth.login.LOGIN", + "com.google.android.gms.auth.proximity.devicesyncservice.START", + "com.google.android.gms.auth.proximity.securechannelservice.START", + "com.google.android.gms.auth.proximity.START", + "com.google.android.gms.auth.service.START", + "com.google.android.gms.backup.ACTION_BACKUP_SETTINGS", + "com.google.android.gms.backup.G1_BACKUP", + "com.google.android.gms.backup.G1_RESTORE", + "com.google.android.gms.backup.GMS_MODULE_RESTORE", + "com.google.android.gms.beacon.internal.IBleService.START", + "com.google.android.gms.car.service.START", + "com.google.android.gms.carrierauth.service.START", + "com.google.android.gms.cast.firstparty.START", + "com.google.android.gms.cast.remote_display.service.START", + "com.google.android.gms.cast.service.BIND_CAST_DEVICE_CONTROLLER_SERVICE", + "com.google.android.gms.cast_mirroring.service.START", + "com.google.android.gms.checkin.BIND_TO_SERVICE", + "com.google.android.gms.chromesync.service.START", + "com.google.android.gms.clearcut.service.START", + "com.google.android.gms.common.account.CHOOSE_ACCOUNT", + "com.google.android.gms.common.download.START", + "com.google.android.gms.common.service.START", + "com.google.android.gms.common.telemetry.service.START", + "com.google.android.gms.config.START", + "com.google.android.gms.constellation.service.START", + "com.google.android.gms.credential.manager.service.firstparty.START", + "com.google.android.gms.deviceconnection.service.START", + "com.google.android.gms.drive.ApiService.RESET_AFTER_BOOT", + "com.google.android.gms.drive.ApiService.START", + "com.google.android.gms.drive.ApiService.STOP", + "com.google.android.gms.droidguard.service.INIT", + "com.google.android.gms.droidguard.service.PING", + "com.google.android.gms.droidguard.service.START", + "com.google.android.gms.enterprise.loader.service.START", + "com.google.android.gms.facs.cache.service.START", + "com.google.android.gms.facs.internal.service.START", + "com.google.android.gms.feedback.internal.IFeedbackService", + "com.google.android.gms.fido.credentialstore.internal_service.START", + "com.google.android.gms.fido.fido2.privileged.START", + "com.google.android.gms.fido.fido2.regular.START", + "com.google.android.gms.fido.fido2.zeroparty.START", + "com.google.android.gms.fido.sourcedevice.service.START", + "com.google.android.gms.fido.targetdevice.internal_service.START", + "com.google.android.gms.fido.u2f.privileged.START", + "com.google.android.gms.fido.u2f.thirdparty.START", + "com.google.android.gms.fido.u2f.zeroparty.START", + "com.google.android.gms.fitness.BleApi", + "com.google.android.gms.fitness.ConfigApi", + "com.google.android.gms.fitness.GoalsApi", + "com.google.android.gms.fitness.GoogleFitnessService.START", + "com.google.android.gms.fitness.HistoryApi", + "com.google.android.gms.fitness.InternalApi", + "com.google.android.gms.fitness.RecordingApi", + "com.google.android.gms.fitness.SensorsApi", + "com.google.android.gms.fitness.SessionsApi", + "com.google.android.gms.fonts.service.START", + "com.google.android.gms.freighter.service.START", + "com.google.android.gms.games.internal.connect.service.START", + "com.google.android.gms.games.PLAY_GAMES_UPGRADE", + "com.google.android.gms.games.service.START", + "com.google.android.gms.gass.START", + "com.google.android.gms.gmscompliance.service.START", + "com.google.android.gms.googlehelp.HELP", + "com.google.android.gms.googlehelp.service.GoogleHelpService.START", + "com.google.android.gms.growth.service.START", + "com.google.android.gms.herrevad.services.LightweightNetworkQualityAndroidService.START", + "com.google.android.gms.icing.INDEX_SERVICE", + "com.google.android.gms.icing.LIGHTWEIGHT_INDEX_SERVICE", + "com.google.android.gms.identity.service.BIND", + "com.google.android.gms.inappreach.service.START", + "com.google.android.gms.instantapps.START", + "com.google.android.gms.kids.service.START", + "com.google.android.gms.languageprofile.service.START", + "com.google.android.gms.learning.internal.dynamitesupport.START", + "com.google.android.gms.learning.intservice.START", + "com.google.android.gms.learning.predictor.START", + "com.google.android.gms.learning.trainer.START", + "com.google.android.gms.learning.training.background.START", + "com.google.android.gms.location.places.GeoDataApi", + "com.google.android.gms.location.places.PlaceDetectionApi", + "com.google.android.gms.location.places.PlacesApi", + "com.google.android.gms.location.reporting.service.START", + "com.google.android.gms.location.settings.LOCATION_HISTORY", + "com.google.android.gms.location.settings.LOCATION_REPORTING_SETTINGS", + "com.google.android.gms.locationsharing.api.START", + "com.google.android.gms.locationsharingreporter.service.START", + "com.google.android.gms.lockbox.service.START", + "com.google.android.gms.matchstick.lighter.service.START", + "com.google.android.gms.mdm.services.DeviceManagerApiService.START", + "com.google.android.gms.mdm.services.START", + "com.google.android.gms.mdns.service.START", + "com.google.android.gms.measurement.START", + "com.google.android.gms.nearby.bootstrap.service.NearbyBootstrapService.START", + "com.google.android.gms.nearby.connection.service.START", + "com.google.android.gms.nearby.fastpair.START", + "com.google.android.gms.nearby.messages.service.NearbyMessagesService.START", + "com.google.android.gms.nearby.sharing.service.NearbySharingService.START", + "com.google.android.gms.nearby.sharing.START_SERVICE", + "com.google.android.gms.notifications.service.START", + "com.google.android.gms.ocr.service.internal.START", + "com.google.android.gms.ocr.service.START", + "com.google.android.gms.oss.licenses.service.START", + "com.google.android.gms.payse.service.BIND", + "com.google.android.gms.people.contactssync.service.START", + "com.google.android.gms.people.service.START", + "com.google.android.gms.phenotype.service.START", + "com.google.android.gms.photos.autobackup.service.START", + "com.google.android.gms.playlog.service.START", + "com.google.android.gms.plus.service.default.INTENT", + "com.google.android.gms.plus.service.image.INTENT", + "com.google.android.gms.plus.service.internal.START", + "com.google.android.gms.plus.service.START", + "com.google.android.gms.potokens.service.START", + "com.google.android.gms.pseudonymous.service.START", + "com.google.android.gms.rcs.START", + "com.google.android.gms.reminders.service.START", + "com.google.android.gms.romanesco.MODULE_BACKUP_AGENT", + "com.google.android.gms.romanesco.service.START", + "com.google.android.gms.safetynet.service.START", + "com.google.android.gms.scheduler.ACTION_PROXY_SCHEDULE", + "com.google.android.gms.search.service.SEARCH_AUTH_START", + "com.google.android.gms.semanticlocation.service.START_ODLH", + "com.google.android.gms.sesame.service.BIND", + "com.google.android.gms.settings.EXPOSURE_NOTIFICATION_SETTINGS", + "com.google.android.gms.setup.auth.SecondDeviceAuth.START", + "com.google.android.gms.signin.service.START", + "com.google.android.gms.smartdevice.d2d.SourceDeviceService.START", + "com.google.android.gms.smartdevice.d2d.TargetDeviceService.START", + "com.google.android.gms.smartdevice.directtransfer.SourceDirectTransferService.START", + "com.google.android.gms.smartdevice.directtransfer.TargetDirectTransferService.START", + "com.google.android.gms.smartdevice.postsetup.PostSetupService.START", + "com.google.android.gms.smartdevice.setup.accounts.AccountsService.START", + "com.google.android.gms.smartdevice.wifi.START_WIFI_HELPER_SERVICE", + "com.google.android.gms.social.location.activity.service.START", + "com.google.android.gms.speech.service.START", + "com.google.android.gms.statementservice.EXECUTE", + "com.google.android.gms.stats.ACTION_UPLOAD_DROPBOX_ENTRIES", + "com.google.android.gms.tapandpay.service.BIND", + "com.google.android.gms.telephonyspam.service.START", + "com.google.android.gms.testsupport.service.START", + "com.google.android.gms.thunderbird.service.START", + "com.google.android.gms.trustagent.BridgeApi.START", + "com.google.android.gms.trustagent.StateApi.START", + "com.google.android.gms.trustagent.trustlet.trustletmanagerservice.BIND", + "com.google.android.gms.trustlet.bluetooth.service.BIND", + "com.google.android.gms.trustlet.connectionlessble.service.BIND", + "com.google.android.gms.trustlet.face.service.BIND", + "com.google.android.gms.trustlet.nfc.service.BIND", + "com.google.android.gms.trustlet.onbody.service.BIND", + "com.google.android.gms.trustlet.place.service.BIND", + "com.google.android.gms.trustlet.voiceunlock.service.BIND", + "com.google.android.gms.udc.service.START", + "com.google.android.gms.update.START_API_SERVICE", + "com.google.android.gms.update.START_SERVICE", + "com.google.android.gms.update.START_SINGLE_USER_API_SERVICE", + "com.google.android.gms.update.START_TV_API_SERVICE", + "com.google.android.gms.usagereporting.service.START", + "com.google.android.gms.userlocation.service.START", + "com.google.android.gms.vehicle.cabin.service.START", + "com.google.android.gms.vehicle.climate.service.START", + "com.google.android.gms.vehicle.info.service.START", + "com.google.android.gms.wallet.service.BIND", + "com.google.android.gms.walletp2p.service.firstparty.BIND", + "com.google.android.gms.walletp2p.service.zeroparty.BIND", + "com.google.android.gms.wearable.BIND", + "com.google.android.gms.wearable.BIND_LISTENER", + "com.google.android.gms.wearable.DATA_CHANGED", + "com.google.android.gms.wearable.MESSAGE_RECEIVED", + "com.google.android.gms.wearable.NODE_CHANGED", + "com.google.android.gsf.action.GET_GLS", + "com.google.android.location.settings.LOCATION_REPORTING_SETTINGS", + "com.google.android.mdd.service.START", + "com.google.android.mdh.service.listener.START", + "com.google.android.mdh.service.START", + "com.google.android.mobstore.service.START", + "com.google.firebase.auth.api.gms.service.START", + "com.google.firebase.dynamiclinks.service.START", + "com.google.iid.TOKEN_REQUEST", + "com.google.android.gms.location.places.ui.PICK_PLACE", + ) + + /** + * All content provider authorities. + */ + val AUTHORITIES = setOf( + "com.google.android.gms.auth.accounts", + "com.google.android.gms.chimera", + "com.google.android.gms.fonts", + "com.google.android.gms.phenotype", + "com.google.android.gsf.gservices", + "com.google.settings", + ) +} + +/** + * 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 toPackageName The package name to fall back to if no custom package name is specified in patch options. + * @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, + toPackageName: String, + spoofedPackageSignature: String, + gmsCoreVendorGroupIdOption: Option, + executeBlock: Patch.(ResourcePatchContext) -> Unit = {}, + block: ResourcePatchBuilder.() -> Unit = {}, +) = resourcePatch { + dependsOn( + changePackageNamePatch, + addResourcesPatch, + ) + + val gmsCoreVendorGroupId by gmsCoreVendorGroupIdOption + + execute { context -> + addResources("shared", "misc.gms.gmsCoreSupportResourcePatch") + + /** + * 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) + } + + context.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 = setOrGetFallbackPackageName(toPackageName) + + 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", + "" to "", + ) + + val manifest = context["AndroidManifest.xml"] + manifest.writeText( + transformations.entries.fold(manifest.readText()) { acc, (from, to) -> + acc.replace( + from, + to, + ) + }, + ) + } + + patchManifest() + addSpoofingMetadata() + + executeBlock(context) + } + + block() +} diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/misc/hex/HexPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/hex/HexPatch.kt new file mode 100644 index 000000000..e372d926c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/shared/misc/hex/HexPatch.kt @@ -0,0 +1,123 @@ +package app.revanced.patches.shared.misc.hex + +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.rawResourcePatch +import kotlin.math.max + +// The replacements being passed using a function is intended. +// Previously the replacements were a property of the patch. Getter were being delegated to that property. +// This late evaluation was being leveraged in app.revanced.patches.all.misc.hex.HexPatch. +// Without the function, the replacements would be evaluated at the time of patch creation. +// This isn't possible because the delegated property is not accessible at that time. +fun hexPatch(replacementsSupplier: () -> Set) = rawResourcePatch { + execute { context -> + replacementsSupplier().groupBy { it.targetFilePath }.forEach { (targetFilePath, replacements) -> + val targetFile = try { + context[targetFilePath, true] + } catch (e: Exception) { + throw PatchException("Could not find target file: $targetFilePath") + } + + // TODO: Use a file channel to read and write the file instead of reading the whole file into memory, + // in order to reduce memory usage. + val targetFileBytes = targetFile.readBytes() + + replacements.forEach { replacement -> + replacement.replacePattern(targetFileBytes) + } + + targetFile.writeBytes(targetFileBytes) + } + } +} + +/** + * Represents a pattern to search for and its replacement pattern. + * + * @property pattern The pattern to search for. + * @property replacementPattern The pattern to replace the [pattern] with. + * @property targetFilePath The path to the file to make the changes in relative to the APK root. + */ +class Replacement( + private val pattern: String, + replacementPattern: String, + internal val targetFilePath: String, +) { + private val patternBytes = pattern.toByteArrayPattern() + private val replacementPattern = replacementPattern.toByteArrayPattern() + + init { + if (this.patternBytes.size != this.replacementPattern.size) { + throw PatchException("Pattern and replacement pattern must have the same length: $pattern") + } + } + + /** + * Replaces the [patternBytes] with the [replacementPattern] in the [targetFileBytes]. + * + * @param targetFileBytes The bytes of the file to make the changes in. + */ + fun replacePattern(targetFileBytes: ByteArray) { + val startIndex = indexOfPatternIn(targetFileBytes) + + if (startIndex == -1) { + throw PatchException("Pattern not found in target file: $pattern") + } + + replacementPattern.copyInto(targetFileBytes, startIndex) + } + + // TODO: Allow searching in a file channel instead of a byte array to reduce memory usage. + /** + * Returns the index of the first occurrence of [patternBytes] in the haystack + * using the Boyer-Moore algorithm. + * + * @param haystack The array to search in. + * + * @return The index of the first occurrence of the [patternBytes] in the haystack or -1 + * if the [patternBytes] is not found. + */ + private fun indexOfPatternIn(haystack: ByteArray): Int { + val needle = patternBytes + + val haystackLength = haystack.size - 1 + val needleLength = needle.size - 1 + val right = IntArray(256) { -1 } + + for (i in 0 until needleLength) right[needle[i].toInt().and(0xFF)] = i + + var skip: Int + for (i in 0..haystackLength - needleLength) { + skip = 0 + + for (j in needleLength - 1 downTo 0) { + if (needle[j] != haystack[i + j]) { + skip = max(1, j - right[haystack[i + j].toInt().and(0xFF)]) + + break + } + } + + if (skip == 0) return i + } + return -1 + } + + companion object { + /** + * Convert a string representing a pattern of hexadecimal bytes to a byte array. + * + * @return The byte array representing the pattern. + * @throws PatchException If the pattern is invalid. + */ + private fun String.toByteArrayPattern() = try { + split(" ").map { it.toInt(16).toByte() }.toByteArray() + } catch (e: NumberFormatException) { + throw PatchException( + "Could not parse pattern: $this. A pattern is a sequence of case insensitive strings " + + "representing hexadecimal bytes separated by spaces", + e, + ) + } + } +} diff --git a/src/main/kotlin/app/revanced/patches/shared/misc/mapping/ResourceMappingPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/mapping/ResourceMappingPatch.kt similarity index 54% rename from src/main/kotlin/app/revanced/patches/shared/misc/mapping/ResourceMappingPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/shared/misc/mapping/ResourceMappingPatch.kt index 2c165e1f1..ef198b491 100644 --- a/src/main/kotlin/app/revanced/patches/shared/misc/mapping/ResourceMappingPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/shared/misc/mapping/ResourceMappingPatch.kt @@ -1,31 +1,33 @@ package app.revanced.patches.shared.misc.mapping -import app.revanced.patcher.data.ResourceContext import app.revanced.patcher.patch.PatchException -import app.revanced.patcher.patch.ResourcePatch +import app.revanced.patcher.patch.resourcePatch import org.w3c.dom.Element import java.util.* import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -object ResourceMappingPatch : ResourcePatch() { - private val resourceMappings = Collections.synchronizedList(mutableListOf()) +// TODO: Probably renaming the patch/this is a good idea. +lateinit var resourceMappings: List + private set - private val THREAD_COUNT = Runtime.getRuntime().availableProcessors() - private val threadPoolExecutor = Executors.newFixedThreadPool(THREAD_COUNT) +val resourceMappingPatch = resourcePatch { + val threadCount = Runtime.getRuntime().availableProcessors() + val threadPoolExecutor = Executors.newFixedThreadPool(threadCount) - override fun execute(context: ResourceContext) { - // sSve the file in memory to concurrently read from it. - val resourceXmlFile = context.get("res/values/public.xml").readBytes() + val resourceMappings = Collections.synchronizedList(mutableListOf()) - for (threadIndex in 0 until THREAD_COUNT) { + execute { context -> + // Save the file in memory to concurrently read from it. + val resourceXmlFile = context["res/values/public.xml"].readBytes() + + for (threadIndex in 0 until threadCount) { threadPoolExecutor.execute thread@{ - context.xmlEditor[resourceXmlFile.inputStream()].use { editor -> - val document = editor.file + context.document[resourceXmlFile.inputStream()].use { document -> val resources = document.documentElement.childNodes val resourcesLength = resources.length - val jobSize = resourcesLength / THREAD_COUNT + val jobSize = resourcesLength / threadCount val batchStart = jobSize * threadIndex val batchEnd = jobSize * (threadIndex + 1) @@ -50,12 +52,13 @@ object ResourceMappingPatch : ResourcePatch() { } threadPoolExecutor.also { it.shutdown() }.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS) + + app.revanced.patches.shared.misc.mapping.resourceMappings = resourceMappings } - - operator fun get(type: String, name: String) = - resourceMappings.firstOrNull { - it.type == type && it.name == name - }?.id ?: throw PatchException("Could not find resource type: $type name: $name") - - data class ResourceElement(val type: String, val name: String, val id: Long) } + +operator fun List.get(type: String, name: String) = resourceMappings.firstOrNull { + it.type == type && it.name == name +}?.id ?: throw PatchException("Could not find resource type: $type name: $name") + +data class ResourceElement internal constructor(val type: String, val name: String, val id: Long) diff --git a/src/main/kotlin/app/revanced/patches/shared/misc/settings/BaseSettingsResourcePatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/SettingsPatch.kt similarity index 57% rename from src/main/kotlin/app/revanced/patches/shared/misc/settings/BaseSettingsResourcePatch.kt rename to patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/SettingsPatch.kt index 1570cc3dc..ea14b4a7b 100644 --- a/src/main/kotlin/app/revanced/patches/shared/misc/settings/BaseSettingsResourcePatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/SettingsPatch.kt @@ -1,9 +1,9 @@ package app.revanced.patches.shared.misc.settings -import app.revanced.patcher.PatchClass -import app.revanced.patcher.data.ResourceContext -import app.revanced.patcher.patch.ResourcePatch -import app.revanced.patches.all.misc.resources.AddResourcesPatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.all.misc.resources.addResource +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch import app.revanced.patches.shared.misc.settings.preference.BasePreference import app.revanced.patches.shared.misc.settings.preference.IntentPreference import app.revanced.util.ResourceGroup @@ -11,42 +11,35 @@ import app.revanced.util.copyResources import app.revanced.util.getNode import app.revanced.util.insertFirst import org.w3c.dom.Node -import java.io.Closeable /** * A resource patch that adds settings to a settings fragment. * * @param rootPreference A pair of an intent preference and the name of the fragment file to add it to. * If null, no preference will be added. - * @param dependencies Additional dependencies of this patch. + * @param preferences A set of preferences to add to the ReVanced fragment. */ -abstract class BaseSettingsResourcePatch( - private val rootPreference: Pair? = null, - dependencies: Set = emptySet(), -) : ResourcePatch( - dependencies = setOf(AddResourcesPatch::class) + dependencies, -), - MutableSet by mutableSetOf(), - Closeable { - private lateinit var context: ResourceContext +fun settingsPatch( + rootPreference: Pair? = null, + preferences: Set, +) = resourcePatch { + dependsOn(addResourcesPatch) - override fun execute(context: ResourceContext) { + execute { context -> context.copyResources( "settings", ResourceGroup("xml", "revanced_prefs.xml"), ) - this.context = context - - AddResourcesPatch(BaseSettingsResourcePatch::class) + addResources("shared", "misc.settings.settingsResourcePatch") } - override fun close() { + finalize { context -> fun Node.addPreference(preference: BasePreference, prepend: Boolean = false) { preference.serialize(ownerDocument) { resource -> // TODO: Currently, resources can only be added to "values", which may not be the correct place. // It may be necessary to ask for the desired resourceValue in the future. - AddResourcesPatch("values", resource) + addResource("values", resource) }.let { preferenceNode -> insertFirst(preferenceNode) } @@ -54,19 +47,15 @@ abstract class BaseSettingsResourcePatch( // Add the root preference to an existing fragment if needed. rootPreference?.let { (intentPreference, fragment) -> - context.xmlEditor["res/xml/$fragment.xml"].use { editor -> - val document = editor.file - + context.document["res/xml/$fragment.xml"].use { document -> document.getNode("PreferenceScreen").addPreference(intentPreference, true) } } // Add all preferences to the ReVanced fragment. - context.xmlEditor["res/xml/revanced_prefs.xml"].use { editor -> - val document = editor.file - + context.document["res/xml/revanced_prefs.xml"].use { document -> val revancedPreferenceScreenNode = document.getNode("PreferenceScreen") - forEach { revancedPreferenceScreenNode.addPreference(it) } + preferences.forEach { revancedPreferenceScreenNode.addPreference(it) } } } } diff --git a/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/BasePreference.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/BasePreference.kt similarity index 100% rename from src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/BasePreference.kt rename to patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/BasePreference.kt diff --git a/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen.kt similarity index 92% rename from src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen.kt rename to patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen.kt index 381a2cf3d..648f5eefd 100644 --- a/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen.kt +++ b/patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/BasePreferenceScreen.kt @@ -1,6 +1,6 @@ package app.revanced.patches.shared.misc.settings.preference -import app.revanced.patches.shared.misc.settings.preference.PreferenceScreen.Sorting +import app.revanced.patches.shared.misc.settings.preference.PreferenceScreenPreference.Sorting import java.io.Closeable abstract class BasePreferenceScreen( @@ -18,7 +18,7 @@ abstract class BasePreferenceScreen( /** * Finalize and insert root preference into resource patch */ - abstract fun commit(screen: PreferenceScreen) + abstract fun commit(screen: PreferenceScreenPreference) open inner class Screen( key: String? = null, @@ -29,13 +29,13 @@ abstract class BasePreferenceScreen( private val sorting: Sorting = Sorting.BY_TITLE, ) : BasePreferenceCollection(key, titleKey, preferences) { - override fun transform(): PreferenceScreen { - return PreferenceScreen( + override fun transform(): PreferenceScreenPreference { + return PreferenceScreenPreference( key, titleKey, summaryKey, sorting, - // Screens and preferences are sorted at runtime by integrations code, + // Screens and preferences are sorted at runtime by extension code, // so title sorting uses the localized language in use. preferences = preferences + categories.map { it.transform() }, ) diff --git a/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/InputType.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/InputType.kt similarity index 100% rename from src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/InputType.kt rename to patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/InputType.kt diff --git a/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/IntentPreference.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/IntentPreference.kt similarity index 100% rename from src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/IntentPreference.kt rename to patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/IntentPreference.kt diff --git a/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/ListPreference.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/ListPreference.kt similarity index 100% rename from src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/ListPreference.kt rename to patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/ListPreference.kt diff --git a/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/NonInteractivePreference.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/NonInteractivePreference.kt similarity index 70% rename from src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/NonInteractivePreference.kt rename to patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/NonInteractivePreference.kt index 787115dba..d4ecaae7e 100644 --- a/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/NonInteractivePreference.kt +++ b/patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/NonInteractivePreference.kt @@ -6,7 +6,7 @@ import org.w3c.dom.Document /** * A non-interactive preference. * - * Typically used to present static text, but also used for custom integration code that responds to taps. + * Typically used to present static text, but also used for custom extension code that responds to taps. * * @param key The preference key. * @param summaryKey The preference summary key. @@ -19,17 +19,8 @@ class NonInteractivePreference( titleKey: String = "${key}_title", summaryKey: String? = "${key}_summary", tag: String = "Preference", - val selectable: Boolean = false + val selectable: Boolean = false, ) : BasePreference(key, titleKey, summaryKey, tag) { - - @Deprecated("Here only for binary compatibility, and should be removed after the next major version update.") - constructor( - key: String, - summaryKey: String? = "${key}_summary", - tag: String = "Preference", - selectable: Boolean = false - ) : this(key, "${key}_title", summaryKey, tag, selectable) - override fun serialize(ownerDocument: Document, resourceCallback: (BaseResource) -> Unit) = super.serialize(ownerDocument, resourceCallback).apply { setAttribute("android:selectable", selectable.toString()) diff --git a/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/PreferenceCategory.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/PreferenceCategory.kt similarity index 100% rename from src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/PreferenceCategory.kt rename to patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/PreferenceCategory.kt diff --git a/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/PreferenceScreen.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/PreferenceScreenPreference.kt similarity index 95% rename from src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/PreferenceScreen.kt rename to patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/PreferenceScreenPreference.kt index 049966a2c..2b21a8413 100644 --- a/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/PreferenceScreen.kt +++ b/patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/PreferenceScreenPreference.kt @@ -15,7 +15,7 @@ import org.w3c.dom.Document * @param preferences The preferences in this screen. */ @Suppress("MemberVisibilityCanBePrivate") -open class PreferenceScreen( +open class PreferenceScreenPreference( key: String? = null, titleKey: String = "${key}_title", summaryKey: String? = "${key}_summary", @@ -26,7 +26,7 @@ open class PreferenceScreen( // an extra bundle parameter can be added to the preferences XML declaration. // This would require bundling and referencing an additional XML file // or adding new attributes to the attrs.xml file. - // Since the key value is not currently used by integrations, + // Since the key value is not currently used by the extensions, // for now it's much simpler to modify the key to include the sort parameter. ) : BasePreference(if (sorting == Sorting.UNSORTED) key else (key + sorting.keySuffix), titleKey, summaryKey, tag) { override fun serialize(ownerDocument: Document, resourceCallback: (BaseResource) -> Unit) = diff --git a/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/SummaryType.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/SummaryType.kt similarity index 100% rename from src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/SummaryType.kt rename to patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/SummaryType.kt diff --git a/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/SwitchPreference.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/SwitchPreference.kt similarity index 100% rename from src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/SwitchPreference.kt rename to patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/SwitchPreference.kt diff --git a/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/TextPreference.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/TextPreference.kt similarity index 90% rename from src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/TextPreference.kt rename to patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/TextPreference.kt index d329a9177..81de61569 100644 --- a/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/TextPreference.kt +++ b/patches/src/main/kotlin/app/revanced/patches/shared/misc/settings/preference/TextPreference.kt @@ -17,7 +17,7 @@ class TextPreference( key: String? = null, titleKey: String = "${key}_title", summaryKey: String? = "${key}_summary", - tag: String = "app.revanced.integrations.shared.settings.preference.ResettableEditTextPreference", + tag: String = "app.revanced.extension.shared.settings.preference.ResettableEditTextPreference", val inputType: InputType = InputType.TEXT ) : BasePreference(key, titleKey, summaryKey, tag) { diff --git a/patches/src/main/kotlin/app/revanced/patches/solidexplorer2/functionality/filesize/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/solidexplorer2/functionality/filesize/Fingerprints.kt new file mode 100644 index 000000000..0e81d7e59 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/solidexplorer2/functionality/filesize/Fingerprints.kt @@ -0,0 +1,15 @@ +package app.revanced.patches.solidexplorer2.functionality.filesize + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.Opcode + +internal val onReadyFingerprint = fingerprint { + opcodes( + Opcode.CONST_WIDE_32, // Constant storing the 2MB limit + Opcode.CMP_LONG, + Opcode.IF_LEZ, + ) + custom { method, _ -> + method.name == "onReady" && method.definingClass == "Lpl/solidexplorer/plugins/texteditor/TextEditor;" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/solidexplorer2/functionality/filesize/RemoveFileSizeLimitPatch.kt b/patches/src/main/kotlin/app/revanced/patches/solidexplorer2/functionality/filesize/RemoveFileSizeLimitPatch.kt new file mode 100644 index 000000000..39a8ae64f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/solidexplorer2/functionality/filesize/RemoveFileSizeLimitPatch.kt @@ -0,0 +1,25 @@ +package app.revanced.patches.solidexplorer2.functionality.filesize + +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import com.android.tools.smali.dexlib2.iface.instruction.ThreeRegisterInstruction + +@Suppress("unused") +val removeFileSizeLimitPatch = bytecodePatch( + name = "Remove file size limit", + description = "Allows opening files larger than 2 MB in the text editor.", +) { + compatibleWith("pl.solidexplorer2") + + val onReadyMatch by onReadyFingerprint() + + execute { + onReadyMatch.mutableMethod.apply { + val cmpIndex = onReadyMatch.patternMatch!!.startIndex + 1 + val cmpResultRegister = getInstruction(cmpIndex).registerA + + replaceInstruction(cmpIndex, "const/4 v$cmpResultRegister, 0x0") + } + } +} diff --git a/src/main/kotlin/app/revanced/patches/songpal/badge/BadgeTabPatch.kt b/patches/src/main/kotlin/app/revanced/patches/songpal/badge/BadgeTabPatch.kt similarity index 60% rename from src/main/kotlin/app/revanced/patches/songpal/badge/BadgeTabPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/songpal/badge/BadgeTabPatch.kt index c32a7b097..d93b31af4 100644 --- a/src/main/kotlin/app/revanced/patches/songpal/badge/BadgeTabPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/songpal/badge/BadgeTabPatch.kt @@ -1,25 +1,24 @@ package app.revanced.patches.songpal.badge -import app.revanced.util.exception -import app.revanced.patcher.data.BytecodeContext import app.revanced.patcher.extensions.InstructionExtensions.addInstructions import app.revanced.patcher.extensions.InstructionExtensions.removeInstructions -import app.revanced.patcher.patch.BytecodePatch -import app.revanced.patcher.patch.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patches.songpal.badge.fingerprints.CreateTabsFingerprint +import app.revanced.patcher.patch.bytecodePatch -@Patch( +internal const val ACTIVITY_TAB_DESCRIPTOR = "Ljp/co/sony/vim/framework/ui/yourheadphones/YhContract\$Tab;" + +@Suppress("unused") +val badgeTabPatch = bytecodePatch( name = "Remove badge tab", description = "Removes the badge tab from the activity tab.", - compatiblePackages = [CompatiblePackage("com.sony.songpal.mdr")] -) -object BadgeTabPatch : BytecodePatch(setOf(CreateTabsFingerprint)) { - const val ACTIVITY_TAB_DESCRIPTOR = "Ljp/co/sony/vim/framework/ui/yourheadphones/YhContract\$Tab;" - private val arrayTabs = listOf("Log", "HealthCare") +) { + compatibleWith("com.sony.songpal.mdr") - override fun execute(context: BytecodeContext) { - CreateTabsFingerprint.result?.mutableMethod?.apply { + val createTabsMatch by createTabsFingerprint() + + val arrayTabs = listOf("Log", "HealthCare") + + execute { + createTabsMatch.mutableMethod.apply { removeInstructions(0, 2) val arrayRegister = 0 @@ -35,7 +34,7 @@ object BadgeTabPatch : BytecodePatch(setOf(CreateTabsFingerprint)) { const/4 v$indexRegister, $index sget-object v$arrayItemRegister, $ACTIVITY_TAB_DESCRIPTOR->$tab:$ACTIVITY_TAB_DESCRIPTOR aput-object v$arrayItemRegister, v$arrayRegister, v$indexRegister - """ + """, ) } @@ -47,9 +46,8 @@ object BadgeTabPatch : BytecodePatch(setOf(CreateTabsFingerprint)) { """ const/4 v$arrayRegister, ${arrayTabs.size} new-array v$arrayRegister, v$arrayRegister, [$ACTIVITY_TAB_DESCRIPTOR - """ + """, ) - - } ?: throw CreateTabsFingerprint.exception + } } } diff --git a/patches/src/main/kotlin/app/revanced/patches/songpal/badge/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/songpal/badge/Fingerprints.kt new file mode 100644 index 000000000..5d7498c5f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/songpal/badge/Fingerprints.kt @@ -0,0 +1,54 @@ +package app.revanced.patches.songpal.badge + +import app.revanced.patcher.fingerprint +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 +import com.android.tools.smali.dexlib2.immutable.reference.ImmutableMethodReference + +// Located @ ub.i0.h#p (9.5.0) +internal val createTabsFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE) + returns("Ljava/util/List;") + custom { method, _ -> + method.implementation?.instructions?.any { instruction -> + if (instruction.opcode != Opcode.INVOKE_STATIC) return@any false + + val reference = (instruction as ReferenceInstruction).reference as MethodReference + + if (reference.parameterTypes.isNotEmpty()) return@any false + if (reference.definingClass != ACTIVITY_TAB_DESCRIPTOR) return@any false + if (reference.returnType != "[${ACTIVITY_TAB_DESCRIPTOR}") return@any false + true + } ?: false + } +} + +// Located @ com.sony.songpal.mdr.vim.activity.MdrRemoteBaseActivity.e#run (9.5.0) +internal val showNotificationFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC) + returns("V") + custom { method, _ -> + method.implementation?.instructions?.any { instruction -> + if (instruction.opcode != Opcode.INVOKE_VIRTUAL) return@any false + + with(expectedReference) { + val currentReference = (instruction as ReferenceInstruction).reference as MethodReference + currentReference.let { + if (it.definingClass != definingClass) return@any false + if (it.parameterTypes != parameterTypes) return@any false + if (it.returnType != returnType) return@any false + } + } + true + } ?: false + } +} + +internal val expectedReference = ImmutableMethodReference( + "Lcom/google/android/material/bottomnavigation/BottomNavigationView;", + "getOrCreateBadge", // Non-obfuscated placeholder method name. + listOf("I"), + "Lcom/google/android/material/badge/BadgeDrawable;", +) diff --git a/patches/src/main/kotlin/app/revanced/patches/songpal/badge/RemoveNotificationBadgePatch.kt b/patches/src/main/kotlin/app/revanced/patches/songpal/badge/RemoveNotificationBadgePatch.kt new file mode 100644 index 000000000..4f59c5f6a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/songpal/badge/RemoveNotificationBadgePatch.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.songpal.badge + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val removeNotificationBadgePatch = bytecodePatch( + name = "Remove notification badge", + description = "Removes the red notification badge from the activity tab.", +) { + compatibleWith("com.sony.songpal.mdr"("10.1.0")) + + val showNotificationMatch by showNotificationFingerprint() + + execute { + showNotificationMatch.mutableMethod.addInstructions(0, "return-void") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/soundcloud/ad/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/soundcloud/ad/Fingerprints.kt new file mode 100644 index 000000000..28780ea57 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/soundcloud/ad/Fingerprints.kt @@ -0,0 +1,30 @@ +package app.revanced.patches.soundcloud.ad + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val interceptFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC) + returns("L") + parameters("L") + opcodes( + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT + ) + strings("SC-Mob-UserPlan", "Configuration") +} + +internal val userConsumerPlanConstructorFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + returns("V") + parameters( + "Ljava/lang/String;", + "Z", + "Ljava/lang/String;", + "Ljava/util/List;", + "Ljava/lang/String;", + "Ljava/lang/String;", + ) +} diff --git a/src/main/kotlin/app/revanced/patches/soundcloud/ad/HideAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/soundcloud/ad/HideAdsPatch.kt similarity index 66% rename from src/main/kotlin/app/revanced/patches/soundcloud/ad/HideAdsPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/soundcloud/ad/HideAdsPatch.kt index ea94b3f2a..19d692863 100644 --- a/src/main/kotlin/app/revanced/patches/soundcloud/ad/HideAdsPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/soundcloud/ad/HideAdsPatch.kt @@ -1,33 +1,29 @@ package app.revanced.patches.soundcloud.ad -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.BytecodePatch -import app.revanced.patcher.patch.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch +import app.revanced.patcher.patch.bytecodePatch import app.revanced.patcher.util.smali.ExternalLabel -import app.revanced.patches.soundcloud.ad.fingerprints.InterceptFingerprint -import app.revanced.patches.soundcloud.shared.fingerprints.FeatureConstructorFingerprint -import app.revanced.patches.soundcloud.ad.fingerprints.UserConsumerPlanConstructorFingerprint -import app.revanced.util.resultOrThrow +import app.revanced.patches.soundcloud.shared.featureConstructorFingerprint -@Patch( - name = "Hide ads", - compatiblePackages = [CompatiblePackage("com.soundcloud.android")], -) @Suppress("unused") -object HideAdsPatch : BytecodePatch( - setOf(FeatureConstructorFingerprint, UserConsumerPlanConstructorFingerprint, InterceptFingerprint), +val hideAdsPatch = bytecodePatch( + name = "Hide ads", ) { - override fun execute(context: BytecodeContext) { + compatibleWith("com.soundcloud.android") + + val featureConstructorMatch by featureConstructorFingerprint() + val userConsumerPlanConstructorMatch by userConsumerPlanConstructorFingerprint() + val interceptMatch by interceptFingerprint() + + execute { // Enable a preset feature to disable audio ads by modifying the JSON server response. // This method is the constructor of a class representing a "Feature" object parsed from JSON data. // p1 is the name of the feature. // p2 is true if the feature is enabled, false otherwise. - FeatureConstructorFingerprint.resultOrThrow().mutableMethod.apply { + featureConstructorMatch.mutableMethod.apply { val afterCheckNotNullIndex = 2 addInstructionsWithLabels( afterCheckNotNullIndex, @@ -49,7 +45,7 @@ object HideAdsPatch : BytecodePatch( // p4 is the "consumerPlanUpsells" value, a list of plans to try to sell to the user. // p5 is the "currentConsumerPlan" value, the type of plan currently subscribed to. // p6 is the "currentConsumerPlanTitle" value, the name of the plan currently subscribed to, shown to the user. - UserConsumerPlanConstructorFingerprint.resultOrThrow().mutableMethod.addInstructions( + userConsumerPlanConstructorMatch.mutableMethod.addInstructions( 0, """ const-string p1, "high_tier" @@ -61,12 +57,10 @@ object HideAdsPatch : BytecodePatch( ) // Prevent verification of an HTTP header containing the user's current plan, which would contradict the previous patch. - InterceptFingerprint.resultOrThrow().let { result -> - val conditionIndex = result.scanResult.patternScanResult!!.endIndex + 1 - result.mutableMethod.addInstruction( - conditionIndex, - "return-object p1", - ) - } + val conditionIndex = interceptMatch.patternMatch!!.endIndex + 1 + interceptMatch.mutableMethod.addInstruction( + conditionIndex, + "return-object p1", + ) } } diff --git a/patches/src/main/kotlin/app/revanced/patches/soundcloud/analytics/DisableTelemetryPatch.kt b/patches/src/main/kotlin/app/revanced/patches/soundcloud/analytics/DisableTelemetryPatch.kt new file mode 100644 index 000000000..e41333879 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/soundcloud/analytics/DisableTelemetryPatch.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.soundcloud.analytics + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch + +val disableTelemetryPatch = bytecodePatch( + name = "Disable telemetry", + description = "Disables SoundCloud's telemetry system.", +) { + compatibleWith("com.soundcloud.android") + + val createTrackingApiMatch by createTrackingApiFingerprint() + + execute { + // Empty the "backend" argument to abort the initializer. + createTrackingApiMatch.mutableMethod.addInstruction(0, "const-string p1, \"\"") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/soundcloud/analytics/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/soundcloud/analytics/Fingerprints.kt new file mode 100644 index 000000000..2954b4d99 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/soundcloud/analytics/Fingerprints.kt @@ -0,0 +1,13 @@ +package app.revanced.patches.soundcloud.analytics + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val createTrackingApiFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC) + returns("L") + custom { methodDef, _ -> + methodDef.name == "create" + } + strings("backend", "boogaloo") +} diff --git a/src/main/kotlin/app/revanced/patches/soundcloud/offlinesync/EnableOfflineSyncPatch.kt b/patches/src/main/kotlin/app/revanced/patches/soundcloud/offlinesync/EnableOfflineSyncPatch.kt similarity index 69% rename from src/main/kotlin/app/revanced/patches/soundcloud/offlinesync/EnableOfflineSyncPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/soundcloud/offlinesync/EnableOfflineSyncPatch.kt index 08446f986..89a2ff5fc 100644 --- a/src/main/kotlin/app/revanced/patches/soundcloud/offlinesync/EnableOfflineSyncPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/soundcloud/offlinesync/EnableOfflineSyncPatch.kt @@ -1,41 +1,34 @@ package app.revanced.patches.soundcloud.offlinesync -import app.revanced.patcher.data.BytecodeContext 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.getInstructions +import app.revanced.patcher.extensions.InstructionExtensions.instructions import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction -import app.revanced.patcher.patch.BytecodePatch -import app.revanced.patcher.patch.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch +import app.revanced.patcher.patch.bytecodePatch import app.revanced.patcher.util.smali.ExternalLabel -import app.revanced.patches.soundcloud.offlinesync.fingerprints.DownloadOperationsHeaderVerificationFingerprint -import app.revanced.patches.soundcloud.offlinesync.fingerprints.DownloadOperationsURLBuilderFingerprint -import app.revanced.patches.soundcloud.shared.fingerprints.FeatureConstructorFingerprint +import app.revanced.patches.soundcloud.shared.featureConstructorFingerprint import app.revanced.util.getReference -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.FieldReference -@Patch( - name = "Enable offline sync", - compatiblePackages = [CompatiblePackage("com.soundcloud.android")], -) @Suppress("unused") -object EnableOfflineSyncPatch : BytecodePatch( - setOf( - FeatureConstructorFingerprint, DownloadOperationsURLBuilderFingerprint, - DownloadOperationsHeaderVerificationFingerprint - ), +val enableOfflineSync = bytecodePatch( + name = "Enable offline sync", ) { - override fun execute(context: BytecodeContext) { + compatibleWith("com.soundcloud.android") + + val featureConstructorMatch by featureConstructorFingerprint() + val downloadOperationsURLBuilderMatch by downloadOperationsURLBuilderFingerprint() + val downloadOperationsHeaderVerificationMatch by downloadOperationsHeaderVerificationFingerprint() + + execute { // Enable the feature to allow offline track syncing by modifying the JSON server response. // This method is the constructor of a class representing a "Feature" object parsed from JSON data. // p1 is the name of the feature. // p2 is true if the feature is enabled, false otherwise. - FeatureConstructorFingerprint.resultOrThrow().mutableMethod.apply { + featureConstructorMatch.mutableMethod.apply { val afterCheckNotNullIndex = 2 addInstructionsWithLabels( @@ -53,7 +46,7 @@ object EnableOfflineSyncPatch : BytecodePatch( // Patch the URL builder to use the HTTPS_STREAM endpoint // instead of the offline sync endpoint to downloading the track. - DownloadOperationsURLBuilderFingerprint.resultOrThrow().mutableMethod.apply { + downloadOperationsURLBuilderMatch.mutableMethod.apply { val getEndpointsEnumFieldIndex = 1 val getEndpointsEnumFieldInstruction = getInstruction(getEndpointsEnumFieldIndex) @@ -62,16 +55,16 @@ object EnableOfflineSyncPatch : BytecodePatch( replaceInstruction( getEndpointsEnumFieldIndex, - "sget-object v$targetRegister, $endpointsType->HTTPS_STREAM:$endpointsType" + "sget-object v$targetRegister, $endpointsType->HTTPS_STREAM:$endpointsType", ) } // The HTTPS_STREAM endpoint does not return the necessary headers for offline sync. // Mock the headers to prevent the app from crashing by setting them to empty strings. // The headers are all cosmetic and do not affect the functionality of the app. - DownloadOperationsHeaderVerificationFingerprint.resultOrThrow().mutableMethod.apply { + downloadOperationsHeaderVerificationMatch.mutableMethod.apply { // The first three null checks need to be patched. - getInstructions().asSequence().filter { + instructions.asSequence().filter { it.opcode == Opcode.IF_EQZ }.take(3).toList().map { it.location.index }.asReversed().forEach { nullCheckIndex -> val headerStringRegister = getInstruction(nullCheckIndex).registerA diff --git a/patches/src/main/kotlin/app/revanced/patches/soundcloud/offlinesync/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/soundcloud/offlinesync/Fingerprints.kt new file mode 100644 index 000000000..688fe3604 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/soundcloud/offlinesync/Fingerprints.kt @@ -0,0 +1,29 @@ +package app.revanced.patches.soundcloud.offlinesync + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val downloadOperationsURLBuilderFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Ljava/lang/String") + parameters("L", "L") + opcodes( + Opcode.IGET_OBJECT, + Opcode.SGET_OBJECT, + Opcode.FILLED_NEW_ARRAY, + ) +} + +internal val downloadOperationsHeaderVerificationFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("L", "L") + opcodes( + Opcode.CONST_STRING, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CONST_STRING, + ) + strings("X-SC-Mime-Type", "X-SC-Preset", "X-SC-Quality") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/soundcloud/shared/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/soundcloud/shared/Fingerprints.kt new file mode 100644 index 000000000..3a50ae407 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/soundcloud/shared/Fingerprints.kt @@ -0,0 +1,16 @@ +package app.revanced.patches.soundcloud.shared + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val featureConstructorFingerprint = fingerprint { + returns("V") + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + parameters("Ljava/lang/String;", "Z", "Ljava/util/List;") + opcodes( + Opcode.SGET_OBJECT, + Opcode.CHECK_CAST, + Opcode.INVOKE_VIRTUAL + ) +} diff --git a/src/main/kotlin/app/revanced/patches/spotify/layout/theme/CustomThemePatch.kt b/patches/src/main/kotlin/app/revanced/patches/spotify/layout/theme/CustomThemePatch.kt similarity index 75% rename from src/main/kotlin/app/revanced/patches/spotify/layout/theme/CustomThemePatch.kt rename to patches/src/main/kotlin/app/revanced/patches/spotify/layout/theme/CustomThemePatch.kt index 9418c9f76..30d8a0bb4 100644 --- a/src/main/kotlin/app/revanced/patches/spotify/layout/theme/CustomThemePatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/spotify/layout/theme/CustomThemePatch.kt @@ -1,20 +1,19 @@ +@file:Suppress("NAME_SHADOWING") + package app.revanced.patches.spotify.layout.theme -import app.revanced.patcher.data.ResourceContext -import app.revanced.patcher.patch.ResourcePatch -import app.revanced.patcher.patch.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patcher.patch.options.PatchOption.PatchExtensions.stringPatchOption +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption import org.w3c.dom.Element -@Patch( +@Suppress("unused") +val customThemePatch = resourcePatch( name = "Custom theme", description = "Applies a custom theme.", - compatiblePackages = [CompatiblePackage("com.spotify.music")], -) -@Suppress("unused") -object CustomThemePatch : ResourcePatch() { - private var backgroundColor by stringPatchOption( +) { + compatibleWith("com.spotify.music") + + val backgroundColor by stringOption( key = "backgroundColor", default = "@android:color/black", title = "Primary background color", @@ -22,7 +21,7 @@ object CustomThemePatch : ResourcePatch() { required = true, ) - private var backgroundColorSecondary by stringPatchOption( + val backgroundColorSecondary by stringOption( key = "backgroundColorSecondary", default = "#ff282828", title = "Secondary background color", @@ -30,7 +29,7 @@ object CustomThemePatch : ResourcePatch() { required = true, ) - private var accentColor by stringPatchOption( + val accentColor by stringOption( key = "accentColor", default = "#ff1ed760", title = "Accent color", @@ -38,7 +37,7 @@ object CustomThemePatch : ResourcePatch() { required = true, ) - private var accentColorPressed by stringPatchOption( + val accentColorPressed by stringOption( key = "accentColorPressed", default = "#ff169c46", title = "Pressed dark theme accent color", @@ -48,15 +47,13 @@ object CustomThemePatch : ResourcePatch() { required = true, ) - override fun execute(context: ResourceContext) { + execute { context -> val backgroundColor = backgroundColor!! val backgroundColorSecondary = backgroundColorSecondary!! val accentColor = accentColor!! val accentColorPressed = accentColorPressed!! - context.xmlEditor["res/values/colors.xml"].use { editor -> - val document = editor.file - + context.document["res/values/colors.xml"].use { document -> val resourcesNode = document.getElementsByTagName("resources").item(0) as Element for (i in 0 until resourcesNode.childNodes.length) { diff --git a/patches/src/main/kotlin/app/revanced/patches/spotify/lite/ondemand/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/spotify/lite/ondemand/Fingerprints.kt new file mode 100644 index 000000000..365235bee --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/spotify/lite/ondemand/Fingerprints.kt @@ -0,0 +1,23 @@ +package app.revanced.patches.spotify.lite.ondemand + +import com.android.tools.smali.dexlib2.Opcode +import app.revanced.patcher.fingerprint + +internal val onDemandFingerprint = fingerprint(fuzzyPatternScanThreshold = 2) { + returns("L") + parameters() + opcodes( + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IF_EQZ, + Opcode.SGET_OBJECT, + Opcode.GOTO, + Opcode.SGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IPUT, + Opcode.RETURN_OBJECT, + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/spotify/lite/ondemand/OnDemandPatch.kt b/patches/src/main/kotlin/app/revanced/patches/spotify/lite/ondemand/OnDemandPatch.kt new file mode 100644 index 000000000..d8a8940d2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/spotify/lite/ondemand/OnDemandPatch.kt @@ -0,0 +1,22 @@ +package app.revanced.patches.spotify.lite.ondemand + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val onDemandPatch = bytecodePatch( + name = "Enable on demand", + description = "Enables listening to songs on-demand, allowing to play any song from playlists, albums or artists without limitations. This does not remove ads.", +) { + compatibleWith("com.spotify.lite") + + val onDemandMatch by onDemandFingerprint() + + execute { + // Spoof a premium account + onDemandMatch.mutableMethod.addInstruction( + onDemandMatch.patternMatch!!.endIndex - 1, + "const/4 v0, 0x2", + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/spotify/navbar/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/spotify/navbar/Fingerprints.kt new file mode 100644 index 000000000..761e32206 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/spotify/navbar/Fingerprints.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.spotify.navbar + +import app.revanced.patcher.fingerprint +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.AccessFlags + +internal val addNavBarItemFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + literal { showBottomNavigationItemsTextId } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/spotify/navbar/PremiumNavbarTabPatch.kt b/patches/src/main/kotlin/app/revanced/patches/spotify/navbar/PremiumNavbarTabPatch.kt new file mode 100644 index 000000000..857842fc6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/spotify/navbar/PremiumNavbarTabPatch.kt @@ -0,0 +1,52 @@ +package app.revanced.patches.spotify.navbar + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings + +internal var showBottomNavigationItemsTextId = -1L + private set +internal var premiumTabId = -1L + private set + +private val premiumNavbarTabResourcePatch = resourcePatch { + dependsOn(resourceMappingPatch) + + execute { + premiumTabId = resourceMappings["id", "premium_tab"] + + showBottomNavigationItemsTextId = resourceMappings[ + "bool", + "show_bottom_navigation_items_text", + ] + } +} + +@Suppress("unused") +val premiumNavbarTabPatch = bytecodePatch( + name = "Premium navbar tab", + description = "Hides the premium tab from the navigation bar.", +) { + dependsOn(premiumNavbarTabResourcePatch) + + compatibleWith("com.spotify.music") + + val addNavbarItemMatch by addNavBarItemFingerprint() + + // If the navigation bar item is the premium tab, do not add it. + execute { + addNavbarItemMatch.mutableMethod.addInstructions( + 0, + """ + const v1, $premiumTabId + if-ne p5, v1, :continue + return-void + :continue + nop + """, + ) + } +} diff --git a/src/main/kotlin/app/revanced/patches/stocard/layout/HideOffersTabPatch.kt b/patches/src/main/kotlin/app/revanced/patches/stocard/layout/HideOffersTabPatch.kt similarity index 59% rename from src/main/kotlin/app/revanced/patches/stocard/layout/HideOffersTabPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/stocard/layout/HideOffersTabPatch.kt index 6b493c4b1..d7e3a3088 100644 --- a/src/main/kotlin/app/revanced/patches/stocard/layout/HideOffersTabPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/stocard/layout/HideOffersTabPatch.kt @@ -1,19 +1,16 @@ package app.revanced.patches.stocard.layout -import app.revanced.patcher.data.ResourceContext -import app.revanced.patcher.patch.ResourcePatch -import app.revanced.patcher.patch.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch +import app.revanced.patcher.patch.resourcePatch import app.revanced.util.childElementsSequence import app.revanced.util.getNode -@Patch( - name = "Hide offers tab", - compatiblePackages = [CompatiblePackage("de.stocard.stocard")], -) @Suppress("unused") -object HideOffersTabPatch : ResourcePatch() { - override fun execute(context: ResourceContext) { +val hideOffersTabPatch = resourcePatch( + name = "Hide offers tab", +) { + compatibleWith("de.stocard.stocard") + + execute { context -> context.document["res/menu/bottom_navigation_menu.xml"].use { document -> document.getNode("menu").apply { removeChild( diff --git a/src/main/kotlin/app/revanced/patches/stocard/layout/HideStoryBubblesPatch.kt b/patches/src/main/kotlin/app/revanced/patches/stocard/layout/HideStoryBubblesPatch.kt similarity index 58% rename from src/main/kotlin/app/revanced/patches/stocard/layout/HideStoryBubblesPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/stocard/layout/HideStoryBubblesPatch.kt index eecdeb38d..80894391e 100644 --- a/src/main/kotlin/app/revanced/patches/stocard/layout/HideStoryBubblesPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/stocard/layout/HideStoryBubblesPatch.kt @@ -1,18 +1,15 @@ package app.revanced.patches.stocard.layout -import app.revanced.patcher.data.ResourceContext -import app.revanced.patcher.patch.ResourcePatch -import app.revanced.patcher.patch.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch +import app.revanced.patcher.patch.resourcePatch import app.revanced.util.getNode -@Patch( - name = "Hide story bubbles", - compatiblePackages = [CompatiblePackage("de.stocard.stocard")], -) @Suppress("unused") -object HideStoryBubblesPatch : ResourcePatch() { - override fun execute(context: ResourceContext) { +val hideStoryBubblesPatch = resourcePatch( + name = "Hide story bubbles", +) { + compatibleWith("de.stocard.stocard") + + execute { context -> context.document["res/layout/rv_story_bubbles_list.xml"].use { document -> document.getNode("androidx.recyclerview.widget.RecyclerView").apply { arrayOf( diff --git a/patches/src/main/kotlin/app/revanced/patches/strava/subscription/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/strava/subscription/Fingerprints.kt new file mode 100644 index 000000000..0458f45d3 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/strava/subscription/Fingerprints.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.strava.subscription + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.Opcode + +internal val getSubscribedFingerprint = fingerprint { + opcodes(Opcode.IGET_BOOLEAN) + custom { method, classDef -> + classDef.endsWith("/SubscriptionDetailResponse;") && method.name == "getSubscribed" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/strava/subscription/UnlockSubscriptionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/strava/subscription/UnlockSubscriptionPatch.kt new file mode 100644 index 000000000..0d96767a2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/strava/subscription/UnlockSubscriptionPatch.kt @@ -0,0 +1,20 @@ +package app.revanced.patches.strava.subscription +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val unlockSubscriptionPatch = bytecodePatch( + name = "Unlock subscription features", + description = "Unlocks \"Routes\", \"Matched Runs\" and \"Segment Efforts\".", +) { + compatibleWith("com.strava") + + val getSubscribedMatch by getSubscribedFingerprint() + + execute { + getSubscribedMatch.mutableMethod.replaceInstruction( + getSubscribedMatch.patternMatch!!.startIndex, + "const/4 v0, 0x1", + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/strava/upselling/DisableSubscriptionSuggestionsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/strava/upselling/DisableSubscriptionSuggestionsPatch.kt new file mode 100644 index 000000000..c1af24825 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/strava/upselling/DisableSubscriptionSuggestionsPatch.kt @@ -0,0 +1,69 @@ +package app.revanced.patches.strava.upselling + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.builder.MutableMethodImplementation +import com.android.tools.smali.dexlib2.immutable.ImmutableMethod + +@Suppress("unused") +val disableSubscriptionSuggestionsPatch = bytecodePatch( + name = "Disable subscription suggestions", +) { + compatibleWith("com.strava"("320.12")) + + val getModulesMatch by getModulesFingerprint() + + execute { + val helperMethodName = "getModulesIfNotUpselling" + val pageSuffix = "_upsell" + val label = "original" + + val className = getModulesMatch.classDef.type + val originalMethod = getModulesMatch.mutableMethod + val returnType = originalMethod.returnType + + getModulesMatch.mutableClass.methods.add( + ImmutableMethod( + className, + helperMethodName, + emptyList(), + returnType, + AccessFlags.PRIVATE.value, + null, + null, + MutableMethodImplementation(3), + ).toMutable().apply { + addInstructions( + """ + iget-object v0, p0, $className->page:Ljava/lang/String; + const-string v1, "$pageSuffix" + invoke-virtual {v0, v1}, Ljava/lang/String;->endsWith(Ljava/lang/String;)Z + move-result v0 + if-eqz v0, :$label + invoke-static {}, Ljava/util/Collections;->emptyList()Ljava/util/List; + move-result-object v0 + return-object v0 + :$label + iget-object v0, p0, $className->modules:Ljava/util/List; + return-object v0 + """, + ) + }, + ) + + val getModulesIndex = getModulesMatch.patternMatch!!.startIndex + with(originalMethod) { + removeInstruction(getModulesIndex) + addInstructions( + getModulesIndex, + """ + invoke-direct {p0}, $className->$helperMethodName()$returnType + move-result-object v0 + """, + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/strava/upselling/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/strava/upselling/Fingerprints.kt new file mode 100644 index 000000000..1204a36f2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/strava/upselling/Fingerprints.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.strava.upselling + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.Opcode + +internal val getModulesFingerprint = fingerprint { + opcodes(Opcode.IGET_OBJECT) + custom { method, classDef -> + classDef.endsWith("/GenericLayoutEntry;") && method.name == "getModules" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/swissid/integritycheck/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/swissid/integritycheck/Fingerprints.kt new file mode 100644 index 000000000..0efa845fb --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/swissid/integritycheck/Fingerprints.kt @@ -0,0 +1,9 @@ +package app.revanced.patches.swissid.integritycheck + +import app.revanced.patcher.fingerprint + +internal val checkIntegrityFingerprint = fingerprint { + returns("V") + parameters("Lcom/swisssign/deviceintegrity/model/DeviceIntegrityResult;") + strings("it", "result") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/swissid/integritycheck/RemoveGooglePlayIntegrityCheckPatch.kt b/patches/src/main/kotlin/app/revanced/patches/swissid/integritycheck/RemoveGooglePlayIntegrityCheckPatch.kt new file mode 100644 index 000000000..82714c651 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/swissid/integritycheck/RemoveGooglePlayIntegrityCheckPatch.kt @@ -0,0 +1,32 @@ +package app.revanced.patches.swissid.integritycheck + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +private const val RESULT_METHOD_REFERENCE = " Lcom/swisssign/deviceintegrity/DeviceintegrityPlugin\$onMethodCall\$1;->" + + "\$result:Lio/flutter/plugin/common/MethodChannel\$Result;" +private const val SUCCESS_METHOD_REFERENCE = + "Lio/flutter/plugin/common/MethodChannel\$Result;->success(Ljava/lang/Object;)V" + +@Suppress("unused") +val removeGooglePlayIntegrityCheckPatch = bytecodePatch( + name = "Remove Google Play Integrity check", + description = "Removes the Google Play Integrity check. With this it's possible to use SwissID on custom ROMS." + + "If the device is rooted, root permissions must be hidden from the app.", +) { + compatibleWith("com.swisssign.swissid.mobile") + + val checkIntegrityMatch by checkIntegrityFingerprint() + + execute { + checkIntegrityMatch.mutableMethod.addInstructions( + 0, + """ + iget-object p1, p0, $RESULT_METHOD_REFERENCE + const-string v0, "VALID" + invoke-interface {p1, v0}, $SUCCESS_METHOD_REFERENCE + return-void + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/ticktick/misc/themeunlock/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/ticktick/misc/themeunlock/Fingerprints.kt new file mode 100644 index 000000000..4bd688de4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/ticktick/misc/themeunlock/Fingerprints.kt @@ -0,0 +1,15 @@ +package app.revanced.patches.ticktick.misc.themeunlock + +import app.revanced.patcher.fingerprint + +internal val checkLockedThemesFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("Theme;") && method.name == "isLockedTheme" + } +} + +internal val setThemeFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("ThemePreviewActivity;") && method.name == "lambda\$updateUserBtn\$1" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/ticktick/misc/themeunlock/UnlockThemePatch.kt b/patches/src/main/kotlin/app/revanced/patches/ticktick/misc/themeunlock/UnlockThemePatch.kt new file mode 100644 index 000000000..5e2aa37d8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/ticktick/misc/themeunlock/UnlockThemePatch.kt @@ -0,0 +1,28 @@ +package app.revanced.patches.ticktick.misc.themeunlock + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.removeInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val unlockProPatch = bytecodePatch( + name = "Unlock themes", + description = "Unlocks all themes that are inaccessible until a certain level is reached.", +) { + compatibleWith("com.ticktick.task") + + val checkLockedThemesMatch by checkLockedThemesFingerprint() + val setThemeMatch by setThemeFingerprint() + + execute { + checkLockedThemesMatch.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x0 + return v0 + """, + ) + + setThemeMatch.mutableMethod.removeInstructions(0, 10) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/feedfilter/FeedFilterPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/feedfilter/FeedFilterPatch.kt new file mode 100644 index 000000000..7301c58ba --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/feedfilter/FeedFilterPatch.kt @@ -0,0 +1,48 @@ +package app.revanced.patches.tiktok.feedfilter + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.instructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.tiktok.misc.extension.sharedExtensionPatch +import app.revanced.patches.tiktok.misc.settings.settingsPatch +import app.revanced.patches.tiktok.misc.settings.settingsStatusLoadFingerprint +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +@Suppress("unused") +val feedFilterPatch = bytecodePatch( + name = "Feed filter", + description = "Removes ads, livestreams, stories, image videos " + + "and videos with a specific amount of views or likes from the feed.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + ) + + compatibleWith( + "com.ss.android.ugc.trill"("36.5.4"), + "com.zhiliaoapp.musically"("36.5.4"), + ) + + val feedApiServiceLIZMatch by feedApiServiceLIZFingerprint() + val settingsStatusLoadMatch by settingsStatusLoadFingerprint() + + execute { + feedApiServiceLIZMatch.mutableMethod.apply { + val returnFeedItemInstruction = instructions.first { it.opcode == Opcode.RETURN_OBJECT } + val feedItemsRegister = (returnFeedItemInstruction as OneRegisterInstruction).registerA + + addInstruction( + returnFeedItemInstruction.location.index, + "invoke-static { v$feedItemsRegister }, " + + "Lapp/revanced/extension/tiktok/feedfilter/FeedItemsFilter;->filter(Lcom/ss/android/ugc/aweme/feed/model/FeedItemList;)V", + ) + } + + settingsStatusLoadMatch.mutableMethod.addInstruction( + 0, + "invoke-static {}, Lapp/revanced/extension/tiktok/settings/SettingsStatus;->enableFeedFilter()V", + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/feedfilter/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/feedfilter/Fingerprints.kt new file mode 100644 index 000000000..4f899661e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/feedfilter/Fingerprints.kt @@ -0,0 +1,9 @@ +package app.revanced.patches.tiktok.feedfilter + +import app.revanced.patcher.fingerprint + +internal val feedApiServiceLIZFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("/FeedApiService;") && method.name == "fetchFeedList" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/cleardisplay/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/cleardisplay/Fingerprints.kt new file mode 100644 index 000000000..eb2868374 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/cleardisplay/Fingerprints.kt @@ -0,0 +1,10 @@ +package app.revanced.patches.tiktok.interaction.cleardisplay + +import app.revanced.patcher.fingerprint + +internal val onClearDisplayEventFingerprint = fingerprint { + custom { method, classDef -> + // Internally the feature is called "Clear mode". + classDef.endsWith("/ClearModePanelComponent;") && method.name == "onClearModeEvent" + } +} diff --git a/src/main/kotlin/app/revanced/patches/tiktok/interaction/cleardisplay/RememberClearDisplayPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/cleardisplay/RememberClearDisplayPatch.kt similarity index 61% rename from src/main/kotlin/app/revanced/patches/tiktok/interaction/cleardisplay/RememberClearDisplayPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/cleardisplay/RememberClearDisplayPatch.kt index b1522e880..73374b513 100644 --- a/src/main/kotlin/app/revanced/patches/tiktok/interaction/cleardisplay/RememberClearDisplayPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/cleardisplay/RememberClearDisplayPatch.kt @@ -1,37 +1,30 @@ package app.revanced.patches.tiktok.interaction.cleardisplay -import app.revanced.patcher.data.BytecodeContext 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.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch +import app.revanced.patcher.patch.bytecodePatch import app.revanced.patcher.util.smali.ExternalLabel -import app.revanced.patches.tiktok.interaction.cleardisplay.fingerprints.OnClearDisplayEventFingerprint -import app.revanced.patches.tiktok.shared.fingerprints.OnRenderFirstFrameFingerprint -import app.revanced.util.exception +import app.revanced.patches.tiktok.shared.onRenderFirstFrameFingerprint import app.revanced.util.indexOfFirstInstructionOrThrow import com.android.tools.smali.dexlib2.Opcode import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction -@Patch( +@Suppress("unused") +val rememberClearDisplayPatch = bytecodePatch( name = "Remember clear display", description = "Remembers the clear display configurations in between videos.", - compatiblePackages = [ - CompatiblePackage("com.ss.android.ugc.trill", ["36.5.4"]), - CompatiblePackage("com.zhiliaoapp.musically", ["36.5.4"]), - ], -) -@Suppress("unused") -object RememberClearDisplayPatch : BytecodePatch( - setOf( - OnClearDisplayEventFingerprint, - OnRenderFirstFrameFingerprint, - ), ) { - override fun execute(context: BytecodeContext) { - OnClearDisplayEventFingerprint.result?.mutableMethod?.let { + compatibleWith( + "com.ss.android.ugc.trill"("36.5.4"), + "com.zhiliaoapp.musically"("36.5.4"), + ) + + val onClearDisplayEventMatch by onClearDisplayEventFingerprint() + val onRenderFirstFrameMatch by onRenderFirstFrameFingerprint() + + execute { + onClearDisplayEventMatch.mutableMethod.let { // region Hook the "Clear display" configuration save event to remember the state of clear display. val isEnabledIndex = it.indexOfFirstInstructionOrThrow(Opcode.IGET_BOOLEAN) + 1 @@ -40,7 +33,7 @@ object RememberClearDisplayPatch : BytecodePatch( it.addInstructions( isEnabledIndex, "invoke-static { v$isEnabledRegister }, " + - "Lapp/revanced/integrations/tiktok/cleardisplay/RememberClearDisplayPatch;->rememberClearDisplayState(Z)V", + "Lapp/revanced/extension/tiktok/cleardisplay/RememberClearDisplayPatch;->rememberClearDisplayState(Z)V", ) // endregion @@ -48,10 +41,9 @@ object RememberClearDisplayPatch : BytecodePatch( // region Override the "Clear display" configuration load event to load the state of clear display. val clearDisplayEventClass = it.parameters[0].type - OnRenderFirstFrameFingerprint.result?.mutableMethod?.apply { - addInstructionsWithLabels( - 0, - """ + onRenderFirstFrameMatch.mutableMethod.addInstructionsWithLabels( + 0, + """ # Create a new clearDisplayEvent and post it to the EventBus (https://github.com/greenrobot/EventBus) # Clear display type such as 0 = LONG_PRESS, 1 = SCREEN_RECORD etc. @@ -64,7 +56,7 @@ object RememberClearDisplayPatch : BytecodePatch( const-string v3, "long_press" # The state of clear display. - invoke-static { }, Lapp/revanced/integrations/tiktok/cleardisplay/RememberClearDisplayPatch;->getClearDisplayState()Z + invoke-static { }, Lapp/revanced/extension/tiktok/cleardisplay/RememberClearDisplayPatch;->getClearDisplayState()Z move-result v4 if-eqz v4, :clear_display_disabled @@ -72,11 +64,10 @@ object RememberClearDisplayPatch : BytecodePatch( invoke-direct { v0, v1, v2, v3, v4 }, $clearDisplayEventClass->(ILjava/lang/String;Ljava/lang/String;Z)V invoke-virtual { v0 }, $clearDisplayEventClass->post()Lcom/ss/android/ugc/governance/eventbus/IEvent; """, - ExternalLabel("clear_display_disabled", getInstruction(0)), - ) - } ?: throw OnRenderFirstFrameFingerprint.exception + ExternalLabel("clear_display_disabled", onRenderFirstFrameMatch.mutableMethod.getInstruction(0)), + ) // endregion - } ?: throw OnClearDisplayEventFingerprint.exception + } } } diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/downloads/DownloadsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/downloads/DownloadsPatch.kt new file mode 100644 index 000000000..94afc211f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/downloads/DownloadsPatch.kt @@ -0,0 +1,98 @@ +package app.revanced.patches.tiktok.interaction.downloads + +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.replaceInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.tiktok.misc.extension.sharedExtensionPatch +import app.revanced.patches.tiktok.misc.settings.settingsPatch +import app.revanced.patches.tiktok.misc.settings.settingsStatusLoadFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +@Suppress("unused") +val downloadsPatch = bytecodePatch( + name = "Downloads", + description = "Removes download restrictions and changes the default path to download to.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + ) + + compatibleWith( + "com.ss.android.ugc.trill"("36.5.4"), + "com.zhiliaoapp.musically"("36.5.4"), + ) + + val aclCommonShareMatch by aclCommonShareFingerprint() + val aclCommonShare2Match by aclCommonShare2Fingerprint() + val aclCommonShare3Match by aclCommonShare3Fingerprint() + val downloadUriMatch by downloadUriFingerprint() + val settingsStatusLoadMatch by settingsStatusLoadFingerprint() + + execute { context -> + aclCommonShareMatch.mutableMethod.replaceInstructions( + 0, + """ + const/4 v0, 0x0 + return v0 + """, + ) + + aclCommonShare2Match.mutableMethod.replaceInstructions( + 0, + """ + const/4 v0, 0x2 + return v0 + """, + ) + + // Download videos without watermark. + aclCommonShare3Match.mutableMethod.addInstructionsWithLabels( + 0, + """ + invoke-static {}, Lapp/revanced/extension/tiktok/download/DownloadsPatch;->shouldRemoveWatermark()Z + move-result v0 + if-eqz v0, :noremovewatermark + const/4 v0, 0x1 + return v0 + :noremovewatermark + nop + """, + ) + + // Change the download path patch. + downloadUriMatch.mutableMethod.apply { + val firstIndex = indexOfFirstInstructionOrThrow { + getReference()?.name == "" + } + val secondIndex = indexOfFirstInstructionOrThrow { + getReference()?.returnType?.contains("Uri") == true + } + + addInstructions( + secondIndex, + """ + invoke-static {}, Lapp/revanced/extension/tiktok/download/DownloadsPatch;->getDownloadPath()Ljava/lang/String; + move-result-object v0 + """, + ) + + addInstructions( + firstIndex, + """ + invoke-static {}, Lapp/revanced/extension/tiktok/download/DownloadsPatch;->getDownloadPath()Ljava/lang/String; + move-result-object v0 + """, + ) + } + + settingsStatusLoadMatch.mutableMethod.addInstruction( + 0, + "invoke-static {}, Lapp/revanced/extension/tiktok/settings/SettingsStatus;->enableDownload()V", + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/downloads/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/downloads/Fingerprints.kt new file mode 100644 index 000000000..f58141fac --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/downloads/Fingerprints.kt @@ -0,0 +1,47 @@ +package app.revanced.patches.tiktok.interaction.downloads + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val aclCommonShareFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("I") + custom { method, classDef -> + classDef.endsWith("/ACLCommonShare;") && + method.name == "getCode" + } +} + +internal val aclCommonShare2Fingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("I") + custom { method, classDef -> + classDef.endsWith("/ACLCommonShare;") && + method.name == "getShowType" + } +} + +internal val aclCommonShare3Fingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("I") + custom { method, classDef -> + classDef.endsWith("/ACLCommonShare;") && + method.name == "getTranscode" + } +} + +internal val downloadUriFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("Landroid/net/Uri;") + parameters( + "Landroid/content/Context;", + "Ljava/lang/String;" + ) + strings( + "/", + "/Camera", + "/Camera/", + "video/mp4" + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/seekbar/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/seekbar/Fingerprints.kt new file mode 100644 index 000000000..ce372ea42 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/seekbar/Fingerprints.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.tiktok.interaction.seekbar + +import app.revanced.patcher.fingerprint + +internal val setSeekBarShowTypeFingerprint = fingerprint { + strings("seekbar show type change, change to:") +} + +internal val shouldShowSeekBarFingerprint = fingerprint { + strings("can not show seekbar, state: 1, not in resume") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/seekbar/ShowSeekbarPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/seekbar/ShowSeekbarPatch.kt new file mode 100644 index 000000000..5cb6db64c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/seekbar/ShowSeekbarPatch.kt @@ -0,0 +1,38 @@ +package app.revanced.patches.tiktok.interaction.seekbar + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val showSeekbarPatch = bytecodePatch( + name = "Show seekbar", + description = "Shows progress bar for all video.", +) { + compatibleWith( + "com.ss.android.ugc.trill", + "com.zhiliaoapp.musically", + ) + + val shouldShowSeekBarMatch by shouldShowSeekBarFingerprint() + val setSeekBarShowTypeMatch by setSeekBarShowTypeFingerprint() + + execute { + shouldShowSeekBarMatch.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + setSeekBarShowTypeMatch.mutableMethod.apply { + val typeRegister = implementation!!.registerCount - 1 + + addInstructions( + 0, + """ + const/16 v$typeRegister, 0x64 + """, + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/speed/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/speed/Fingerprints.kt new file mode 100644 index 000000000..221036bb9 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/speed/Fingerprints.kt @@ -0,0 +1,17 @@ +package app.revanced.patches.tiktok.interaction.speed + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val getSpeedFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("/BaseListFragmentPanel;") && method.name == "onFeedSpeedSelectedEvent" + } +} + +internal val setSpeedFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("V") + parameters("Ljava/lang/String;", "Lcom/ss/android/ugc/aweme/feed/model/Aweme;", "F") + strings("enterFrom") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/speed/PlaybackSpeedPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/speed/PlaybackSpeedPatch.kt new file mode 100644 index 000000000..81153a1dc --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/interaction/speed/PlaybackSpeedPatch.kt @@ -0,0 +1,74 @@ +package app.revanced.patches.tiktok.interaction.speed + +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.tiktok.shared.getEnterFromFingerprint +import app.revanced.patches.tiktok.shared.onRenderFirstFrameFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction11x +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +@Suppress("unused") +val playbackSpeedPatch = bytecodePatch( + name = "Playback speed", + description = "Enables the playback speed option for all videos and " + + "retains the speed configurations in between videos.", +) { + compatibleWith( + "com.ss.android.ugc.trill"("36.5.4"), + "com.zhiliaoapp.musically"("36.5.4"), + ) + + val getSpeedMatch by getSpeedFingerprint() + val onRenderFirstFrameMatch by onRenderFirstFrameFingerprint() + val setSpeedMatch by setSpeedFingerprint() + val getEnterFromMatch by getEnterFromFingerprint() + + execute { + setSpeedMatch.let { onVideoSwiped -> + getSpeedMatch.mutableMethod.apply { + val injectIndex = indexOfFirstInstructionOrThrow { getReference()?.returnType == "F" } + 2 + val register = getInstruction(injectIndex - 1).registerA + + addInstruction( + injectIndex, + "invoke-static { v$register }," + + " Lapp/revanced/extension/tiktok/speed/PlaybackSpeedPatch;->rememberPlaybackSpeed(F)V", + ) + } + + // By default, the playback speed will reset to 1.0 at the start of each video. + // Instead, override it with the desired playback speed. + onRenderFirstFrameMatch.mutableMethod.addInstructions( + 0, + """ + # Video playback location (e.g. home page, following page or search result page) retrieved using getEnterFrom method. + const/4 v0, 0x1 + invoke-virtual { p0, v0 }, ${getEnterFromMatch.method} + move-result-object v0 + + # Model of current video retrieved using getCurrentAweme method. + invoke-virtual { p0 }, Lcom/ss/android/ugc/aweme/feed/panel/BaseListFragmentPanel;->getCurrentAweme()Lcom/ss/android/ugc/aweme/feed/model/Aweme; + move-result-object v1 + + # Desired playback speed retrieved using getPlaybackSpeed method. + invoke-static { }, Lapp/revanced/extension/tiktok/speed/PlaybackSpeedPatch;->getPlaybackSpeed()F + move-result v2 + invoke-static { v0, v1, v2 }, ${onVideoSwiped.method} + """, + ) + + // Force enable the playback speed option for all videos. + onVideoSwiped.mutableClass.methods.find { method -> method.returnType == "Z" }?.addInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/extension/ExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/extension/ExtensionPatch.kt new file mode 100644 index 000000000..b714b9d1c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/extension/ExtensionPatch.kt @@ -0,0 +1,5 @@ +package app.revanced.patches.tiktok.misc.extension + +import app.revanced.patches.shared.misc.extension.sharedExtensionPatch + +val sharedExtensionPatch = sharedExtensionPatch(initHook) diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/extension/Hooks.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/extension/Hooks.kt new file mode 100644 index 000000000..24a49dd35 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/extension/Hooks.kt @@ -0,0 +1,14 @@ +package app.revanced.patches.tiktok.misc.extension + +import app.revanced.patches.shared.misc.extension.extensionHook +import com.android.tools.smali.dexlib2.AccessFlags + +internal val initHook = extensionHook( + insertIndexResolver = { 1 }, // Insert after call to super class. +) { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + custom { method, classDef -> + classDef.endsWith("/AwemeHostApplication;") && + method.name == "" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/login/disablerequirement/DisableLoginRequirementPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/login/disablerequirement/DisableLoginRequirementPatch.kt new file mode 100644 index 000000000..1b2f0a0b0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/login/disablerequirement/DisableLoginRequirementPatch.kt @@ -0,0 +1,32 @@ +package app.revanced.patches.tiktok.misc.login.disablerequirement + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val disableLoginRequirementPatch = bytecodePatch( + name = "Disable login requirement", +) { + compatibleWith( + "com.ss.android.ugc.trill", + "com.zhiliaoapp.musically", + ) + + val mandatoryLoginServiceMatch by mandatoryLoginServiceFingerprint() + val mandatoryLoginService2Match by mandatoryLoginService2Fingerprint() + + execute { + listOf( + mandatoryLoginServiceMatch.mutableMethod, + mandatoryLoginService2Match.mutableMethod, + ).forEach { method -> + method.addInstructions( + 0, + """ + const/4 v0, 0x0 + return v0 + """, + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/login/disablerequirement/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/login/disablerequirement/Fingerprints.kt new file mode 100644 index 000000000..929ef8672 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/login/disablerequirement/Fingerprints.kt @@ -0,0 +1,17 @@ +package app.revanced.patches.tiktok.misc.login.disablerequirement + +import app.revanced.patcher.fingerprint + +internal val mandatoryLoginServiceFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("/MandatoryLoginService;") && + method.name == "enableForcedLogin" + } +} + +internal val mandatoryLoginService2Fingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("/MandatoryLoginService;") && + method.name == "shouldShowForcedLogin" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/login/fixgoogle/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/login/fixgoogle/Fingerprints.kt new file mode 100644 index 000000000..a40f5251f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/login/fixgoogle/Fingerprints.kt @@ -0,0 +1,22 @@ +package app.revanced.patches.tiktok.misc.login.fixgoogle + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val googleAuthAvailableFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Z") + parameters() + custom { method, _ -> + method.definingClass == "Lcom/bytedance/lobby/google/GoogleAuth;" + } +} + +internal val googleOneTapAuthAvailableFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Z") + parameters() + custom { method, _ -> + method.definingClass == "Lcom/bytedance/lobby/google/GoogleOneTapAuth;" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/login/fixgoogle/FixGoogleLoginPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/login/fixgoogle/FixGoogleLoginPatch.kt new file mode 100644 index 000000000..214868d04 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/login/fixgoogle/FixGoogleLoginPatch.kt @@ -0,0 +1,33 @@ +package app.revanced.patches.tiktok.misc.login.fixgoogle + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val fixGoogleLoginPatch = bytecodePatch( + name = "Fix Google login", + description = "Allows logging in with a Google account.", +) { + compatibleWith( + "com.ss.android.ugc.trill", + "com.zhiliaoapp.musically", + ) + + val googleOneTapAuthAvailableMatch by googleOneTapAuthAvailableFingerprint() + val googleAuthAvailableMatch by googleAuthAvailableFingerprint() + + execute { + listOf( + googleOneTapAuthAvailableMatch.mutableMethod, + googleAuthAvailableMatch.mutableMethod, + ).forEach { method -> + method.addInstructions( + 0, + """ + const/4 v0, 0x0 + return v0 + """, + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/settings/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/settings/Fingerprints.kt new file mode 100644 index 000000000..d1c4d6de6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/settings/Fingerprints.kt @@ -0,0 +1,35 @@ +package app.revanced.patches.tiktok.misc.settings + +import app.revanced.patcher.fingerprint + +internal val addSettingsEntryFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("/SettingNewVersionFragment;") && + method.name == "initUnitManger" + } +} + +internal val adPersonalizationActivityOnCreateFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("/AdPersonalizationActivity;") && + method.name == "onCreate" + } +} + +internal val settingsEntryFingerprint = fingerprint { + strings("pls pass item or extends the EventUnit") +} + +internal val settingsEntryInfoFingerprint = fingerprint { + strings( + "ExposeItem(title=", + ", icon=", + ) +} + +internal val settingsStatusLoadFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("Lapp/revanced/extension/tiktok/settings/SettingsStatus;") && + method.name == "load" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/settings/SettingsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/settings/SettingsPatch.kt new file mode 100644 index 000000000..79ee38711 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/settings/SettingsPatch.kt @@ -0,0 +1,102 @@ +package app.revanced.patches.tiktok.misc.settings + +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.tiktok.misc.extension.sharedExtensionPatch +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction22c +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +internal const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/tiktok/settings/AdPersonalizationActivityHook;" + +@Suppress("unused") +val settingsPatch = bytecodePatch( + name = "Settings", + description = "Adds ReVanced settings to TikTok.", +) { + dependsOn(sharedExtensionPatch) + + compatibleWith( + "com.ss.android.ugc.trill"("36.5.4"), + "com.zhiliaoapp.musically"("36.5.4"), + ) + + val adPersonalizationActivityOnCreateMatch by adPersonalizationActivityOnCreateFingerprint() + val addSettingsEntryMatch by addSettingsEntryFingerprint() + val settingsEntryMatch by settingsEntryFingerprint() + val settingsEntryInfoMatch by settingsEntryInfoFingerprint() + + execute { + val initializeSettingsMethodDescriptor = + "$EXTENSION_CLASS_DESCRIPTOR->initialize(" + + "Lcom/bytedance/ies/ugc/aweme/commercialize/compliance/personalization/AdPersonalizationActivity;" + + ")Z" + + val createSettingsEntryMethodDescriptor = + "$EXTENSION_CLASS_DESCRIPTOR->createSettingsEntry(" + + "Ljava/lang/String;" + + "Ljava/lang/String;" + + ")Ljava/lang/Object;" + + fun String.toClassName(): String = substring(1, this.length - 1).replace("/", ".") + + // Find the class name of classes which construct a settings entry + val settingsButtonClass = settingsEntryMatch.classDef.type.toClassName() + val settingsButtonInfoClass = settingsEntryInfoMatch.classDef.type.toClassName() + + // Create a settings entry for 'revanced settings' and add it to settings fragment + addSettingsEntryMatch.mutableMethod.apply { + val markIndex = implementation!!.instructions.indexOfFirst { + it.opcode == Opcode.IGET_OBJECT && ((it as Instruction22c).reference as FieldReference).name == "headerUnit" + } + + val getUnitManager = getInstruction(markIndex + 2) + val addEntry = getInstruction(markIndex + 1) + + addInstructions( + markIndex + 2, + listOf( + getUnitManager, + addEntry, + ), + ) + + addInstructions( + markIndex + 2, + """ + const-string v0, "$settingsButtonClass" + const-string v1, "$settingsButtonInfoClass" + invoke-static {v0, v1}, $createSettingsEntryMethodDescriptor + move-result-object v0 + check-cast v0, ${settingsEntryMatch.classDef.type} + """, + ) + } + + // Initialize the settings menu once the replaced setting entry is clicked. + adPersonalizationActivityOnCreateMatch.mutableMethod.apply { + val initializeSettingsIndex = implementation!!.instructions.indexOfFirst { + it.opcode == Opcode.INVOKE_SUPER + } + 1 + + val thisRegister = getInstruction(initializeSettingsIndex - 1).registerC + val usableRegister = implementation!!.registerCount - parameters.size - 2 + + addInstructionsWithLabels( + initializeSettingsIndex, + """ + invoke-static {v$thisRegister}, $initializeSettingsMethodDescriptor + move-result v$usableRegister + if-eqz v$usableRegister, :do_not_open + return-void + """, + ExternalLabel("do_not_open", getInstruction(initializeSettingsIndex)), + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patches/tiktok/misc/spoof/sim/SpoofSimPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/spoof/sim/SpoofSimPatch.kt similarity index 58% rename from src/main/kotlin/app/revanced/patches/tiktok/misc/spoof/sim/SpoofSimPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/tiktok/misc/spoof/sim/SpoofSimPatch.kt index 1c40b5d9c..408958179 100644 --- a/src/main/kotlin/app/revanced/patches/tiktok/misc/spoof/sim/SpoofSimPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/misc/spoof/sim/SpoofSimPatch.kt @@ -1,44 +1,46 @@ package app.revanced.patches.tiktok.misc.spoof.sim -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.patch.BytecodePatch -import app.revanced.patcher.patch.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod -import app.revanced.patches.tiktok.misc.integrations.IntegrationsPatch -import app.revanced.patches.tiktok.misc.settings.SettingsPatch -import app.revanced.patches.tiktok.misc.settings.fingerprints.SettingsStatusLoadFingerprint +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.tiktok.misc.extension.sharedExtensionPatch +import app.revanced.patches.tiktok.misc.settings.settingsStatusLoadFingerprint +import app.revanced.patches.twitch.misc.settings.settingsPatch import app.revanced.util.findMutableMethodOf import com.android.tools.smali.dexlib2.Opcode import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c import com.android.tools.smali.dexlib2.iface.reference.MethodReference -@Patch( +@Suppress("unused") +val spoofSimPatch = bytecodePatch( name = "SIM spoof", description = "Spoofs the information which is retrieved from the SIM card.", - dependencies = [IntegrationsPatch::class, SettingsPatch::class], - compatiblePackages = [ - CompatiblePackage("com.ss.android.ugc.trill"), - CompatiblePackage("com.zhiliaoapp.musically"), - ], use = false, -) -@Suppress("unused") -object SpoofSimPatch : BytecodePatch(emptySet()) { - private val replacements = hashMapOf( - "getSimCountryIso" to "getCountryIso", - "getNetworkCountryIso" to "getCountryIso", - "getSimOperator" to "getOperator", - "getNetworkOperator" to "getOperator", - "getSimOperatorName" to "getOperatorName", - "getNetworkOperatorName" to "getOperatorName", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, ) - override fun execute(context: BytecodeContext) { + compatibleWith( + "com.ss.android.ugc.trill", + "com.zhiliaoapp.musically", + ) + + val settingsStatusLoadMatch by settingsStatusLoadFingerprint() + + execute { context -> + val replacements = hashMapOf( + "getSimCountryIso" to "getCountryIso", + "getNetworkCountryIso" to "getCountryIso", + "getSimOperator" to "getOperator", + "getNetworkOperator" to "getOperator", + "getSimOperatorName" to "getOperatorName", + "getNetworkOperatorName" to "getOperatorName", + ) + // Find all api call to check sim information. buildMap { context.classes.forEach { classDef -> @@ -74,7 +76,17 @@ object SpoofSimPatch : BytecodePatch(emptySet()) { with(findMutableMethodOf(method)) { while (!patches.isEmpty()) { val (index, replacement) = patches.removeLast() - replaceReference(index, replacement) + + val resultReg = getInstruction(index + 1).registerA + + // Patch Android API and return fake sim information. + addInstructions( + index + 2, + """ + invoke-static {v$resultReg}, Lapp/revanced/extension/tiktok/spoof/sim/SpoofSimPatch;->$replacement(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$resultReg + """, + ) } } } @@ -82,24 +94,9 @@ object SpoofSimPatch : BytecodePatch(emptySet()) { } // Enable patch in settings. - with(SettingsStatusLoadFingerprint.result!!.mutableMethod) { - addInstruction( - 0, - "invoke-static {}, Lapp/revanced/integrations/tiktok/settings/SettingsStatus;->enableSimSpoof()V", - ) - } - } - - // Patch Android API and return fake sim information. - private fun MutableMethod.replaceReference(index: Int, replacement: String) { - val resultReg = getInstruction(index + 1).registerA - - addInstructions( - index + 2, - """ - invoke-static {v$resultReg}, Lapp/revanced/integrations/tiktok/spoof/sim/SpoofSimPatch;->$replacement(Ljava/lang/String;)Ljava/lang/String; - move-result-object v$resultReg - """, + settingsStatusLoadMatch.mutableMethod.addInstruction( + 0, + "invoke-static {}, Lapp/revanced/extension/tiktok/settings/SettingsStatus;->enableSimSpoof()V", ) } } diff --git a/patches/src/main/kotlin/app/revanced/patches/tiktok/shared/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/tiktok/shared/Fingerprints.kt new file mode 100644 index 000000000..3e98d213e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tiktok/shared/Fingerprints.kt @@ -0,0 +1,30 @@ +package app.revanced.patches.tiktok.shared + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val getEnterFromFingerprint = fingerprint { + returns("Ljava/lang/String;") + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + parameters("Z") + opcodes( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.RETURN_OBJECT, + ) + custom { methodDef, _ -> + methodDef.definingClass.endsWith("/BaseListFragmentPanel;") + } +} + +internal val onRenderFirstFrameFingerprint = fingerprint { + strings("method_enable_viewpager_preload_duration") + custom { _, classDef -> + classDef.endsWith("/BaseListFragmentPanel;") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/trakt/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/trakt/Fingerprints.kt new file mode 100644 index 000000000..4a02c6221 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/trakt/Fingerprints.kt @@ -0,0 +1,25 @@ +package app.revanced.patches.trakt + +import app.revanced.patcher.fingerprint + +internal val isVIPEPFingerprint = fingerprint { + custom { method, classDef -> + if (!classDef.endsWith("RemoteUser;")) return@custom false + + method.name == "isVIPEP" + } +} + +internal val isVIPFingerprint = fingerprint { + custom { method, classDef -> + if (!classDef.endsWith("RemoteUser;")) return@custom false + + method.name == "isVIP" + } +} + +internal val remoteUserFingerprint = fingerprint { + custom { _, classDef -> + classDef.endsWith("RemoteUser;") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/trakt/UnlockProPatch.kt b/patches/src/main/kotlin/app/revanced/patches/trakt/UnlockProPatch.kt new file mode 100644 index 000000000..4b3138d67 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/trakt/UnlockProPatch.kt @@ -0,0 +1,36 @@ +package app.revanced.patches.trakt + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.util.exception + +@Suppress("unused") +val unlockProPatch = bytecodePatch( + name = "Unlock pro", +) { + compatibleWith("tv.trakt.trakt"("1.1.1")) + + val remoteUserMatch by remoteUserFingerprint() + + execute { context -> + remoteUserMatch.classDef.let { remoteUserClass -> + arrayOf(isVIPFingerprint, isVIPEPFingerprint).onEach { fingerprint -> + // Resolve both fingerprints on the same class. + if (!fingerprint.match(context, remoteUserClass)) { + throw fingerprint.exception + } + }.forEach { fingerprint -> + // Return true for both VIP check methods. + fingerprint.match?.mutableMethod?.addInstructions( + 0, + """ + const/4 v0, 0x1 + invoke-static {v0}, Ljava/lang/Boolean;->valueOf(Z)Ljava/lang/Boolean; + move-result-object v1 + return-object v1 + """, + ) ?: throw fingerprint.exception + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tudortmund/lockscreen/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/tudortmund/lockscreen/Fingerprints.kt new file mode 100644 index 000000000..12ffa15c1 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tudortmund/lockscreen/Fingerprints.kt @@ -0,0 +1,15 @@ +package app.revanced.patches.tudortmund.lockscreen + +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val brightnessFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC) + returns("V") + parameters() + custom { method, classDef -> + method.name == "run" && + method.definingClass.contains("/ScreenPlugin\$") && + classDef.fields.any { it.type == "Ljava/lang/Float;" } + } +} diff --git a/src/main/kotlin/app/revanced/patches/tudortmund/lockscreen/patch/ShowOnLockscreenPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tudortmund/lockscreen/ShowOnLockscreenPatch.kt similarity index 70% rename from src/main/kotlin/app/revanced/patches/tudortmund/lockscreen/patch/ShowOnLockscreenPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/tudortmund/lockscreen/ShowOnLockscreenPatch.kt index 368879cfa..c8121f705 100644 --- a/src/main/kotlin/app/revanced/patches/tudortmund/lockscreen/patch/ShowOnLockscreenPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/tudortmund/lockscreen/ShowOnLockscreenPatch.kt @@ -1,36 +1,35 @@ -package app.revanced.patches.tudortmund.lockscreen.patch +package app.revanced.patches.tudortmund.lockscreen -import app.revanced.util.exception -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.instructions import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction -import app.revanced.patcher.patch.BytecodePatch -import app.revanced.patcher.patch.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patches.tudortmund.lockscreen.fingerprints.BrightnessFingerprint +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.tudortmund.misc.extension.sharedExtensionPatch import com.android.tools.smali.dexlib2.Opcode import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction22c import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c import com.android.tools.smali.dexlib2.iface.reference.FieldReference import com.android.tools.smali.dexlib2.iface.reference.MethodReference -@Patch( +internal const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/tudortmund/lockscreen/ShowOnLockscreenPatch;" + +@Suppress("unused") +val showOnLockscreenPatch = bytecodePatch( name = "Show on lockscreen", description = "Shows student id and student ticket on lockscreen.", - compatiblePackages = [CompatiblePackage("de.tudortmund.app")], - requiresIntegrations = true -) -@Suppress("unused") -object ShowOnLockscreenPatch : BytecodePatch( - setOf(BrightnessFingerprint) ) { - private const val INTEGRATIONS_CLASS_DESCRIPTOR = "Lapp/revanced/integrations/tudortmund/lockscreen/ShowOnLockscreenPatch;" + dependsOn(sharedExtensionPatch) - override fun execute(context: BytecodeContext) { - BrightnessFingerprint.result?.mutableMethod?.apply { + compatibleWith("de.tudortmund.app") + + val brightnessMatch by brightnessFingerprint() + + execute { + brightnessMatch.mutableMethod.apply { // Find the instruction where the brightness value is loaded into a register - val brightnessInstruction = implementation!!.instructions.firstNotNullOf { instruction -> + val brightnessInstruction = instructions.firstNotNullOf { instruction -> if (instruction.opcode != Opcode.IGET_OBJECT) return@firstNotNullOf null val getInstruction = instruction as Instruction22c @@ -45,29 +44,30 @@ object ShowOnLockscreenPatch : BytecodePatch( // Gets the index of that instruction and the register of the Activity. val (windowIndex, activityRegister) = implementation!!.instructions.withIndex() .firstNotNullOf { (index, instruction) -> - if (instruction.opcode != Opcode.INVOKE_VIRTUAL) + if (instruction.opcode != Opcode.INVOKE_VIRTUAL) { return@firstNotNullOf null + } val invokeInstruction = instruction as Instruction35c val methodRef = invokeInstruction.reference as MethodReference - if (methodRef.name != "getWindow" || methodRef.returnType != "Landroid/view/Window;") + if (methodRef.name != "getWindow" || methodRef.returnType != "Landroid/view/Window;") { return@firstNotNullOf null + } Pair(index, invokeInstruction.registerC) } // The register in which the brightness value is loaded val brightnessRegister = brightnessInstruction.registerA - // Replaces the getWindow call with our custom one to run the lockscreen code replaceInstruction( windowIndex, "invoke-static { v$activityRegister, v$brightnessRegister }, " + - "$INTEGRATIONS_CLASS_DESCRIPTOR->" + - "getWindow" + - "(Landroidx/appcompat/app/AppCompatActivity;F)" + - "Landroid/view/Window;" + "$EXTENSION_CLASS_DESCRIPTOR->" + + "getWindow" + + "(Landroidx/appcompat/app/AppCompatActivity;F)" + + "Landroid/view/Window;", ) // Normally, the brightness is loaded into a register after the getWindow call. @@ -79,11 +79,10 @@ object ShowOnLockscreenPatch : BytecodePatch( """ invoke-virtual { v$brightnessRegister }, Ljava/lang/Float;->floatValue()F move-result v$brightnessRegister - """ + """, ) addInstruction(windowIndex, brightnessInstruction) - - } ?: throw BrightnessFingerprint.exception + } } } diff --git a/patches/src/main/kotlin/app/revanced/patches/tudortmund/misc/extension/ExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tudortmund/misc/extension/ExtensionPatch.kt new file mode 100644 index 000000000..1c056b0c6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tudortmund/misc/extension/ExtensionPatch.kt @@ -0,0 +1,5 @@ +package app.revanced.patches.tudortmund.misc.extension + +import app.revanced.patches.shared.misc.extension.sharedExtensionPatch + +val sharedExtensionPatch = sharedExtensionPatch() diff --git a/src/main/kotlin/app/revanced/patches/tumblr/ads/DisableDashboardAds.kt b/patches/src/main/kotlin/app/revanced/patches/tumblr/ads/DisableDashboardAds.kt similarity index 66% rename from src/main/kotlin/app/revanced/patches/tumblr/ads/DisableDashboardAds.kt rename to patches/src/main/kotlin/app/revanced/patches/tumblr/ads/DisableDashboardAds.kt index 77d31b9f3..4a9bbe297 100644 --- a/src/main/kotlin/app/revanced/patches/tumblr/ads/DisableDashboardAds.kt +++ b/patches/src/main/kotlin/app/revanced/patches/tumblr/ads/DisableDashboardAds.kt @@ -1,20 +1,19 @@ package app.revanced.patches.tumblr.ads -import app.revanced.patcher.data.BytecodeContext -import app.revanced.patcher.patch.BytecodePatch -import app.revanced.patcher.patch.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patches.tumblr.timelinefilter.TimelineFilterPatch +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.tumblr.timelinefilter.addTimelineObjectTypeFilter +import app.revanced.patches.tumblr.timelinefilter.filterTimelineObjectsPatch -@Patch( +@Suppress("unused") +val disableDashboardAdsPatch = bytecodePatch( name = "Disable dashboard ads", description = "Disables ads in the dashboard.", - compatiblePackages = [CompatiblePackage("com.tumblr")], - dependencies = [TimelineFilterPatch::class], -) -@Suppress("unused") -object DisableDashboardAds : BytecodePatch(emptySet()) { - override fun execute(context: BytecodeContext) { +) { + dependsOn(filterTimelineObjectsPatch) + + compatibleWith("com.tumblr") + + execute { // The timeline object types are filtered by their name in the TimelineObjectType enum. // This is often different from the "object_type" returned in the api (noted in comments here) arrayOf( @@ -31,7 +30,7 @@ object DisableDashboardAds : BytecodePatch(emptySet()) { "FACEBOOK_BIDDAABLE", // "facebook_biddable_sdk_ad" "GOOGLE_NATIVE", // "google_native_ad" ).forEach { - TimelineFilterPatch.addObjectTypeFilter(it) + addTimelineObjectTypeFilter(it) } } } diff --git a/patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/adfree/DisableAdFreeBannerPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/adfree/DisableAdFreeBannerPatch.kt new file mode 100644 index 000000000..c166cb83b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/adfree/DisableAdFreeBannerPatch.kt @@ -0,0 +1,20 @@ +package app.revanced.patches.tumblr.annoyances.adfree + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.tumblr.featureflags.addFeatureFlagOverride +import app.revanced.patches.tumblr.featureflags.overrideFeatureFlagsPatch + +@Suppress("unused") +val disableAdFreeBannerPatch = bytecodePatch( + name = "Disable Ad-Free Banner", + description = "Disables the banner with a frog, prompting you to buy Tumblr Ad-Free.", +) { + dependsOn(overrideFeatureFlagsPatch) + + compatibleWith("com.tumblr") + + execute { + // Disable the "AD_FREE_CTA_BANNER" ("Whether or not to show ad free prompt") feature flag. + addFeatureFlagOverride("adFreeCtaBanner", "false") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/inappupdate/DisableInAppUpdatePatch.kt b/patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/inappupdate/DisableInAppUpdatePatch.kt new file mode 100644 index 000000000..3a46c19b2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/inappupdate/DisableInAppUpdatePatch.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.tumblr.annoyances.inappupdate + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.tumblr.featureflags.addFeatureFlagOverride +import app.revanced.patches.tumblr.featureflags.overrideFeatureFlagsPatch + +@Suppress("unused") +val disableInAppUpdatePatch = bytecodePatch( + name = "Disable in-app update", + description = "Disables the in-app update check and update prompt.", +) { + dependsOn(overrideFeatureFlagsPatch) + + compatibleWith("com.tumblr") + + execute { + // Before checking for updates using Google Play core AppUpdateManager, the value of this feature flag is checked. + // If this flag is false or the last update check was today and no update check is performed. + addFeatureFlagOverride("inAppUpdate", "false") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/notifications/DisableBlogNotificationReminderPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/notifications/DisableBlogNotificationReminderPatch.kt new file mode 100644 index 000000000..fbd7cf712 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/notifications/DisableBlogNotificationReminderPatch.kt @@ -0,0 +1,25 @@ +package app.revanced.patches.tumblr.annoyances.notifications + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val disableBlogNotificationReminderPatch = bytecodePatch( + name = "Disable blog notification reminder", + description = "Disables the reminder to enable notifications for blogs you visit.", +) { + compatibleWith("com.tumblr") + + val isBlogNotifyEnabledMatch by isBlogNotifyEnabledFingerprint() + + execute { + isBlogNotifyEnabledMatch.mutableMethod.addInstructions( + 0, + """ + # Return false for BlogNotifyCtaDialog.isEnabled() method. + const/4 v0, 0x0 + return v0 + """, + ) + } +} diff --git a/src/main/kotlin/app/revanced/patches/tumblr/annoyances/notifications/fingerprints/IsBlogNotifyEnabledFingerprint.kt b/patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/notifications/Fingerprints.kt similarity index 55% rename from src/main/kotlin/app/revanced/patches/tumblr/annoyances/notifications/fingerprints/IsBlogNotifyEnabledFingerprint.kt rename to patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/notifications/Fingerprints.kt index 55b67f2fa..67e051a7b 100644 --- a/src/main/kotlin/app/revanced/patches/tumblr/annoyances/notifications/fingerprints/IsBlogNotifyEnabledFingerprint.kt +++ b/patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/notifications/Fingerprints.kt @@ -1,9 +1,11 @@ -package app.revanced.patches.tumblr.annoyances.notifications.fingerprints +package app.revanced.patches.tumblr.annoyances.notifications -import app.revanced.patcher.fingerprint.MethodFingerprint +import app.revanced.patcher.fingerprint // The BlogNotifyCtaDialog asks you if you want to enable notifications for a blog. // It shows whenever you visit a certain blog for the second time and disables itself // if it was shown a total of 3 times (stored in app storage). // This targets the BlogNotifyCtaDialog.isEnabled() method to let it always return false. -internal object IsBlogNotifyEnabledFingerprint : MethodFingerprint(strings = listOf("isEnabled --> ", "blog_notify_enabled")) \ No newline at end of file +internal val isBlogNotifyEnabledFingerprint = fingerprint { + strings("isEnabled --> ", "blog_notify_enabled") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/popups/DisableGiftMessagePopupPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/popups/DisableGiftMessagePopupPatch.kt new file mode 100644 index 000000000..7f1bc208f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/popups/DisableGiftMessagePopupPatch.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.tumblr.annoyances.popups + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val disableGiftMessagePopupPatch = bytecodePatch( + name = "Disable gift message popup", + description = "Disables the popup suggesting to buy TumblrMart items for other people.", +) { + compatibleWith("com.tumblr") + + val showGiftMessagePopupMatch by showGiftMessagePopupFingerprint() + + execute { + showGiftMessagePopupMatch.mutableMethod.addInstructions(0, "return-void") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/popups/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/popups/Fingerprints.kt new file mode 100644 index 000000000..7d00f2f1b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tumblr/annoyances/popups/Fingerprints.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.tumblr.annoyances.popups + +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +// This method is responsible for loading and displaying the visual Layout of the Gift Message Popup. +internal val showGiftMessagePopupFingerprint = fingerprint { + accessFlags(AccessFlags.FINAL, AccessFlags.PUBLIC) + returns("V") + strings("activity", "anchorView", "textMessage") +} diff --git a/src/main/kotlin/app/revanced/patches/tumblr/featureflags/fingerprints/GetFeatureValueFingerprint.kt b/patches/src/main/kotlin/app/revanced/patches/tumblr/featureflags/Fingerprints.kt similarity index 66% rename from src/main/kotlin/app/revanced/patches/tumblr/featureflags/fingerprints/GetFeatureValueFingerprint.kt rename to patches/src/main/kotlin/app/revanced/patches/tumblr/featureflags/Fingerprints.kt index 0088c3aa6..e24ca2884 100644 --- a/src/main/kotlin/app/revanced/patches/tumblr/featureflags/fingerprints/GetFeatureValueFingerprint.kt +++ b/patches/src/main/kotlin/app/revanced/patches/tumblr/featureflags/Fingerprints.kt @@ -1,9 +1,8 @@ -package app.revanced.patches.tumblr.featureflags.fingerprints +package app.revanced.patches.tumblr.featureflags -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 +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint // This fingerprint targets the method to get the value of a Feature in the class "com.tumblr.configuration.Feature". // Features seem to be Tumblr's A/B testing program. @@ -14,14 +13,14 @@ import com.android.tools.smali.dexlib2.Opcode // Some features seem to be very old and never removed, though, such as Google Login. // The startIndex of the opcode pattern is at the start of the function after the arg null check. // we want to insert our instructions there. -internal object GetFeatureValueFingerprint : MethodFingerprint( - strings = listOf("feature"), - opcodes = listOf( +internal val getFeatureValueFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Ljava/lang/String;") + parameters("L", "Z") + opcodes( Opcode.IF_EQZ, Opcode.INVOKE_STATIC, - Opcode.MOVE_RESULT - ), - returnType = "Ljava/lang/String;", - parameters = listOf("L", "Z"), - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL -) \ No newline at end of file + Opcode.MOVE_RESULT, + ) + strings("feature") +} diff --git a/src/main/kotlin/app/revanced/patches/tumblr/featureflags/OverrideFeatureFlagsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tumblr/featureflags/OverrideFeatureFlagsPatch.kt similarity index 61% rename from src/main/kotlin/app/revanced/patches/tumblr/featureflags/OverrideFeatureFlagsPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/tumblr/featureflags/OverrideFeatureFlagsPatch.kt index 94367009c..e3d420e93 100644 --- a/src/main/kotlin/app/revanced/patches/tumblr/featureflags/OverrideFeatureFlagsPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/tumblr/featureflags/OverrideFeatureFlagsPatch.kt @@ -1,49 +1,47 @@ package app.revanced.patches.tumblr.featureflags -import app.revanced.util.exception -import app.revanced.patcher.data.BytecodeContext import app.revanced.patcher.extensions.InstructionExtensions.addInstructions import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels -import app.revanced.patcher.extensions.or -import app.revanced.patcher.patch.BytecodePatch -import app.revanced.patcher.patch.annotation.Patch +import app.revanced.patcher.patch.bytecodePatch import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable -import app.revanced.patches.tumblr.featureflags.fingerprints.GetFeatureValueFingerprint import com.android.tools.smali.dexlib2.AccessFlags import com.android.tools.smali.dexlib2.builder.MutableMethodImplementation import com.android.tools.smali.dexlib2.immutable.ImmutableMethod import com.android.tools.smali.dexlib2.immutable.ImmutableMethodParameter -@Patch(description = "Forcibly set the value of A/B testing features of your choice.") -object OverrideFeatureFlagsPatch : BytecodePatch( - setOf(GetFeatureValueFingerprint) -) { - /** - * Override a feature flag with a value. - * - * @param name The name of the feature flag to override. - * @param value The value to override the feature flag with. - */ - @Suppress("KDocUnresolvedReference") - internal lateinit var addOverride: (name: String, value: String) -> Unit private set +/** + * Override a feature flag with a value. + * + * @param name The name of the feature flag to override. + * @param value The value to override the feature flag with. + */ +@Suppress("KDocUnresolvedReference") +internal lateinit var addFeatureFlagOverride: (name: String, value: String) -> Unit + private set - override fun execute(context: BytecodeContext) = GetFeatureValueFingerprint.result?.let { - val configurationClass = it.method.definingClass - val featureClass = it.method.parameterTypes[0].toString() +@Suppress("unused") +val overrideFeatureFlagsPatch = bytecodePatch( + description = "Forcibly set the value of A/B testing features of your choice.", +) { + val getFeatureValueMatch by getFeatureValueFingerprint() + + execute { + val configurationClass = getFeatureValueMatch.method.definingClass + val featureClass = getFeatureValueMatch.method.parameterTypes[0].toString() // The method we want to inject into does not have enough registers, so we inject a helper method // and inject more instructions into it later, see addOverride. - // This is not in an integration since the unused variable would get compiled away and the method would + // This is not in an extension since the unused variable would get compiled away and the method would // get compiled to only have one register, which is not enough for our later injected instructions. val helperMethod = ImmutableMethod( - it.method.definingClass, + getFeatureValueMatch.method.definingClass, "getValueOverride", listOf(ImmutableMethodParameter(featureClass, null, "feature")), "Ljava/lang/String;", - AccessFlags.PUBLIC or AccessFlags.FINAL, + AccessFlags.PUBLIC.value or AccessFlags.FINAL.value, null, null, - MutableMethodImplementation(4) + MutableMethodImplementation(4), ).toMutable().apply { // This is the equivalent of // String featureName = feature.toString() @@ -63,36 +61,36 @@ object OverrideFeatureFlagsPatch : BytecodePatch( # If none of the overrides returned a value, we should return null const/4 v0, 0x0 return-object v0 - """ + """, ) }.also { helperMethod -> - it.mutableClass.methods.add(helperMethod) + getFeatureValueMatch.mutableClass.methods.add(helperMethod) } // Here we actually insert the hook to call our helper method and return its value if it returns not null // This is equivalent to // String forcedValue = getValueOverride(feature) // if (forcedValue != null) return forcedValue - val getFeatureIndex = it.scanResult.patternScanResult!!.startIndex - it.mutableMethod.addInstructionsWithLabels( + val getFeatureIndex = getFeatureValueMatch.patternMatch!!.startIndex + getFeatureValueMatch.mutableMethod.addInstructionsWithLabels( getFeatureIndex, """ - # Call the Helper Method with the Feature - invoke-virtual {p0, p1}, $configurationClass->getValueOverride($featureClass)Ljava/lang/String; - move-result-object v0 - # If it returned null, skip - if-eqz v0, :is_null - # If it didnt return null, return that string - return-object v0 - - # If our override helper returned null, we let the function continue normally - :is_null - nop - """ + # Call the Helper Method with the Feature + invoke-virtual {p0, p1}, $configurationClass->getValueOverride($featureClass)Ljava/lang/String; + move-result-object v0 + # If it returned null, skip + if-eqz v0, :is_null + # If it didnt return null, return that string + return-object v0 + + # If our override helper returned null, we let the function continue normally + :is_null + nop + """, ) val helperInsertIndex = 2 - addOverride = { name, value -> + addFeatureFlagOverride = { name, value -> // For every added override, we add a few instructions in the middle of the helper method // to check if the feature is the one we want and return the override value if it is. // This is equivalent to @@ -112,8 +110,8 @@ object OverrideFeatureFlagsPatch : BytecodePatch( # Else we just continue... :no_override nop - """ + """, ) } - } ?: throw GetFeatureValueFingerprint.exception -} \ No newline at end of file + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tumblr/fixes/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/tumblr/fixes/Fingerprints.kt new file mode 100644 index 000000000..11616fcc9 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tumblr/fixes/Fingerprints.kt @@ -0,0 +1,23 @@ +package app.revanced.patches.tumblr.fixes + +import com.android.tools.smali.dexlib2.Opcode +import app.revanced.patcher.fingerprint + +// Fingerprint for the addQueryParam method from retrofit2 +// https://github.com/square/retrofit/blob/trunk/retrofit/src/main/java/retrofit2/RequestBuilder.java#L186 +// Injecting here allows modifying dynamically set query parameters +internal val addQueryParamFingerprint = fingerprint { + parameters("Ljava/lang/String;", "Ljava/lang/String;", "Z") + strings("Malformed URL. Base: ", ", Relative: ") +} + +// Fingerprint for the parseHttpMethodAndPath method from retrofit2 +// https://github.com/square/retrofit/blob/ebf87b10997e2136af4d335276fa950221852c64/retrofit/src/main/java/retrofit2/RequestFactory.java#L270-L302 +// Injecting here allows modifying the path/query params of API endpoints defined via annotations +internal val httpPathParserFingerprint = fingerprint { + opcodes( + Opcode.IPUT_OBJECT, + Opcode.IPUT_BOOLEAN, + ) + strings("Only one HTTP method is allowed. Found: %s and %s.") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tumblr/fixes/FixOldVersionsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tumblr/fixes/FixOldVersionsPatch.kt new file mode 100644 index 000000000..d51aead2b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tumblr/fixes/FixOldVersionsPatch.kt @@ -0,0 +1,57 @@ +package app.revanced.patches.tumblr.fixes + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val fixOldVersionsPatch = bytecodePatch( + name = "Fix old versions", + description = "Fixes old versions of the app (v33.2 and earlier) breaking due to Tumblr removing remnants of Tumblr" + + " Live from the API, which causes many requests to fail. This patch has no effect on newer versions of the app.", + use = false, +) { + compatibleWith("com.tumblr") + + val httpPathParserMatch by httpPathParserFingerprint() + val addQueryParamMatch by addQueryParamFingerprint() + + execute { + val liveQueryParameters = listOf( + ",?live_now", + ",?live_streaming_user_id", + ) + + // Remove the live query parameters from the path when it's specified via a @METHOD annotation. + for (liveQueryParameter in liveQueryParameters) { + httpPathParserMatch.mutableMethod.addInstructions( + httpPathParserMatch.patternMatch!!.endIndex + 1, + """ + # urlPath = urlPath.replace(liveQueryParameter, "") + const-string p1, "$liveQueryParameter" + const-string p3, "" + invoke-virtual {p2, p1, p3}, Ljava/lang/String;->replace(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Ljava/lang/String; + move-result-object p2 + """, + ) + } + + // Remove the live query parameters when passed via a parameter which has the @Query annotation. + // e.g. an API call could be defined like this: + // @GET("api/me/info") + // ApiResponse getCurrentUserInfo(@Query("fields[blog]") String value) + // which would result in the path "api/me/inf0?fields[blog]=${value}" + // Here we make sure that this value doesn't contain the broken query parameters. + for (liveQueryParameter in liveQueryParameters) { + addQueryParamMatch.mutableMethod.addInstructions( + 0, + """ + # queryParameterValue = queryParameterValue.replace(liveQueryParameter, "") + const-string v0, "$liveQueryParameter" + const-string v1, "" + invoke-virtual {p2, v0, v1}, Ljava/lang/String;->replace(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Ljava/lang/String; + move-result-object p2 + """, + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tumblr/live/DisableTumblrLivePatch.kt b/patches/src/main/kotlin/app/revanced/patches/tumblr/live/DisableTumblrLivePatch.kt new file mode 100644 index 000000000..64fa2ed79 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tumblr/live/DisableTumblrLivePatch.kt @@ -0,0 +1,29 @@ +package app.revanced.patches.tumblr.live + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.tumblr.featureflags.addFeatureFlagOverride +import app.revanced.patches.tumblr.featureflags.overrideFeatureFlagsPatch +import app.revanced.patches.tumblr.timelinefilter.addTimelineObjectTypeFilter +import app.revanced.patches.tumblr.timelinefilter.filterTimelineObjectsPatch + +@Suppress("unused") +@Deprecated("Tumblr Live was removed and is no longer served in the feed, making this patch useless.") +val disableTumblrLivePatch = bytecodePatch( + description = "Disable the Tumblr Live tab button and dashboard carousel.", +) { + dependsOn( + overrideFeatureFlagsPatch, + filterTimelineObjectsPatch, + ) + + compatibleWith("com.tumblr") + + execute { + // Hide the LIVE_MARQUEE timeline element that appears in the feed + // Called "live_marquee" in api response + addTimelineObjectTypeFilter("LIVE_MARQUEE") + + // Hide the Tab button for Tumblr Live by forcing the feature flag to false + addFeatureFlagOverride("liveStreaming", "false") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tumblr/misc/extension/ExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tumblr/misc/extension/ExtensionPatch.kt new file mode 100644 index 000000000..3a69b74a5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tumblr/misc/extension/ExtensionPatch.kt @@ -0,0 +1,5 @@ +package app.revanced.patches.tumblr.misc.extension + +import app.revanced.patches.shared.misc.extension.sharedExtensionPatch + +val sharedExtensionPatch = sharedExtensionPatch() diff --git a/patches/src/main/kotlin/app/revanced/patches/tumblr/timelinefilter/FilterTimelineObjectsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/tumblr/timelinefilter/FilterTimelineObjectsPatch.kt new file mode 100644 index 000000000..ff44e1bdb --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tumblr/timelinefilter/FilterTimelineObjectsPatch.kt @@ -0,0 +1,67 @@ +package app.revanced.patches.tumblr.timelinefilter + +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.removeInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.tumblr.misc.extension.sharedExtensionPatch +import com.android.tools.smali.dexlib2.builder.instruction.BuilderInstruction35c + +/** + * Add a filter to hide the given timeline object type. + * The list of all Timeline object types is found in the TimelineObjectType class, + * where they are mapped from their api name (returned by tumblr via the HTTP API) to the enum value name. + * + * @param typeName The enum name of the timeline object type to hide. + */ +@Suppress("KDocUnresolvedReference") +lateinit var addTimelineObjectTypeFilter: (typeName: String) -> Unit + +@Suppress("unused") +val filterTimelineObjectsPatch = bytecodePatch( + description = "Filter timeline objects.", +) { + dependsOn(sharedExtensionPatch) + + val timelineConstructorMatch by timelineConstructorFingerprint() + val timelineFilterExtensionMatch by timelineFilterExtensionFingerprint() + val postsResponseConstructorMatch by postsResponseConstructorFingerprint() + + execute { + val filterInsertIndex = timelineFilterExtensionMatch.patternMatch!!.startIndex + + timelineFilterExtensionMatch.mutableMethod.apply { + val addInstruction = getInstruction(filterInsertIndex + 1) + + val filterListRegister = addInstruction.registerC + val stringRegister = addInstruction.registerD + + // Remove "BLOCKED_OBJECT_DUMMY" + removeInstructions(filterInsertIndex, 2) + + addTimelineObjectTypeFilter = { typeName -> + // blockedObjectTypes.add({typeName}) + addInstructionsWithLabels( + filterInsertIndex, + """ + const-string v$stringRegister, "$typeName" + invoke-virtual { v$filterListRegister, v$stringRegister }, Ljava/util/HashSet;->add(Ljava/lang/Object;)Z + """, + ) + } + } + + mapOf( + timelineConstructorMatch to 1, + postsResponseConstructorMatch to 2, + ).forEach { (match, timelineObjectsRegister) -> + match.mutableMethod.addInstructions( + 0, + "invoke-static {p$timelineObjectsRegister}, " + + "Lapp/revanced/extension/tumblr/patches/TimelineFilterPatch;->" + + "filterTimeline(Ljava/util/List;)V", + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/tumblr/timelinefilter/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/tumblr/timelinefilter/Fingerprints.kt new file mode 100644 index 000000000..b8ed3bfc4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/tumblr/timelinefilter/Fingerprints.kt @@ -0,0 +1,36 @@ +package app.revanced.patches.tumblr.timelinefilter + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +// This is the constructor of the PostsResponse class. +// The same applies here as with the TimelineConstructorFingerprint. +internal val postsResponseConstructorFingerprint = fingerprint { + accessFlags(AccessFlags.CONSTRUCTOR, AccessFlags.PUBLIC) + custom { method, classDef -> classDef.endsWith("/PostsResponse;") && method.parameters.size == 4 } +} + +// This is the constructor of the Timeline class. +// It receives the List as an argument with a @Json annotation, so this should be the first time +// that the List is exposed in non-library code. +internal val timelineConstructorFingerprint = fingerprint { + strings("timelineObjectsList") + custom { method, classDef -> + classDef.endsWith("/Timeline;") && method.parameters[0].type == "Ljava/util/List;" + } +} + +// This fingerprints the extension TimelineFilterPatch.filterTimeline method. +// The opcode fingerprint is searching for +// if ("BLOCKED_OBJECT_DUMMY".equals(elementType)) iterator.remove(); +internal val timelineFilterExtensionFingerprint = fingerprint { + opcodes( + Opcode.CONST_STRING, // "BLOCKED_OBJECT_DUMMY" + Opcode.INVOKE_VIRTUAL, // HashSet.add(^) + ) + strings("BLOCKED_OBJECT_DUMMY") + custom { _, classDef -> + classDef.endsWith("/TimelineFilterPatch;") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitch/ad/audio/AudioAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitch/ad/audio/AudioAdsPatch.kt new file mode 100644 index 000000000..6827a99e3 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitch/ad/audio/AudioAdsPatch.kt @@ -0,0 +1,48 @@ +package app.revanced.patches.twitch.ad.audio + +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.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.twitch.misc.extension.sharedExtensionPatch +import app.revanced.patches.twitch.misc.settings.PreferenceScreen +import app.revanced.patches.twitch.misc.settings.settingsPatch + +@Suppress("unused") +val audioAdsPatch = bytecodePatch( + name = "Block audio ads", + description = "Blocks audio ads in streams and VODs.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith("tv.twitch.android.app"("15.4.1", "16.1.0", "16.9.1")) + + val audioAdsPresenterPlayMatch by audioAdsPresenterPlayFingerprint() + + execute { + addResources("twitch", "ad.audio.audioAdsPatch") + + PreferenceScreen.ADS.CLIENT_SIDE.addPreferences( + SwitchPreference("revanced_block_audio_ads"), + ) + + // Block playAds call + audioAdsPresenterPlayMatch.mutableMethod.addInstructionsWithLabels( + 0, + """ + invoke-static { }, Lapp/revanced/extension/twitch/patches/AudioAdsPatch;->shouldBlockAudioAds()Z + move-result v0 + if-eqz v0, :show_audio_ads + return-void + """, + ExternalLabel("show_audio_ads", audioAdsPresenterPlayMatch.mutableMethod.getInstruction(0)), + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitch/ad/audio/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/twitch/ad/audio/Fingerprints.kt new file mode 100644 index 000000000..21e9cb6d2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitch/ad/audio/Fingerprints.kt @@ -0,0 +1,9 @@ +package app.revanced.patches.twitch.ad.audio + +import app.revanced.patcher.fingerprint + +internal val audioAdsPresenterPlayFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("AudioAdsPlayerPresenter;") && method.name == "playAd" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitch/ad/embedded/EmbeddedAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitch/ad/embedded/EmbeddedAdsPatch.kt new file mode 100644 index 000000000..4d36bc22d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitch/ad/embedded/EmbeddedAdsPatch.kt @@ -0,0 +1,44 @@ +package app.revanced.patches.twitch.ad.embedded + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.shared.misc.settings.preference.ListPreference +import app.revanced.patches.twitch.ad.video.videoAdsPatch +import app.revanced.patches.twitch.misc.extension.sharedExtensionPatch +import app.revanced.patches.twitch.misc.settings.PreferenceScreen +import app.revanced.patches.twitch.misc.settings.settingsPatch + +@Suppress("unused") +val embeddedAdsPatch = bytecodePatch( + name = "Block embedded ads", + description = "Blocks embedded stream ads using services like Luminous or PurpleAdBlocker.", +) { + dependsOn( + videoAdsPatch, + sharedExtensionPatch, + settingsPatch, + ) + + compatibleWith("tv.twitch.android.app"("15.4.1", "16.1.0", "16.9.1")) + + val createUsherClientMatch by createsUsherClientFingerprint() + + execute { + addResources("twitch", "ad.embedded.embeddedAdsPatch") + + PreferenceScreen.ADS.SURESTREAM.addPreferences( + ListPreference("revanced_block_embedded_ads", summaryKey = null), + ) + + // Inject OkHttp3 application interceptor + createUsherClientMatch.mutableMethod.addInstructions( + 3, + """ + invoke-static {}, Lapp/revanced/extension/twitch/patches/EmbeddedAdsPatch;->createRequestInterceptor()Lapp/revanced/extension/twitch/api/RequestInterceptor; + move-result-object v2 + invoke-virtual {v0, v2}, Lokhttp3/OkHttpClient${"$"}Builder;->addInterceptor(Lokhttp3/Interceptor;)Lokhttp3/OkHttpClient${"$"}Builder; + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitch/ad/embedded/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/twitch/ad/embedded/Fingerprints.kt new file mode 100644 index 000000000..cbf817469 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitch/ad/embedded/Fingerprints.kt @@ -0,0 +1,9 @@ +package app.revanced.patches.twitch.ad.embedded + +import app.revanced.patcher.fingerprint + +internal val createsUsherClientFingerprint = fingerprint { + custom { method, _ -> + method.definingClass.endsWith("Ltv/twitch/android/network/OkHttpClientFactory;") && method.name == "buildOkHttpClient" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitch/ad/shared/util/AdPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitch/ad/shared/util/AdPatch.kt new file mode 100644 index 000000000..48ecefba8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitch/ad/shared/util/AdPatch.kt @@ -0,0 +1,67 @@ +package app.revanced.patches.twitch.ad.shared.util + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.BytecodePatchBuilder +import app.revanced.patcher.patch.BytecodePatchContext +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel + +fun adPatch( + conditionCall: String, + skipLabelName: String, + block: BytecodePatchBuilder.( + createConditionInstructions: (register: String) -> String, + blockMethods: BytecodePatchContext.( + clazz: String, + methodNames: Set, + returnMethod: ReturnMethod, + ) -> Boolean, + ) -> Unit, +) = bytecodePatch { + fun createConditionInstructions(register: String) = """ + invoke-static { }, $conditionCall + move-result $register + if-eqz $register, :$skipLabelName + """ + + fun BytecodePatchContext.blockMethods( + classDefType: String, + methodNames: Set, + returnMethod: ReturnMethod, + ) = with(classBy { classDefType == it.type }?.mutableClass) { + this ?: return false + + methods.filter { it.name in methodNames }.forEach { + val retInstruction = when (returnMethod.returnType) { + 'V' -> "return-void" + 'Z' -> + """ + const/4 v0, ${returnMethod.value} + return v0 + """ + + else -> throw NotImplementedError() + } + + it.addInstructionsWithLabels( + 0, + """ + ${createConditionInstructions("v0")} + $retInstruction + """, + ExternalLabel(skipLabelName, it.getInstruction(0)), + ) + } + + true + } + + block(::createConditionInstructions, BytecodePatchContext::blockMethods) +} + +class ReturnMethod(val returnType: Char, val value: String) { + companion object { + val default = ReturnMethod('V', "") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitch/ad/video/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/twitch/ad/video/Fingerprints.kt new file mode 100644 index 000000000..d03449733 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitch/ad/video/Fingerprints.kt @@ -0,0 +1,28 @@ +package app.revanced.patches.twitch.ad.video + +import app.revanced.patcher.fingerprint + +internal val checkAdEligibilityLambdaFingerprint = fingerprint { + returns("Lio/reactivex/Single;") + parameters("L") + custom { method, _ -> + method.definingClass.endsWith("/AdEligibilityFetcher;") && + method.name == "shouldRequestAd" + } +} + +internal val contentConfigShowAdsFingerprint = fingerprint { + returns("Z") + parameters() + custom { method, _ -> + method.definingClass.endsWith("/ContentConfigData;") && method.name == "getShowAds" + } +} + +internal val getReadyToShowAdFingerprint = fingerprint { + returns("Ltv/twitch/android/core/mvp/presenter/StateAndAction;") + parameters("L", "L") + custom { method, _ -> + method.definingClass.endsWith("/StreamDisplayAdsPresenter;") && method.name == "getReadyToShowAdOrAbort" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitch/ad/video/VideoAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitch/ad/video/VideoAdsPatch.kt new file mode 100644 index 000000000..5c0eed6eb --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitch/ad/video/VideoAdsPatch.kt @@ -0,0 +1,167 @@ +package app.revanced.patches.twitch.ad.video + +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.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.twitch.ad.shared.util.ReturnMethod +import app.revanced.patches.twitch.ad.shared.util.adPatch +import app.revanced.patches.twitch.misc.extension.sharedExtensionPatch +import app.revanced.patches.twitch.misc.settings.PreferenceScreen +import app.revanced.patches.twitch.misc.settings.settingsPatch + +val videoAdsPatch = bytecodePatch( + name = "Block video ads", + description = "Blocks video ads in streams and VODs.", +) { + val conditionCall = "Lapp/revanced/extension/twitch/patches/VideoAdsPatch;->shouldBlockVideoAds()Z" + val skipLabelName = "show_video_ads" + + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + adPatch(conditionCall, skipLabelName) { createConditionInstructions, blockMethods -> + val checkAdEligibilityLambdaMatch by checkAdEligibilityLambdaFingerprint() + val getReadyToShowAdMatch by getReadyToShowAdFingerprint() + val contentConfigShowAdsMatch by contentConfigShowAdsFingerprint() + + execute { context -> + addResources("twitch", "ad.video.videoAdsPatch") + + PreferenceScreen.ADS.CLIENT_SIDE.addPreferences( + SwitchPreference("revanced_block_video_ads"), + ) + + /* Amazon ads SDK */ + context.blockMethods( + "Lcom/amazon/ads/video/player/AdsManagerImpl;", + setOf("playAds"), + ReturnMethod.default, + ) + + /* Twitch ads manager */ + context.blockMethods( + "Ltv/twitch/android/shared/ads/VideoAdManager;", + setOf( + "checkAdEligibilityAndRequestAd", + "requestAd", + "requestAds", + ), + ReturnMethod.default, + ) + + /* Various ad presenters */ + context.blockMethods( + "Ltv/twitch/android/shared/ads/AdsPlayerPresenter;", + setOf( + "requestAd", + "requestFirstAd", + "requestFirstAdIfEligible", + "requestMidroll", + "requestAdFromMultiAdFormatEvent", + ), + ReturnMethod.default, + ) + + context.blockMethods( + "Ltv/twitch/android/shared/ads/AdsVodPlayerPresenter;", + setOf( + "requestAd", + "requestFirstAd", + ), + ReturnMethod.default, + ) + + context.blockMethods( + "Ltv/twitch/android/feature/theatre/ads/AdEdgeAllocationPresenter;", + setOf( + "parseAdAndCheckEligibility", + "requestAdsAfterEligibilityCheck", + "showAd", + "bindMultiAdFormatAllocation", + ), + ReturnMethod.default, + ) + + /* A/B ad testing experiments */ + context.blockMethods( + "Ltv/twitch/android/provider/experiments/helpers/DisplayAdsExperimentHelper;", + setOf("areDisplayAdsEnabled"), + ReturnMethod('Z', "0"), + ) + + context.blockMethods( + "Ltv/twitch/android/shared/ads/tracking/MultiFormatAdsTrackingExperiment;", + setOf( + "shouldUseMultiAdFormatTracker", + "shouldUseVideoAdTracker", + ), + ReturnMethod('Z', "0"), + ) + + context.blockMethods( + "Ltv/twitch/android/shared/ads/MultiformatAdsExperiment;", + setOf( + "shouldDisableClientSideLivePreroll", + "shouldDisableClientSideVodPreroll", + ), + ReturnMethod('Z', "1"), + ) + + // Pretend our player is ineligible for all ads. + checkAdEligibilityLambdaMatch.mutableMethod.addInstructionsWithLabels( + 0, + """ + ${createConditionInstructions("v0")} + const/4 v0, 0 + invoke-static {v0}, Lio/reactivex/Single;->just(Ljava/lang/Object;)Lio/reactivex/Single; + move-result-object p0 + return-object p0 + """, + ExternalLabel( + skipLabelName, + checkAdEligibilityLambdaMatch.mutableMethod.getInstruction(0), + ), + ) + + val adFormatDeclined = + "Ltv/twitch/android/shared/display/ads/theatre/StreamDisplayAdsPresenter\$Action\$AdFormatDeclined;" + getReadyToShowAdMatch.mutableMethod.addInstructionsWithLabels( + 0, + """ + ${createConditionInstructions("v0")} + sget-object p2, $adFormatDeclined->INSTANCE:$adFormatDeclined + invoke-static {p1, p2}, Ltv/twitch/android/core/mvp/presenter/StateMachineKt;->plus(Ltv/twitch/android/core/mvp/presenter/PresenterState;Ltv/twitch/android/core/mvp/presenter/PresenterAction;)Ltv/twitch/android/core/mvp/presenter/StateAndAction; + move-result-object p1 + return-object p1 + """, + ExternalLabel(skipLabelName, getReadyToShowAdMatch.mutableMethod.getInstruction(0)), + ) + + // Spoof showAds JSON field. + contentConfigShowAdsMatch.mutableMethod.addInstructions( + 0, + """ + ${createConditionInstructions("v0")} + const/4 v0, 0 + :$skipLabelName + return v0 + """, + ) + } + }, + ) + + compatibleWith( + "tv.twitch.android.app"( + "15.4.1", + "16.1.0", + "16.9.1", + ), + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitch/chat/antidelete/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/twitch/chat/antidelete/Fingerprints.kt new file mode 100644 index 000000000..21da99a5b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitch/chat/antidelete/Fingerprints.kt @@ -0,0 +1,24 @@ +package app.revanced.patches.twitch.chat.antidelete + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val chatUtilCreateDeletedSpanFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("ChatUtil\$Companion;") && method.name == "createDeletedSpanFromChatMessageSpan" + } +} + +internal val deletedMessageClickableSpanCtorFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + returns("V") + custom { _, classDef -> + classDef.endsWith("DeletedMessageClickableSpan;") + } +} + +internal val setHasModAccessFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("DeletedMessageClickableSpan;") && method.name == "setHasModAccess" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitch/chat/antidelete/ShowDeletedMessagesPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitch/chat/antidelete/ShowDeletedMessagesPatch.kt new file mode 100644 index 000000000..93996e77a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitch/chat/antidelete/ShowDeletedMessagesPatch.kt @@ -0,0 +1,78 @@ +package app.revanced.patches.twitch.chat.antidelete + +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.smali.ExternalLabel +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.ListPreference +import app.revanced.patches.twitch.misc.extension.sharedExtensionPatch +import app.revanced.patches.twitch.misc.settings.PreferenceScreen +import app.revanced.patches.twitch.misc.settings.settingsPatch + +@Suppress("unused") +val showDeletedMessagesPatch = bytecodePatch( + name = "Show deleted messages", + description = "Shows deleted chat messages behind a clickable spoiler.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith("tv.twitch.android.app"("15.4.1", "16.1.0", "16.9.1")) + + fun createSpoilerConditionInstructions(register: String = "v0") = """ + invoke-static {}, Lapp/revanced/extension/twitch/patches/ShowDeletedMessagesPatch;->shouldUseSpoiler()Z + move-result $register + if-eqz $register, :no_spoiler + """ + + val setHasModAccessMatch by setHasModAccessFingerprint() + val deletedMessageClickableSpanCtorMatch by deletedMessageClickableSpanCtorFingerprint() + val chatUtilCreateDeletedSpanMatch by chatUtilCreateDeletedSpanFingerprint() + + execute { + addResources("twitch", "chat.antidelete.showDeletedMessagesPatch") + + PreferenceScreen.CHAT.GENERAL.addPreferences( + ListPreference( + key = "revanced_show_deleted_messages", + summaryKey = null, + ), + ) + + // Spoiler mode: Force set hasModAccess member to true in constructor + deletedMessageClickableSpanCtorMatch.mutableMethod.apply { + addInstructionsWithLabels( + implementation!!.instructions.lastIndex, /* place in front of return-void */ + """ + ${createSpoilerConditionInstructions()} + const/4 v0, 1 + iput-boolean v0, p0, $definingClass->hasModAccess:Z + """, + ExternalLabel("no_spoiler", getInstruction(implementation!!.instructions.lastIndex)), + ) + } + + // Spoiler mode: Disable setHasModAccess setter + setHasModAccessMatch.mutableMethod.addInstruction(0, "return-void") + + // Cross-out mode: Reformat span of deleted message + chatUtilCreateDeletedSpanMatch.mutableMethod.apply { + addInstructionsWithLabels( + 0, + """ + invoke-static {p2}, Lapp/revanced/extension/twitch/patches/ShowDeletedMessagesPatch;->reformatDeletedMessage(Landroid/text/Spanned;)Landroid/text/Spanned; + move-result-object v0 + if-eqz v0, :no_reformat + return-object v0 + """, + ExternalLabel("no_reformat", getInstruction(0)), + ) + } + } +} diff --git a/src/main/kotlin/app/revanced/patches/twitch/chat/autoclaim/AutoClaimChannelPointsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitch/chat/autoclaim/AutoClaimChannelPointsPatch.kt similarity index 50% rename from src/main/kotlin/app/revanced/patches/twitch/chat/autoclaim/AutoClaimChannelPointsPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/twitch/chat/autoclaim/AutoClaimChannelPointsPatch.kt index 740f8cedf..15a03f7c3 100644 --- a/src/main/kotlin/app/revanced/patches/twitch/chat/autoclaim/AutoClaimChannelPointsPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/twitch/chat/autoclaim/AutoClaimChannelPointsPatch.kt @@ -1,41 +1,42 @@ package app.revanced.patches.twitch.chat.autoclaim -import app.revanced.patcher.data.BytecodeContext 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.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch +import app.revanced.patcher.patch.bytecodePatch import app.revanced.patcher.util.smali.ExternalLabel -import app.revanced.patches.all.misc.resources.AddResourcesPatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch import app.revanced.patches.shared.misc.settings.preference.SwitchPreference -import app.revanced.patches.twitch.chat.autoclaim.fingerprints.CommunityPointsButtonViewDelegateFingerprint -import app.revanced.patches.twitch.misc.settings.SettingsPatch -import app.revanced.util.exception +import app.revanced.patches.twitch.misc.settings.PreferenceScreen +import app.revanced.patches.twitch.misc.settings.settingsPatch -@Patch( +@Suppress("unused") +val autoClaimChannelPointsPatch = bytecodePatch( name = "Auto claim channel points", description = "Automatically claim Channel Points.", - dependencies = [SettingsPatch::class, AddResourcesPatch::class], - compatiblePackages = [CompatiblePackage("tv.twitch.android.app", ["15.4.1", "16.1.0", "16.9.1"])] -) -@Suppress("unused") -object AutoClaimChannelPointsPatch : BytecodePatch( - setOf(CommunityPointsButtonViewDelegateFingerprint) ) { - override fun execute(context: BytecodeContext) { - AddResourcesPatch(this::class) + dependsOn( + settingsPatch, + addResourcesPatch, + ) - SettingsPatch.PreferenceScreen.CHAT.GENERAL.addPreferences( - SwitchPreference("revanced_auto_claim_channel_points") + compatibleWith("tv.twitch.android.app"("15.4.1", "16.1.0", "16.9.1")) + + val communityPointsButtonViewDelegateMatch by communityPointsButtonViewDelegateFingerprint() + + execute { + addResources("twitch", "chat.autoclaim.autoClaimChannelPointsPatch") + + PreferenceScreen.CHAT.GENERAL.addPreferences( + SwitchPreference("revanced_auto_claim_channel_points"), ) - CommunityPointsButtonViewDelegateFingerprint.result?.mutableMethod?.apply { + communityPointsButtonViewDelegateMatch.mutableMethod.apply { val lastIndex = implementation!!.instructions.lastIndex addInstructionsWithLabels( lastIndex, // place in front of return-void """ - invoke-static {}, Lapp/revanced/integrations/twitch/patches/AutoClaimChannelPointsPatch;->shouldAutoClaim()Z + invoke-static {}, Lapp/revanced/extension/twitch/patches/AutoClaimChannelPointsPatch;->shouldAutoClaim()Z move-result v0 if-eqz v0, :auto_claim @@ -44,8 +45,8 @@ object AutoClaimChannelPointsPatch : BytecodePatch( iget-object v0, p0, Ltv/twitch/android/shared/community/points/viewdelegate/CommunityPointsButtonViewDelegate;->buttonLayout:Landroid/view/ViewGroup; invoke-virtual { v0 }, Landroid/view/View;->callOnClick()Z """, - ExternalLabel("auto_claim", getInstruction(lastIndex)) + ExternalLabel("auto_claim", getInstruction(lastIndex)), ) - } ?: throw CommunityPointsButtonViewDelegateFingerprint.exception + } } } diff --git a/patches/src/main/kotlin/app/revanced/patches/twitch/chat/autoclaim/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/twitch/chat/autoclaim/Fingerprints.kt new file mode 100644 index 000000000..80abc9ac4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitch/chat/autoclaim/Fingerprints.kt @@ -0,0 +1,10 @@ +package app.revanced.patches.twitch.chat.autoclaim + +import app.revanced.patcher.fingerprint + +internal val communityPointsButtonViewDelegateFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("CommunityPointsButtonViewDelegate;") && + method.name == "showClaimAvailable" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitch/debug/DebugModePatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitch/debug/DebugModePatch.kt new file mode 100644 index 000000000..25cd5acc1 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitch/debug/DebugModePatch.kt @@ -0,0 +1,52 @@ +package app.revanced.patches.twitch.debug + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.twitch.misc.extension.sharedExtensionPatch +import app.revanced.patches.twitch.misc.settings.PreferenceScreen +import app.revanced.patches.twitch.misc.settings.settingsPatch + +@Suppress("unused") +val debugModePatch = bytecodePatch( + name = "Debug mode", + description = "Enables Twitch's internal debugging mode.", + use = false, +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith("tv.twitch.android.app") + + val isDebugConfigEnabledMatch by isDebugConfigEnabledFingerprint() + val isOmVerificationEnabledMatch by isOmVerificationEnabledFingerprint() + val shouldShowDebugOptionsMatch by shouldShowDebugOptionsFingerprint() + + execute { + addResources("twitch", "debug.debugModePatch") + + PreferenceScreen.MISC.OTHER.addPreferences( + SwitchPreference("revanced_twitch_debug_mode"), + ) + + listOf( + isDebugConfigEnabledMatch, + isOmVerificationEnabledMatch, + shouldShowDebugOptionsMatch, + ).forEach { + it.mutableMethod.addInstructions( + 0, + """ + invoke-static {}, Lapp/revanced/extension/twitch/patches/DebugModePatch;->isDebugModeEnabled()Z + move-result v0 + return v0 + """, + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitch/debug/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/twitch/debug/Fingerprints.kt new file mode 100644 index 000000000..665180c19 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitch/debug/Fingerprints.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.twitch.debug + +import app.revanced.patcher.fingerprint + +internal val isDebugConfigEnabledFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("/BuildConfigUtil;") && method.name == "isDebugConfigEnabled" + } +} + +internal val isOmVerificationEnabledFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("/BuildConfigUtil;") && method.name == "isOmVerificationEnabled" + } +} + +internal val shouldShowDebugOptionsFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("/BuildConfigUtil;") && method.name == "shouldShowDebugOptions" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitch/misc/extension/Hooks.kt b/patches/src/main/kotlin/app/revanced/patches/twitch/misc/extension/Hooks.kt new file mode 100644 index 000000000..9a46867ab --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitch/misc/extension/Hooks.kt @@ -0,0 +1,9 @@ +package app.revanced.patches.twitch.misc.extension + +import app.revanced.patches.shared.misc.extension.extensionHook + +internal val initHook = extensionHook { + custom { method, classDef -> + method.name == "onCreate" && classDef.endsWith("/TwitchApplication;") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitch/misc/extension/SharedExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitch/misc/extension/SharedExtensionPatch.kt new file mode 100644 index 000000000..2299d3bb5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitch/misc/extension/SharedExtensionPatch.kt @@ -0,0 +1,5 @@ +package app.revanced.patches.twitch.misc.extension + +import app.revanced.patches.shared.misc.extension.sharedExtensionPatch + +val sharedExtensionPatch = sharedExtensionPatch(initHook) diff --git a/patches/src/main/kotlin/app/revanced/patches/twitch/misc/settings/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/twitch/misc/settings/Fingerprints.kt new file mode 100644 index 000000000..43d5bb39b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitch/misc/settings/Fingerprints.kt @@ -0,0 +1,34 @@ +package app.revanced.patches.twitch.misc.settings + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val menuGroupsOnClickFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.STATIC, AccessFlags.FINAL) + returns("V") + parameters("L", "L", "L") + custom { method, classDef -> + classDef.endsWith("/SettingsMenuViewDelegate;") && + method.name.contains("render") + } +} + +internal val menuGroupsUpdatedFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("/SettingsMenuPresenter\$Event\$MenuGroupsUpdated;") && + method.name == "" + } +} + +internal val settingsActivityOnCreateFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("/SettingsActivity;") && + method.name == "onCreate" + } +} + +internal val settingsMenuItemEnumFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("/SettingsMenuItem;") && method.name == "" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitch/misc/settings/SettingsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitch/misc/settings/SettingsPatch.kt new file mode 100644 index 000000000..423687792 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitch/misc/settings/SettingsPatch.kt @@ -0,0 +1,208 @@ +package app.revanced.patches.twitch.misc.settings + +import app.revanced.patcher.Match +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.MutableField.Companion.toMutable +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.BasePreference +import app.revanced.patches.shared.misc.settings.preference.BasePreferenceScreen +import app.revanced.patches.shared.misc.settings.preference.PreferenceCategory +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.shared.misc.settings.settingsPatch +import app.revanced.patches.twitch.misc.extension.sharedExtensionPatch +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.immutable.ImmutableField + +private const val REVANCED_SETTINGS_MENU_ITEM_NAME = "RevancedSettings" +private const val REVANCED_SETTINGS_MENU_ITEM_ID = 0x7 +private const val REVANCED_SETTINGS_MENU_ITEM_TITLE_RES = "revanced_settings" +private const val REVANCED_SETTINGS_MENU_ITEM_ICON_RES = "ic_settings" + +private const val MENU_ITEM_ENUM_CLASS_DESCRIPTOR = "Ltv/twitch/android/feature/settings/menu/SettingsMenuItem;" +private const val MENU_DISMISS_EVENT_CLASS_DESCRIPTOR = + "Ltv/twitch/android/feature/settings/menu/SettingsMenuViewDelegate\$Event\$OnDismissClicked;" + +private const val EXTENSION_PACKAGE = "app/revanced/extension/twitch" +private const val ACTIVITY_HOOKS_CLASS_DESCRIPTOR = "L$EXTENSION_PACKAGE/settings/AppCompatActivityHook;" +private const val UTILS_CLASS_DESCRIPTOR = "L$EXTENSION_PACKAGE/Utils;" + +private val preferences = mutableSetOf() + +fun addSettingPreference(screen: BasePreference) { + preferences += screen +} + +val settingsPatch = bytecodePatch( + name = "Settings", + description = "Adds settings menu to Twitch.", +) { + dependsOn( + sharedExtensionPatch, + addResourcesPatch, + settingsPatch(preferences = preferences), + ) + + compatibleWith( + "tv.twitch.android.app"( + "15.4.1", + "16.1.0", + "16.9.1", + ), + ) + + val settingsActivityOnCreateMatch by settingsActivityOnCreateFingerprint() + val settingsMenuItemEnumMatch by settingsMenuItemEnumFingerprint() + val menuGroupsUpdatedMatch by menuGroupsUpdatedFingerprint() + val menuGroupsOnClickMatch by menuGroupsOnClickFingerprint() + + execute { + addResources("twitch", "misc.settings.settingsPatch") + + PreferenceScreen.MISC.OTHER.addPreferences( + // The debug setting is shared across multiple apps and the key must be the same. + // But the title and summary must be different, otherwise when the strings file is flattened + // for Crowdin push, Crowdin gets confused by the duplicate keys. + // FIXME: Ideally the shared debug strings are extracted into a common app group + // and then both apps import that. But for now unique unique title and summary keys also works. + SwitchPreference( + key = "revanced_debug", + titleKey = "revanced_twitch_debug_title", + summaryOnKey = "revanced_twitch_debug_summary_on", + summaryOffKey = "revanced_twitch_debug_summary_off", + ), + ) + + // Hook onCreate to handle fragment creation. + val insertIndex = settingsActivityOnCreateMatch.mutableMethod.implementation!!.instructions.size - 2 + settingsActivityOnCreateMatch.mutableMethod.addInstructionsWithLabels( + insertIndex, + """ + invoke-static { p0 }, $ACTIVITY_HOOKS_CLASS_DESCRIPTOR->handleSettingsCreation(Landroidx/appcompat/app/AppCompatActivity;)Z + move-result v0 + if-eqz v0, :no_rv_settings_init + return-void + """, + ExternalLabel( + "no_rv_settings_init", + settingsActivityOnCreateMatch.mutableMethod.getInstruction(insertIndex), + ), + ) + + // Create new menu item for settings menu. + fun Match.injectMenuItem( + name: String, + value: Int, + titleResourceName: String, + iconResourceName: String, + ) { + // Add new static enum member field + mutableClass.staticFields.add( + ImmutableField( + mutableMethod.definingClass, + name, + MENU_ITEM_ENUM_CLASS_DESCRIPTOR, + AccessFlags.PUBLIC.value or + AccessFlags.FINAL.value or + AccessFlags.ENUM.value or + AccessFlags.STATIC.value, + null, + null, + null, + ).toMutable(), + ) + + // Add initializer for the new enum member + mutableMethod.addInstructions( + mutableMethod.implementation!!.instructions.size - 4, + """ + new-instance v0, $MENU_ITEM_ENUM_CLASS_DESCRIPTOR + const-string v1, "$titleResourceName" + invoke-static {v1}, $UTILS_CLASS_DESCRIPTOR->getStringId(Ljava/lang/String;)I + move-result v1 + const-string v3, "$iconResourceName" + invoke-static {v3}, $UTILS_CLASS_DESCRIPTOR->getDrawableId(Ljava/lang/String;)I + move-result v3 + const-string v4, "$name" + const/4 v5, $value + invoke-direct {v0, v4, v5, v1, v3}, $MENU_ITEM_ENUM_CLASS_DESCRIPTOR->(Ljava/lang/String;III)V + sput-object v0, $MENU_ITEM_ENUM_CLASS_DESCRIPTOR->$name:$MENU_ITEM_ENUM_CLASS_DESCRIPTOR + """, + ) + } + + settingsMenuItemEnumMatch.injectMenuItem( + REVANCED_SETTINGS_MENU_ITEM_NAME, + REVANCED_SETTINGS_MENU_ITEM_ID, + REVANCED_SETTINGS_MENU_ITEM_TITLE_RES, + REVANCED_SETTINGS_MENU_ITEM_ICON_RES, + ) + + // Intercept settings menu creation and add new menu item. + menuGroupsUpdatedMatch.mutableMethod.addInstructions( + 0, + """ + sget-object v0, $MENU_ITEM_ENUM_CLASS_DESCRIPTOR->$REVANCED_SETTINGS_MENU_ITEM_NAME:$MENU_ITEM_ENUM_CLASS_DESCRIPTOR + invoke-static { p1, v0 }, $ACTIVITY_HOOKS_CLASS_DESCRIPTOR->handleSettingMenuCreation(Ljava/util/List;Ljava/lang/Object;)Ljava/util/List; + move-result-object p1 + """, + ) + + // Intercept onclick events for the settings menu + + menuGroupsOnClickMatch.mutableMethod.addInstructionsWithLabels( + 0, + """ + invoke-static {p1}, $ACTIVITY_HOOKS_CLASS_DESCRIPTOR->handleSettingMenuOnClick(Ljava/lang/Enum;)Z + move-result p2 + if-eqz p2, :no_rv_settings_onclick + sget-object p1, $MENU_DISMISS_EVENT_CLASS_DESCRIPTOR->INSTANCE:$MENU_DISMISS_EVENT_CLASS_DESCRIPTOR + invoke-virtual { p0, p1 }, Ltv/twitch/android/core/mvp/viewdelegate/RxViewDelegate;->pushEvent(Ltv/twitch/android/core/mvp/viewdelegate/ViewDelegateEvent;)V + return-void + """, + ExternalLabel( + "no_rv_settings_onclick", + menuGroupsOnClickMatch.mutableMethod.getInstruction(0), + ), + ) + } + + finalize { + PreferenceScreen.close() + } +} + +/** + * Preference screens patches should add their settings to. + */ +@Suppress("ktlint:standard:property-naming") +internal object PreferenceScreen : BasePreferenceScreen() { + val ADS = CustomScreen("revanced_ads_screen") + val CHAT = CustomScreen("revanced_chat_screen") + val MISC = CustomScreen("revanced_misc_screen") + + internal class CustomScreen(key: String) : Screen(key) { + /* Categories */ + val GENERAL = CustomCategory("revanced_general_category") + val OTHER = CustomCategory("revanced_other_category") + val CLIENT_SIDE = CustomCategory("revanced_client_ads_category") + val SURESTREAM = CustomCategory("revanced_surestream_ads_category") + + internal inner class CustomCategory(key: String) : Category(key) { + /* For Twitch, we need to load our CustomPreferenceCategory class instead of the default one. */ + override fun transform(): PreferenceCategory = PreferenceCategory( + key, + preferences = preferences, + tag = "app.revanced.extension.twitch.settings.preference.CustomPreferenceCategory", + ) + } + } + + override fun commit(screen: app.revanced.patches.shared.misc.settings.preference.PreferenceScreenPreference) { + preferences += screen + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitter/interaction/downloads/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/twitter/interaction/downloads/Fingerprints.kt new file mode 100644 index 000000000..dc100acb1 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitter/interaction/downloads/Fingerprints.kt @@ -0,0 +1,27 @@ +package app.revanced.patches.twitter.interaction.downloads + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val buildMediaOptionsSheetFingerprint = fingerprint { + opcodes( + Opcode.IF_EQ, + Opcode.SGET_OBJECT, + Opcode.GOTO_16, + Opcode.NEW_INSTANCE, + ) + strings("mediaEntity", "media_options_sheet") +} + +internal val constructMediaOptionsSheetFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + returns("V") + strings("captionsState") +} + +internal val showDownloadVideoUpsellBottomSheetFingerprint = fingerprint { + returns("Z") + strings("mediaEntity", "url") + opcodes(Opcode.IF_EQZ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitter/interaction/downloads/UnlockDownloadsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitter/interaction/downloads/UnlockDownloadsPatch.kt new file mode 100644 index 000000000..0f9157d6b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitter/interaction/downloads/UnlockDownloadsPatch.kt @@ -0,0 +1,70 @@ +package app.revanced.patches.twitter.interaction.downloads + +import app.revanced.patcher.Match +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.instructions +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +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 + +@Suppress("unused") +val unlockDownloadsPatch = bytecodePatch( + name = "Unlock downloads", + description = "Unlocks the ability to download any video. GIFs can be downloaded via the menu on long press.", +) { + compatibleWith("com.twitter.android") + + val constructMediaOptionsSheetMatch by constructMediaOptionsSheetFingerprint() + val showDownloadVideoUpsellBottomSheetMatch by showDownloadVideoUpsellBottomSheetFingerprint() + val buildMediaOptionsSheetMatch by buildMediaOptionsSheetFingerprint() + + fun Match.patch(getRegisterAndIndex: Match.() -> Pair) { + val (index, register) = getRegisterAndIndex() + mutableMethod.addInstruction(index, "const/4 v$register, 0x1") + } + + execute { + // Allow downloads for non-premium users. + showDownloadVideoUpsellBottomSheetMatch.patch { + val checkIndex = patternMatch!!.startIndex + val register = mutableMethod.getInstruction(checkIndex).registerA + + checkIndex to register + } + + // Force show the download menu item. + constructMediaOptionsSheetMatch.patch { + val showDownloadButtonIndex = mutableMethod.instructions.lastIndex - 1 + val register = mutableMethod.getInstruction(showDownloadButtonIndex).registerA + + showDownloadButtonIndex to register + } + + // Make GIFs downloadable. + val patternMatch = buildMediaOptionsSheetMatch.patternMatch!! + buildMediaOptionsSheetMatch.mutableMethod.apply { + val checkMediaTypeIndex = patternMatch.startIndex + val checkMediaTypeInstruction = getInstruction(checkMediaTypeIndex) + + // Treat GIFs as videos. + addInstructionsWithLabels( + checkMediaTypeIndex + 1, + """ + const/4 v${checkMediaTypeInstruction.registerB}, 0x2 # GIF + if-eq v${checkMediaTypeInstruction.registerA}, v${checkMediaTypeInstruction.registerB}, :video + """, + ExternalLabel("video", getInstruction(patternMatch.endIndex)), + ) + + // Remove media.isDownloadable check. + removeInstruction( + instructions.first { it.opcode == Opcode.IGET_BOOLEAN }.location.index + 1, + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitter/layout/viewcount/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/twitter/layout/viewcount/Fingerprints.kt new file mode 100644 index 000000000..625b6f0bb --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitter/layout/viewcount/Fingerprints.kt @@ -0,0 +1,8 @@ +package app.revanced.patches.twitter.layout.viewcount + +import app.revanced.patcher.fingerprint + +internal val viewCountsEnabledFingerprint = fingerprint { + returns("Z") + strings("view_counts_public_visibility_enabled") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitter/layout/viewcount/HideViewCountPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitter/layout/viewcount/HideViewCountPatch.kt new file mode 100644 index 000000000..83b65581e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitter/layout/viewcount/HideViewCountPatch.kt @@ -0,0 +1,25 @@ +package app.revanced.patches.twitter.layout.viewcount + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val hideViewCountPatch = bytecodePatch( + name = "Hide view count", + description = "Hides the view count of Posts.", + use = false, +) { + compatibleWith("com.twitter.android") + + val viewCountsEnabledMatch by viewCountsEnabledFingerprint() + + execute { + viewCountsEnabledMatch.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x0 + return v0 + """, + ) + } +} diff --git a/src/main/kotlin/app/revanced/patches/twitter/misc/dynamiccolor/DynamicColorPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/dynamiccolor/DynamicColorPatch.kt similarity index 81% rename from src/main/kotlin/app/revanced/patches/twitter/misc/dynamiccolor/DynamicColorPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/twitter/misc/dynamiccolor/DynamicColorPatch.kt index 51d62a998..b7c3d3e7a 100644 --- a/src/main/kotlin/app/revanced/patches/twitter/misc/dynamiccolor/DynamicColorPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/dynamiccolor/DynamicColorPatch.kt @@ -1,22 +1,19 @@ package app.revanced.patches.twitter.misc.dynamiccolor -import app.revanced.patcher.data.ResourceContext import app.revanced.patcher.patch.PatchException -import app.revanced.patcher.patch.ResourcePatch -import app.revanced.patcher.patch.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch +import app.revanced.patcher.patch.resourcePatch import java.io.FileWriter import java.nio.file.Files -@Patch( +@Suppress("unused") +val dynamicColorPatch = resourcePatch( name = "Dynamic color", description = "Replaces the default X (Formerly Twitter) Blue with the user's Material You palette.", - compatiblePackages = [CompatiblePackage("com.twitter.android")], -) -@Suppress("unused") -object DynamicColorPatch : ResourcePatch() { - override fun execute(context: ResourceContext) { - val resDirectory = context.get("res") +) { + compatibleWith("com.twitter.android") + + execute { context -> + val resDirectory = context["res"] if (!resDirectory.isDirectory) throw PatchException("The res folder can not be found.") val valuesV31Directory = resDirectory.resolve("values-v31") @@ -35,8 +32,7 @@ object DynamicColorPatch : ResourcePatch() { } } - context.xmlEditor["res/values-v31/colors.xml"].use { editor -> - val document = editor.file + context.document["res/values-v31/colors.xml"].use { document -> mapOf( "ps__twitter_blue" to "@color/twitter_blue", @@ -57,9 +53,7 @@ object DynamicColorPatch : ResourcePatch() { } } - context.xmlEditor["res/values-night-v31/colors.xml"].use { editor -> - val document = editor.file - + context.document["res/values-night-v31/colors.xml"].use { document -> mapOf( "twitter_blue" to "@android:color/system_accent1_200", "twitter_blue_fill_pressed" to "@android:color/system_accent1_300", diff --git a/patches/src/main/kotlin/app/revanced/patches/twitter/misc/extension/ExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/extension/ExtensionPatch.kt new file mode 100644 index 000000000..f1e6a879b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/extension/ExtensionPatch.kt @@ -0,0 +1,5 @@ +package app.revanced.patches.twitter.misc.extension + +import app.revanced.patches.shared.misc.extension.sharedExtensionPatch + +val sharedExtensionPatch = sharedExtensionPatch() diff --git a/patches/src/main/kotlin/app/revanced/patches/twitter/misc/hook/HideAdsHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/hook/HideAdsHookPatch.kt new file mode 100644 index 000000000..4f9b38ff6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/hook/HideAdsHookPatch.kt @@ -0,0 +1,7 @@ +package app.revanced.patches.twitter.misc.hook + +@Suppress("unused") +val hideAdsHookPatch = hookPatch( + name = "Hide ads", + hookClassDescriptor = "Lapp/revanced/extension/twitter/patches/hook/patch/ads/HideAdsHook;", +) diff --git a/patches/src/main/kotlin/app/revanced/patches/twitter/misc/hook/HideRecommendedUsersPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/hook/HideRecommendedUsersPatch.kt new file mode 100644 index 000000000..253379615 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/hook/HideRecommendedUsersPatch.kt @@ -0,0 +1,7 @@ +package app.revanced.patches.twitter.misc.hook + +@Suppress("unused") +val hideRecommendedUsersPatch = hookPatch( + name = "Hide recommended users", + hookClassDescriptor = "Lapp/revanced/extension/twitter/patches/hook/patch/recommendation/RecommendedUsersHook;", +) diff --git a/patches/src/main/kotlin/app/revanced/patches/twitter/misc/hook/HookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/hook/HookPatch.kt new file mode 100644 index 000000000..ca757dcdd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/hook/HookPatch.kt @@ -0,0 +1,19 @@ +package app.revanced.patches.twitter.misc.hook + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.twitter.misc.hook.json.JsonHook +import app.revanced.patches.twitter.misc.hook.json.jsonHookPatch +import app.revanced.patches.twitter.misc.hook.json.jsonHooks + +fun hookPatch( + name: String, + hookClassDescriptor: String, +) = bytecodePatch(name) { + dependsOn(jsonHookPatch) + + compatibleWith("com.twitter.android") + + execute { + jsonHooks.addHook(JsonHook(it, hookClassDescriptor)) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitter/misc/hook/json/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/hook/json/Fingerprints.kt new file mode 100644 index 000000000..cf4578b0c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/hook/json/Fingerprints.kt @@ -0,0 +1,27 @@ +package app.revanced.patches.twitter.misc.hook.json + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.Opcode + +internal val jsonHookPatchFingerprint = fingerprint { + opcodes( + Opcode.INVOKE_INTERFACE, // Add dummy hook to hooks list. + // Add hooks to the hooks list. + Opcode.INVOKE_STATIC, // Call buildList. + ) + custom { method, _ -> method.name == "" } +} + +internal val jsonInputStreamFingerprint = fingerprint { + custom { method, _ -> + if (method.parameterTypes.size == 0) { + false + } else { + method.parameterTypes.first() == "Ljava/io/InputStream;" + } + } +} + +internal val loganSquareFingerprint = fingerprint { + custom { _, classDef -> classDef.endsWith("LoganSquare;") } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitter/misc/hook/json/JsonHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/hook/json/JsonHookPatch.kt new file mode 100644 index 000000000..6cde25b50 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/hook/json/JsonHookPatch.kt @@ -0,0 +1,141 @@ +package app.revanced.patches.twitter.misc.hook.json + +import app.revanced.patcher.Fingerprint +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.removeInstructions +import app.revanced.patcher.patch.BytecodePatchContext +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.reddit.misc.extension.sharedExtensionPatch +import java.io.Closeable +import java.io.InvalidClassException + +/** + * The [JsonHookPatchHook] of the [jsonHookPatch]. + * + * @see JsonHookPatchHook + */ +internal lateinit var jsonHooks: JsonHookPatchHook + private set + +private const val JSON_HOOK_CLASS_NAMESPACE = "app/revanced/extension/twitter/patches/hook/json" +private const val JSON_HOOK_PATCH_CLASS_DESCRIPTOR = "L$JSON_HOOK_CLASS_NAMESPACE/JsonHookPatch;" +private const val BASE_PATCH_CLASS_NAME = "BaseJsonHook" +private const val JSON_HOOK_CLASS_DESCRIPTOR = "L$JSON_HOOK_CLASS_NAMESPACE/$BASE_PATCH_CLASS_NAME;" + +val jsonHookPatch = bytecodePatch( + description = "Hooks the stream which reads JSON responses.", +) { + dependsOn(sharedExtensionPatch) + + val loganSquareMatch by loganSquareFingerprint() + + execute { context -> + jsonHookPatchFingerprint.apply { + // Make sure the extension is present. + val jsonHookPatch = context.classBy { classDef -> classDef.type == JSON_HOOK_PATCH_CLASS_DESCRIPTOR } + ?: throw PatchException("Could not find the extension.") + + if (!match(context, jsonHookPatch.immutableClass)) { + throw PatchException("Unexpected extension.") + } + }.let { jsonHooks = JsonHookPatchHook(it) } + + // Conveniently find the type to hook a method in, via a named field. + val jsonFactory = loganSquareMatch + .classDef + .fields + .firstOrNull { it.name == "JSON_FACTORY" } + ?.type + .let { type -> + context.classBy { it.type == type }?.mutableClass + } ?: throw PatchException("Could not find required class.") + + // Hook the methods first parameter. + jsonInputStreamFingerprint + .apply { match(context, jsonFactory) } + .match + ?.mutableMethod + ?.addInstructions( + 0, + """ + invoke-static { p1 }, $JSON_HOOK_PATCH_CLASS_DESCRIPTOR->parseJsonHook(Ljava/io/InputStream;)Ljava/io/InputStream; + move-result-object p1 + """, + ) ?: throw PatchException("Could not find method to hook.") + } + + finalize { + jsonHooks.close() + } +} + +/** + * Create a hook class. + * The class has to extend on **JsonHook**. + * The class has to be a Kotlin object class, or at least have an INSTANCE field of itself. + * + * @param context The [BytecodePatchContext] of the current patch. + * @param descriptor The class descriptor of the hook. + * @throws ClassNotFoundException If the class could not be found. + */ +class JsonHook(context: BytecodePatchContext, internal val descriptor: String) { + internal var added = false + + init { + context.classBy { it.type == descriptor }?.let { + it.mutableClass.also { classDef -> + if ( + classDef.superclass != JSON_HOOK_CLASS_DESCRIPTOR || + !classDef.fields.any { field -> field.name == "INSTANCE" } + ) { + throw InvalidClassException(classDef.type, "Not a hook class") + } + } + } ?: throw ClassNotFoundException("Failed to find hook class $descriptor") + } +} + +/** + * A hook for the [jsonHookPatch]. + * + * @param jsonHookPatchFingerprint The [jsonHookPatchFingerprint] to hook. + */ +class JsonHookPatchHook(jsonHookPatchFingerprint: Fingerprint) : Closeable { + private val jsonHookPatchMatch = jsonHookPatchFingerprint.match!! + private val jsonHookPatchIndex = jsonHookPatchMatch.patternMatch!!.endIndex + + /** + * Add a hook to the [jsonHookPatch]. + * Will not add the hook if it's already added. + * + * @param jsonHook The [JsonHook] to add. + */ + fun addHook(jsonHook: JsonHook) { + if (jsonHook.added) return + + jsonHookPatchMatch.mutableMethod.apply { + // Insert hooks right before calling buildList. + val insertIndex = jsonHookPatchIndex + + addInstructions( + insertIndex, + """ + sget-object v1, ${jsonHook.descriptor}->INSTANCE:${jsonHook.descriptor} + invoke-interface {v0, v1}, Ljava/util/List;->add(Ljava/lang/Object;)Z + """, + ) + } + + jsonHook.added = true + } + + override fun close() { + // Remove hooks.add(dummyHook). + jsonHookPatchMatch.mutableMethod.apply { + val addDummyHookIndex = jsonHookPatchIndex - 2 + + removeInstructions(addDummyHookIndex, 2) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitter/misc/links/ChangeLinkSharingDomainPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/links/ChangeLinkSharingDomainPatch.kt new file mode 100644 index 000000000..cefe3226f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/links/ChangeLinkSharingDomainPatch.kt @@ -0,0 +1,96 @@ +package app.revanced.patches.twitter.misc.links + +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.resourcePatch +import app.revanced.patcher.patch.stringOption +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings +import app.revanced.util.indexOfFirstWideLiteralInstructionValueOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c + +internal var tweetShareLinkTemplateId = -1L + private set + +internal val changeLinkSharingDomainResourcePatch = resourcePatch { + dependsOn(resourceMappingPatch) + + execute { + tweetShareLinkTemplateId = resourceMappings["string", "tweet_share_link"] + } +} + +// This method is used to build the link that is shared when the "Share via..." button is pressed. +private const val FORMAT_METHOD_RESOURCE_REFERENCE = + "Lapp/revanced/extension/twitter/patches/links/ChangeLinkSharingDomainPatch;->" + + "formatResourceLink([Ljava/lang/Object;)Ljava/lang/String;" + +// This method is used to build the link that is shared when the "Copy link" button is pressed. +private const val FORMAT_METHOD_REFERENCE = + "Lapp/revanced/extension/twitter/patches/links/ChangeLinkSharingDomainPatch;->" + + "formatLink(JLjava/lang/String;)Ljava/lang/String;" + +@Suppress("unused") +val changeLinkSharingDomainPatch = bytecodePatch( + name = "Change link sharing domain", + description = "Replaces the domain name of Twitter links when sharing them.", +) { + dependsOn(changeLinkSharingDomainResourcePatch) + + compatibleWith("com.twitter.android") + + val domainName by stringOption( + key = "domainName", + default = "fxtwitter.com", + title = "Domain name", + description = "The domain name to use when sharing links.", + required = true, + ) + + val linkSharingDomainMatch by linkSharingDomainFingerprint() + val linkBuilderMatch by linkBuilderFingerprint() + val linkResourceGetterMatch by linkResourceGetterFingerprint() + + execute { + val replacementIndex = + linkSharingDomainMatch.stringMatches!!.first().index + val domainRegister = + linkSharingDomainMatch.mutableMethod.getInstruction(replacementIndex).registerA + + linkSharingDomainMatch.mutableMethod.replaceInstruction( + replacementIndex, + "const-string v$domainRegister, \"https://$domainName\"", + ) + + // Replace the domain name when copying a link with "Copy link" button. + linkBuilderMatch.mutableMethod.apply { + addInstructions( + 0, + """ + invoke-static { p0, p1, p2 }, $FORMAT_METHOD_REFERENCE + move-result-object p0 + return-object p0 + """, + ) + } + + // Used in the Share via... dialog. + linkResourceGetterMatch.mutableMethod.apply { + val templateIdConstIndex = indexOfFirstWideLiteralInstructionValueOrThrow(tweetShareLinkTemplateId) + + // Format the link with the new domain name register (1 instruction below the const). + val formatLinkCallIndex = templateIdConstIndex + 1 + val formatLinkCall = getInstruction(formatLinkCallIndex) + + // Replace the original method call with the new method call. + replaceInstruction( + formatLinkCallIndex, + "invoke-static { v${formatLinkCall.registerE} }, $FORMAT_METHOD_RESOURCE_REFERENCE", + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitter/misc/links/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/links/Fingerprints.kt new file mode 100644 index 000000000..0d5d0e6f8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/links/Fingerprints.kt @@ -0,0 +1,31 @@ +package app.revanced.patches.twitter.misc.links + +import app.revanced.patcher.fingerprint +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.AccessFlags + +internal val openLinkFingerprint = fingerprint { + returns("V") + parameters("Landroid/content/Context;", "Landroid/content/Intent;", "Landroid/os/Bundle;") +} + +internal val sanitizeSharingLinksFingerprint = fingerprint { + returns("Ljava/lang/String;") + strings("", "shareParam", "sessionToken") +} + +// Returns a shareable link string based on a tweet ID and a username. +internal val linkBuilderFingerprint = fingerprint { + strings("/%1\$s/status/%2\$d") +} + +// Gets Resource string for share link view available by pressing "Share via" button. +internal val linkResourceGetterFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + parameters("Landroid/content/res/Resources;") + literal { tweetShareLinkTemplateId } +} + +internal val linkSharingDomainFingerprint = fingerprint { + strings("https://fxtwitter.com") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitter/misc/links/OpenLinksWithAppChooserPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/links/OpenLinksWithAppChooserPatch.kt new file mode 100644 index 000000000..c9cea00c7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/links/OpenLinksWithAppChooserPatch.kt @@ -0,0 +1,30 @@ +package app.revanced.patches.twitter.misc.links + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val openLinksWithAppChooserPatch = bytecodePatch( + name = "Open links with app chooser", + description = "Instead of opening links directly, open them with an app chooser. " + + "As a result you can select a browser to open the link with.", + use = false, +) { + compatibleWith("com.twitter.android"("10.48.0-release.0")) + + val openLinkMatch by openLinkFingerprint() + + execute { + val methodReference = + "Lapp/revanced/extension/twitter/patches/links/OpenLinksWithAppChooserPatch;->" + + "openWithChooser(Landroid/content/Context;Landroid/content/Intent;)V" + + openLinkMatch.mutableMethod.addInstructions( + 0, + """ + invoke-static { p0, p1 }, $methodReference + return-void + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/twitter/misc/links/SanitizeSharingLinksPatch.kt b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/links/SanitizeSharingLinksPatch.kt new file mode 100644 index 000000000..62e7a4f00 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/twitter/misc/links/SanitizeSharingLinksPatch.kt @@ -0,0 +1,25 @@ +package app.revanced.patches.twitter.misc.links + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val sanitizeSharingLinksPatch = bytecodePatch( + name = "Sanitize sharing links", + description = "Removes the tracking query parameters from links before they are shared.", +) { + compatibleWith("com.twitter.android") + + val sanitizeSharingLinksMatch by sanitizeSharingLinksFingerprint() + + execute { + sanitizeSharingLinksMatch.mutableMethod.addInstructions( + 0, + """ + # Method takes in a link (string, param 0) and then appends the tracking query params, + # so all we need to do is return back the passed-in string + return-object p0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/vsco/misc/pro/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/vsco/misc/pro/Fingerprints.kt new file mode 100644 index 000000000..0809324c7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/vsco/misc/pro/Fingerprints.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.vsco.misc.pro + +import app.revanced.patcher.fingerprint + +internal val revCatSubscriptionFingerprint = fingerprint { + returns("V") + strings("use_debug_subscription_settings") + custom { _, classDef -> + classDef.endsWith("/RevCatSubscriptionSettingsRepository;") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/vsco/misc/pro/UnlockProPatch.kt b/patches/src/main/kotlin/app/revanced/patches/vsco/misc/pro/UnlockProPatch.kt new file mode 100644 index 000000000..dae609164 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/vsco/misc/pro/UnlockProPatch.kt @@ -0,0 +1,19 @@ +package app.revanced.patches.vsco.misc.pro + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val unlockProPatch = bytecodePatch( + name = "Unlock pro", + description = "Unlocks pro features.", +) { + compatibleWith("com.vsco.cam"("345")) + + val revCatSubscriptionMatch by revCatSubscriptionFingerprint() + + execute { + // Set isSubscribed to true. + revCatSubscriptionMatch.mutableMethod.addInstruction(0, "const p1, 0x1") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/warnwetter/misc/firebasegetcert/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/warnwetter/misc/firebasegetcert/Fingerprints.kt new file mode 100644 index 000000000..41627aaaa --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/warnwetter/misc/firebasegetcert/Fingerprints.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.warnwetter.misc.firebasegetcert + +import app.revanced.patcher.fingerprint + +internal val getMessagingCertFingerprint = fingerprint { + returns("Ljava/lang/String;") + strings( + "ContentValues", + "Could not get fingerprint hash for package: ", + "No such package: ", + ) +} + +internal val getReqistrationCertFingerprint = fingerprint { + returns("Ljava/lang/String;") + strings( + "FirebaseRemoteConfig", + "Could not get fingerprint hash for package: ", + "No such package: ", + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/warnwetter/misc/firebasegetcert/FirebaseGetCertPatch.kt b/patches/src/main/kotlin/app/revanced/patches/warnwetter/misc/firebasegetcert/FirebaseGetCertPatch.kt new file mode 100644 index 000000000..818c04cbd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/warnwetter/misc/firebasegetcert/FirebaseGetCertPatch.kt @@ -0,0 +1,26 @@ +package app.revanced.patches.warnwetter.misc.firebasegetcert + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val firebaseGetCertPatch = bytecodePatch( + description = "Spoofs the X-Android-Cert header.", +) { + compatibleWith("de.dwd.warnapp") + + val getRegistrationCertMatch by getReqistrationCertFingerprint() + val getMessagingCertMatch by getMessagingCertFingerprint() + + execute { + listOf(getRegistrationCertMatch, getMessagingCertMatch).forEach { match -> + match.mutableMethod.addInstructions( + 0, + """ + const-string v0, "0799DDF0414D3B3475E88743C91C0676793ED450" + return-object v0 + """, + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/warnwetter/misc/promocode/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/warnwetter/misc/promocode/Fingerprints.kt new file mode 100644 index 000000000..d33880de7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/warnwetter/misc/promocode/Fingerprints.kt @@ -0,0 +1,9 @@ +package app.revanced.patches.warnwetter.misc.promocode + +import app.revanced.patcher.fingerprint + +internal val promoCodeUnlockFingerprint = fingerprint { + custom { method, classDef -> + classDef.endsWith("PromoTokenVerification;") && method.name == "isValid" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/warnwetter/misc/promocode/PromoCodeUnlockPatch.kt b/patches/src/main/kotlin/app/revanced/patches/warnwetter/misc/promocode/PromoCodeUnlockPatch.kt new file mode 100644 index 000000000..67b930ae8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/warnwetter/misc/promocode/PromoCodeUnlockPatch.kt @@ -0,0 +1,27 @@ +package app.revanced.patches.warnwetter.misc.promocode + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.warnwetter.misc.firebasegetcert.firebaseGetCertPatch + +@Suppress("unused") +val promoCodeUnlockPatch = bytecodePatch( + name = "Promo code unlock", + description = "Disables the validation of promo code. Any code will work to unlock all features.", +) { + dependsOn(firebaseGetCertPatch) + + compatibleWith("de.dwd.warnapp"("4.2.2")) + + val promoCodeUnlockMatch by promoCodeUnlockFingerprint() + + execute { + promoCodeUnlockMatch.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/willhaben/ads/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/willhaben/ads/Fingerprints.kt new file mode 100644 index 000000000..e326c2682 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/willhaben/ads/Fingerprints.kt @@ -0,0 +1,26 @@ +package app.revanced.patches.willhaben.ads + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val adResolverFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("L") + parameters("L", "L") + strings( + "Google Ad is invalid ", + "Google Native Ad is invalid ", + "Criteo Ad is invalid ", + "Amazon Ad is invalid ", + ) +} + +internal val whAdViewInjectorFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("L", "L", "L", "Z") + strings("successfulAdView") + custom { _, classDef -> + classDef.type == "Lat/willhaben/advertising/WHAdView;" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/willhaben/ads/HideAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/willhaben/ads/HideAdsPatch.kt new file mode 100644 index 000000000..6c0d99cca --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/willhaben/ads/HideAdsPatch.kt @@ -0,0 +1,20 @@ +package app.revanced.patches.willhaben.ads + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.util.returnEarly + +@Suppress("unused") +internal val hideAdsPatch = bytecodePatch( + name = "Hide ads", + description = "Hides all in-app ads.", +) { + compatibleWith("at.willhaben") + + adResolverFingerprint() + whAdViewInjectorFingerprint() + + execute { + adResolverFingerprint.returnEarly() + whAdViewInjectorFingerprint.returnEarly() + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/windyapp/misc/unlockpro/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/windyapp/misc/unlockpro/Fingerprints.kt new file mode 100644 index 000000000..f199f127c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/windyapp/misc/unlockpro/Fingerprints.kt @@ -0,0 +1,10 @@ +package app.revanced.patches.windyapp.misc.unlockpro + +import app.revanced.patcher.fingerprint + +internal val checkProFingerprint = fingerprint { + returns("I") + custom { method, classDef -> + classDef.endsWith("RawUserData;") && method.name == "isPro" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/windyapp/misc/unlockpro/UnlockProPatch.kt b/patches/src/main/kotlin/app/revanced/patches/windyapp/misc/unlockpro/UnlockProPatch.kt new file mode 100644 index 000000000..3f4cac757 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/windyapp/misc/unlockpro/UnlockProPatch.kt @@ -0,0 +1,24 @@ +package app.revanced.patches.windyapp.misc.unlockpro + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +@Suppress("unused") +val unlockProPatch = bytecodePatch( + name = "Unlock pro", + description = "Unlocks all pro features.", +) { + compatibleWith("co.windyapp.android") + + val checkProMatch by checkProFingerprint() + + execute { + checkProMatch.mutableMethod.addInstructions( + 0, + """ + const/16 v0, 0x1 + return v0 + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/ad/general/HideAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/ad/general/HideAdsPatch.kt new file mode 100644 index 000000000..7abaeda88 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/ad/general/HideAdsPatch.kt @@ -0,0 +1,118 @@ +package app.revanced.patches.youtube.ad.general + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.fix.verticalscroll.verticalScrollPatch +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.ad.getpremium.hideGetPremiumPatch +import app.revanced.patches.youtube.misc.fix.backtoexitgesture.fixBackToExitGesturePatch +import app.revanced.patches.youtube.misc.litho.filter.addLithoFilter +import app.revanced.patches.youtube.misc.litho.filter.lithoFilterPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.util.findMutableMethodOf +import app.revanced.util.injectHideViewCall +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction31i +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c + +internal var adAttributionId = -1L + private set + +private val hideAdsResourcePatch = resourcePatch { + dependsOn( + lithoFilterPatch, + settingsPatch, + resourceMappingPatch, + addResourcesPatch, + ) + + execute { + addResources("youtube", "ad.general.hideAdsResourcePatch") + + PreferenceScreen.ADS.addPreferences( + SwitchPreference("revanced_hide_general_ads"), + SwitchPreference("revanced_hide_fullscreen_ads"), + SwitchPreference("revanced_hide_buttoned_ads"), + SwitchPreference("revanced_hide_paid_promotion_label"), + SwitchPreference("revanced_hide_player_store_shelf"), + SwitchPreference("revanced_hide_self_sponsor_ads"), + SwitchPreference("revanced_hide_products_banner"), + SwitchPreference("revanced_hide_shopping_links"), + SwitchPreference("revanced_hide_visit_store_button"), + SwitchPreference("revanced_hide_web_search_results"), + SwitchPreference("revanced_hide_merchandise_banners"), + ) + + addLithoFilter("Lapp/revanced/extension/youtube/patches/components/AdsFilter;") + + adAttributionId = resourceMappings["id", "ad_attribution"] + } +} + +@Suppress("unused") +val hideAdsPatch = bytecodePatch( + name = "Hide ads", + description = "Adds options to remove general ads.", +) { + dependsOn( + hideGetPremiumPatch, + hideAdsResourcePatch, + verticalScrollPatch, + fixBackToExitGesturePatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + execute { context -> + context.classes.forEach { classDef -> + classDef.methods.forEach { method -> + with(method.implementation) { + 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 != adAttributionId) { + return@forEachIndexed + } + + val insertIndex = index + 1 + + // Call to get the view with the id adAttribution + with(instructions.elementAt(insertIndex)) { + if (opcode != Opcode.INVOKE_VIRTUAL) { + return@forEachIndexed + } + + // Hide the view + val viewRegister = (this as Instruction35c).registerC + context.proxy(classDef) + .mutableClass + .findMutableMethodOf(method) + .injectHideViewCall( + insertIndex, + viewRegister, + "Lapp/revanced/extension/youtube/patches/components/AdsFilter;", + "hideAdAttributionView", + ) + } + } + } + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/ad/getpremium/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/ad/getpremium/Fingerprints.kt new file mode 100644 index 000000000..7629d1760 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/ad/getpremium/Fingerprints.kt @@ -0,0 +1,21 @@ +package app.revanced.patches.youtube.ad.getpremium + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val getPremiumViewFingerprint = fingerprint { + accessFlags(AccessFlags.PROTECTED, AccessFlags.FINAL) + returns("V") + parameters("I", "I") + opcodes( + Opcode.ADD_INT_2ADDR, + Opcode.ADD_INT_2ADDR, + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN_VOID, + ) + custom { method, _ -> + method.name == "onMeasure" && + method.definingClass == "Lcom/google/android/apps/youtube/app/red/presenter/CompactYpcOfferModuleView;" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/ad/getpremium/HideGetPremiumPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/ad/getpremium/HideGetPremiumPatch.kt new file mode 100644 index 000000000..ced812393 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/ad/getpremium/HideGetPremiumPatch.kt @@ -0,0 +1,70 @@ +package app.revanced.patches.youtube.ad.getpremium + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction + +internal const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/HideGetPremiumPatch;" + +@Suppress("unused") +val hideGetPremiumPatch = bytecodePatch( + description = "Hides YouTube Premium signup promotions under the video player.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val getPremiumViewMatch by getPremiumViewFingerprint() + + execute { + addResources("youtube", "ad.getpremium.hideGetPremiumPatch") + + PreferenceScreen.ADS.addPreferences( + SwitchPreference("revanced_hide_get_premium"), + ) + + getPremiumViewMatch.mutableMethod.apply { + val startIndex = getPremiumViewMatch.patternMatch!!.startIndex + val measuredWidthRegister = getInstruction(startIndex).registerA + val measuredHeightInstruction = getInstruction(startIndex + 1) + + val measuredHeightRegister = measuredHeightInstruction.registerA + val tempRegister = measuredHeightInstruction.registerB + + addInstructionsWithLabels( + startIndex + 2, + """ + # Override the internal measurement of the layout with zero values. + invoke-static {}, $EXTENSION_CLASS_DESCRIPTOR->hideGetPremiumView()Z + move-result v$tempRegister + if-eqz v$tempRegister, :allow + const/4 v$measuredWidthRegister, 0x0 + const/4 v$measuredHeightRegister, 0x0 + :allow + nop + # Layout width/height is then passed to a protected class method. + """, + ) + } + } +} diff --git a/src/main/kotlin/app/revanced/patches/youtube/ad/video/fingerprints/LoadVideoAdsFingerprint.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/ad/video/Fingerprints.kt similarity index 56% rename from src/main/kotlin/app/revanced/patches/youtube/ad/video/fingerprints/LoadVideoAdsFingerprint.kt rename to patches/src/main/kotlin/app/revanced/patches/youtube/ad/video/Fingerprints.kt index 240886f01..91cc0e8df 100644 --- a/src/main/kotlin/app/revanced/patches/youtube/ad/video/fingerprints/LoadVideoAdsFingerprint.kt +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/ad/video/Fingerprints.kt @@ -1,12 +1,11 @@ -package app.revanced.patches.youtube.ad.video.fingerprints +package app.revanced.patches.youtube.ad.video +import app.revanced.patcher.fingerprint -import app.revanced.patcher.fingerprint.MethodFingerprint - -internal object LoadVideoAdsFingerprint : MethodFingerprint( - strings = listOf( +internal val loadVideoAdsFingerprint = fingerprint { + strings( "TriggerBundle doesn't have the required metadata specified by the trigger ", "Tried to enter slot with no assigned slotAdapter", "Trying to enter a slot when a slot of same type and physical position is already active. Its status: ", ) -) \ No newline at end of file +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/ad/video/VideoAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/ad/video/VideoAdsPatch.kt new file mode 100644 index 000000000..5c098d551 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/ad/video/VideoAdsPatch.kt @@ -0,0 +1,55 @@ +package app.revanced.patches.youtube.ad.video + +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.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch + +@Suppress("unused") +val videoAdsPatch = bytecodePatch( + name = "Video ads", + description = "Adds an option to remove ads in the video player.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val loadVideoAdsMatch by loadVideoAdsFingerprint() + + execute { + addResources("youtube", "ad.video.videoAdsPatch") + + PreferenceScreen.ADS.addPreferences( + SwitchPreference("revanced_hide_video_ads"), + ) + + loadVideoAdsMatch.mutableMethod.addInstructionsWithLabels( + 0, + """ + invoke-static { }, Lapp/revanced/extension/youtube/patches/VideoAdsPatch;->shouldShowAds()Z + move-result v0 + if-nez v0, :show_video_ads + return-void + """, + ExternalLabel("show_video_ads", loadVideoAdsMatch.mutableMethod.getInstruction(0)), + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/copyvideourl/CopyVideoUrlPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/copyvideourl/CopyVideoUrlPatch.kt new file mode 100644 index 000000000..0c9fe544c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/copyvideourl/CopyVideoUrlPatch.kt @@ -0,0 +1,76 @@ +package app.revanced.patches.youtube.interaction.copyvideourl + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.playercontrols.* +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.patches.youtube.video.information.videoInformationPatch +import app.revanced.util.ResourceGroup +import app.revanced.util.copyResources + +private val copyVideoUrlResourcePatch = resourcePatch { + dependsOn( + settingsPatch, + playerControlsResourcePatch, + addResourcesPatch, + ) + + execute { context -> + addResources("youtube", "interaction.copyvideourl.copyVideoUrlResourcePatch") + + PreferenceScreen.PLAYER.addPreferences( + SwitchPreference("revanced_copy_video_url"), + SwitchPreference("revanced_copy_video_url_timestamp"), + ) + + context.copyResources( + "copyvideourl", + ResourceGroup( + resourceDirectoryName = "drawable", + "revanced_yt_copy.xml", + "revanced_yt_copy_timestamp.xml", + ), + ) + + addBottomControl("copyvideourl") + } +} + +@Suppress("unused") +val copyVideoUrlPatch = bytecodePatch( + name = "Copy video URL", + description = "Adds options to display buttons in the video player to copy video URLs.", +) { + dependsOn( + copyVideoUrlResourcePatch, + playerControlsPatch, + videoInformationPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + execute { + val extensionPlayerPackage = "Lapp/revanced/extension/youtube/videoplayer" + val buttonsDescriptors = listOf( + "$extensionPlayerPackage/CopyVideoUrlButton;", + "$extensionPlayerPackage/CopyVideoUrlTimestampButton;", + ) + + buttonsDescriptors.forEach { descriptor -> + initializeBottomControl(descriptor) + injectVisibilityCheckCall(descriptor) + } + } +} diff --git a/src/main/kotlin/app/revanced/patches/youtube/interaction/dialog/fingerprints/CreateDialogFingerprint.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/dialog/Fingerprints.kt similarity index 51% rename from src/main/kotlin/app/revanced/patches/youtube/interaction/dialog/fingerprints/CreateDialogFingerprint.kt rename to patches/src/main/kotlin/app/revanced/patches/youtube/interaction/dialog/Fingerprints.kt index 232e65e6a..b25287590 100644 --- a/src/main/kotlin/app/revanced/patches/youtube/interaction/dialog/fingerprints/CreateDialogFingerprint.kt +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/dialog/Fingerprints.kt @@ -1,14 +1,14 @@ -package app.revanced.patches.youtube.interaction.dialog.fingerprints +package app.revanced.patches.youtube.interaction.dialog -import app.revanced.patcher.fingerprint.MethodFingerprint -import com.android.tools.smali.dexlib2.AccessFlags import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint -internal object CreateDialogFingerprint : MethodFingerprint( - "V", - AccessFlags.PROTECTED.value, - listOf("L", "L", "Ljava/lang/String;"), - listOf( +internal val createDialogFingerprint = fingerprint { + accessFlags(AccessFlags.PROTECTED) + returns("V") + parameters("L", "L", "Ljava/lang/String;") + opcodes( Opcode.INVOKE_VIRTUAL, Opcode.MOVE_RESULT_OBJECT, Opcode.INVOKE_VIRTUAL, @@ -17,6 +17,6 @@ internal object CreateDialogFingerprint : MethodFingerprint( Opcode.MOVE_RESULT_OBJECT, Opcode.IPUT_OBJECT, Opcode.IGET_OBJECT, - Opcode.INVOKE_VIRTUAL // dialog.show() + Opcode.INVOKE_VIRTUAL, // dialog.show() ) -) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/dialog/RemoveViewerDiscretionDialogPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/dialog/RemoveViewerDiscretionDialogPatch.kt new file mode 100644 index 000000000..caccae382 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/dialog/RemoveViewerDiscretionDialogPatch.kt @@ -0,0 +1,59 @@ +package app.revanced.patches.youtube.interaction.dialog + +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction + +@Suppress("unused") +val removeViewerDiscretionDialogPatch = bytecodePatch( + 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.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val createDialogMatch by createDialogFingerprint() + + val extensionMethodDescriptor = + "Lapp/revanced/extension/youtube/patches/RemoveViewerDiscretionDialogPatch;->" + + "confirmDialog(Landroid/app/AlertDialog;)V" + + execute { + addResources("youtube", "interaction.dialog.removeViewerDiscretionDialogPatch") + + PreferenceScreen.GENERAL_LAYOUT.addPreferences( + SwitchPreference("revanced_remove_viewer_discretion_dialog"), + ) + + createDialogMatch.mutableMethod.apply { + val showDialogIndex = implementation!!.instructions.lastIndex - 2 + val dialogRegister = getInstruction(showDialogIndex).registerC + + replaceInstructions( + showDialogIndex, + "invoke-static { v$dialogRegister }, $extensionMethodDescriptor", + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/downloads/DownloadsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/downloads/DownloadsPatch.kt new file mode 100644 index 000000000..34b64a8ad --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/downloads/DownloadsPatch.kt @@ -0,0 +1,108 @@ +package app.revanced.patches.youtube.interaction.downloads + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.InputType +import app.revanced.patches.shared.misc.settings.preference.PreferenceScreenPreference +import app.revanced.patches.shared.misc.settings.preference.PreferenceScreenPreference.Sorting +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.shared.misc.settings.preference.TextPreference +import app.revanced.patches.youtube.misc.playercontrols.* +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.patches.youtube.shared.mainActivityFingerprint +import app.revanced.patches.youtube.video.information.videoInformationPatch +import app.revanced.util.ResourceGroup +import app.revanced.util.copyResources + +private val downloadsResourcePatch = resourcePatch { + dependsOn( + playerControlsResourcePatch, + settingsPatch, + addResourcesPatch, + ) + + execute { context -> + addResources("youtube", "interaction.downloads.downloadsResourcePatch") + + PreferenceScreen.PLAYER.addPreferences( + PreferenceScreenPreference( + key = "revanced_external_downloader_screen", + sorting = Sorting.UNSORTED, + preferences = setOf( + SwitchPreference("revanced_external_downloader"), + SwitchPreference("revanced_external_downloader_action_button"), + TextPreference("revanced_external_downloader_name", inputType = InputType.TEXT), + ), + ), + ) + + context.copyResources( + "downloads", + ResourceGroup("drawable", "revanced_yt_download_button.xml"), + ) + + addBottomControl("downloads") + } +} + +internal const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/youtube/patches/DownloadsPatch;" + +internal const val BUTTON_DESCRIPTOR = "Lapp/revanced/extension/youtube/videoplayer/ExternalDownloadButton;" + +@Suppress("unused") +val downloadsPatch = bytecodePatch( + name = "Downloads", + description = "Adds support to download videos with an external downloader app " + + "using the in-app download button or a video player action button.", +) { + dependsOn( + downloadsResourcePatch, + playerControlsPatch, + videoInformationPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val offlineVideoEndpointMatch by offlineVideoEndpointFingerprint() + val mainActivityMatch by mainActivityFingerprint() + + execute { + initializeBottomControl(BUTTON_DESCRIPTOR) + injectVisibilityCheckCall(BUTTON_DESCRIPTOR) + + // Main activity is used to launch downloader intent. + mainActivityMatch.mutableMethod.apply { + addInstruction( + implementation!!.instructions.lastIndex, + "invoke-static { p0 }, $EXTENSION_CLASS_DESCRIPTOR->activityCreated(Landroid/app/Activity;)V", + ) + } + + offlineVideoEndpointMatch.mutableMethod.apply { + addInstructionsWithLabels( + 0, + """ + invoke-static/range {p3 .. p3}, $EXTENSION_CLASS_DESCRIPTOR->inAppDownloadButtonOnClick(Ljava/lang/String;)Z + move-result v0 + if-eqz v0, :show_native_downloader + return-void + :show_native_downloader + nop + """, + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/downloads/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/downloads/Fingerprints.kt new file mode 100644 index 000000000..f10fc8e83 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/downloads/Fingerprints.kt @@ -0,0 +1,16 @@ +package app.revanced.patches.youtube.interaction.downloads + +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val offlineVideoEndpointFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters( + "Ljava/util/Map;", + "L", + "Ljava/lang/String", // VideoId + "L", + ) + strings("Object is not an offlineable video: ") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/seekbar/DisablePreciseSeekingGesturePatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/seekbar/DisablePreciseSeekingGesturePatch.kt new file mode 100644 index 000000000..df2f161e4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/seekbar/DisablePreciseSeekingGesturePatch.kt @@ -0,0 +1,80 @@ +package app.revanced.patches.youtube.interaction.seekbar + +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.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.util.applyMatch + +@Suppress("unused") +val disablePreciseSeekingGesturePatch = bytecodePatch( + name = "Disable precise seeking gesture", + description = "Adds an option to disable precise seeking when swiping up on the seekbar.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val swipingUpGestureParentMatch by swipingUpGestureParentFingerprint() + + execute { context -> + addResources("youtube", "interaction.seekbar.disablePreciseSeekingGesturePatch") + + PreferenceScreen.SEEKBAR.addPreferences( + SwitchPreference("revanced_disable_precise_seeking_gesture"), + ) + val extensionMethodDescriptor = + "Lapp/revanced/extension/youtube/patches/DisablePreciseSeekingGesturePatch;" + + allowSwipingUpGestureFingerprint.applyMatch( + context, + swipingUpGestureParentMatch, + ).mutableMethod.apply { + addInstructionsWithLabels( + 0, + """ + invoke-static { }, $extensionMethodDescriptor->isGestureDisabled()Z + move-result v0 + if-eqz v0, :disabled + return-void + """, + ExternalLabel("disabled", getInstruction(0)), + ) + } + + showSwipingUpGuideFingerprint.applyMatch( + context, + swipingUpGestureParentMatch, + ).mutableMethod.apply { + addInstructionsWithLabels( + 0, + """ + invoke-static { }, $extensionMethodDescriptor->isGestureDisabled()Z + move-result v0 + if-eqz v0, :disabled + const/4 v0, 0x0 + return v0 + """, + ExternalLabel("disabled", getInstruction(0)), + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/seekbar/EnableSeekbarTappingPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/seekbar/EnableSeekbarTappingPatch.kt new file mode 100644 index 000000000..f07aeeca0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/seekbar/EnableSeekbarTappingPatch.kt @@ -0,0 +1,86 @@ +package app.revanced.patches.youtube.interaction.seekbar + +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.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +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 enableSeekbarTappingPatch = bytecodePatch( + name = "Seekbar tapping", + description = "Adds an option to enable tap-to-seek on the seekbar of the video player.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + // 18.38.44 patches but crashes on startup. + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val onTouchEventHandlerMatch by onTouchEventHandlerFingerprint() + val seekbarTappingMatch by seekbarTappingFingerprint() + + execute { + addResources("youtube", "interaction.seekbar.enableSeekbarTappingPatch") + + PreferenceScreen.SEEKBAR.addPreferences( + SwitchPreference("revanced_seekbar_tapping"), + ) + + // Find the required methods to tap the seekbar. + val patternMatch = onTouchEventHandlerMatch.patternMatch!! + + fun getReference(index: Int) = onTouchEventHandlerMatch.mutableMethod.getInstruction(index) + .reference as MethodReference + + val seekbarTappingMethods = buildMap { + put("N", getReference(patternMatch.startIndex)) + put("O", getReference(patternMatch.endIndex)) + } + + val insertIndex = seekbarTappingMatch.patternMatch!!.endIndex - 1 + + seekbarTappingMatch.mutableMethod.apply { + val thisInstanceRegister = getInstruction(insertIndex - 1).registerC + + val freeRegister = 0 + val xAxisRegister = 2 + + val oMethod = seekbarTappingMethods["O"]!! + val nMethod = seekbarTappingMethods["N"]!! + + fun MethodReference.toInvokeInstructionString() = + "invoke-virtual { v$thisInstanceRegister, v$xAxisRegister }, $this" + + addInstructionsWithLabels( + insertIndex, + """ + invoke-static { }, Lapp/revanced/extension/youtube/patches/SeekbarTappingPatch;->seekbarTappingEnabled()Z + move-result v$freeRegister + if-eqz v$freeRegister, :disabled + ${oMethod.toInvokeInstructionString()} + ${nMethod.toInvokeInstructionString()} + """, + ExternalLabel("disabled", getInstruction(insertIndex)), + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/seekbar/EnableSlideToSeekPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/seekbar/EnableSlideToSeekPatch.kt new file mode 100644 index 000000000..2e5eebba9 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/seekbar/EnableSlideToSeekPatch.kt @@ -0,0 +1,127 @@ +package app.revanced.patches.youtube.interaction.seekbar + +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.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.playservice.is_19_17_or_greater +import app.revanced.patches.youtube.misc.playservice.versionCheckPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.util.getReference +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.sun.org.apache.bcel.internal.generic.InstructionConst.getInstruction + +internal const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/youtube/patches/SlideToSeekPatch;" + +@Suppress("unused") +val enableSlideToSeekPatch = bytecodePatch( + name = "Enable slide to seek", + description = "Adds an option to enable slide to seek " + + "instead of playing at 2x speed when pressing and holding in the video player. " + + "Including this patch may cause issues with tapping or double tapping the video player overlay.", + use = false, +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + versionCheckPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val slideToSeekMatch by slideToSeekFingerprint() + val disableFastForwardLegacyMatch by disableFastForwardLegacyFingerprint() + val disableFastForwardGestureMatch by disableFastForwardGestureFingerprint() + val disableFastForwardNoticeMatch by disableFastForwardNoticeFingerprint() + + execute { + addResources("youtube", "interaction.seekbar.enableSlideToSeekPatch") + + PreferenceScreen.SEEKBAR.addPreferences( + SwitchPreference("revanced_slide_to_seek"), + ) + + var modifiedMethods = false + + // Restore the behaviour to slide to seek. + val checkIndex = + slideToSeekMatch.patternMatch!!.startIndex + val checkReference = + slideToSeekMatch.mutableMethod.getInstruction(checkIndex).getReference()!! + + // A/B check method was only called on this class. + slideToSeekMatch.mutableClass.methods.forEach { method -> + method.implementation!!.instructions.forEachIndexed { index, instruction -> + if (instruction.opcode == Opcode.INVOKE_VIRTUAL && + instruction.getReference() == checkReference + ) { + method.apply { + val targetRegister = getInstruction(index + 1).registerA + + addInstructions( + index + 2, + """ + invoke-static { v$targetRegister }, $EXTENSION_CLASS_DESCRIPTOR + move-result v$targetRegister + """, + ) + } + + modifiedMethods = true + } + } + } + + if (!modifiedMethods) throw PatchException("Could not find methods to modify") + + // Disable the double speed seek gesture. + if (!is_19_17_or_greater) { + disableFastForwardLegacyMatch.mutableMethod.apply { + val insertIndex = disableFastForwardLegacyMatch.patternMatch!!.endIndex + 1 + val targetRegister = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, + """ + invoke-static { v$targetRegister }, $EXTENSION_CLASS_DESCRIPTOR + move-result v$targetRegister + """, + ) + } + } else { + arrayOf( + disableFastForwardGestureMatch, + disableFastForwardNoticeMatch, + ).forEach { + it.mutableMethod.apply { + val targetIndex = it.patternMatch!!.endIndex + val targetRegister = getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, + """ + invoke-static { v$targetRegister }, $EXTENSION_CLASS_DESCRIPTOR + move-result v$targetRegister + """, + ) + } + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/seekbar/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/seekbar/Fingerprints.kt new file mode 100644 index 000000000..3087769f8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/seekbar/Fingerprints.kt @@ -0,0 +1,119 @@ +package app.revanced.patches.youtube.interaction.seekbar + +import app.revanced.patcher.fingerprint +import app.revanced.util.containsWideLiteralInstructionValue +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val swipingUpGestureParentFingerprint = fingerprint { + returns("Z") + parameters() + literal { 45379021 } +} + +/** + * Resolves using the class found in [swipingUpGestureParentFingerprint]. + */ +internal val showSwipingUpGuideFingerprint = fingerprint { + accessFlags(AccessFlags.FINAL) + returns("Z") + parameters() + literal { 1 } +} + +/** + * Resolves using the class found in [swipingUpGestureParentFingerprint]. + */ +internal val allowSwipingUpGestureFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("L") +} + +internal val disableFastForwardLegacyFingerprint = fingerprint { + returns("Z") + parameters() + opcodes(Opcode.MOVE_RESULT) + literal { 45411330 } +} + +internal val disableFastForwardGestureFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Z") + parameters() + opcodes( + Opcode.IF_EQZ, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + ) + custom { methodDef, classDef -> + methodDef.implementation!!.instructions.count() > 30 && + classDef.type.endsWith("/NextGenWatchLayout;") + } +} + +internal val disableFastForwardNoticeFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters() + opcodes( + Opcode.CHECK_CAST, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + ) + strings("Failed to easy seek haptics vibrate") +} + +internal val onTouchEventHandlerFingerprint = fingerprint(fuzzyPatternScanThreshold = 3) { + accessFlags(AccessFlags.PUBLIC, AccessFlags.PUBLIC) + returns("Z") + parameters("L") + opcodes( + Opcode.INVOKE_VIRTUAL, // nMethodReference + Opcode.RETURN, + Opcode.IGET_OBJECT, + Opcode.IGET_BOOLEAN, + Opcode.IF_EQZ, + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN, + Opcode.INT_TO_FLOAT, + Opcode.INT_TO_FLOAT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_EQZ, + Opcode.INVOKE_VIRTUAL, + Opcode.INVOKE_VIRTUAL, // oMethodReference + ) + custom { method, _ -> method.name == "onTouchEvent" } +} + +internal val seekbarTappingFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Z") + parameters("L") + opcodes( + Opcode.IPUT_OBJECT, + Opcode.INVOKE_VIRTUAL, + // Insert seekbar tapping instructions here. + Opcode.RETURN, + Opcode.INVOKE_VIRTUAL, + ) + custom { method, _ -> + method.containsWideLiteralInstructionValue(Integer.MAX_VALUE.toLong()) + } +} + +internal val slideToSeekFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.FINAL) + returns("Z") + parameters("Landroid/view/View;", "F") + opcodes( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_EQZ, + Opcode.GOTO_16, + ) + literal { 67108864 } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/swipecontrols/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/swipecontrols/Fingerprints.kt new file mode 100644 index 000000000..0a31f1c30 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/swipecontrols/Fingerprints.kt @@ -0,0 +1,12 @@ +package app.revanced.patches.youtube.interaction.swipecontrols + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val swipeControlsHostActivityFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + parameters() + custom { method, _ -> + method.definingClass == "Lapp/revanced/extension/youtube/swipecontrols/SwipeControlsHostActivity;" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/swipecontrols/SwipeControlsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/swipecontrols/SwipeControlsPatch.kt new file mode 100644 index 000000000..3cee8b453 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/swipecontrols/SwipeControlsPatch.kt @@ -0,0 +1,107 @@ +package app.revanced.patches.youtube.interaction.swipecontrols + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.InputType +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.shared.misc.settings.preference.TextPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.playertype.playerTypeHookPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.patches.youtube.shared.mainActivityFingerprint +import app.revanced.util.ResourceGroup +import app.revanced.util.copyResources +import app.revanced.util.transformMethods +import app.revanced.util.traverseClassHierarchy +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.immutable.ImmutableMethod + +private val swipeControlsResourcePatch = resourcePatch { + dependsOn( + settingsPatch, + addResourcesPatch, + ) + + execute { context -> + addResources("youtube", "interaction.swipecontrols.swipeControlsResourcePatch") + + PreferenceScreen.SWIPE_CONTROLS.addPreferences( + SwitchPreference("revanced_swipe_brightness"), + SwitchPreference("revanced_swipe_volume"), + SwitchPreference("revanced_swipe_press_to_engage"), + SwitchPreference("revanced_swipe_haptic_feedback"), + SwitchPreference("revanced_swipe_save_and_restore_brightness"), + SwitchPreference("revanced_swipe_lowest_value_enable_auto_brightness"), + TextPreference("revanced_swipe_overlay_timeout", inputType = InputType.NUMBER), + TextPreference("revanced_swipe_text_overlay_size", inputType = InputType.NUMBER), + TextPreference("revanced_swipe_overlay_background_alpha", inputType = InputType.NUMBER), + TextPreference("revanced_swipe_threshold", inputType = InputType.NUMBER), + ) + + context.copyResources( + "swipecontrols", + ResourceGroup( + "drawable", + "revanced_ic_sc_brightness_auto.xml", + "revanced_ic_sc_brightness_manual.xml", + "revanced_ic_sc_volume_mute.xml", + "revanced_ic_sc_volume_normal.xml", + ), + ) + } +} + +@Suppress("unused") +val swipeControlsPatch = bytecodePatch( + name = "Swipe controls", + description = "Adds options to enable and configure volume and brightness swipe controls.", +) { + dependsOn( + sharedExtensionPatch, + playerTypeHookPatch, + swipeControlsResourcePatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val mainActivityMatch by mainActivityFingerprint() + val swipeControlsHostActivityMatch by swipeControlsHostActivityFingerprint() + + execute { context -> + val wrapperClass = swipeControlsHostActivityMatch.mutableClass + val targetClass = mainActivityMatch.mutableClass + + // Inject the wrapper class from the extension into the class hierarchy of MainActivity. + wrapperClass.setSuperClass(targetClass.superclass) + targetClass.setSuperClass(wrapperClass.type) + + // Ensure all classes and methods in the hierarchy are non-final, so we can override them in the extension. + context.traverseClassHierarchy(targetClass) { + accessFlags = accessFlags and AccessFlags.FINAL.value.inv() + transformMethods { + ImmutableMethod( + definingClass, + name, + parameters, + returnType, + accessFlags and AccessFlags.FINAL.value.inv(), + annotations, + hiddenApiRestrictions, + implementation, + ).toMutable() + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/autocaptions/AutoCaptionsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/autocaptions/AutoCaptionsPatch.kt new file mode 100644 index 000000000..8a46798d4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/autocaptions/AutoCaptionsPatch.kt @@ -0,0 +1,73 @@ +package app.revanced.patches.youtube.layout.autocaptions + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.patches.youtube.shared.subtitleButtonControllerFingerprint + +@Suppress("unused") +val autoCaptionsPatch = bytecodePatch( + name = "Disable auto captions", + description = "Adds an option to disable captions from being automatically enabled.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val startVideoInformerMatch by startVideoInformerFingerprint() + val subtitleButtonControllerMatch by subtitleButtonControllerFingerprint() + val subtitleTrackMatch by subtitleTrackFingerprint() + + execute { + addResources("youtube", "layout.autocaptions.autoCaptionsPatch") + + PreferenceScreen.PLAYER.addPreferences( + SwitchPreference("revanced_auto_captions"), + ) + + mapOf( + startVideoInformerMatch to 0, + subtitleButtonControllerMatch to 1, + ).forEach { (match, enabled) -> + match.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x$enabled + sput-boolean v0, Lapp/revanced/extension/youtube/patches/DisableAutoCaptionsPatch;->captionsButtonDisabled:Z + """, + ) + } + + subtitleTrackMatch.mutableMethod.addInstructions( + 0, + """ + invoke-static {}, Lapp/revanced/extension/youtube/patches/DisableAutoCaptionsPatch;->autoCaptionsEnabled()Z + move-result v0 + if-eqz v0, :auto_captions_enabled + sget-boolean v0, Lapp/revanced/extension/youtube/patches/DisableAutoCaptionsPatch;->captionsButtonDisabled:Z + if-nez v0, :auto_captions_enabled + const/4 v0, 0x1 + return v0 + :auto_captions_enabled + nop + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/autocaptions/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/autocaptions/Fingerprints.kt new file mode 100644 index 000000000..3e1c15629 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/autocaptions/Fingerprints.kt @@ -0,0 +1,33 @@ +package app.revanced.patches.youtube.layout.autocaptions + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val startVideoInformerFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + opcodes( + Opcode.INVOKE_INTERFACE, + Opcode.RETURN_VOID, + ) + strings("pc") +} + +internal val subtitleTrackFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Z") + parameters() + opcodes( + Opcode.CONST_STRING, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.RETURN, + ) + strings("DISABLE_CAPTIONS_OPTION") + custom { _, classDef -> + classDef.endsWith("SubtitleTrack;") + } +} diff --git a/src/main/kotlin/app/revanced/patches/youtube/layout/branding/CustomBrandingPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/branding/CustomBrandingPatch.kt similarity index 68% rename from src/main/kotlin/app/revanced/patches/youtube/layout/branding/CustomBrandingPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/youtube/layout/branding/CustomBrandingPatch.kt index ec720ec97..d1670aeda 100644 --- a/src/main/kotlin/app/revanced/patches/youtube/layout/branding/CustomBrandingPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/branding/CustomBrandingPatch.kt @@ -1,45 +1,40 @@ package app.revanced.patches.youtube.layout.branding -import app.revanced.patcher.data.ResourceContext -import app.revanced.patcher.patch.ResourcePatch -import app.revanced.patcher.patch.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patcher.patch.options.PatchOption.PatchExtensions.stringPatchOption +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption import app.revanced.util.ResourceGroup import app.revanced.util.Utils.trimIndentMultiline import app.revanced.util.copyResources import java.io.File import java.nio.file.Files -@Patch( +private const val REVANCED_ICON = "ReVanced*Logo" // Can never be a valid path. +private const val APP_NAME = "YouTube ReVanced" + +private val iconResourceFileNames = arrayOf( + "adaptiveproduct_youtube_background_color_108", + "adaptiveproduct_youtube_foreground_color_108", + "ic_launcher", + "ic_launcher_round", +).map { "$it.png" }.toTypedArray() + +private val mipmapDirectories = arrayOf( + "xxxhdpi", + "xxhdpi", + "xhdpi", + "hdpi", + "mdpi", +).map { "mipmap-$it" } + +@Suppress("unused") +val customBrandingPatch = resourcePatch( name = "Custom branding", description = "Applies a custom app name and icon. Defaults to \"YouTube ReVanced\" and the ReVanced logo.", - compatiblePackages = [ - CompatiblePackage("com.google.android.youtube"), - ], use = false, -) -@Suppress("unused") -object CustomBrandingPatch : ResourcePatch() { - private const val REVANCED_ICON = "ReVanced*Logo" // Can never be a valid path. - private const val APP_NAME = "YouTube ReVanced" +) { + compatibleWith("com.google.android.youtube") - private val iconResourceFileNames = arrayOf( - "adaptiveproduct_youtube_background_color_108", - "adaptiveproduct_youtube_foreground_color_108", - "ic_launcher", - "ic_launcher_round", - ).map { "$it.png" }.toTypedArray() - - private val mipmapDirectories = arrayOf( - "xxxhdpi", - "xxhdpi", - "xhdpi", - "hdpi", - "mdpi", - ).map { "mipmap-$it" } - - private var appName by stringPatchOption( + val appName by stringOption( key = "appName", default = APP_NAME, values = mapOf( @@ -52,7 +47,7 @@ object CustomBrandingPatch : ResourcePatch() { description = "The name of the app.", ) - private var icon by stringPatchOption( + val icon by stringOption( key = "iconPath", default = REVANCED_ICON, values = mapOf("ReVanced Logo" to REVANCED_ICON), @@ -70,7 +65,7 @@ object CustomBrandingPatch : ResourcePatch() { """.trimIndentMultiline(), ) - override fun execute(context: ResourceContext) { + execute { context -> icon?.let { icon -> // Change the app icon. mipmapDirectories.map { directory -> @@ -81,7 +76,7 @@ object CustomBrandingPatch : ResourcePatch() { }.let { resourceGroups -> if (icon != REVANCED_ICON) { val path = File(icon) - val resourceDirectory = context.get("res") + val resourceDirectory = context["res"] resourceGroups.forEach { group -> val fromDirectory = path.resolve(group.resourceDirectoryName) @@ -102,7 +97,7 @@ object CustomBrandingPatch : ResourcePatch() { appName?.let { name -> // Change the app name. - val manifest = context.get("AndroidManifest.xml") + val manifest = context["AndroidManifest.xml"] manifest.writeText( manifest.readText() .replace( diff --git a/src/main/kotlin/app/revanced/patches/youtube/layout/branding/header/ChangeHeaderPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/branding/header/ChangeHeaderPatch.kt similarity index 78% rename from src/main/kotlin/app/revanced/patches/youtube/layout/branding/header/ChangeHeaderPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/youtube/layout/branding/header/ChangeHeaderPatch.kt index d0614297a..8b184c57f 100644 --- a/src/main/kotlin/app/revanced/patches/youtube/layout/branding/header/ChangeHeaderPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/branding/header/ChangeHeaderPatch.kt @@ -1,47 +1,42 @@ package app.revanced.patches.youtube.layout.branding.header -import app.revanced.patcher.data.ResourceContext import app.revanced.patcher.patch.PatchException -import app.revanced.patcher.patch.ResourcePatch -import app.revanced.patcher.patch.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patcher.patch.options.PatchOption.PatchExtensions.stringPatchOption +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.patch.stringOption import app.revanced.util.ResourceGroup import app.revanced.util.Utils.trimIndentMultiline import app.revanced.util.copyResources import java.io.File -@Patch( +private const val HEADER_FILE_NAME = "yt_wordmark_header" +private const val PREMIUM_HEADER_FILE_NAME = "yt_premium_wordmark_header" + +private const val HEADER_OPTION = "header*" +private const val PREMIUM_HEADER_OPTION = "premium*header" +private const val REVANCED_HEADER_OPTION = "revanced*" +private const val REVANCED_BORDERLESS_HEADER_OPTION = "revanced*borderless" + +private val targetResourceDirectoryNames = mapOf( + "xxxhdpi" to "512px 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") + +@Suppress("unused") +val changeHeaderPatch = resourcePatch( name = "Change header", description = "Applies a custom header in the top left corner within the app. Defaults to the ReVanced header.", - compatiblePackages = [ - CompatiblePackage("com.google.android.youtube"), - ], use = false, -) -@Suppress("unused") -object ChangeHeaderPatch : ResourcePatch() { - private const val HEADER_FILE_NAME = "yt_wordmark_header" - private const val PREMIUM_HEADER_FILE_NAME = "yt_premium_wordmark_header" +) { + compatibleWith("com.google.android.youtube") - private const val HEADER_OPTION = "header*" - private const val PREMIUM_HEADER_OPTION = "premium*header" - private const val REVANCED_HEADER_OPTION = "revanced*" - private const val REVANCED_BORDERLESS_HEADER_OPTION = "revanced*borderless" - - private val targetResourceDirectoryNames = mapOf( - "xxxhdpi" to "512px 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 header by stringPatchOption( + val header by stringOption( key = "header", default = REVANCED_BORDERLESS_HEADER_OPTION, values = mapOf( @@ -68,10 +63,10 @@ object ChangeHeaderPatch : ResourcePatch() { required = true, ) - override fun execute(context: ResourceContext) { + execute { context -> // The directories to copy the header to. val targetResourceDirectories = targetResourceDirectoryNames.keys.mapNotNull { - context.get("res").resolve(it).takeIf(File::exists) + context["res"].resolve(it).takeIf(File::exists) } // The files to replace in the target directories. val targetResourceFiles = targetResourceDirectoryNames.keys.map { directoryName -> diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/action/HideButtonsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/action/HideButtonsPatch.kt new file mode 100644 index 000000000..e0ee58273 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/action/HideButtonsPatch.kt @@ -0,0 +1,55 @@ +package app.revanced.patches.youtube.layout.buttons.action + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.settings.preference.PreferenceScreenPreference +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.litho.filter.addLithoFilter +import app.revanced.patches.youtube.misc.litho.filter.lithoFilterPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen + +@Suppress("unused") +val hideButtonsPatch = resourcePatch( + name = "Hide video action buttons", + description = "Adds options to hide action buttons (such as the Download button) under videos.", +) { + dependsOn( + resourceMappingPatch, + lithoFilterPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + execute { + addResources("youtube", "layout.buttons.action.hideButtonsPatch") + + PreferenceScreen.PLAYER.addPreferences( + PreferenceScreenPreference( + "revanced_hide_buttons_screen", + preferences = setOf( + SwitchPreference("revanced_hide_like_dislike_button"), + SwitchPreference("revanced_hide_share_button"), + SwitchPreference("revanced_hide_report_button"), + SwitchPreference("revanced_hide_remix_button"), + SwitchPreference("revanced_hide_download_button"), + SwitchPreference("revanced_hide_thanks_button"), + SwitchPreference("revanced_hide_clip_button"), + SwitchPreference("revanced_hide_playlist_button"), + ), + ), + ) + + addLithoFilter("Lapp/revanced/extension/youtube/patches/components/ButtonsFilter;") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/navigation/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/navigation/Fingerprints.kt new file mode 100644 index 000000000..eebd0befb --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/navigation/Fingerprints.kt @@ -0,0 +1,25 @@ +package app.revanced.patches.youtube.layout.buttons.navigation + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal const val ANDROID_AUTOMOTIVE_STRING = "Android Automotive" + +internal val addCreateButtonViewFingerprint = fingerprint { + strings("Android Wear", ANDROID_AUTOMOTIVE_STRING) +} + +internal val createPivotBarFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + returns("V") + parameters( + "Lcom/google/android/libraries/youtube/rendering/ui/pivotbar/PivotBar;", + "Landroid/widget/TextView;", + "Ljava/lang/CharSequence;", + ) + opcodes( + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN_VOID, + ) +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/navigation/NavigationButtonsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/navigation/NavigationButtonsPatch.kt new file mode 100644 index 000000000..43b221ade --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/navigation/NavigationButtonsPatch.kt @@ -0,0 +1,106 @@ +package app.revanced.patches.youtube.layout.buttons.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.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.PreferenceScreenPreference +import app.revanced.patches.shared.misc.settings.preference.PreferenceScreenPreference.Sorting +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.navigation.hookNavigationButtonCreated +import app.revanced.patches.youtube.misc.navigation.navigationBarHookPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +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 + +internal const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/NavigationButtonsPatch;" + +@Suppress("unused") +val navigationButtonsPatch = bytecodePatch( + name = "Navigation buttons", + description = "Adds options to hide and change navigation buttons (such as the Shorts button).", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + navigationBarHookPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val addCreateButtonViewMatch by addCreateButtonViewFingerprint() + val createPivotBarMatch by createPivotBarFingerprint() + + execute { + addResources("youtube", "layout.buttons.navigation.navigationButtonsPatch") + + PreferenceScreen.GENERAL_LAYOUT.addPreferences( + PreferenceScreenPreference( + key = "revanced_navigation_buttons_screen", + sorting = Sorting.UNSORTED, + preferences = setOf( + SwitchPreference("revanced_hide_home_button"), + SwitchPreference("revanced_hide_shorts_button"), + SwitchPreference("revanced_hide_create_button"), + SwitchPreference("revanced_hide_subscriptions_button"), + SwitchPreference("revanced_switch_create_with_notifications_button"), + SwitchPreference("revanced_hide_navigation_button_labels"), + ), + ), + ) + + // Switch create with notifications button. + addCreateButtonViewMatch.mutableMethod.apply { + val stringIndex = addCreateButtonViewMatch.stringMatches!!.find { match -> + match.string == ANDROID_AUTOMOTIVE_STRING + }!!.index + + val conditionalCheckIndex = stringIndex - 1 + val conditionRegister = + getInstruction(conditionalCheckIndex).registerA + + addInstructions( + conditionalCheckIndex, + """ + invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->switchCreateWithNotificationButton()Z + move-result v$conditionRegister + """, + ) + } + + // Hide navigation button labels. + createPivotBarMatch.mutableMethod.apply { + val setTextIndex = indexOfFirstInstructionOrThrow { + getReference()?.name == "setText" + } + + val targetRegister = getInstruction(setTextIndex).registerC + + addInstruction( + setTextIndex, + "invoke-static { v$targetRegister }, " + + "$EXTENSION_CLASS_DESCRIPTOR->hideNavigationButtonLabels(Landroid/widget/TextView;)V", + ) + } + + // Hook navigation button created, in order to hide them. + hookNavigationButtonCreated(EXTENSION_CLASS_DESCRIPTOR) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/overlay/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/overlay/Fingerprints.kt new file mode 100644 index 000000000..386f18bcd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/overlay/Fingerprints.kt @@ -0,0 +1,22 @@ +package app.revanced.patches.youtube.layout.buttons.overlay + +import app.revanced.patcher.fingerprint +import app.revanced.util.containsWideLiteralInstructionValue +import com.android.tools.smali.dexlib2.AccessFlags + +internal val playerControlsPreviousNextOverlayTouchFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + strings("1.0x") + custom { methodDef, _ -> + methodDef.containsWideLiteralInstructionValue(playerControlPreviousButtonTouchArea) && + methodDef.containsWideLiteralInstructionValue(playerControlNextButtonTouchArea) + } +} + +internal val mediaRouteButtonFingerprint = fingerprint { + parameters("I") + custom { methodDef, _ -> + methodDef.definingClass.endsWith("/MediaRouteButton;") && methodDef.name == "setVisibility" + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/overlay/HidePlayerOverlayButtonsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/overlay/HidePlayerOverlayButtonsPatch.kt new file mode 100644 index 000000000..380b9309f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/overlay/HidePlayerOverlayButtonsPatch.kt @@ -0,0 +1,158 @@ +package app.revanced.patches.youtube.layout.buttons.overlay + +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.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.patches.youtube.shared.layoutConstructorFingerprint +import app.revanced.patches.youtube.shared.subtitleButtonControllerFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstWideLiteralInstructionValueOrThrow +import app.revanced.util.indexOfIdResourceOrThrow +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 + +internal var playerControlPreviousButtonTouchArea = -1L + private set +internal var playerControlNextButtonTouchArea = -1L + private set + +private val hidePlayerOverlayButtonsResourcePatch = resourcePatch { + dependsOn(resourceMappingPatch) + + execute { + playerControlPreviousButtonTouchArea = resourceMappings["id", "player_control_previous_button_touch_area"] + playerControlNextButtonTouchArea = resourceMappings["id", "player_control_next_button_touch_area"] + } +} + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/HidePlayerOverlayButtonsPatch;" + +@Suppress("unused") +val hidePlayerOverlayButtonsPatch = bytecodePatch( + name = "Hide player overlay buttons", + description = "Adds options to hide the player cast, autoplay, caption button and next/ previous buttons.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + hidePlayerOverlayButtonsResourcePatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val playerControlsPreviousNextOverlayTouchMatch by playerControlsPreviousNextOverlayTouchFingerprint() + val mediaRouteButtonMatch by mediaRouteButtonFingerprint() + val subtitleButtonControllerMatch by subtitleButtonControllerFingerprint() + val layoutConstructorMatch by layoutConstructorFingerprint() + + execute { + addResources("youtube", "layout.buttons.overlay.hidePlayerOverlayButtonsPatch") + + PreferenceScreen.PLAYER.addPreferences( + SwitchPreference("revanced_hide_player_previous_next_buttons"), + SwitchPreference("revanced_hide_cast_button"), + SwitchPreference("revanced_hide_captions_button"), + SwitchPreference("revanced_hide_autoplay_button"), + ) + + // region Hide player next/previous button. + + playerControlsPreviousNextOverlayTouchMatch.mutableMethod.apply { + val resourceIndex = indexOfFirstWideLiteralInstructionValueOrThrow(playerControlPreviousButtonTouchArea) + + val insertIndex = indexOfFirstInstructionOrThrow(resourceIndex) { + opcode == Opcode.INVOKE_STATIC && + getReference()?.parameterTypes?.firstOrNull() == "Landroid/view/View;" + } + + val viewRegister = getInstruction(insertIndex).registerC + + addInstruction( + insertIndex, + "invoke-static { v$viewRegister }, $EXTENSION_CLASS_DESCRIPTOR" + + "->hidePreviousNextButtons(Landroid/view/View;)V", + ) + } + + // endregion + + // region Hide cast button. + + mediaRouteButtonMatch.mutableMethod.addInstructions( + 0, + """ + invoke-static { p1 }, $EXTENSION_CLASS_DESCRIPTOR->getCastButtonOverrideV2(I)I + move-result p1 + """, + ) + + // endregion + + // region Hide captions button. + + subtitleButtonControllerMatch.mutableMethod.apply { + // Due to previously applied patches, scanResult index cannot be used in this context + val insertIndex = indexOfFirstInstructionOrThrow(Opcode.IGET_BOOLEAN) + 1 + + addInstruction( + insertIndex, + "invoke-static {v0}, $EXTENSION_CLASS_DESCRIPTOR->hideCaptionsButton(Landroid/widget/ImageView;)V", + ) + } + + // endregion + + // region Hide autoplay button. + + layoutConstructorMatch.mutableMethod.apply { + val constIndex = indexOfIdResourceOrThrow("autonav_toggle") + val constRegister = getInstruction(constIndex).registerA + + // Add a conditional branch around the code that inflates and adds the auto-repeat button. + val gotoIndex = indexOfFirstInstructionOrThrow(constIndex) { + val parameterTypes = getReference()?.parameterTypes + opcode == Opcode.INVOKE_VIRTUAL && + parameterTypes?.size == 2 && + parameterTypes.first() == "Landroid/view/ViewStub;" + } + 1 + + addInstructionsWithLabels( + constIndex, + """ + invoke-static {}, $EXTENSION_CLASS_DESCRIPTOR->hideAutoPlayButton()Z + move-result v$constRegister + if-nez v$constRegister, :hidden + """, + ExternalLabel("hidden", getInstruction(gotoIndex)), + ) + } + + // endregion + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/player/hide/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/player/hide/Fingerprints.kt new file mode 100644 index 000000000..f165098f0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/buttons/player/hide/Fingerprints.kt @@ -0,0 +1,9 @@ +package app.revanced.patches.youtube.layout.buttons.player.hide + +import com.android.tools.smali.dexlib2.Opcode +import app.revanced.patcher.fingerprint + +internal val playerControlsVisibilityModelFingerprint = fingerprint { + opcodes(Opcode.INVOKE_DIRECT_RANGE) + strings("Missing required properties:", "hasNext", "hasPrevious") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/breakingnews/BreakingNewsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/breakingnews/BreakingNewsPatch.kt new file mode 100644 index 000000000..a7955c59e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/breakingnews/BreakingNewsPatch.kt @@ -0,0 +1,10 @@ +package app.revanced.patches.youtube.layout.hide.breakingnews + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.layout.hide.general.hideLayoutComponentsPatch + +@Deprecated("This patch has been merged to HideLayoutComponentsPatch.") +@Suppress("unused") +val breakingNewsPatch = bytecodePatch { + dependsOn(hideLayoutComponentsPatch) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/endscreencards/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/endscreencards/Fingerprints.kt new file mode 100644 index 000000000..59d859e80 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/endscreencards/Fingerprints.kt @@ -0,0 +1,40 @@ +package app.revanced.patches.youtube.layout.hide.endscreencards + +import app.revanced.patcher.fingerprint +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.Opcode + +internal val layoutCircleFingerprint = fingerprint { + returns("Landroid/view/View;") + opcodes( + Opcode.CONST, + Opcode.CONST_4, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + ) + literal { layoutCircle } +} + +internal val layoutIconFingerprint = fingerprint { + returns("Landroid/view/View;") + opcodes( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + + ) + literal { layoutIcon } +} + +internal val layoutVideoFingerprint = fingerprint { + returns("Landroid/view/View;") + opcodes( + Opcode.CONST, + Opcode.CONST_4, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + ) + literal { layoutVideo } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/endscreencards/HideEndscreenCardsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/endscreencards/HideEndscreenCardsPatch.kt new file mode 100644 index 000000000..e63f8950d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/endscreencards/HideEndscreenCardsPatch.kt @@ -0,0 +1,90 @@ +package app.revanced.patches.youtube.layout.hide.endscreencards + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction21c + +internal var layoutCircle = -1L + private set +internal var layoutIcon = -1L + private set +internal var layoutVideo = -1L + private set + +private val hideEndscreenCardsResourcePatch = resourcePatch { + dependsOn( + settingsPatch, + resourceMappingPatch, + addResourcesPatch, + ) + + execute { + addResources("youtube", "layout.hide.endscreencards.hideEndscreenCardsResourcePatch") + + PreferenceScreen.PLAYER.addPreferences( + SwitchPreference("revanced_hide_endscreen_cards"), + ) + + fun idOf(name: String) = resourceMappings["layout", "endscreen_element_layout_$name"] + + layoutCircle = idOf("circle") + layoutIcon = idOf("icon") + layoutVideo = idOf("video") + } +} + +@Suppress("unused") +val hideEndscreenCardsPatch = bytecodePatch( + name = "Hide endscreen cards", + description = "Adds an option to hide suggested video cards at the end of videos.", +) { + dependsOn( + sharedExtensionPatch, + hideEndscreenCardsResourcePatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val layoutCircleMatch by layoutCircleFingerprint() + val layoutIconMatch by layoutIconFingerprint() + val layoutVideoMatch by layoutVideoFingerprint() + + execute { + listOf( + layoutCircleMatch, + layoutIconMatch, + layoutVideoMatch, + ).forEach { + it.mutableMethod.apply { + val insertIndex = it.patternMatch!!.endIndex + 1 + val viewRegister = getInstruction(insertIndex - 1).registerA + + addInstruction( + insertIndex, + "invoke-static { v$viewRegister }, " + + "Lapp/revanced/extension/youtube/patches/HideEndscreenCardsPatch;->" + + "hideEndscreen(Landroid/view/View;)V", + ) + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/fullscreenambientmode/DisableFullscreenAmbientModePatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/fullscreenambientmode/DisableFullscreenAmbientModePatch.kt new file mode 100644 index 000000000..a286d70fd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/fullscreenambientmode/DisableFullscreenAmbientModePatch.kt @@ -0,0 +1,67 @@ +package app.revanced.patches.youtube.layout.hide.fullscreenambientmode + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.playservice.is_19_43_or_greater +import app.revanced.patches.youtube.misc.playservice.versionCheckPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import java.util.logging.Logger + +internal const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/DisableFullscreenAmbientModePatch;" + +@Suppress("unused") +val disableFullscreenAmbientModePatch = bytecodePatch( + name = "Disable fullscreen ambient mode", + description = "Adds an option to disable the ambient mode when in fullscreen.", +) { + dependsOn( + settingsPatch, + sharedExtensionPatch, + addResourcesPatch, + versionCheckPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val initializeAmbientModeMatch by initializeAmbientModeFingerprint() + + execute { + // TODO: fix this patch when 19.43+ is eventually supported. + if (is_19_43_or_greater) { + // 19.43+ the feature flag was inlined as false and no longer exists. + // This patch can be updated to change a single method, but for now show a more descriptive error. + return@execute Logger.getLogger(this::class.java.name) + .severe("'Disable fullscreen ambient mode' does not yet support 19.43+") + } + + addResources("youtube", "layout.hide.fullscreenambientmode.disableFullscreenAmbientModePatch") + + PreferenceScreen.PLAYER.addPreferences( + SwitchPreference("revanced_disable_fullscreen_ambient_mode"), + ) + + initializeAmbientModeMatch.mutableMethod.apply { + val moveIsEnabledIndex = initializeAmbientModeMatch.patternMatch!!.endIndex + + addInstruction( + moveIsEnabledIndex, + "invoke-static { }, " + + "$EXTENSION_CLASS_DESCRIPTOR->enableFullScreenAmbientMode()Z", + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/fullscreenambientmode/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/fullscreenambientmode/Fingerprints.kt new file mode 100644 index 000000000..7f20c1433 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/fullscreenambientmode/Fingerprints.kt @@ -0,0 +1,13 @@ +package app.revanced.patches.youtube.layout.hide.fullscreenambientmode + +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val initializeAmbientModeFingerprint = fingerprint { + returns("V") + accessFlags(AccessFlags.CONSTRUCTOR, AccessFlags.PUBLIC) + opcodes(Opcode.MOVE_RESULT) + literal { 45389368 } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/general/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/general/Fingerprints.kt new file mode 100644 index 000000000..463e4cdc6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/general/Fingerprints.kt @@ -0,0 +1,116 @@ +package app.revanced.patches.youtube.layout.hide.general + +import app.revanced.patcher.fingerprint +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val hideShowMoreButtonFingerprint = fingerprint { + opcodes( + Opcode.CONST, + Opcode.CONST_4, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + ) + literal { expandButtonDownId } +} + +internal val parseElementFromBufferFingerprint = fingerprint { + parameters("L", "L", "[B", "L", "L") + opcodes( + Opcode.IGET_OBJECT, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + ) + strings("Failed to parse Element") // String is a partial match. +} + +internal val playerOverlayFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("L") + strings("player_overlay_in_video_programming") +} + +internal val showWatermarkFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("L", "L") +} + +internal val yoodlesImageViewFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Landroid/view/View;") + parameters("L", "L") + literal { youTubeLogo } +} + +internal val crowdfundingBoxFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + opcodes( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IPUT_OBJECT, + ) + literal { crowdfundingBoxId } +} + +internal val albumCardsFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + opcodes( + Opcode.MOVE_RESULT_OBJECT, + Opcode.CONST, + Opcode.CONST_4, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + ) + literal { albumCardId } +} + +internal val filterBarHeightFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + returns("V") + opcodes( + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IPUT, + ) + literal { filterBarHeightId } +} + +internal val relatedChipCloudFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + returns("V") + opcodes( + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + ) + literal { relatedChipCloudMarginId } +} + +internal val searchResultsChipBarFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + returns("V") + opcodes( + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + ) + literal { barContainerHeightId } +} + +internal val showFloatingMicrophoneButtonFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters() + opcodes( + Opcode.IGET_BOOLEAN, + Opcode.IF_EQZ, + Opcode.RETURN_VOID, + ) + literal { fabButtonId } +} diff --git a/src/main/kotlin/app/revanced/patches/youtube/layout/hide/general/HideLayoutComponentsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/general/HideLayoutComponentsPatch.kt similarity index 53% rename from src/main/kotlin/app/revanced/patches/youtube/layout/hide/general/HideLayoutComponentsPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/general/HideLayoutComponentsPatch.kt index 229f08d30..0b4e008f9 100644 --- a/src/main/kotlin/app/revanced/patches/youtube/layout/hide/general/HideLayoutComponentsPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/general/HideLayoutComponentsPatch.kt @@ -1,98 +1,153 @@ package app.revanced.patches.youtube.layout.hide.general -import app.revanced.patcher.data.BytecodeContext +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.getInstructions +import app.revanced.patcher.extensions.InstructionExtensions.instructions import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction -import app.revanced.patcher.fingerprint.MethodFingerprint -import app.revanced.patcher.patch.BytecodePatch -import app.revanced.patcher.patch.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch import app.revanced.patcher.util.smali.ExternalLabel -import app.revanced.patches.all.misc.resources.AddResourcesPatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings import app.revanced.patches.shared.misc.settings.preference.* -import app.revanced.patches.shared.misc.settings.preference.PreferenceScreen.Sorting -import app.revanced.patches.youtube.layout.hide.general.fingerprints.AlbumCardsFingerprint -import app.revanced.patches.youtube.layout.hide.general.fingerprints.CrowdfundingBoxFingerprint -import app.revanced.patches.youtube.layout.hide.general.fingerprints.FilterBarHeightFingerprint -import app.revanced.patches.youtube.layout.hide.general.fingerprints.HideShowMoreButtonFingerprint -import app.revanced.patches.youtube.layout.hide.general.fingerprints.ParseElementFromBufferFingerprint -import app.revanced.patches.youtube.layout.hide.general.fingerprints.PlayerOverlayFingerprint -import app.revanced.patches.youtube.layout.hide.general.fingerprints.RelatedChipCloudFingerprint -import app.revanced.patches.youtube.layout.hide.general.fingerprints.SearchResultsChipBarFingerprint -import app.revanced.patches.youtube.layout.hide.general.fingerprints.ShowFloatingMicrophoneButtonFingerprint -import app.revanced.patches.youtube.layout.hide.general.fingerprints.ShowWatermarkFingerprint -import app.revanced.patches.youtube.layout.hide.general.fingerprints.YoodlesImageViewFingerprint -import app.revanced.patches.youtube.misc.litho.filter.LithoFilterPatch -import app.revanced.patches.youtube.misc.navigation.NavigationBarHookPatch -import app.revanced.patches.youtube.misc.settings.SettingsPatch -import app.revanced.util.alsoResolve +import app.revanced.patches.youtube.misc.litho.filter.addLithoFilter +import app.revanced.patches.youtube.misc.litho.filter.lithoFilterPatch +import app.revanced.patches.youtube.misc.navigation.navigationBarHookPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.util.applyMatch import app.revanced.util.findOpcodeIndicesReversed import app.revanced.util.getReference -import app.revanced.util.resultOrThrow 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.TwoRegisterInstruction import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.sun.org.apache.bcel.internal.generic.InstructionConst.getInstruction -@Patch( +var expandButtonDownId = -1L + private set +var albumCardId = -1L + private set +var crowdfundingBoxId = -1L + private set +var youTubeLogo = -1L + private set + +var filterBarHeightId = -1L + private set +var relatedChipCloudMarginId = -1L + private set +var barContainerHeightId = -1L + private set + +var fabButtonId = -1L + private set + +private val hideLayoutComponentsResourcePatch = resourcePatch { + dependsOn(resourceMappingPatch) + + execute { + expandButtonDownId = resourceMappings[ + "layout", + "expand_button_down", + ] + + albumCardId = resourceMappings[ + "layout", + "album_card", + ] + + crowdfundingBoxId = resourceMappings[ + "layout", + "donation_companion", + ] + + youTubeLogo = resourceMappings[ + "id", + "youtube_logo", + ] + + relatedChipCloudMarginId = resourceMappings[ + "layout", + "related_chip_cloud_reduced_margins", + ] + + filterBarHeightId = resourceMappings[ + "dimen", + "filter_bar_height", + ] + + barContainerHeightId = resourceMappings[ + "dimen", + "bar_container_height", + ] + + fabButtonId = resourceMappings[ + "id", + "fab", + ] + } +} + +private const val LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/components/LayoutComponentsFilter;" +private const val DESCRIPTION_COMPONENTS_FILTER_CLASS_NAME = + "Lapp/revanced/extension/youtube/patches/components/DescriptionComponentsFilter;" +private const val COMMENTS_FILTER_CLASS_NAME = + "Lapp/revanced/extension/youtube/patches/components/CommentsFilter;" +private const val CUSTOM_FILTER_CLASS_NAME = + "Lapp/revanced/extension/youtube/patches/components/CustomFilter;" +private const val KEYWORD_FILTER_CLASS_NAME = + "Lapp/revanced/extension/youtube/patches/components/KeywordContentFilter;" + +@Suppress("unused") +val hideLayoutComponentsPatch = bytecodePatch( name = "Hide layout components", description = "Adds options to hide general layout components.", - dependencies = [ - LithoFilterPatch::class, - SettingsPatch::class, - AddResourcesPatch::class, - HideLayoutComponentsResourcePatch::class, - NavigationBarHookPatch::class, - ], - compatiblePackages = [ - CompatiblePackage( - "com.google.android.youtube", - [ - "18.38.44", - "18.49.37", - "19.16.39", - "19.25.37", - "19.34.42", - ], - ), - ], -) -@Suppress("unused") -object HideLayoutComponentsPatch : BytecodePatch( - setOf( - ParseElementFromBufferFingerprint, - PlayerOverlayFingerprint, - HideShowMoreButtonFingerprint, - AlbumCardsFingerprint, - CrowdfundingBoxFingerprint, - YoodlesImageViewFingerprint, - RelatedChipCloudFingerprint, - SearchResultsChipBarFingerprint, - ShowFloatingMicrophoneButtonFingerprint, - FilterBarHeightFingerprint - ), + ) { - private const val LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR = - "Lapp/revanced/integrations/youtube/patches/components/LayoutComponentsFilter;" - private const val DESCRIPTION_COMPONENTS_FILTER_CLASS_NAME = - "Lapp/revanced/integrations/youtube/patches/components/DescriptionComponentsFilter;" - private const val COMMENTS_FILTER_CLASS_NAME = - "Lapp/revanced/integrations/youtube/patches/components/CommentsFilter;" - private const val CUSTOM_FILTER_CLASS_NAME = - "Lapp/revanced/integrations/youtube/patches/components/CustomFilter;" - private const val KEYWORD_FILTER_CLASS_NAME = - "Lapp/revanced/integrations/youtube/patches/components/KeywordContentFilter;" + dependsOn( + lithoFilterPatch, + settingsPatch, + addResourcesPatch, + hideLayoutComponentsResourcePatch, + navigationBarHookPatch, + ) - override fun execute(context: BytecodeContext) { - AddResourcesPatch(this::class) + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) - SettingsPatch.PreferenceScreen.PLAYER.addPreferences( - PreferenceScreen( + val parseElementFromBufferMatch by parseElementFromBufferFingerprint() + val playerOverlayMatch by playerOverlayFingerprint() + val hideShowMoreButtonMatch by hideShowMoreButtonFingerprint() + val albumCardsMatch by albumCardsFingerprint() + val crowdfundingBoxMatch by crowdfundingBoxFingerprint() + val yoodlesImageViewMatch by yoodlesImageViewFingerprint() + val relatedChipCloudMatch by relatedChipCloudFingerprint() + val searchResultsChipBarMatch by searchResultsChipBarFingerprint() + val showFloatingMicrophoneButtonMatch by showFloatingMicrophoneButtonFingerprint() + val filterBarHeightMatch by filterBarHeightFingerprint() + + execute { context -> + addResources("youtube", "layout.hide.general.hideLayoutComponentsPatch") + + PreferenceScreen.PLAYER.addPreferences( + PreferenceScreenPreference( key = "revanced_hide_description_components_screen", preferences = setOf( SwitchPreference("revanced_hide_attributes_section"), @@ -103,7 +158,7 @@ object HideLayoutComponentsPatch : BytecodePatch( SwitchPreference("revanced_hide_transcript_section"), ), ), - PreferenceScreen( + PreferenceScreenPreference( "revanced_comments_screen", preferences = setOf( SwitchPreference("revanced_hide_comments_by_members_header"), @@ -111,9 +166,9 @@ object HideLayoutComponentsPatch : BytecodePatch( SwitchPreference("revanced_hide_comments_create_a_short_button"), SwitchPreference("revanced_hide_comments_preview_comment"), SwitchPreference("revanced_hide_comments_thanks_button"), - SwitchPreference("revanced_hide_comments_timestamp_and_emoji_buttons") + SwitchPreference("revanced_hide_comments_timestamp_and_emoji_buttons"), ), - sorting = Sorting.UNSORTED + sorting = PreferenceScreenPreference.Sorting.UNSORTED, ), SwitchPreference("revanced_hide_channel_bar"), SwitchPreference("revanced_hide_channel_guidelines"), @@ -131,21 +186,23 @@ object HideLayoutComponentsPatch : BytecodePatch( SwitchPreference("revanced_hide_timed_reactions"), ) - SettingsPatch.PreferenceScreen.FEED.addPreferences( - PreferenceScreen( + PreferenceScreen.FEED.addPreferences( + PreferenceScreenPreference( key = "revanced_hide_keyword_content_screen", - sorting = Sorting.UNSORTED, + sorting = PreferenceScreenPreference.Sorting.UNSORTED, preferences = setOf( SwitchPreference("revanced_hide_keyword_content_home"), SwitchPreference("revanced_hide_keyword_content_subscriptions"), SwitchPreference("revanced_hide_keyword_content_search"), TextPreference("revanced_hide_keyword_content_phrases", inputType = InputType.TEXT_MULTI_LINE), NonInteractivePreference("revanced_hide_keyword_content_about"), - NonInteractivePreference(key = "revanced_hide_keyword_content_about_whole_words", - tag = "app.revanced.integrations.youtube.settings.preference.HtmlPreference") - ) + NonInteractivePreference( + key = "revanced_hide_keyword_content_about_whole_words", + tag = "app.revanced.extension.youtube.settings.preference.HtmlPreference", + ), + ), ), - PreferenceScreen( + PreferenceScreenPreference( key = "revanced_hide_filter_bar_screen", preferences = setOf( SwitchPreference("revanced_hide_filter_bar_feed_in_feed"), @@ -176,11 +233,11 @@ object HideLayoutComponentsPatch : BytecodePatch( SwitchPreference("revanced_hide_doodles"), ) - SettingsPatch.PreferenceScreen.GENERAL_LAYOUT.addPreferences( + PreferenceScreen.GENERAL_LAYOUT.addPreferences( SwitchPreference("revanced_hide_gray_separator"), - PreferenceScreen( + PreferenceScreenPreference( key = "revanced_custom_filter_screen", - sorting = Sorting.UNSORTED, + sorting = PreferenceScreenPreference.Sorting.UNSORTED, preferences = setOf( SwitchPreference("revanced_custom_filter"), // TODO: This should be a dynamic ListPreference, which does not exist yet @@ -189,43 +246,41 @@ object HideLayoutComponentsPatch : BytecodePatch( ), ) - LithoFilterPatch.addFilter(LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR) - LithoFilterPatch.addFilter(DESCRIPTION_COMPONENTS_FILTER_CLASS_NAME) - LithoFilterPatch.addFilter(COMMENTS_FILTER_CLASS_NAME) - LithoFilterPatch.addFilter(KEYWORD_FILTER_CLASS_NAME) - LithoFilterPatch.addFilter(CUSTOM_FILTER_CLASS_NAME) + addLithoFilter(LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR) + addLithoFilter(DESCRIPTION_COMPONENTS_FILTER_CLASS_NAME) + addLithoFilter(COMMENTS_FILTER_CLASS_NAME) + addLithoFilter(KEYWORD_FILTER_CLASS_NAME) + addLithoFilter(CUSTOM_FILTER_CLASS_NAME) // region Mix playlists - ParseElementFromBufferFingerprint.resultOrThrow().let { result -> - val startIndex = result.scanResult.patternScanResult!!.startIndex + val startIndex = parseElementFromBufferMatch.patternMatch!!.startIndex - result.mutableMethod.apply { - val freeRegister = "v0" - val byteArrayParameter = "p3" - val conversionContextRegister = getInstruction(startIndex).registerA - val returnEmptyComponentInstruction = getInstructions().last { it.opcode == Opcode.INVOKE_STATIC } + parseElementFromBufferMatch.mutableMethod.apply { + val freeRegister = "v0" + val byteArrayParameter = "p3" + val conversionContextRegister = getInstruction(startIndex).registerA + val returnEmptyComponentInstruction = instructions.last { it.opcode == Opcode.INVOKE_STATIC } - addInstructionsWithLabels( - startIndex + 1, - """ + addInstructionsWithLabels( + startIndex + 1, + """ invoke-static { v$conversionContextRegister, $byteArrayParameter }, $LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR->filterMixPlaylists(Ljava/lang/Object;[B)Z move-result $freeRegister if-nez $freeRegister, :return_empty_component const/4 $freeRegister, 0x0 # Restore register, required for 19.16 """, - ExternalLabel("return_empty_component", returnEmptyComponentInstruction), - ) - } + ExternalLabel("return_empty_component", returnEmptyComponentInstruction), + ) } // endregion // region Watermark (legacy code for old versions of YouTube) - ShowWatermarkFingerprint.alsoResolve( + showWatermarkFingerprint.applyMatch( context, - PlayerOverlayFingerprint + playerOverlayMatch, ).mutableMethod.apply { val index = implementation!!.instructions.size - 5 @@ -243,33 +298,31 @@ object HideLayoutComponentsPatch : BytecodePatch( // region Show more button - HideShowMoreButtonFingerprint.resultOrThrow().let { - it.mutableMethod.apply { - val moveRegisterIndex = it.scanResult.patternScanResult!!.endIndex - val viewRegister = - getInstruction(moveRegisterIndex).registerA + hideShowMoreButtonMatch.mutableMethod.apply { + val moveRegisterIndex = hideShowMoreButtonMatch.patternMatch!!.endIndex + val viewRegister = getInstruction(moveRegisterIndex).registerA - val insertIndex = moveRegisterIndex + 1 - addInstruction( - insertIndex, - "invoke-static { v$viewRegister }, $LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR" + - "->hideShowMoreButton(Landroid/view/View;)V", - ) - } + val insertIndex = moveRegisterIndex + 1 + addInstruction( + insertIndex, + "invoke-static { v$viewRegister }, $LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR" + + "->hideShowMoreButton(Landroid/view/View;)V", + ) } // endregion - // region crowd funding box - CrowdfundingBoxFingerprint.resultOrThrow().let { + // region crowdfunding box + crowdfundingBoxMatch.let { it.mutableMethod.apply { - val insertIndex = it.scanResult.patternScanResult!!.endIndex + val insertIndex = it.patternMatch!!.endIndex val objectRegister = getInstruction(insertIndex).registerA addInstruction( insertIndex, "invoke-static {v$objectRegister}, $LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR" + - "->hideCrowdfundingBox(Landroid/view/View;)V") + "->hideCrowdfundingBox(Landroid/view/View;)V", + ) } } @@ -277,16 +330,16 @@ object HideLayoutComponentsPatch : BytecodePatch( // region hide album cards - AlbumCardsFingerprint.resultOrThrow().let { + albumCardsMatch.let { it.mutableMethod.apply { - val checkCastAnchorIndex = it.scanResult.patternScanResult!!.endIndex + val checkCastAnchorIndex = it.patternMatch!!.endIndex val insertIndex = checkCastAnchorIndex + 1 val register = getInstruction(checkCastAnchorIndex).registerA addInstruction( insertIndex, "invoke-static { v$register }, $LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR" + - "->hideAlbumCard(Landroid/view/View;)V" + "->hideAlbumCard(Landroid/view/View;)V", ) } } @@ -295,17 +348,17 @@ object HideLayoutComponentsPatch : BytecodePatch( // region hide floating microphone - ShowFloatingMicrophoneButtonFingerprint.resultOrThrow().let { result -> - with(result.mutableMethod) { - val startIndex = result.scanResult.patternScanResult!!.startIndex + showFloatingMicrophoneButtonMatch.let { + it.mutableMethod.apply { + val startIndex = it.patternMatch!!.startIndex val register = getInstruction(startIndex).registerA addInstructions( startIndex + 1, """ - invoke-static { v$register }, $LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR->hideFloatingMicrophoneButton(Z)Z - move-result v$register - """ + invoke-static { v$register }, $LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR->hideFloatingMicrophoneButton(Z)Z + move-result v$register + """, ) } } @@ -314,10 +367,9 @@ object HideLayoutComponentsPatch : BytecodePatch( // region 'Yoodles' - YoodlesImageViewFingerprint.resultOrThrow().mutableMethod.apply { - findOpcodeIndicesReversed{ - opcode == Opcode.INVOKE_VIRTUAL - && getReference()?.name == "setImageDrawable" + yoodlesImageViewMatch.mutableMethod.apply { + findOpcodeIndicesReversed { + getReference()?.name == "setImageDrawable" }.forEach { insertIndex -> val register = getInstruction(insertIndex).registerD @@ -337,41 +389,20 @@ object HideLayoutComponentsPatch : BytecodePatch( // region hide filter bar - FilterBarHeightFingerprint.patch { register -> - """ - invoke-static { v$register }, $LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR->hideInFeed(I)I - move-result v$register - """ - } - - SearchResultsChipBarFingerprint.patch(-1, -2) { register -> - """ - invoke-static { v$register }, $LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR->hideInSearch(I)I - move-result v$register - """ - } - - RelatedChipCloudFingerprint.patch(1) { register -> - "invoke-static { v$register }, " + - "$LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR->hideInRelatedVideos(Landroid/view/View;)V" - } - } - - /** - * Patch a [MethodFingerprint] with a given [instructions]. - * - * @param RegisterInstruction The type of instruction to get the register from. - * @param insertIndexOffset The offset to add to the end index of the [MethodFingerprint]. - * @param hookRegisterOffset The offset to add to the register of the hook. - * @param instructions The instructions to add with the register as a parameter. - */ - private fun MethodFingerprint.patch( - insertIndexOffset: Int = 0, - hookRegisterOffset: Int = 0, - instructions: (Int) -> String - ) = resultOrThrow().let { - it.mutableMethod.apply { - val endIndex = it.scanResult.patternScanResult!!.endIndex + /** + * Patch a [Method] with a given [instructions]. + * + * @param RegisterInstruction The type of instruction to get the register from. + * @param insertIndexOffset The offset to add to the end index of the [Match.patternMatch]. + * @param hookRegisterOffset The offset to add to the register of the hook. + * @param instructions The instructions to add with the register as a parameter. + */ + fun Match.patch( + insertIndexOffset: Int = 0, + hookRegisterOffset: Int = 0, + instructions: (Int) -> String, + ) = mutableMethod.apply { + val endIndex = patternMatch!!.endIndex val insertIndex = endIndex + insertIndexOffset val register = @@ -379,5 +410,24 @@ object HideLayoutComponentsPatch : BytecodePatch( addInstructions(insertIndex, instructions(register)) } + + filterBarHeightMatch.patch { register -> + """ + invoke-static { v$register }, $LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR->hideInFeed(I)I + move-result v$register + """ + } + + searchResultsChipBarMatch.patch(-1, -2) { register -> + """ + invoke-static { v$register }, $LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR->hideInSearch(I)I + move-result v$register + """ + } + + relatedChipCloudMatch.patch(1) { register -> + "invoke-static { v$register }, " + + "$LAYOUT_COMPONENTS_FILTER_CLASS_DESCRIPTOR->hideInRelatedVideos(Landroid/view/View;)V" + } } } diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/infocards/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/infocards/Fingerprints.kt new file mode 100644 index 000000000..5088472a1 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/infocards/Fingerprints.kt @@ -0,0 +1,29 @@ +package app.revanced.patches.youtube.layout.hide.infocards + +import app.revanced.patcher.fingerprint +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val infocardsIncognitoFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Ljava/lang/Boolean;") + parameters("L", "J") + strings("vibrator") +} + +internal val infocardsIncognitoParentFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Ljava/lang/String;") + strings("player_overlay_info_card_teaser") +} + +internal val infocardsMethodCallFingerprint = fingerprint { + opcodes( + Opcode.INVOKE_VIRTUAL, + Opcode.IGET_OBJECT, + Opcode.INVOKE_INTERFACE, + ) + strings("Missing ControlsOverlayPresenter for InfoCards to work.") + literal { drawerResourceId } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/infocards/HideInfoCardsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/infocards/HideInfoCardsPatch.kt new file mode 100644 index 000000000..5e5b4c9a6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/infocards/HideInfoCardsPatch.kt @@ -0,0 +1,109 @@ +package app.revanced.patches.youtube.layout.hide.infocards + +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.patch.resourcePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.litho.filter.addLithoFilter +import app.revanced.patches.youtube.misc.litho.filter.lithoFilterPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.util.applyMatch +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.sun.org.apache.bcel.internal.generic.InstructionConst.getInstruction + +internal var drawerResourceId = -1L + private set + +private val hideInfocardsResourcePatch = resourcePatch { + dependsOn( + settingsPatch, + resourceMappingPatch, + addResourcesPatch, + ) + execute { + addResources("youtube", "layout.hide.infocards.hideInfocardsResourcePatch") + + PreferenceScreen.PLAYER.addPreferences( + SwitchPreference("revanced_hide_info_cards"), + ) + + drawerResourceId = resourceMappings[ + "id", + "info_cards_drawer_header", + ] + } +} + +@Suppress("unused") +val hideInfoCardsPatch = bytecodePatch( + name = "Hide info cards", + description = "Adds an option to hide info cards that creators add in the video player.", +) { + dependsOn( + sharedExtensionPatch, + lithoFilterPatch, + hideInfocardsResourcePatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val infocardsIncognitoParentMatch by infocardsIncognitoParentFingerprint() + val infocardsMethodCallMatch by infocardsMethodCallFingerprint() + + execute { context -> + infocardsIncognitoFingerprint.applyMatch(context, infocardsIncognitoParentMatch).mutableMethod.apply { + val invokeInstructionIndex = implementation!!.instructions.indexOfFirst { + it.opcode.ordinal == Opcode.INVOKE_VIRTUAL.ordinal && + ((it as ReferenceInstruction).reference.toString() == "Landroid/view/View;->setVisibility(I)V") + } + + addInstruction( + invokeInstructionIndex, + "invoke-static {v${getInstruction(invokeInstructionIndex).registerC}}," + + " Lapp/revanced/extension/youtube/patches/HideInfoCardsPatch;->hideInfoCardsIncognito(Landroid/view/View;)V", + ) + } + + val hideInfoCardsCallMethod = infocardsMethodCallMatch.mutableMethod + + val invokeInterfaceIndex = infocardsMethodCallMatch.patternMatch!!.endIndex + val toggleRegister = infocardsMethodCallMatch.mutableMethod.implementation!!.registerCount - 1 + + hideInfoCardsCallMethod.addInstructionsWithLabels( + invokeInterfaceIndex, + """ + invoke-static {}, Lapp/revanced/extension/youtube/patches/HideInfoCardsPatch;->hideInfoCardsMethodCall()Z + move-result v$toggleRegister + if-nez v$toggleRegister, :hide_info_cards + """, + ExternalLabel( + "hide_info_cards", + hideInfoCardsCallMethod.getInstruction(invokeInterfaceIndex + 1), + ), + ) + + // Info cards can also appear as Litho components. + val filterClassDescriptor = "Lapp/revanced/extension/youtube/patches/components/HideInfoCardsFilterPatch;" + addLithoFilter(filterClassDescriptor) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/player/flyoutmenupanel/HidePlayerFlyoutMenuPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/player/flyoutmenupanel/HidePlayerFlyoutMenuPatch.kt new file mode 100644 index 000000000..9062a5331 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/player/flyoutmenupanel/HidePlayerFlyoutMenuPatch.kt @@ -0,0 +1,62 @@ +package app.revanced.patches.youtube.layout.hide.player.flyoutmenupanel + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.PreferenceScreenPreference +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.litho.filter.addLithoFilter +import app.revanced.patches.youtube.misc.litho.filter.lithoFilterPatch +import app.revanced.patches.youtube.misc.playertype.playerTypeHookPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch + +@Suppress("unused") +val hidePlayerFlyoutMenuPatch = bytecodePatch( + name = "Hide player flyout menu items", + description = "Adds options to hide menu items that appear when pressing the gear icon in the video player.", +) { + dependsOn( + lithoFilterPatch, + playerTypeHookPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + execute { + val filterClassDescriptor = "Lapp/revanced/extension/youtube/patches/components/PlayerFlyoutMenuItemsFilter;" + + addResources("youtube", "layout.hide.player.flyoutmenupanel.hidePlayerFlyoutMenuPatch") + + PreferenceScreen.PLAYER.addPreferences( + PreferenceScreenPreference( + key = "revanced_hide_player_flyout", + preferences = setOf( + SwitchPreference("revanced_hide_player_flyout_captions"), + SwitchPreference("revanced_hide_player_flyout_additional_settings"), + SwitchPreference("revanced_hide_player_flyout_loop_video"), + SwitchPreference("revanced_hide_player_flyout_ambient_mode"), + SwitchPreference("revanced_hide_player_flyout_help"), + SwitchPreference("revanced_hide_player_flyout_speed"), + SwitchPreference("revanced_hide_player_flyout_lock_screen"), + SwitchPreference("revanced_hide_player_flyout_more_info"), + SwitchPreference("revanced_hide_player_flyout_audio_track"), + SwitchPreference("revanced_hide_player_flyout_watch_in_vr"), + SwitchPreference("revanced_hide_video_quality_menu_footer"), + ), + ), + ) + + addLithoFilter(filterClassDescriptor) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/rollingnumber/DisableRollingNumberAnimationPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/rollingnumber/DisableRollingNumberAnimationPatch.kt new file mode 100644 index 000000000..2c8ac9d05 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/rollingnumber/DisableRollingNumberAnimationPatch.kt @@ -0,0 +1,74 @@ +package app.revanced.patches.youtube.layout.hide.rollingnumber + +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.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.patches.youtube.shared.rollingNumberTextViewAnimationUpdateFingerprint +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/DisableRollingNumberAnimationsPatch;" + +@Suppress("unused") +val disableRollingNumberAnimationPatch = bytecodePatch( + name = "Disable rolling number animations", + description = "Adds an option to disable rolling number animations of video view count, user likes, and upload time.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + // 18.43 is the earliest target this patch works. + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val rollingNumberTextViewAnimationUpdateMatch by rollingNumberTextViewAnimationUpdateFingerprint() + + execute { + addResources("youtube", "layout.hide.rollingnumber.disableRollingNumberAnimationPatch") + + PreferenceScreen.PLAYER.addPreferences( + SwitchPreference("revanced_disable_rolling_number_animations"), + ) + + // Animations are disabled by preventing an Image from being applied to the text span, + // which prevents the animations from appearing. + val patternMatch = rollingNumberTextViewAnimationUpdateMatch.patternMatch!! + val blockStartIndex = patternMatch.startIndex + val blockEndIndex = patternMatch.endIndex + 1 + rollingNumberTextViewAnimationUpdateMatch.mutableMethod.apply { + val freeRegister = getInstruction(blockStartIndex).registerA + + // ReturnYouTubeDislike also makes changes to this same method, + // and must add control flow label to a noop instruction to + // ensure RYD patch adds its changes after the control flow label. + addInstructions(blockEndIndex, "nop") + + addInstructionsWithLabels( + blockStartIndex, + """ + invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->disableRollingNumberAnimations()Z + move-result v$freeRegister + if-nez v$freeRegister, :disable_animations + """, + ExternalLabel("disable_animations", getInstruction(blockEndIndex)), + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/seekbar/HideSeekbarPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/seekbar/HideSeekbarPatch.kt new file mode 100644 index 000000000..0759935cd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/seekbar/HideSeekbarPatch.kt @@ -0,0 +1,61 @@ +package app.revanced.patches.youtube.layout.hide.seekbar + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.layout.seekbar.seekbarColorPatch +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.patches.youtube.shared.seekbarFingerprint +import app.revanced.patches.youtube.shared.seekbarOnDrawFingerprint +import app.revanced.util.applyMatch + +@Suppress("unused") +val hideSeekbarPatch = bytecodePatch( + name = "Hide seekbar", + description = "Adds an option to hide the seekbar.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + seekbarColorPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val seekbarMatch by seekbarFingerprint() + + execute { context -> + addResources("youtube", "layout.hide.seekbar.hideSeekbarPatch") + + PreferenceScreen.SEEKBAR.addPreferences( + SwitchPreference("revanced_hide_seekbar"), + SwitchPreference("revanced_hide_seekbar_thumbnail"), + ) + + seekbarOnDrawFingerprint.applyMatch(context, seekbarMatch).mutableMethod.addInstructionsWithLabels( + 0, + """ + const/4 v0, 0x0 + invoke-static { }, Lapp/revanced/extension/youtube/patches/HideSeekbarPatch;->hideSeekbar()Z + move-result v0 + if-eqz v0, :hide_seekbar + return-void + :hide_seekbar + nop + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/shorts/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/shorts/Fingerprints.kt new file mode 100644 index 000000000..e38ea167b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/shorts/Fingerprints.kt @@ -0,0 +1,103 @@ +package app.revanced.patches.youtube.layout.hide.shorts + +import app.revanced.patcher.fingerprint +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val bottomNavigationBarFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("Landroid/view/View;", "Landroid/os/Bundle;") + opcodes( + Opcode.CONST, // R.id.app_engagement_panel_wrapper + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, + Opcode.IGET_OBJECT, + Opcode.IGET_OBJECT, + ) + strings("ReelWatchPaneFragmentViewModelKey") +} + +internal val legacyRenderBottomNavigationBarParentFingerprint = fingerprint { + parameters( + "I", + "I", + "L", + "L", + "J", + "L", + ) + strings("aa") +} + +internal val shortsBottomBarContainerFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("Landroid/view/View;", "Landroid/os/Bundle;") + strings("r_pfvc") + literal { bottomBarContainer } +} + +internal val createShortsButtonsFingerprint = fingerprint { + returns("V") + literal { reelPlayerRightCellButtonHeight } +} + +internal val reelConstructorFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + opcodes(Opcode.INVOKE_VIRTUAL) + literal { reelMultipleItemShelfId } +} + +internal val renderBottomNavigationBarFingerprint = fingerprint { + returns("V") + parameters("Ljava/lang/String;") + opcodes( + Opcode.IGET_OBJECT, + Opcode.MONITOR_ENTER, + Opcode.IGET_OBJECT, + Opcode.IF_EQZ, + Opcode.INVOKE_INTERFACE, + Opcode.MONITOR_EXIT, + Opcode.RETURN_VOID, + Opcode.MOVE_EXCEPTION, + Opcode.MONITOR_EXIT, + Opcode.THROW, + ) +} + +/** + * Identical to [legacyRenderBottomNavigationBarParentFingerprint] + * except this has an extra parameter. + */ +internal val renderBottomNavigationBarParentFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + parameters( + "I", + "I", + "L", // ReelWatchEndpointOuterClass + "L", + "J", + "Ljava/lang/String;", + "L", + ) + strings("aa") +} + +internal val setPivotBarVisibilityFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.FINAL) + returns("V") + parameters("Z") + opcodes( + Opcode.CHECK_CAST, + Opcode.IF_EQZ, + ) +} + +internal val setPivotBarVisibilityParentFingerprint = fingerprint { + parameters("Z") + strings("FEnotifications_inbox") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/shorts/HideShortsComponentsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/shorts/HideShortsComponentsPatch.kt new file mode 100644 index 000000000..6d84b1526 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/shorts/HideShortsComponentsPatch.kt @@ -0,0 +1,329 @@ +package app.revanced.patches.youtube.layout.hide.shorts + +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.booleanOption +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings +import app.revanced.patches.shared.misc.settings.preference.PreferenceScreenPreference +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.litho.filter.addLithoFilter +import app.revanced.patches.youtube.misc.litho.filter.lithoFilterPatch +import app.revanced.patches.youtube.misc.navigation.navigationBarHookPatch +import app.revanced.patches.youtube.misc.playservice.is_19_03_or_greater +import app.revanced.patches.youtube.misc.playservice.is_19_41_or_greater +import app.revanced.patches.youtube.misc.playservice.versionCheckPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.util.* +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.MethodReference + +internal var reelMultipleItemShelfId = -1L + private set +internal var reelPlayerRightCellButtonHeight = -1L + private set +internal var bottomBarContainer = -1L + private set +internal var reelPlayerRightPivotV2Size = -1L + private set + +internal val hideShortsAppShortcutOption = booleanOption( + key = "hideShortsAppShortcut", + default = false, + title = "Hide Shorts app shortcut", + description = "Permanently hides the shortcut to open Shorts when long pressing the app icon in your launcher.", +) + +internal val hideShortsWidgetOption = booleanOption( + key = "hideShortsWidget", + default = false, + title = "Hide Shorts widget", + description = "Permanently hides the launcher widget Shorts button.", +) + +private val hideShortsComponentsResourcePatch = resourcePatch { + dependsOn( + settingsPatch, + resourceMappingPatch, + addResourcesPatch, + versionCheckPatch, + ) + + execute { context -> + val hideShortsAppShortcut by hideShortsAppShortcutOption + val hideShortsWidget by hideShortsWidgetOption + + addResources("youtube", "layout.hide.shorts.hideShortsComponentsResourcePatch") + + PreferenceScreen.SHORTS.addPreferences( + SwitchPreference("revanced_hide_shorts_home"), + SwitchPreference("revanced_hide_shorts_subscriptions"), + SwitchPreference("revanced_hide_shorts_search"), + + PreferenceScreenPreference( + key = "revanced_shorts_player_screen", + sorting = PreferenceScreenPreference.Sorting.UNSORTED, + preferences = setOf( + // Shorts player components. + // Ideally each group should be ordered similar to how they appear in the UI + + // Vertical row of buttons on right side of the screen. + SwitchPreference("revanced_hide_shorts_like_fountain"), + SwitchPreference("revanced_hide_shorts_like_button"), + SwitchPreference("revanced_hide_shorts_dislike_button"), + SwitchPreference("revanced_hide_shorts_comments_button"), + SwitchPreference("revanced_hide_shorts_share_button"), + SwitchPreference("revanced_hide_shorts_remix_button"), + SwitchPreference("revanced_hide_shorts_sound_button"), + + // Upper and middle area of the player. + SwitchPreference("revanced_hide_shorts_join_button"), + SwitchPreference("revanced_hide_shorts_subscribe_button"), + SwitchPreference("revanced_hide_shorts_paused_overlay_buttons"), + + // Suggested actions. + SwitchPreference("revanced_hide_shorts_save_sound_button"), + SwitchPreference("revanced_hide_shorts_use_template_button"), + SwitchPreference("revanced_hide_shorts_upcoming_button"), + SwitchPreference("revanced_hide_shorts_green_screen_button"), + SwitchPreference("revanced_hide_shorts_hashtag_button"), + SwitchPreference("revanced_hide_shorts_shop_button"), + SwitchPreference("revanced_hide_shorts_tagged_products"), + SwitchPreference("revanced_hide_shorts_search_suggestions"), + SwitchPreference("revanced_hide_shorts_super_thanks_button"), + SwitchPreference("revanced_hide_shorts_stickers"), + + // Bottom of the screen. + SwitchPreference("revanced_hide_shorts_location_label"), + SwitchPreference("revanced_hide_shorts_channel_bar"), + SwitchPreference("revanced_hide_shorts_info_panel"), + SwitchPreference("revanced_hide_shorts_full_video_link_label"), + SwitchPreference("revanced_hide_shorts_video_title"), + SwitchPreference("revanced_hide_shorts_sound_metadata_label"), + SwitchPreference("revanced_hide_shorts_navigation_bar"), + ), + ), + ) + + // Verify the file has the expected node, even if the patch option is off. + context.document["res/xml/main_shortcuts.xml"].use { document -> + val shortsItem = document.childNodes.findElementByAttributeValueOrThrow( + "android:shortcutId", + "shorts-shortcut", + ) + + if (hideShortsAppShortcut == true) { + shortsItem.parentNode.removeChild(shortsItem) + } + } + + context.document["res/layout/appwidget_two_rows.xml"].use { document -> + val shortsItem = document.childNodes.findElementByAttributeValueOrThrow( + "android:id", + "@id/button_shorts_container", + ) + + if (hideShortsWidget == true) { + shortsItem.parentNode.removeChild(shortsItem) + } + } + + reelPlayerRightCellButtonHeight = resourceMappings[ + "dimen", + "reel_player_right_cell_button_height", + ] + + bottomBarContainer = resourceMappings[ + "id", + "bottom_bar_container", + ] + + reelPlayerRightPivotV2Size = resourceMappings[ + "dimen", + "reel_player_right_pivot_v2_size", + ] + + if (!is_19_03_or_greater) { + reelMultipleItemShelfId = resourceMappings[ + "dimen", + "reel_player_right_cell_button_height", + ] + } + } +} + +private const val FILTER_CLASS_DESCRIPTOR = "Lapp/revanced/extension/youtube/patches/components/ShortsFilter;" + +@Suppress("unused") +val hideShortsComponentsPatch = bytecodePatch( + name = "Hide Shorts components", + description = "Adds options to hide components related to YouTube Shorts.", +) { + dependsOn( + sharedExtensionPatch, + lithoFilterPatch, + hideShortsComponentsResourcePatch, + resourceMappingPatch, + navigationBarHookPatch, + versionCheckPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + hideShortsAppShortcutOption() + hideShortsWidgetOption() + + val createShortsButtonsMatch by createShortsButtonsFingerprint() + val shortsBottomBarContainerMatch by shortsBottomBarContainerFingerprint() + val legacyRenderBottomNavigationBarParentMatch by legacyRenderBottomNavigationBarParentFingerprint() + val renderBottomNavigationBarParentMatch by renderBottomNavigationBarParentFingerprint() + val setPivotBarVisibilityParentMatch by setPivotBarVisibilityParentFingerprint() + reelConstructorFingerprint() + + execute { context -> + // region Hide the Shorts shelf. + + // This patch point is not present in 19.03.x and greater. + if (!is_19_03_or_greater) { + reelConstructorFingerprint.match?.let { + it.mutableMethod.apply { + val insertIndex = it.patternMatch!!.startIndex + 2 + val viewRegister = getInstruction(insertIndex).registerA + + injectHideViewCall( + insertIndex, + viewRegister, + FILTER_CLASS_DESCRIPTOR, + "hideShortsShelf", + ) + } + } + } + + // endregion + + // region Hide the Shorts buttons in older versions of YouTube. + + // Some Shorts buttons are views, hide them by setting their visibility to GONE. + ShortsButtons.entries.forEach { button -> button.injectHideCall(createShortsButtonsMatch.mutableMethod) } + + // endregion + + // region Hide the Shorts buttons in newer versions of YouTube. + + addLithoFilter(FILTER_CLASS_DESCRIPTOR) + + context.forEachLiteralValueInstruction( + reelPlayerRightPivotV2Size, + ) { literalInstructionIndex -> + val targetIndex = indexOfFirstInstructionOrThrow(literalInstructionIndex) { + getReference()?.name == "getDimensionPixelSize" + } + 1 + + val sizeRegister = getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, + """ + invoke-static { v$sizeRegister }, $FILTER_CLASS_DESCRIPTOR->getSoundButtonSize(I)I + move-result v$sizeRegister + """, + ) + } + + // endregion + + // region Hide the navigation bar. + + // Hook to get the pivotBar view. + setPivotBarVisibilityFingerprint.applyMatch( + context, + setPivotBarVisibilityParentMatch, + ).let { result -> + result.mutableMethod.apply { + val insertIndex = result.patternMatch!!.endIndex + val viewRegister = getInstruction(insertIndex - 1).registerA + addInstruction( + insertIndex, + "invoke-static {v$viewRegister}," + + " $FILTER_CLASS_DESCRIPTOR->setNavigationBar(Lcom/google/android/libraries/youtube/rendering/ui/pivotbar/PivotBar;)V", + ) + } + } + + // Hook to hide the shared navigation bar when the Shorts player is opened. + renderBottomNavigationBarFingerprint.applyMatch( + context, + if (is_19_41_or_greater) { + renderBottomNavigationBarParentMatch + } else { + legacyRenderBottomNavigationBarParentMatch + }, + ).mutableMethod.addInstruction( + 0, + "invoke-static { p1 }, $FILTER_CLASS_DESCRIPTOR->hideNavigationBar(Ljava/lang/String;)V", + ) + + // Hide the bottom bar container of the Shorts player. + shortsBottomBarContainerMatch.mutableMethod.apply { + val resourceIndex = indexOfFirstWideLiteralInstructionValue(bottomBarContainer) + + val targetIndex = indexOfFirstInstructionOrThrow(resourceIndex) { + getReference()?.name == "getHeight" + } + 1 + + val heightRegister = getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, + """ + invoke-static { v$heightRegister }, $FILTER_CLASS_DESCRIPTOR->getNavigationBarHeight(I)I + move-result v$heightRegister + """, + ) + } + + // endregion + } +} + +private enum class ShortsButtons(private val resourceName: String, private val methodName: String) { + LIKE("reel_dyn_like", "hideLikeButton"), + DISLIKE("reel_dyn_dislike", "hideDislikeButton"), + COMMENTS("reel_dyn_comment", "hideShortsCommentsButton"), + REMIX("reel_dyn_remix", "hideShortsRemixButton"), + SHARE("reel_dyn_share", "hideShortsShareButton"), + ; + + fun injectHideCall(method: MutableMethod) { + val referencedIndex = method.indexOfIdResourceOrThrow(resourceName) + + val setIdIndex = method.indexOfFirstInstructionOrThrow(referencedIndex) { + opcode == Opcode.INVOKE_VIRTUAL && getReference()?.name == "setId" + } + + val viewRegister = method.getInstruction(setIdIndex).registerC + + method.injectHideViewCall(setIdIndex + 1, viewRegister, FILTER_CLASS_DESCRIPTOR, methodName) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/suggestedvideoendscreen/DisableSuggestedVideoEndScreenPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/suggestedvideoendscreen/DisableSuggestedVideoEndScreenPatch.kt new file mode 100644 index 000000000..436f3db2f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/suggestedvideoendscreen/DisableSuggestedVideoEndScreenPatch.kt @@ -0,0 +1,79 @@ +package app.revanced.patches.youtube.layout.hide.suggestedvideoendscreen + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction + +internal var sizeAdjustableLiteAutoNavOverlay = -1L + private set + +internal val disableSuggestedVideoEndScreenResourcePatch = resourcePatch { + dependsOn( + settingsPatch, + resourceMappingPatch, + addResourcesPatch, + ) + + execute { + addResources("youtube", "layout.hide.suggestedvideoendscreen.disableSuggestedVideoEndScreenResourcePatch") + + PreferenceScreen.PLAYER.addPreferences( + SwitchPreference("revanced_disable_suggested_video_end_screen"), + ) + + sizeAdjustableLiteAutoNavOverlay = resourceMappings[ + "layout", + "size_adjustable_lite_autonav_overlay", + ] + } +} + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/DisableSuggestedVideoEndScreenPatch;" + +@Suppress("unused") +val disableSuggestedVideoEndScreenPatch = bytecodePatch( + name = "Disable suggested video end screen", + description = "Adds an option to disable the suggested video end screen at the end of videos.", +) { + dependsOn( + sharedExtensionPatch, + disableSuggestedVideoEndScreenResourcePatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val createEndScreenViewMatch by createEndScreenViewFingerprint() + + execute { + createEndScreenViewMatch.mutableMethod.apply { + val addOnClickEventListenerIndex = createEndScreenViewMatch.patternMatch!!.endIndex - 1 + val viewRegister = getInstruction(addOnClickEventListenerIndex).registerC + + addInstruction( + addOnClickEventListenerIndex + 1, + "invoke-static {v$viewRegister}, " + + "$EXTENSION_CLASS_DESCRIPTOR->closeEndScreen(Landroid/widget/ImageView;)V", + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/suggestedvideoendscreen/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/suggestedvideoendscreen/Fingerprints.kt new file mode 100644 index 000000000..78efc0680 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/suggestedvideoendscreen/Fingerprints.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.youtube.layout.hide.suggestedvideoendscreen + +import app.revanced.patcher.fingerprint +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val createEndScreenViewFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Landroid/view/View;") + parameters("Landroid/content/Context;") + opcodes( + Opcode.INVOKE_DIRECT, + Opcode.INVOKE_VIRTUAL, + Opcode.CONST, + ) + literal { sizeAdjustableLiteAutoNavOverlay } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/time/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/time/Fingerprints.kt new file mode 100644 index 000000000..d109c6455 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/time/Fingerprints.kt @@ -0,0 +1,24 @@ +package app.revanced.patches.youtube.layout.hide.time + +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import app.revanced.patcher.fingerprint + +internal val timeCounterFingerprint = fingerprint( + fuzzyPatternScanThreshold = 1, +) { + returns("V") + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + parameters() + opcodes( + Opcode.SUB_LONG_2ADDR, + Opcode.IGET_WIDE, + Opcode.SUB_LONG_2ADDR, + Opcode.IGET_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IGET_WIDE, + Opcode.IGET_WIDE, + Opcode.SUB_LONG_2ADDR + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/time/HideTimestampPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/time/HideTimestampPatch.kt new file mode 100644 index 000000000..ae9604807 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/hide/time/HideTimestampPatch.kt @@ -0,0 +1,54 @@ +package app.revanced.patches.youtube.layout.hide.time + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch + +@Suppress("unused") +val hideTimestampPatch = bytecodePatch( + name = "Hide timestamp", + description = "Adds an option to hide the timestamp in the bottom left of the video player.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val timeCounterMatch by timeCounterFingerprint() + + execute { + addResources("youtube", "layout.hide.time.hideTimestampPatch") + + PreferenceScreen.SEEKBAR.addPreferences( + SwitchPreference("revanced_hide_timestamp"), + ) + + timeCounterMatch.mutableMethod.addInstructionsWithLabels( + 0, + """ + invoke-static { }, Lapp/revanced/extension/youtube/patches/HideTimestampPatch;->hideTimestamp()Z + move-result v0 + if-eqz v0, :hide_time + return-void + :hide_time + nop + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/miniplayer/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/miniplayer/Fingerprints.kt new file mode 100644 index 000000000..28abd6cba --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/miniplayer/Fingerprints.kt @@ -0,0 +1,152 @@ +package app.revanced.patches.youtube.layout.miniplayer + +import app.revanced.patcher.fingerprint +import app.revanced.util.containsWideLiteralInstructionValue +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val miniplayerDimensionsCalculatorParentFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("L") + literal { floatyBarButtonTopMargin } +} + +/** + * Matches using the class found in [miniplayerModernViewParentFingerprint]. + */ +internal val miniplayerModernAddViewListenerFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("Landroid/view/View;") +} + +/** + * Matches using the class found in [miniplayerModernViewParentFingerprint]. + */ + +internal val miniplayerModernCloseButtonFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Landroid/widget/ImageView;") + parameters() + literal { modernMiniplayerClose } +} + +const val MODERN_FEATURE_FLAGS_ENABLED_KEY_LITERAL = 45622882L + +// In later targets this feature flag does nothing and is dead code. +const val MODERN_MINIPLAYER_ENABLED_OLD_TARGETS_FEATURE_KEY = 45630429L +const val DOUBLE_TAP_ENABLED_FEATURE_KEY_LITERAL = 45628823L +const val DRAG_DROP_ENABLED_FEATURE_KEY_LITERAL = 45628752L +const val INITIAL_SIZE_FEATURE_KEY_LITERAL = 45640023L +const val ANIMATION_INTERPOLATION_FEATURE_KEY = 45647018L +const val DROP_SHADOW_FEATURE_KEY = 45652223L +const val ROUNDED_CORNERS_FEATURE_KEY = 45652224L + +internal val miniplayerModernConstructorFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + parameters("L") + literal { 45623000L } +} + +/** + * Matches using the class found in [miniplayerModernViewParentFingerprint]. + */ +internal val miniplayerModernExpandButtonFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Landroid/widget/ImageView;") + parameters() + literal { modernMiniplayerExpand } +} + +/** + * Matches using the class found in [miniplayerModernViewParentFingerprint]. + */ +internal val miniplayerModernExpandCloseDrawablesFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("L") + literal { ytOutlinePictureInPictureWhite24 } +} + +/** + * Matches using the class found in [miniplayerModernViewParentFingerprint]. + */ +internal val miniplayerModernForwardButtonFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Landroid/widget/ImageView;") + parameters() + literal { modernMiniplayerForwardButton } +} + +/** + * Matches using the class found in [miniplayerModernViewParentFingerprint]. + */ +internal val miniplayerModernOverlayViewFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters() + literal { scrimOverlay } +} + +/** + * Matches using the class found in [miniplayerModernViewParentFingerprint]. + */ +internal val miniplayerModernRewindButtonFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Landroid/widget/ImageView;") + parameters() + literal { modernMiniplayerRewindButton } +} + +internal val miniplayerModernViewParentFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Ljava/lang/String;") + parameters() + strings("player_overlay_modern_mini_player_controls") +} + +internal val miniplayerMinimumSizeFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + custom { method, _ -> + method.containsWideLiteralInstructionValue(192) && + method.containsWideLiteralInstructionValue(128) && + method.containsWideLiteralInstructionValue(miniplayerMaxSize) + } +} + +internal val miniplayerOverrideFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("L") + strings("appName") +} + +internal val miniplayerOverrideNoContextFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.FINAL) + returns("Z") + opcodes(Opcode.IGET_BOOLEAN) // Anchor to insert the instruction. +} + +internal val miniplayerResponseModelSizeCheckFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("L") + parameters("Ljava/lang/Object;", "Ljava/lang/Object;") + opcodes( + Opcode.RETURN_OBJECT, + Opcode.CHECK_CAST, + Opcode.CHECK_CAST, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT, + Opcode.IF_NEZ, + ) +} + +internal const val YOUTUBE_PLAYER_OVERLAYS_LAYOUT_CLASS_NAME = + "Lcom/google/android/apps/youtube/app/common/player/overlay/YouTubePlayerOverlaysLayout;" + +internal val playerOverlaysLayoutFingerprint = fingerprint { + custom { method, _ -> + method.definingClass == YOUTUBE_PLAYER_OVERLAYS_LAYOUT_CLASS_NAME + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/miniplayer/MiniplayerPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/miniplayer/MiniplayerPatch.kt new file mode 100644 index 000000000..111f93426 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/miniplayer/MiniplayerPatch.kt @@ -0,0 +1,560 @@ +package app.revanced.patches.youtube.layout.miniplayer + +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.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.proxy.mutableTypes.MutableMethod +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings +import app.revanced.patches.shared.misc.settings.preference.* +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.playservice.* +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.util.* +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.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.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 + +var floatyBarButtonTopMargin = -1L + private set + +// Only available in 19.15 and upwards. +var ytOutlineXWhite24 = -1L + private set +var ytOutlinePictureInPictureWhite24 = -1L + private set +var scrimOverlay = -1L + private set +var modernMiniplayerClose = -1L + private set +var modernMiniplayerExpand = -1L + private set +var modernMiniplayerRewindButton = -1L + private set +var modernMiniplayerForwardButton = -1L + private set +var playerOverlays = -1L + private set +var miniplayerMaxSize = -1L + private set + +private val miniplayerResourcePatch = resourcePatch { + dependsOn( + resourceMappingPatch, + versionCheckPatch, + ) + + execute { + floatyBarButtonTopMargin = resourceMappings[ + "dimen", + "floaty_bar_button_top_margin", + ] + + scrimOverlay = resourceMappings[ + "id", + "scrim_overlay", + ] + + playerOverlays = resourceMappings[ + "layout", + "player_overlays", + ] + + if (is_19_16_or_greater) { + modernMiniplayerClose = resourceMappings[ + "id", + "modern_miniplayer_close", + ] + + modernMiniplayerExpand = resourceMappings[ + "id", + "modern_miniplayer_expand", + ] + + modernMiniplayerRewindButton = resourceMappings[ + "id", + "modern_miniplayer_rewind_button", + ] + + modernMiniplayerForwardButton = resourceMappings[ + "id", + "modern_miniplayer_forward_button", + ] + + // Resource id is not used during patching, but is used by extension. + // Verify the resource is present while patching. + resourceMappings[ + "id", + "modern_miniplayer_subtitle_text", + ] + + // Only required for exactly 19.16 + if (!is_19_17_or_greater) { + ytOutlinePictureInPictureWhite24 = resourceMappings[ + "drawable", + "yt_outline_picture_in_picture_white_24", + ] + + ytOutlineXWhite24 = resourceMappings[ + "drawable", + "yt_outline_x_white_24", + ] + } + + if (is_19_26_or_greater) { + miniplayerMaxSize = resourceMappings[ + "dimen", + "miniplayer_max_size", + ] + } + } + } +} + +private const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/youtube/patches/MiniplayerPatch;" + +@Suppress("unused") +val miniplayerPatch = bytecodePatch( + name = "Miniplayer", + description = "Adds options to change the in app minimized player. " + + "Patching target 19.16+ adds modern miniplayers.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + miniplayerResourcePatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + // 19.14.43 // Incomplete code for modern miniplayers. + // 19.15.36 // Different code for handling subtitle texts and not worth supporting. + "19.16.39", // First with modern miniplayers. + // 19.17.41 // Works without issues, but no reason to recommend over 19.16. + // 19.18.41 // Works without issues, but no reason to recommend over 19.16. + // 19.19.39 // Last bug free version with smaller Modern 1 miniplayer, but no reason to recommend over 19.16. + // 19.20.35 // Cannot swipe to expand. + // 19.21.40 // Cannot swipe to expand. + // 19.22.43 // Cannot swipe to expand. + // 19.23.40 // First with Modern 1 drag and drop, Cannot swipe to expand. + // 19.24.45 // First with larger Modern 1, Cannot swipe to expand. + "19.25.37", // First with double tap, last with skip forward/back buttons, last with swipe to expand/close, and last before double tap to expand seems to be required. + // 19.26.42 // Modern 1 Pause/play button are always hidden. Unusable. + // 19.28.42 // First with custom miniplayer size, screen flickers when swiping to maximize Modern 1. Swipe to close miniplayer is broken. + // 19.29.42 // All modern players are broken and ignore tapping the miniplayer video. + // 19.30.39 // Modern 3 is less broken when double tap expand is enabled, but cannot swipe to expand when double tap is off. + // 19.31.36 // All Modern 1 buttons are missing. Unusable. + // 19.32.36 // 19.32+ and beyond all work without issues. + // 19.33.35 + "19.34.42", + ), + ) + + val miniplayerDimensionsCalculatorParentMatch by miniplayerDimensionsCalculatorParentFingerprint() + val miniplayerResponseModelSizeCheckMatch by miniplayerResponseModelSizeCheckFingerprint() + val miniplayerOverrideMatch by miniplayerOverrideFingerprint() + val miniplayerModernConstructorMatch by miniplayerModernConstructorFingerprint() + val miniplayerModernViewParentMatch by miniplayerModernViewParentFingerprint() + val miniplayerMinimumSizeMatch by miniplayerMinimumSizeFingerprint() + val playerOverlaysLayoutMatch by playerOverlaysLayoutFingerprint() + + execute { context -> + addResources("youtube", "layout.miniplayer.miniplayerPatch") + + val preferences = mutableSetOf() + + if (!is_19_16_or_greater) { + preferences += ListPreference( + "revanced_miniplayer_type", + summaryKey = null, + entriesKey = "revanced_miniplayer_type_legacy_entries", + entryValuesKey = "revanced_miniplayer_type_legacy_entry_values", + ) + } else { + preferences += ListPreference( + "revanced_miniplayer_type", + summaryKey = null, + ) + + if (is_19_25_or_greater) { + if (!is_19_29_or_greater) { + preferences += SwitchPreference("revanced_miniplayer_double_tap_action") + } + preferences += SwitchPreference("revanced_miniplayer_drag_and_drop") + } + + if (is_19_36_or_greater) { + preferences += SwitchPreference("revanced_miniplayer_rounded_corners") + } + + preferences += SwitchPreference("revanced_miniplayer_hide_subtext") + + preferences += if (is_19_26_or_greater) { + SwitchPreference("revanced_miniplayer_hide_expand_close") + } else { + SwitchPreference( + key = "revanced_miniplayer_hide_expand_close", + titleKey = "revanced_miniplayer_hide_expand_close_legacy_title", + summaryOnKey = "revanced_miniplayer_hide_expand_close_legacy_summary_on", + summaryOffKey = "revanced_miniplayer_hide_expand_close_legacy_summary_off", + ) + } + + if (!is_19_26_or_greater) { + preferences += SwitchPreference("revanced_miniplayer_hide_rewind_forward") + } + + if (is_19_26_or_greater) { + preferences += TextPreference("revanced_miniplayer_width_dip", inputType = InputType.NUMBER) + } + + preferences += TextPreference("revanced_miniplayer_opacity", inputType = InputType.NUMBER) + } + + PreferenceScreen.PLAYER.addPreferences( + PreferenceScreenPreference( + key = "revanced_miniplayer_screen", + sorting = PreferenceScreenPreference.Sorting.UNSORTED, + preferences = preferences, + ), + ) + + 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 + """, + ) + } + + fun Method.findReturnIndicesReversed() = findOpcodeIndicesReversed(Opcode.RETURN) + + /** + * 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") + } + + fun Match.insertLiteralValueBooleanOverride( + literal: Long, + extensionMethod: String, + ) { + mutableMethod.apply { + val literalIndex = indexOfFirstWideLiteralInstructionValueOrThrow(literal) + val targetIndex = indexOfFirstInstructionOrThrow(literalIndex, Opcode.MOVE_RESULT) + + insertBooleanOverride(targetIndex + 1, extensionMethod) + } + } + + fun Match.insertLiteralValueFloatOverride( + literal: Long, + extensionMethod: String, + ) { + mutableMethod.apply { + val literalIndex = indexOfFirstWideLiteralInstructionValueOrThrow(literal) + val targetIndex = indexOfFirstInstructionOrThrow(literalIndex, Opcode.DOUBLE_TO_FLOAT) + val register = getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, + """ + invoke-static {v$register}, $EXTENSION_CLASS_DESCRIPTOR->$extensionMethod(F)F + move-result v$register + """, + ) + } + } + + /** + * Adds an override to specify which modern miniplayer is used. + */ + fun MutableMethod.insertModernMiniplayerTypeOverride(iPutIndex: Int) { + val targetInstruction = getInstruction(iPutIndex) + + addInstructionsAtControlFlowLabel( + iPutIndex, + """ + invoke-static { v${targetInstruction.registerA} }, $EXTENSION_CLASS_DESCRIPTOR->getModernMiniplayerOverrideType(I)I + move-result v${targetInstruction.registerA} + """, + ) + } + + fun MutableMethod.hookInflatedView( + literalValue: Long, + hookedClassType: String, + extensionMethodName: String, + ) { + val imageViewIndex = indexOfFirstInstructionOrThrow( + indexOfFirstWideLiteralInstructionValueOrThrow(literalValue), + ) { + opcode == Opcode.CHECK_CAST && getReference()?.type == hookedClassType + } + + val register = getInstruction(imageViewIndex).registerA + addInstruction( + imageViewIndex + 1, + "invoke-static { v$register }, $extensionMethodName", + ) + } + + // region Enable tablet miniplayer. + + miniplayerOverrideNoContextFingerprint.applyMatch( + context, + miniplayerDimensionsCalculatorParentMatch, + ).mutableMethod.apply { + findReturnIndicesReversed().forEach { index -> insertLegacyTabletMiniplayerOverride(index) } + } + + // endregion + + // region Legacy tablet miniplayer hooks. + + val appNameStringIndex = miniplayerOverrideMatch.stringMatches!!.first().index + 2 + context.navigate(miniplayerOverrideMatch.mutableMethod).at(appNameStringIndex).mutable().apply { + findReturnIndicesReversed().forEach { index -> insertLegacyTabletMiniplayerOverride(index) } + } + + miniplayerResponseModelSizeCheckMatch.let { + it.mutableMethod.insertLegacyTabletMiniplayerOverride(it.patternMatch!!.endIndex) + } + + if (!is_19_16_or_greater) { + // Return here, as patch below is only for the current versions of the app. + return@execute + } + + // endregion + + // region Enable modern miniplayer. + + miniplayerModernConstructorMatch.mutableClass.methods.forEach { + it.apply { + if (AccessFlags.CONSTRUCTOR.isSet(accessFlags)) { + val iPutIndex = indexOfFirstInstructionOrThrow { + this.opcode == Opcode.IPUT && this.getReference()?.type == "I" + } + + insertModernMiniplayerTypeOverride(iPutIndex) + } else { + findReturnIndicesReversed().forEach { index -> insertModernMiniplayerOverride(index) } + } + } + } + + if (is_19_23_or_greater) { + miniplayerModernConstructorMatch.insertLiteralValueBooleanOverride( + DRAG_DROP_ENABLED_FEATURE_KEY_LITERAL, + "enableMiniplayerDragAndDrop", + ) + } + + if (is_19_25_or_greater) { + miniplayerModernConstructorMatch.insertLiteralValueBooleanOverride( + MODERN_MINIPLAYER_ENABLED_OLD_TARGETS_FEATURE_KEY, + "getModernMiniplayerOverride", + ) + + miniplayerModernConstructorMatch.insertLiteralValueBooleanOverride( + MODERN_FEATURE_FLAGS_ENABLED_KEY_LITERAL, + "getModernFeatureFlagsActiveOverride", + ) + + miniplayerModernConstructorMatch.insertLiteralValueBooleanOverride( + DOUBLE_TAP_ENABLED_FEATURE_KEY_LITERAL, + "enableMiniplayerDoubleTapAction", + ) + } + + if (is_19_26_or_greater) { + miniplayerModernConstructorMatch.mutableMethod.apply { + val literalIndex = indexOfFirstWideLiteralInstructionValueOrThrow( + INITIAL_SIZE_FEATURE_KEY_LITERAL, + ) + val targetIndex = indexOfFirstInstructionOrThrow(literalIndex, Opcode.LONG_TO_INT) + + val register = getInstruction(targetIndex).registerA + + addInstructions( + targetIndex + 1, + """ + invoke-static { v$register }, $EXTENSION_CLASS_DESCRIPTOR->setMiniplayerDefaultSize(I)I + move-result v$register + """, + ) + } + + // Override a mininimum miniplayer size constant. + miniplayerMinimumSizeMatch.mutableMethod.apply { + val index = indexOfFirstInstructionOrThrow { + opcode == Opcode.CONST_16 && (this as NarrowLiteralInstruction).narrowLiteral == 192 + } + val register = getInstruction(index).registerA + + // Smaller sizes can be used, but the miniplayer will always start in size 170 if set any smaller. + // The 170 initial limit probably could be patched to allow even smaller initial sizes, + // but 170 is already half the horizontal space and smaller does not seem useful. + replaceInstruction(index, "const/16 v$register, 170") + } + } + + if (is_19_32_or_greater) { + // Feature is not exposed in the settings, and currently only for debugging. + miniplayerModernConstructorMatch.insertLiteralValueFloatOverride( + ANIMATION_INTERPOLATION_FEATURE_KEY, + "setMovementBoundFactor", + ) + } + + if (is_19_36_or_greater) { + miniplayerModernConstructorMatch.insertLiteralValueBooleanOverride( + DROP_SHADOW_FEATURE_KEY, + "setDropShadow", + ) + + miniplayerModernConstructorMatch.insertLiteralValueBooleanOverride( + ROUNDED_CORNERS_FEATURE_KEY, + "setRoundedCorners", + ) + } + + // 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 (ytOutlinePictureInPictureWhite24 >= 0) { + miniplayerModernExpandCloseDrawablesFingerprint.applyMatch( + context, + miniplayerModernViewParentMatch, + ).mutableMethod.apply { + listOf( + ytOutlinePictureInPictureWhite24 to ytOutlineXWhite24, + ytOutlineXWhite24 to ytOutlinePictureInPictureWhite24, + ).forEach { (originalResource, replacementResource) -> + val imageResourceIndex = indexOfFirstWideLiteralInstructionValueOrThrow(originalResource) + val register = getInstruction(imageResourceIndex).registerA + + replaceInstruction(imageResourceIndex, "const v$register, $replacementResource") + } + } + } + + // endregion + + // region Add hooks to hide 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.applyMatch( + context, + miniplayerModernViewParentMatch, + ).mutableMethod.hookInflatedView( + literalValue, + "Landroid/widget/ImageView;", + "$EXTENSION_CLASS_DESCRIPTOR->$methodName(Landroid/widget/ImageView;)V", + ) + } + + miniplayerModernAddViewListenerFingerprint.applyMatch( + context, + miniplayerModernViewParentMatch, + ).mutableMethod.addInstruction( + 0, + "invoke-static { p1 }, $EXTENSION_CLASS_DESCRIPTOR->" + + "hideMiniplayerSubTexts(Landroid/view/View;)V", + ) + + // 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. + // + // NOTE: Modern 2 uses the same video UI as the regular player except resized to smaller. + // This patch code could be used to hide other player overlays that do not use Litho. + playerOverlaysLayoutMatch.mutableClass.methods.add( + ImmutableMethod( + YOUTUBE_PLAYER_OVERLAYS_LAYOUT_CLASS_NAME, + "addView", + listOf( + ImmutableMethodParameter("Landroid/view/View;", null, null), + ImmutableMethodParameter("I", null, null), + ImmutableMethodParameter("Landroid/view/ViewGroup\$LayoutParams;", null, null), + ), + "V", + AccessFlags.PUBLIC.value, + null, + 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 + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/panels/popup/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/panels/popup/Fingerprints.kt new file mode 100644 index 000000000..0c31cc83b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/panels/popup/Fingerprints.kt @@ -0,0 +1,13 @@ +package app.revanced.patches.youtube.layout.panels.popup + +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val engagementPanelControllerFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.FINAL) + returns("L") + strings( + "EngagementPanelController: cannot show EngagementPanel before EngagementPanelController.init() has been called.", + "[EngagementPanel] Cannot show EngagementPanel before EngagementPanelController.init() has been called.", + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/panels/popup/PlayerPopupPanelsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/panels/popup/PlayerPopupPanelsPatch.kt new file mode 100644 index 000000000..3914d53e2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/panels/popup/PlayerPopupPanelsPatch.kt @@ -0,0 +1,56 @@ +package app.revanced.patches.youtube.layout.panels.popup + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch + +@Suppress("unused") +val playerPopupPanelsPatch = bytecodePatch( + name = "Disable player popup panels", + description = "Adds an option to disable panels (such as live chat) from opening automatically.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val engagementPanelControllerMatch by engagementPanelControllerFingerprint() + + execute { + addResources("youtube", "layout.panels.popup.playerPopupPanelsPatch") + + PreferenceScreen.PLAYER.addPreferences( + SwitchPreference("revanced_hide_player_popup_panels"), + ) + + engagementPanelControllerMatch.mutableMethod.addInstructionsWithLabels( + 0, + """ + invoke-static { }, Lapp/revanced/extension/youtube/patches/DisablePlayerPopupPanelsPatch;->disablePlayerPopupPanels()Z + move-result v0 + if-eqz v0, :player_popup_panels + if-eqz p4, :player_popup_panels + const/4 v0, 0x0 + return-object v0 + :player_popup_panels + nop + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/player/background/PlayerControlsBackgroundPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/player/background/PlayerControlsBackgroundPatch.kt new file mode 100644 index 000000000..433cce068 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/player/background/PlayerControlsBackgroundPatch.kt @@ -0,0 +1,35 @@ +package app.revanced.patches.youtube.layout.player.background + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.util.doRecursively +import org.w3c.dom.Element + +@Suppress("unused") +val playerControlsBackgroundPatch = resourcePatch( + name = "Remove player controls background", + description = "Removes the dark background surrounding the video player controls.", + use = false, +) { + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + execute { context -> + context.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" + } + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/player/overlay/CustomPlayerOverlayOpacityPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/player/overlay/CustomPlayerOverlayOpacityPatch.kt new file mode 100644 index 000000000..998bd6129 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/player/overlay/CustomPlayerOverlayOpacityPatch.kt @@ -0,0 +1,71 @@ +package app.revanced.patches.youtube.layout.player.overlay + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings +import app.revanced.patches.shared.misc.settings.preference.InputType +import app.revanced.patches.shared.misc.settings.preference.TextPreference +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.util.indexOfFirstWideLiteralInstructionValueOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +internal var scrimOverlayId = -1L + private set + +private val customPlayerOverlayOpacityResourcePatch = resourcePatch { + dependsOn( + settingsPatch, + resourceMappingPatch, + addResourcesPatch, + ) + + execute { + addResources("youtube", "layout.player.overlay.customPlayerOverlayOpacityResourcePatch") + + PreferenceScreen.PLAYER.addPreferences( + TextPreference("revanced_player_overlay_opacity", inputType = InputType.NUMBER), + ) + + scrimOverlayId = resourceMappings[ + "id", + "scrim_overlay", + ] + } +} + +private const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/youtube/patches/CustomPlayerOverlayOpacityPatch;" + +@Suppress("unused") +val customPlayerOverlayOpacityPatch = bytecodePatch( + name = "Custom player overlay opacity", + description = "Adds an option to change the opacity of the video player background when player controls are visible.", +) { + dependsOn(customPlayerOverlayOpacityResourcePatch) + + compatibleWith("com.google.android.youtube") + + val createPlayerOverviewMatch by createPlayerOverviewFingerprint() + + execute { + createPlayerOverviewMatch.mutableMethod.apply { + val viewRegisterIndex = + indexOfFirstWideLiteralInstructionValueOrThrow(scrimOverlayId) + 3 + val viewRegister = + getInstruction(viewRegisterIndex).registerA + + val insertIndex = viewRegisterIndex + 1 + addInstruction( + insertIndex, + "invoke-static { v$viewRegister }, " + + "$EXTENSION_CLASS_DESCRIPTOR->changeOpacity(Landroid/widget/ImageView;)V", + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/player/overlay/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/player/overlay/Fingerprints.kt new file mode 100644 index 000000000..abe87d8cf --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/player/overlay/Fingerprints.kt @@ -0,0 +1,20 @@ +package app.revanced.patches.youtube.layout.player.overlay + +import app.revanced.patcher.fingerprint +import app.revanced.util.containsWideLiteralInstructionValue +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val createPlayerOverviewFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.FINAL) + returns("V") + opcodes( + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + ) + custom { method, _ -> + method.containsWideLiteralInstructionValue(scrimOverlayId) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/returnyoutubedislike/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/returnyoutubedislike/Fingerprints.kt new file mode 100644 index 000000000..a56b1d9dd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/returnyoutubedislike/Fingerprints.kt @@ -0,0 +1,155 @@ +package app.revanced.patches.youtube.layout.returnyoutubedislike + +import app.revanced.patcher.fingerprint +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val conversionContextFingerprint = fingerprint { + returns("Ljava/lang/String;") + parameters() + strings( + ", widthConstraint=", + ", heightConstraint=", + ", templateLoggerFactory=", + ", rootDisposableContainer=", + "ConversionContext{containerInternal=", + ) +} + +internal val dislikeFingerprint = fingerprint { + returns("V") + strings("like/dislike") +} + +internal val dislikesOldLayoutTextViewFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("L") + opcodes( + Opcode.CONST, // resource identifier register + Opcode.INVOKE_VIRTUAL, + Opcode.INVOKE_VIRTUAL, + Opcode.IGET_OBJECT, + Opcode.IF_NEZ, // textview register + Opcode.GOTO, + ) + literal { oldUIDislikeId } +} + +internal val likeFingerprint = fingerprint { + returns("V") + strings("like/like") +} + +internal val removeLikeFingerprint = fingerprint { + returns("V") + strings("like/removelike") +} + +internal val rollingNumberMeasureAnimatedTextFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("Lj\$/util/Optional;") + parameters("L", "Ljava/lang/String;", "L") + opcodes( + Opcode.IGET, // First instruction of method + Opcode.IGET_OBJECT, + Opcode.IGET_OBJECT, + Opcode.CONST_HIGH16, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT, + Opcode.CONST_4, + Opcode.AGET, + Opcode.CONST_4, + Opcode.CONST_4, // Measured text width + ) +} + +/** + * Matches to class found in [rollingNumberMeasureStaticLabelParentFingerprint]. + */ +internal val rollingNumberMeasureStaticLabelFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("F") + parameters("Ljava/lang/String;") + opcodes( + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.RETURN, + ) +} + +internal val rollingNumberMeasureStaticLabelParentFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Ljava/lang/String;") + parameters() + strings("RollingNumberFontProperties{paint=") +} + +internal val rollingNumberSetterFingerprint = fingerprint { + opcodes( + Opcode.INVOKE_DIRECT, + Opcode.IGET_OBJECT, + ) + // Partial string match. + strings("RollingNumberType required properties missing! Need") +} + +internal val rollingNumberTextViewFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("L", "F", "F") + opcodes( + Opcode.IPUT, + null, // invoke-direct or invoke-virtual + Opcode.IPUT_OBJECT, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN_VOID, + ) + custom { _, classDef -> + classDef.superclass == + "Landroid/support/v7/widget/AppCompatTextView;" || + classDef.superclass == + "Lcom/google/android/libraries/youtube/rendering/ui/spec/typography/YouTubeAppCompatTextView;" + } +} + +internal val shortsTextViewFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("L", "L") + opcodes( + Opcode.INVOKE_SUPER, // first instruction of method + Opcode.IF_NEZ, + null, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + ) +} + +internal val textComponentConstructorFingerprint = fingerprint { + accessFlags(AccessFlags.CONSTRUCTOR, AccessFlags.PRIVATE) + strings("TextComponent") +} + +internal val textComponentDataFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + parameters("L", "L") + strings("text") + custom { _, classDef -> + classDef.fields.find { it.type == "Ljava/util/BitSet;" } != null + } +} + +/** + * Matches against the same class found in [textComponentConstructorFingerprint]. + */ +internal val textComponentLookupFingerprint = fingerprint { + accessFlags(AccessFlags.PROTECTED, AccessFlags.FINAL) + returns("L") + parameters("L") + strings("…") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/returnyoutubedislike/ReturnYouTubeDislikePatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/returnyoutubedislike/ReturnYouTubeDislikePatch.kt new file mode 100644 index 000000000..20f47e958 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/returnyoutubedislike/ReturnYouTubeDislikePatch.kt @@ -0,0 +1,389 @@ +package app.revanced.patches.youtube.layout.returnyoutubedislike + +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.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappings +import app.revanced.patches.shared.misc.settings.preference.IntentPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.litho.filter.addLithoFilter +import app.revanced.patches.youtube.misc.litho.filter.lithoFilterPatch +import app.revanced.patches.youtube.misc.playertype.playerTypeHookPatch +import app.revanced.patches.youtube.misc.playservice.is_19_33_or_greater +import app.revanced.patches.youtube.misc.playservice.versionCheckPatch +import app.revanced.patches.youtube.misc.settings.addSettingPreference +import app.revanced.patches.youtube.misc.settings.newIntent +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.patches.youtube.shared.rollingNumberTextViewAnimationUpdateFingerprint +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.* +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.iface.reference.TypeReference +import com.sun.org.apache.bcel.internal.generic.InstructionConst.getInstruction + +internal var oldUIDislikeId = -1L + private set + +private val returnYouTubeDislikeResourcePatch = resourcePatch { + dependsOn( + settingsPatch, + addResourcesPatch, + ) + + execute { + addResources("youtube", "layout.returnyoutubedislike.returnYouTubeDislikeResourcePatch") + + addSettingPreference( + IntentPreference( + key = "revanced_settings_screen_09", + titleKey = "revanced_ryd_settings_title", + summaryKey = null, + intent = newIntent("revanced_ryd_settings_intent"), + ), + ) + + oldUIDislikeId = resourceMappings[ + "id", + "dislike_button", + ] + } +} + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/ReturnYouTubeDislikePatch;" + +private const val FILTER_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/components/ReturnYouTubeDislikeFilterPatch;" + +@Suppress("unused") +val returnYouTubeDislikePatch = bytecodePatch( + name = "Return YouTube Dislike", + description = "Adds an option to show the dislike count of videos with Return YouTube Dislike.", +) { + dependsOn( + sharedExtensionPatch, + lithoFilterPatch, + videoIdPatch, + returnYouTubeDislikeResourcePatch, + playerTypeHookPatch, + versionCheckPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val conversionContextMatch by conversionContextFingerprint() + val textComponentConstructorMatch by textComponentConstructorFingerprint() + val textComponentDataMatch by textComponentDataFingerprint() + val shortsTextViewMatch by shortsTextViewFingerprint() + val dislikesOldLayoutTextViewMatch by dislikesOldLayoutTextViewFingerprint() + val likeMatch by likeFingerprint() + val dislikeMatch by dislikeFingerprint() + val removeLikeMatch by removeLikeFingerprint() + val rollingNumberSetterMatch by rollingNumberSetterFingerprint() + val rollingNumberMeasureStaticLabelParentMatch by rollingNumberMeasureStaticLabelParentFingerprint() + val rollingNumberMeasureAnimatedTextMatch by rollingNumberMeasureAnimatedTextFingerprint() + val rollingNumberTextViewMatch by rollingNumberTextViewFingerprint() + val rollingNumberTextViewAnimationUpdateMatch by rollingNumberTextViewAnimationUpdateFingerprint() + + execute { context -> + // region Inject newVideoLoaded event handler to update dislikes when a new video is loaded. + + hookVideoId("$EXTENSION_CLASS_DESCRIPTOR->newVideoLoaded(Ljava/lang/String;)V") + + // Hook the player response video id, to start loading RYD sooner in the background. + hookPlayerResponseVideoId("$EXTENSION_CLASS_DESCRIPTOR->preloadVideoId(Ljava/lang/String;Z)V") + + // endregion + + // region Hook like/dislike/remove like button clicks to send votes to the API. + + data class VotePatch(val fingerprint: Match, val voteKind: Vote) + + listOf( + VotePatch(likeMatch, Vote.LIKE), + VotePatch(dislikeMatch, Vote.DISLIKE), + VotePatch(removeLikeMatch, Vote.REMOVE_LIKE), + ).forEach { (match, vote) -> + match.mutableMethod.addInstructions( + 0, + """ + const/4 v0, ${vote.value} + invoke-static {v0}, $EXTENSION_CLASS_DESCRIPTOR->sendVote(I)V + """, + ) + } + + // endregion + + // region Hook code for creation and cached lookup of text Spans. + + // Alternatively the hook can be made at tht it fails to update the Span when the user dislikes, + // // since the underlying (likes only) tee creation of Spans in TextComponentSpec, + // And it works in all situations excepxt did not change. + // This hook handles all situations, as it's where the created Spans are stored and later reused. + // Find the field name of the conversion context. + val conversionContextField = textComponentConstructorMatch.classDef.fields.find { + it.type == conversionContextMatch.classDef.type + } ?: throw PatchException("Could not find conversion context field") + + textComponentLookupFingerprint.applyMatch(context, textComponentConstructorMatch) + textComponentLookupFingerprint.matchOrThrow.mutableMethod.apply { + // Find the instruction for creating the text data object. + val textDataClassType = textComponentDataMatch.classDef.type + + val insertIndex: Int + val tempRegister: Int + val charSequenceRegister: Int + + if (is_19_33_or_greater) { + insertIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_STATIC_RANGE && + getReference()?.returnType == textDataClassType + } + + tempRegister = getInstruction(insertIndex + 1).registerA + + // Find the instruction that sets the span to an instance field. + // The instruction is only a few lines after the creation of the instance. + charSequenceRegister = getInstruction( + indexOfFirstInstructionOrThrow(insertIndex) { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.parameterTypes?.firstOrNull() == "Ljava/lang/CharSequence;" + }, + ).registerD + } else { + insertIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.NEW_INSTANCE && + getReference()?.type == textDataClassType + } + + tempRegister = getInstruction(insertIndex).registerA + + charSequenceRegister = getInstruction( + indexOfFirstInstructionOrThrow(insertIndex) { + opcode == Opcode.IPUT_OBJECT && + getReference()?.type == "Ljava/lang/CharSequence;" + }, + ).registerA + } + + addInstructionsAtControlFlowLabel( + insertIndex, + """ + # Copy conversion context + move-object/from16 v$tempRegister, p0 + iget-object v$tempRegister, v$tempRegister, $conversionContextField + invoke-static { v$tempRegister, v$charSequenceRegister }, $EXTENSION_CLASS_DESCRIPTOR->onLithoTextLoaded(Ljava/lang/Object;Ljava/lang/CharSequence;)Ljava/lang/CharSequence; + move-result-object v$charSequenceRegister + """, + ) + } + + // endregion + + // region Hook for non-litho Short videos. + + shortsTextViewMatch.mutableMethod.apply { + val insertIndex = shortsTextViewMatch.patternMatch!!.endIndex + 1 + + // If the field is true, the TextView is for a dislike button. + val isDisLikesBooleanInstruction = instructions.first { instruction -> + instruction.opcode == Opcode.IGET_BOOLEAN + } as ReferenceInstruction + + val isDisLikesBooleanReference = isDisLikesBooleanInstruction.reference + + // Like/Dislike button TextView field. + val textViewFieldInstruction = instructions.first { instruction -> + instruction.opcode == Opcode.IGET_OBJECT + } as ReferenceInstruction + + val textViewFieldReference = textViewFieldInstruction.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. + addInstructionsWithLabels( + insertIndex, + """ + # Check, if the TextView is for a dislike button + iget-boolean v0, p0, $isDisLikesBooleanReference + if-eqz v0, :is_like + + # Hook the TextView, if it is for the dislike button + iget-object v0, p0, $textViewFieldReference + invoke-static {v0}, $EXTENSION_CLASS_DESCRIPTOR->setShortsDislikes(Landroid/view/View;)Z + move-result v0 + if-eqz v0, :ryd_disabled + return-void + + :is_like + :ryd_disabled + nop + """, + ) + } + + // endregion + + // region Hook for litho Shorts + + // Filter that parses the video id from the UI + addLithoFilter(FILTER_CLASS_DESCRIPTOR) + + // Player response video id is needed to search for the video ids in Shorts litho components. + hookPlayerResponseVideoId("$FILTER_CLASS_DESCRIPTOR->newPlayerResponseVideoId(Ljava/lang/String;Z)V") + + // endregion + + // region Hook old UI layout dislikes, for the older app spoofs used with spoof-app-version. + + dislikesOldLayoutTextViewMatch.mutableMethod.apply { + val startIndex = dislikesOldLayoutTextViewMatch.patternMatch!!.startIndex + + val resourceIdentifierRegister = getInstruction(startIndex).registerA + val textViewRegister = getInstruction(startIndex + 4).registerA + + addInstruction( + startIndex + 4, + "invoke-static {v$resourceIdentifierRegister, v$textViewRegister}, " + + "$EXTENSION_CLASS_DESCRIPTOR->setOldUILayoutDislikes(ILandroid/widget/TextView;)V", + ) + } + + // endregion + + // region Hook rolling numbers. + + // Do this last to allow patching old unsupported versions (if the user really wants), + // On older unsupported version this will fail to match and throw an exception, + // but everything will still work correctly anyway. + + val dislikesIndex = rollingNumberSetterMatch.patternMatch!!.endIndex + + rollingNumberSetterMatch.mutableMethod.apply { + val insertIndex = 1 + + val charSequenceInstanceRegister = + getInstruction(0).registerA + val charSequenceFieldReference = + getInstruction(dislikesIndex).reference + + 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_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. + val patternMatch = rollingNumberMeasureAnimatedTextMatch.patternMatch!! + // Additional check to verify the opcodes are at the start of the method + if (patternMatch.startIndex != 0) throw PatchException("Unexpected opcode location") + val endIndex = patternMatch.endIndex + rollingNumberMeasureAnimatedTextMatch.mutableMethod.apply { + val measuredTextWidthRegister = getInstruction(endIndex).registerA + + addInstructions( + endIndex + 1, + """ + invoke-static {p1, v$measuredTextWidthRegister}, $EXTENSION_CLASS_DESCRIPTOR->onRollingNumberMeasured(Ljava/lang/String;F)F + move-result v$measuredTextWidthRegister + """, + ) + } + + // Additional text measurement method. Used if YouTube decides not to animate the likes count + // and sometimes used for initial video load. + rollingNumberMeasureStaticLabelFingerprint.applyMatch( + context, + rollingNumberMeasureStaticLabelParentMatch, + ).let { + val measureTextIndex = it.patternMatch!!.startIndex + 1 + it.mutableMethod.apply { + val freeRegister = getInstruction(0).registerA + + addInstructions( + measureTextIndex + 1, + """ + move-result v$freeRegister + invoke-static {p1, v$freeRegister}, $EXTENSION_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. + // Initial TextView is set in this method. + val initiallyCreatedTextViewMethod = rollingNumberTextViewMatch.mutableMethod + + // Videos 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. + arrayOf( + initiallyCreatedTextViewMethod, + rollingNumberTextViewAnimationUpdateMatch.mutableMethod, + ).forEach { insertMethod -> + insertMethod.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_CLASS_DESCRIPTOR->updateRollingNumber(Landroid/widget/TextView;Ljava/lang/CharSequence;)Ljava/lang/CharSequence; + move-result-object v$textSpanRegister + """, + ) + } + } + + // endregion + } +} + +enum class Vote(val value: Int) { + LIKE(1), + DISLIKE(-1), + REMOVE_LIKE(0), +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/searchbar/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/searchbar/Fingerprints.kt new file mode 100644 index 000000000..69fc26dae --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/searchbar/Fingerprints.kt @@ -0,0 +1,31 @@ +package app.revanced.patches.youtube.layout.searchbar + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val createSearchSuggestionsFingerprint = fingerprint { + opcodes( + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT, + Opcode.CONST_4, + ) + strings("ss_rds") +} + +internal val setWordmarkHeaderFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("Landroid/widget/ImageView;") + opcodes( + Opcode.IGET_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT, + Opcode.IF_NEZ, + Opcode.IGET_BOOLEAN, + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, + Opcode.CONST, + null, // invoke-static or invoke-virtual. + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/searchbar/WideSearchbarPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/searchbar/WideSearchbarPatch.kt new file mode 100644 index 000000000..7f39f9bb5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/searchbar/WideSearchbarPatch.kt @@ -0,0 +1,84 @@ +package app.revanced.patches.youtube.layout.searchbar + +import app.revanced.patcher.Match +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.BytecodePatchContext +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/WideSearchbarPatch;" + +@Suppress("unused") +val wideSearchbarPatch = bytecodePatch( + name = "Wide searchbar", + description = "Adds an option to replace the search icon with a wide search bar. This will hide the YouTube logo when active.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val setWordmarkHeaderMatch by setWordmarkHeaderFingerprint() + val createSearchSuggestionsMatch by createSearchSuggestionsFingerprint() + + execute { context -> + addResources("youtube", "layout.searchbar.wideSearchbarPatch") + + PreferenceScreen.FEED.addPreferences( + SwitchPreference("revanced_wide_searchbar"), + ) + + /** + * Navigate a fingerprints method at a given index mutably. + * + * @param index The index to navigate to. + * @param fromMatch The fingerprint match to navigate the method on. + * @return The [MutableMethod] which was navigated on. + */ + fun BytecodePatchContext.walkMutable(index: Int, fromMatch: Match) = + navigate(fromMatch.method).at(index).mutable() + + /** + * Injects instructions required for certain methods. + */ + fun MutableMethod.injectSearchBarHook() { + val insertIndex = implementation!!.instructions.size - 1 + val insertRegister = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, + """ + invoke-static {v$insertRegister}, $EXTENSION_CLASS_DESCRIPTOR->enableWideSearchbar(Z)Z + move-result v$insertRegister + """, + ) + } + + mapOf( + setWordmarkHeaderMatch to 1, + createSearchSuggestionsMatch to createSearchSuggestionsMatch.patternMatch!!.startIndex, + ).forEach { (fingerprint, callIndex) -> + context.walkMutable(callIndex, fingerprint).injectSearchBarHook() + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/seekbar/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/seekbar/Fingerprints.kt new file mode 100644 index 000000000..35fafcfe1 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/seekbar/Fingerprints.kt @@ -0,0 +1,50 @@ +package app.revanced.patches.youtube.layout.seekbar + +import app.revanced.patcher.fingerprint +import app.revanced.util.containsWideLiteralInstructionValue +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val fullscreenSeekbarThumbnailsFingerprint = fingerprint { + returns("Z") + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + parameters() + literal { 45398577 } +} + +internal val playerSeekbarColorFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + custom { method, _ -> + method.containsWideLiteralInstructionValue(inlineTimeBarColorizedBarPlayedColorDarkId) && + method.containsWideLiteralInstructionValue(inlineTimeBarPlayedNotHighlightedColorId) + } +} + +internal val setSeekbarClickedColorFingerprint = fingerprint { + opcodes(Opcode.CONST_HIGH16) + strings("YOUTUBE", "PREROLL", "POSTROLL") + custom { _, classDef -> + classDef.endsWith("ControlsOverlayStyle;") + } +} + +internal val shortsSeekbarColorFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + literal { reelTimeBarPlayedColorId } +} + +const val PLAYER_SEEKBAR_GRADIENT_FEATURE_FLAG = 45617850L + +internal val playerSeekbarGradientConfigFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Z") + parameters() + literal { PLAYER_SEEKBAR_GRADIENT_FEATURE_FLAG } +} + +internal val lithoLinearGradientFingerprint = fingerprint { + accessFlags(AccessFlags.STATIC) + returns("Landroid/graphics/LinearGradient;") + parameters("F", "F", "F", "F", "[I", "[F") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/seekbar/RestoreOldSeekbarThumbnailsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/seekbar/RestoreOldSeekbarThumbnailsPatch.kt new file mode 100644 index 000000000..a21927289 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/seekbar/RestoreOldSeekbarThumbnailsPatch.kt @@ -0,0 +1,61 @@ +package app.revanced.patches.youtube.layout.seekbar + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.instructions +import app.revanced.patcher.patch.PatchException +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.playservice.is_19_17_or_greater +import app.revanced.patches.youtube.misc.playservice.versionCheckPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/RestoreOldSeekbarThumbnailsPatch;" + +@Suppress("unused") +val restoreOldSeekbarThumbnailsPatch = bytecodePatch( + name = "Restore old seekbar thumbnails", + description = "Adds an option to restore the old seekbar thumbnails that appear above the seekbar while seeking instead of fullscreen thumbnails.", +) { + dependsOn( + sharedExtensionPatch, + addResourcesPatch, + versionCheckPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + // 19.17+ is not supported. + ), + ) + + val fullscreenSeekbarThumbnailsMatch by fullscreenSeekbarThumbnailsFingerprint() + + execute { + if (is_19_17_or_greater) { + // Give a more informative error, if the user has turned off version checks. + throw PatchException("'Restore old seekbar thumbnails' cannot be patched to any version after 19.16.39") + } + + addResources("youtube", "layout.seekbar.restoreOldSeekbarThumbnailsPatch") + + PreferenceScreen.SEEKBAR.addPreferences( + SwitchPreference("revanced_restore_old_seekbar_thumbnails"), + ) + + fullscreenSeekbarThumbnailsMatch.mutableMethod.apply { + val moveResultIndex = instructions.lastIndex - 1 + + addInstruction( + moveResultIndex, + "invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->useFullscreenSeekbarThumbnails()Z", + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/seekbar/SeekbarColorPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/seekbar/SeekbarColorPatch.kt new file mode 100644 index 000000000..8b114bf3c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/seekbar/SeekbarColorPatch.kt @@ -0,0 +1,151 @@ +package app.revanced.patches.youtube.layout.seekbar + +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.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings +import app.revanced.patches.youtube.layout.theme.lithoColorHookPatch +import app.revanced.patches.youtube.layout.theme.lithoColorOverrideHook +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.playservice.is_19_23_or_greater +import app.revanced.patches.youtube.misc.playservice.versionCheckPatch +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstWideLiteralInstructionValueOrThrow +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.sun.org.apache.bcel.internal.generic.InstructionConst.getInstruction +import org.w3c.dom.Element + +internal var reelTimeBarPlayedColorId = -1L + private set +internal var inlineTimeBarColorizedBarPlayedColorDarkId = -1L + private set +internal var inlineTimeBarPlayedNotHighlightedColorId = -1L + private set + +private val seekbarColorResourcePatch = resourcePatch { + dependsOn( + settingsPatch, + resourceMappingPatch, + versionCheckPatch, + ) + + execute { context -> + reelTimeBarPlayedColorId = resourceMappings[ + "color", + "reel_time_bar_played_color", + ] + inlineTimeBarColorizedBarPlayedColorDarkId = resourceMappings[ + "color", + "inline_time_bar_colorized_bar_played_color_dark", + ] + inlineTimeBarPlayedNotHighlightedColorId = resourceMappings[ + "color", + "inline_time_bar_played_not_highlighted_color", + ] + + // Edit the resume playback drawable and replace the progress bar with a custom drawable + context.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.theme.ProgressBarDrawable", + ) + scaleNode.replaceChild(replacementNode, shapeNode) + } + } +} + +private const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/youtube/patches/theme/SeekbarColorPatch;" + +val seekbarColorPatch = bytecodePatch( + description = "Hide or set a custom seekbar color", +) { + dependsOn( + sharedExtensionPatch, + lithoColorHookPatch, + seekbarColorResourcePatch, + ) + + val playerSeekbarColorMatch by playerSeekbarColorFingerprint() + val shortsSeekbarColorMatch by shortsSeekbarColorFingerprint() + val setSeekbarClickedColorMatch by setSeekbarClickedColorFingerprint() + val playerSeekbarGradientConfigMatch by playerSeekbarGradientConfigFingerprint() + val lithoLinearGradientMatch by lithoLinearGradientFingerprint() + + execute { context -> + fun MutableMethod.addColorChangeInstructions(resourceId: Long) { + val registerIndex = indexOfFirstWideLiteralInstructionValueOrThrow(resourceId) + 2 + val colorRegister = getInstruction(registerIndex).registerA + addInstructions( + registerIndex + 1, + """ + invoke-static { v$colorRegister }, $EXTENSION_CLASS_DESCRIPTOR->getVideoPlayerSeekbarColor(I)I + move-result v$colorRegister + """, + ) + } + + playerSeekbarColorMatch.mutableMethod.apply { + addColorChangeInstructions(inlineTimeBarColorizedBarPlayedColorDarkId) + addColorChangeInstructions(inlineTimeBarPlayedNotHighlightedColorId) + } + + shortsSeekbarColorMatch.mutableMethod.apply { + addColorChangeInstructions(reelTimeBarPlayedColorId) + } + + setSeekbarClickedColorMatch.mutableMethod.let { + val setColorMethodIndex = setSeekbarClickedColorMatch.patternMatch!!.startIndex + 1 + val method = context.navigate(it).at(setColorMethodIndex).mutable() + + method.apply { + val colorRegister = getInstruction(0).registerA + addInstructions( + 0, + """ + invoke-static { v$colorRegister }, $EXTENSION_CLASS_DESCRIPTOR->getVideoPlayerSeekbarClickedColor(I)I + move-result v$colorRegister + """, + ) + } + } + + if (is_19_23_or_greater) { + playerSeekbarGradientConfigMatch.mutableMethod.apply { + val literalIndex = indexOfFirstWideLiteralInstructionValueOrThrow(PLAYER_SEEKBAR_GRADIENT_FEATURE_FLAG) + val resultIndex = indexOfFirstInstructionOrThrow(literalIndex, Opcode.MOVE_RESULT) + val register = getInstruction(resultIndex).registerA + + addInstructions( + resultIndex + 1, + """ + invoke-static { v$register }, $EXTENSION_CLASS_DESCRIPTOR->playerSeekbarGradientEnabled(Z)Z + move-result v$register + """, + ) + } + + lithoLinearGradientMatch.mutableMethod.addInstruction( + 0, + "invoke-static/range { p4 .. p5 }, $EXTENSION_CLASS_DESCRIPTOR->setLinearGradient([I[F)V", + ) + } + + lithoColorOverrideHook(EXTENSION_CLASS_DESCRIPTOR, "getLithoColor") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/shortsautoplay/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/shortsautoplay/Fingerprints.kt new file mode 100644 index 000000000..cd48868f5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/shortsautoplay/Fingerprints.kt @@ -0,0 +1,22 @@ +package app.revanced.patches.youtube.layout.shortsautoplay + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val reelEnumConstructorFingerprint = fingerprint { + accessFlags(AccessFlags.STATIC, AccessFlags.CONSTRUCTOR) + opcodes(Opcode.RETURN_VOID) + strings( + "REEL_LOOP_BEHAVIOR_UNKNOWN", + "REEL_LOOP_BEHAVIOR_SINGLE_PLAY", + "REEL_LOOP_BEHAVIOR_REPEAT", + "REEL_LOOP_BEHAVIOR_END_SCREEN", + ) +} + +internal val reelPlaybackRepeatFingerprint = fingerprint { + returns("V") + parameters("L") + strings("YoutubePlayerState is in throwing an Error.") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/shortsautoplay/ShortsAutoplayPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/shortsautoplay/ShortsAutoplayPatch.kt new file mode 100644 index 000000000..d9953de47 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/shortsautoplay/ShortsAutoplayPatch.kt @@ -0,0 +1,103 @@ +package app.revanced.patches.youtube.layout.shortsautoplay + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.playservice.is_19_34_or_greater +import app.revanced.patches.youtube.misc.playservice.versionCheckPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.patches.youtube.shared.mainActivityOnCreateFingerprint +import app.revanced.util.findOpcodeIndicesReversed +import app.revanced.util.getReference +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +private const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/youtube/patches/ShortsAutoplayPatch;" + +@Suppress("unused") +val shortsAutoplayPatch = bytecodePatch( + name = "Shorts autoplay", + description = "Adds options to automatically play the next Short.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + resourceMappingPatch, + versionCheckPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val mainActivityOnCreateMatch by mainActivityOnCreateFingerprint() + val reelEnumConstructorMatch by reelEnumConstructorFingerprint() + val reelPlaybackRepeatMatch by reelPlaybackRepeatFingerprint() + + execute { + addResources("youtube", "layout.shortsautoplay.shortsAutoplayPatch") + + PreferenceScreen.SHORTS.addPreferences( + SwitchPreference("revanced_shorts_autoplay"), + ) + + if (is_19_34_or_greater) { + PreferenceScreen.SHORTS.addPreferences( + SwitchPreference("revanced_shorts_autoplay_background"), + ) + } + + // Main activity is used to check if app is in pip mode. + mainActivityOnCreateMatch.mutableMethod.addInstructions( + 0, + "invoke-static/range { p0 .. p0 }, $EXTENSION_CLASS_DESCRIPTOR->" + + "setMainActivity(Landroid/app/Activity;)V", + ) + + val reelEnumClass = reelEnumConstructorMatch.classDef.type + + reelEnumConstructorMatch.mutableMethod.apply { + val insertIndex = reelEnumConstructorMatch.patternMatch!!.startIndex + + addInstructions( + insertIndex, + """ + # Pass the first enum value to extension. + # Any enum value of this type will work. + sget-object v0, $reelEnumClass->a:$reelEnumClass + invoke-static { v0 }, $EXTENSION_CLASS_DESCRIPTOR->setYTShortsRepeatEnum(Ljava/lang/Enum;)V + """, + ) + } + + reelPlaybackRepeatMatch.mutableMethod.apply { + // The behavior enums are looked up from an ordinal value to an enum type. + findOpcodeIndicesReversed { + val reference = getReference() + reference?.definingClass == reelEnumClass && + reference.parameterTypes.firstOrNull() == "I" && + reference.returnType == reelEnumClass + }.forEach { index -> + val register = getInstruction(index + 1).registerA + + addInstructions( + index + 2, + """ + invoke-static {v$register}, $EXTENSION_CLASS_DESCRIPTOR->changeShortsRepeatBehavior(Ljava/lang/Enum;)Ljava/lang/Enum; + move-result-object v$register + """, + ) + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/sponsorblock/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/sponsorblock/Fingerprints.kt new file mode 100644 index 000000000..612ceb6c8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/sponsorblock/Fingerprints.kt @@ -0,0 +1,64 @@ +package app.revanced.patches.youtube.layout.sponsorblock + +import app.revanced.patcher.fingerprint +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 appendTimeFingerprint = fingerprint { + returns("V") + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + parameters("Ljava/lang/CharSequence;", "Ljava/lang/CharSequence;", "Ljava/lang/CharSequence;") + opcodes( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IGET_OBJECT, + Opcode.IGET_OBJECT, + Opcode.CHECK_CAST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT, + Opcode.IF_NEZ, + Opcode.IGET_OBJECT, + Opcode.IGET_OBJECT, + Opcode.CHECK_CAST, + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN_VOID, + ) +} + +internal val controlsOverlayFingerprint = fingerprint { + returns("V") + accessFlags(AccessFlags.PRIVATE, AccessFlags.FINAL) + parameters() + opcodes( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, // R.id.inset_overlay_view_layout + Opcode.IPUT_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + Opcode.NEW_INSTANCE, + ) +} + +internal val rectangleFieldInvalidatorFingerprint = fingerprint { + returns("V") + custom { method, _ -> + val instructions = method.implementation?.instructions!! + val instructionCount = instructions.count() + + // the method has definitely more than 5 instructions + if (instructionCount < 5) return@custom false + + val referenceInstruction = instructions.elementAt(instructionCount - 2) // the second to last instruction + val reference = ((referenceInstruction as? ReferenceInstruction)?.reference as? MethodReference) + + reference?.parameterTypes?.size == 1 && reference.name == "invalidate" // the reference is the invalidate(..) method + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/sponsorblock/SponsorBlockPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/sponsorblock/SponsorBlockPatch.kt new file mode 100644 index 000000000..644866da8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/sponsorblock/SponsorBlockPatch.kt @@ -0,0 +1,258 @@ +package app.revanced.patches.youtube.layout.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.instructions +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.util.proxy.mutableTypes.MutableMethod +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.settings.preference.IntentPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.playercontrols.* +import app.revanced.patches.youtube.misc.playercontrols.addTopControl +import app.revanced.patches.youtube.misc.playertype.playerTypeHookPatch +import app.revanced.patches.youtube.misc.settings.addSettingPreference +import app.revanced.patches.youtube.misc.settings.newIntent +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.patches.youtube.shared.* +import app.revanced.patches.youtube.video.information.onCreateHook +import app.revanced.patches.youtube.video.information.videoInformationPatch +import app.revanced.patches.youtube.video.information.videoTimeHook +import app.revanced.patches.youtube.video.videoid.hookBackgroundPlayVideoId +import app.revanced.patches.youtube.video.videoid.videoIdPatch +import app.revanced.util.* +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.* +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.iface.reference.StringReference +import com.sun.org.apache.bcel.internal.generic.InstructionConst.getInstruction + +private val sponsorBlockResourcePatch = resourcePatch { + dependsOn( + settingsPatch, + resourceMappingPatch, + addResourcesPatch, + playerControlsPatch, + ) + + execute { context -> + addResources("youtube", "layout.sponsorblock.sponsorBlockResourcePatch") + + addSettingPreference( + IntentPreference( + key = "revanced_settings_screen_10", + titleKey = "revanced_sb_settings_title", + summaryKey = null, + intent = newIntent("revanced_sb_settings_intent"), + ), + ) + + arrayOf( + ResourceGroup( + "layout", + "revanced_sb_inline_sponsor_overlay.xml", + "revanced_sb_new_segment.xml", + "revanced_sb_skip_sponsor_button.xml", + ), + ResourceGroup( + // required resource for back button, because when the base APK is used, this resource will not exist + "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", + ), + ResourceGroup( + // required resource for back button, because when the base APK is used, this resource will not exist + "drawable-xxxhdpi", + "quantum_ic_skip_next_white_24.png", + ), + ).forEach { resourceGroup -> + context.copyResources("sponsorblock", resourceGroup) + } + + addTopControl("sponsorblock") + } +} + +private const val EXTENSION_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/sponsorblock/SegmentPlaybackController;" +private const val EXTENSION_CREATE_SEGMENT_BUTTON_CONTROLLER_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/sponsorblock/ui/CreateSegmentButtonController;" +private const val EXTENSION_VOTING_BUTTON_CONTROLLER_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/sponsorblock/ui/VotingButtonController;" +private const val EXTENSION_SPONSORBLOCK_VIEW_CONTROLLER_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/sponsorblock/ui/SponsorBlockViewController;" + +@Suppress("unused") +val sponsorBlockPatch = bytecodePatch( + name = "SponsorBlock", + description = "Adds options to enable and configure SponsorBlock, which can skip undesired video segments such as sponsored content.", +) { + dependsOn( + sharedExtensionPatch, + videoIdPatch, + // Required to skip segments on time. + videoInformationPatch, + // Used to prevent SponsorBlock from running on Shorts because SponsorBlock does not yet support Shorts. + playerTypeHookPatch, + playerControlsPatch, + sponsorBlockResourcePatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val seekbarMatch by seekbarFingerprint() + val appendTimeMatch by appendTimeFingerprint() + val layoutConstructorMatch by layoutConstructorFingerprint() + val autoRepeatParentMatch by autoRepeatParentFingerprint() + + execute { context -> + // Hook the video time methods. + videoTimeHook( + EXTENSION_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR, + "setVideoTime", + ) + + hookBackgroundPlayVideoId( + EXTENSION_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR + + "->setCurrentVideoId(Ljava/lang/String;)V", + ) + + // Seekbar drawing + seekbarOnDrawFingerprint.applyMatch(context, seekbarMatch).mutableMethod.apply { + // Get left and right of seekbar rectangle. + val moveRectangleToRegisterIndex = indexOfFirstInstructionOrThrow(Opcode.MOVE_OBJECT_FROM16) + + addInstruction( + moveRectangleToRegisterIndex + 1, + "invoke-static/range { p0 .. p0 }, " + + "$EXTENSION_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR->setSponsorBarRect(Ljava/lang/Object;)V", + ) + + // Set the thickness of the segment. + val thicknessIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_STATIC && getReference()?.name == "round" + } + val thicknessRegister = getInstruction(thicknessIndex).registerC + addInstruction( + thicknessIndex + 2, + "invoke-static { v$thicknessRegister }, " + + "$EXTENSION_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR->setSponsorBarThickness(I)V", + ) + + // Find the drawCircle call and draw the segment before it. + val drawCircleIndex = indexOfFirstInstructionReversedOrThrow { + getReference()?.name == "drawCircle" + } + val drawCircleInstruction = getInstruction(drawCircleIndex) + val canvasInstanceRegister = drawCircleInstruction.registerC + val centerYRegister = drawCircleInstruction.registerE + + addInstruction( + drawCircleIndex, + "invoke-static { v$canvasInstanceRegister, v$centerYRegister }, " + + "$EXTENSION_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR->" + + "drawSponsorTimeBars(Landroid/graphics/Canvas;F)V", + ) + } + + // Change visibility of the buttons. + initializeTopControl(EXTENSION_CREATE_SEGMENT_BUTTON_CONTROLLER_CLASS_DESCRIPTOR) + injectVisibilityCheckCall(EXTENSION_CREATE_SEGMENT_BUTTON_CONTROLLER_CLASS_DESCRIPTOR) + + initializeTopControl(EXTENSION_VOTING_BUTTON_CONTROLLER_CLASS_DESCRIPTOR) + injectVisibilityCheckCall(EXTENSION_VOTING_BUTTON_CONTROLLER_CLASS_DESCRIPTOR) + + // Append the new time to the player layout. + val appendTimePatternScanStartIndex = appendTimeMatch.patternMatch!!.startIndex + appendTimeMatch.mutableMethod.apply { + val register = getInstruction(appendTimePatternScanStartIndex + 1).registerA + + addInstructions( + appendTimePatternScanStartIndex + 2, + """ + invoke-static { v$register }, $EXTENSION_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR->appendTimeWithoutSegments(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$register + """, + ) + } + + // Initialize the player controller. + onCreateHook(EXTENSION_SEGMENT_PLAYBACK_CONTROLLER_CLASS_DESCRIPTOR, "initialize") + + // Initialize the SponsorBlock view. + controlsOverlayFingerprint.applyMatch(context, layoutConstructorMatch).let { + val startIndex = it.patternMatch!!.startIndex + it.mutableMethod.apply { + val frameLayoutRegister = (getInstruction(startIndex + 2) as OneRegisterInstruction).registerA + addInstruction( + startIndex + 3, + "invoke-static {v$frameLayoutRegister}, $EXTENSION_SPONSORBLOCK_VIEW_CONTROLLER_CLASS_DESCRIPTOR->initialize(Landroid/view/ViewGroup;)V", + ) + } + } + + // Set seekbar draw rectangle. + rectangleFieldInvalidatorFingerprint.applyMatch(context, seekbarOnDrawFingerprint.match!!).mutableMethod.apply { + val fieldIndex = instructions.count() - 3 + val fieldReference = getInstruction(fieldIndex).reference as FieldReference + + // replace the "replaceMeWith*" strings + context + .proxy(context.classes.first { it.type.endsWith("SegmentPlaybackController;") }) + .mutableClass + .methods + .find { it.name == "setSponsorBarRect" } + ?.let { method -> + fun MutableMethod.replaceStringInstruction(index: Int, instruction: Instruction, with: String) { + val register = (instruction as OneRegisterInstruction).registerA + this.replaceInstruction( + index, + "const-string v$register, \"$with\"", + ) + } + for ((index, it) in method.instructions.withIndex()) { + if (it.opcode.ordinal != Opcode.CONST_STRING.ordinal) continue + + when (((it as ReferenceInstruction).reference as StringReference).string) { + "replaceMeWithsetSponsorBarRect" -> method.replaceStringInstruction( + index, + it, + fieldReference.name, + ) + } + } + } ?: throw PatchException("Could not find the method which contains the replaceMeWith* strings") + } + + // 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. + autoRepeatFingerprint.applyMatch(context, autoRepeatParentMatch).mutableMethod.addInstruction( + 0, + "invoke-static {}, $EXTENSION_SPONSORBLOCK_VIEW_CONTROLLER_CLASS_DESCRIPTOR->endOfVideoReached()V", + ) + + // TODO: Channel whitelisting. + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/spoofappversion/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/spoofappversion/Fingerprints.kt new file mode 100644 index 000000000..969d90e02 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/spoofappversion/Fingerprints.kt @@ -0,0 +1,20 @@ +package app.revanced.patches.youtube.layout.spoofappversion + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val spoofAppVersionFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("L") + parameters("L") + opcodes( + Opcode.IGET_OBJECT, + Opcode.GOTO, + Opcode.CONST_STRING, + ) + // Instead of applying a bytecode patch, it might be possible to only rely on code from the extension and + // manually set the desired version string as this keyed value in the SharedPreferences. + // But, this bytecode patch is simple and it works. + strings("pref_override_build_version_name") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/spoofappversion/SpoofAppVersionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/spoofappversion/SpoofAppVersionPatch.kt new file mode 100644 index 000000000..3ec97ea95 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/spoofappversion/SpoofAppVersionPatch.kt @@ -0,0 +1,65 @@ +package app.revanced.patches.youtube.layout.spoofappversion + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.ListPreference +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/spoof/SpoofAppVersionPatch;" + +@Suppress("unused") +val spoofAppVersionPatch = bytecodePatch( + name = "Spoof app version", + description = "Adds an option to trick YouTube into thinking you are running an older version of the app. " + + "This can be used to restore old UI elements and features.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val spoofAppVersionMatch by spoofAppVersionFingerprint() + + execute { + addResources("youtube", "layout.spoofappversion.spoofAppVersionPatch") + + PreferenceScreen.GENERAL_LAYOUT.addPreferences( + SwitchPreference("revanced_spoof_app_version"), + ListPreference( + key = "revanced_spoof_app_version_target", + summaryKey = null, + ), + ) + + val insertIndex = spoofAppVersionMatch.patternMatch!!.startIndex + 1 + val buildOverrideNameRegister = + spoofAppVersionMatch.mutableMethod.getInstruction(insertIndex - 1).registerA + + spoofAppVersionMatch.mutableMethod.addInstructions( + insertIndex, + """ + invoke-static {v$buildOverrideNameRegister}, $EXTENSION_CLASS_DESCRIPTOR->getYouTubeVersionOverride(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$buildOverrideNameRegister + """, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/startpage/ChangeStartPagePatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/startpage/ChangeStartPagePatch.kt new file mode 100644 index 000000000..5c62ff3b2 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/startpage/ChangeStartPagePatch.kt @@ -0,0 +1,77 @@ +package app.revanced.patches.youtube.layout.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.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.ListPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.StringReference + +private const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/youtube/patches/ChangeStartPagePatch;" + +@Suppress("unused") +val changeStartPagePatch = bytecodePatch( + name = "Change start page", + description = "Adds an option to set which page the app opens in instead of the homepage.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val browseIdMatch by browseIdFingerprint() + val intentActionMatch by intentActionFingerprint() + + execute { _ -> + addResources("youtube", "layout.startpage.changeStartPagePatch") + + PreferenceScreen.GENERAL_LAYOUT.addPreferences( + ListPreference( + key = "revanced_change_start_page", + summaryKey = null, + ), + ) + + // Hook browseId. + browseIdMatch.mutableMethod.apply { + val browseIdIndex = indexOfFirstInstructionOrThrow { + 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 browserId assigned to Shorts and Search. + // Just hook the Intent action. + intentActionMatch.mutableMethod.addInstruction( + 0, + "invoke-static { p1 }, $EXTENSION_CLASS_DESCRIPTOR->overrideIntentAction(Landroid/content/Intent;)V", + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/startpage/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/startpage/Fingerprints.kt new file mode 100644 index 000000000..022084020 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/startpage/Fingerprints.kt @@ -0,0 +1,20 @@ +package app.revanced.patches.youtube.layout.startpage + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.Opcode + +internal val intentActionFingerprint = fingerprint { + parameters("Landroid/content/Intent;") + strings("has_handled_intent") +} + +internal val browseIdFingerprint = fingerprint { + returns("Lcom/google/android/apps/youtube/app/common/ui/navigation/PaneDescriptor;") + parameters() + opcodes( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.RETURN_OBJECT, + ) + strings("FEwhat_to_watch") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/startupshortsreset/DisableResumingShortsOnStartupPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/startupshortsreset/DisableResumingShortsOnStartupPatch.kt new file mode 100644 index 000000000..9f88c08b0 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/startupshortsreset/DisableResumingShortsOnStartupPatch.kt @@ -0,0 +1,99 @@ +package app.revanced.patches.youtube.layout.startupshortsreset + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.util.addInstructionsAtControlFlowLabel +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 const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/DisableResumingStartupShortsPlayerPatch;" + +@Suppress("unused") +val disableResumingShortsOnStartupPatch = bytecodePatch( + 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.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val userWasInShortsConfigMatch by userWasInShortsConfigFingerprint() + val userWasInShortsMatch by userWasInShortsFingerprint() + + execute { context -> + addResources("youtube", "layout.startupshortsreset.disableResumingShortsOnStartupPatch") + + PreferenceScreen.SHORTS.addPreferences( + SwitchPreference("revanced_disable_resuming_shorts_player"), + ) + + userWasInShortsConfigMatch.mutableMethod.apply { + val startIndex = indexOfOptionalInstruction(this) + val walkerIndex = indexOfFirstInstructionOrThrow(startIndex) { + val reference = getReference() + opcode == Opcode.INVOKE_VIRTUAL && + reference?.returnType == "Z" && + reference.definingClass != "Lj${'$'}/util/Optional;" && + reference.parameterTypes.size == 0 + } + + // Presumably a method that processes the ProtoDataStore value (boolean) for the 'user_was_in_shorts' key. + context.navigate(this).at(walkerIndex).mutable().addInstructionsWithLabels( + 0, + """ + invoke-static {}, $EXTENSION_CLASS_DESCRIPTOR->disableResumingStartupShortsPlayer()Z + move-result v0 + if-eqz v0, :show + const/4 v0, 0x0 + return v0 + :show + nop + """, + ) + } + + userWasInShortsMatch.mutableMethod.apply { + val listenableInstructionIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_INTERFACE && + getReference()?.definingClass == "Lcom/google/common/util/concurrent/ListenableFuture;" && + getReference()?.name == "isDone" + } + val freeRegister = getInstruction(listenableInstructionIndex + 1).registerA + + addInstructionsAtControlFlowLabel( + listenableInstructionIndex, + """ + invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->disableResumingStartupShortsPlayer()Z + move-result v$freeRegister + if-eqz v$freeRegister, :show_startup_shorts_player + return-void + :show_startup_shorts_player + nop + """, + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/startupshortsreset/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/startupshortsreset/Fingerprints.kt new file mode 100644 index 000000000..326ebbe14 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/startupshortsreset/Fingerprints.kt @@ -0,0 +1,41 @@ +package app.revanced.patches.youtube.layout.startupshortsreset + +import app.revanced.patcher.fingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.immutable.reference.ImmutableMethodReference +import com.android.tools.smali.dexlib2.util.MethodUtil + +internal val userWasInShortsFingerprint = fingerprint { + returns("V") + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + parameters("Ljava/lang/Object;") + strings("Failed to read user_was_in_shorts proto after successful warmup") +} + +/** + * 18.15.40+ + */ +internal val userWasInShortsConfigFingerprint = fingerprint { + returns("V") + strings("Failed to get offline response: ") + custom { method, _ -> + indexOfOptionalInstruction(method) >= 0 + } +} + +private val optionalOfMethodReference = ImmutableMethodReference( + "Lj${'$'}/util/Optional;", + "of", + listOf("Ljava/lang/Object;"), + "Lj${'$'}/util/Optional;", +) + +fun indexOfOptionalInstruction(method: Method) = method.indexOfFirstInstruction { + val reference = getReference() ?: return@indexOfFirstInstruction false + + MethodUtil.methodSignaturesMatch(reference, optionalOfMethodReference) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/tablet/EnableTabletLayoutPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/tablet/EnableTabletLayoutPatch.kt new file mode 100644 index 000000000..7b07f5ecf --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/tablet/EnableTabletLayoutPatch.kt @@ -0,0 +1,65 @@ +package app.revanced.patches.youtube.layout.tablet + +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.bytecodePatch +import app.revanced.patcher.util.smali.ExternalLabel +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch + +const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/youtube/patches/TabletLayoutPatch;" + +@Suppress("unused") +val enableTabletLayoutPatch = bytecodePatch( + name = "Enable tablet layout", + description = "Adds an option to enable tablet layout.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val getFormFactorMatch by getFormFactorFingerprint() + + execute { + addResources("youtube", "layout.tablet.enableTabletLayoutPatch") + + PreferenceScreen.GENERAL_LAYOUT.addPreferences( + SwitchPreference("revanced_tablet_layout"), + ) + + getFormFactorMatch.mutableMethod.apply { + val returnIsLargeFormFactorIndex = instructions.lastIndex - 4 + val returnIsLargeFormFactorLabel = getInstruction(returnIsLargeFormFactorIndex) + + addInstructionsWithLabels( + 0, + """ + invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->getTabletLayoutEnabled()Z + move-result v0 + if-nez v0, :is_large_form_factor + """, + ExternalLabel( + "is_large_form_factor", + returnIsLargeFormFactorLabel, + ), + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/tablet/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/tablet/Fingerprints.kt new file mode 100644 index 000000000..30667a85b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/tablet/Fingerprints.kt @@ -0,0 +1,25 @@ +package app.revanced.patches.youtube.layout.tablet + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val getFormFactorFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("L") + parameters("Landroid/content/Context;", "Ljava/util/List;") + opcodes( + Opcode.SGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_EQZ, + Opcode.SGET_OBJECT, + Opcode.RETURN_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.RETURN_OBJECT, + ) + strings("") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/Fingerprints.kt new file mode 100644 index 000000000..9ed098a1b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/Fingerprints.kt @@ -0,0 +1,56 @@ +package app.revanced.patches.youtube.layout.theme + +import app.revanced.patcher.fingerprint +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val lithoThemeFingerprint = fingerprint { + accessFlags(AccessFlags.PROTECTED, AccessFlags.FINAL) + returns("V") + parameters("Landroid/graphics/Rect;") + opcodes( + Opcode.APUT, + Opcode.NEW_INSTANCE, + Opcode.INVOKE_DIRECT, + Opcode.IGET_OBJECT, + Opcode.SGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.IPUT_OBJECT, + Opcode.IGET, + Opcode.IF_EQZ, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_NEZ, + Opcode.IGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.RETURN_VOID, + ) + custom { method, _ -> + method.name == "onBoundsChange" + } +} + +internal val themeHelperDarkColorFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.STATIC) + returns("Ljava/lang/String;") + parameters() + custom { method, _ -> + method.name == "darkThemeResourceName" && + method.definingClass == "Lapp/revanced/extension/youtube/ThemeHelper;" + } +} + +internal val themeHelperLightColorFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.STATIC) + returns("Ljava/lang/String;") + parameters() + custom { method, _ -> + method.name == "lightThemeResourceName" && + method.definingClass == "Lapp/revanced/extension/youtube/ThemeHelper;" + } +} + +internal val useGradientLoadingScreenFingerprint = fingerprint { + literal { GRADIENT_LOADING_SCREEN_AB_CONSTANT } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/LithoColorHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/LithoColorHookPatch.kt new file mode 100644 index 000000000..5fa87d900 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/LithoColorHookPatch.kt @@ -0,0 +1,28 @@ +package app.revanced.patches.youtube.layout.theme + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch + +lateinit var lithoColorOverrideHook: (targetMethodClass: String, targetMethodName: String) -> Unit + private set + +val lithoColorHookPatch = bytecodePatch( + description = "Adds a hook to set color of Litho components.", +) { + val lithoThemeMatch by lithoThemeFingerprint() + + execute { + var insertionIndex = lithoThemeMatch.patternMatch!!.endIndex - 1 + + lithoColorOverrideHook = { targetMethodClass, targetMethodName -> + lithoThemeMatch.mutableMethod.addInstructions( + insertionIndex, + """ + invoke-static { p1 }, $targetMethodClass->$targetMethodName(I)I + move-result p1 + """, + ) + insertionIndex += 2 + } + } +} 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 000000000..a99daf720 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/ThemePatch.kt @@ -0,0 +1,245 @@ +package app.revanced.patches.youtube.layout.theme + +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.patch.stringOption +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.settings.preference.InputType +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.shared.misc.settings.preference.TextPreference +import app.revanced.patches.youtube.layout.seekbar.seekbarColorPatch +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.util.forEachChildElement +import app.revanced.util.indexOfFirstWideLiteralInstructionValueOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import org.w3c.dom.Element + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/theme/ThemePatch;" + +internal const val GRADIENT_LOADING_SCREEN_AB_CONSTANT = 45412406L + +@Suppress("unused") +val themePatch = bytecodePatch( + name = "Theme", + description = "Adds options for theming and applies a custom background theme (dark background theme defaults to amoled black).", +) { + val amoledBlackColor = "@android:color/black" + val whiteColor = "@android:color/white" + + val darkThemeBackgroundColor by stringOption( + key = "darkThemeBackgroundColor", + default = amoledBlackColor, + values = mapOf( + "Amoled black" to amoledBlackColor, + "Material You" to "@android:color/system_neutral1_900", + "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", + ), + title = "Dark theme background color", + description = "Can be a hex color (#AARRGGBB) or a color resource reference.", + ) + + val lightThemeBackgroundColor by stringOption( + key = "lightThemeBackgroundColor", + default = whiteColor, + values = mapOf( + "White" to whiteColor, + "Material You" to "@android:color/system_neutral1_50", + "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", + ), + title = "Light theme background color", + description = "Can be a hex color (#AARRGGBB) or a color resource reference.", + ) + + dependsOn( + lithoColorHookPatch, + seekbarColorPatch, + resourcePatch { + dependsOn( + settingsPatch, + resourceMappingPatch, + addResourcesPatch, + ) + + execute { context -> + addResources("youtube", "layout.theme.themeResourcePatch") + + PreferenceScreen.SEEKBAR.addPreferences( + SwitchPreference("revanced_seekbar_custom_color"), + TextPreference("revanced_seekbar_custom_color_value", inputType = InputType.TEXT_CAP_CHARACTERS), + ) + + // Edit theme colors via resources. + context.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_black0", "yt_black1", "yt_black1_opacity95", "yt_black1_opacity98", "yt_black2", "yt_black3", + "yt_black4", "yt_status_bar_background_dark", "material_grey_850", + -> darkThemeBackgroundColor ?: continue + + "yt_white1", "yt_white1_opacity95", "yt_white1_opacity98", + "yt_white2", "yt_white3", "yt_white4", + -> lightThemeBackgroundColor ?: continue + + else -> continue + } + } + } + + fun addColorResource( + resourceFile: String, + colorName: String, + colorValue: String, + ) { + context.document[resourceFile].use { document -> + + val resourcesNode = document.getElementsByTagName("resources").item(0) as Element + + resourcesNode.appendChild( + document.createElement("color").apply { + setAttribute("name", colorName) + setAttribute("category", "color") + textContent = colorValue + }, + ) + } + } + + val splashBackgroundColor = "revanced_splash_background_color" + + // Add a dynamic background color to the colors.xml file. + lightThemeBackgroundColor?.let { + addColorResource("res/values/colors.xml", splashBackgroundColor, it) + } + + darkThemeBackgroundColor?.let { + addColorResource("res/values-night/colors.xml", splashBackgroundColor, it) + } + + // Edit splash screen files and change the background color, + // if the background colors are set. + if (darkThemeBackgroundColor != null && lightThemeBackgroundColor != null) { + val splashScreenResourceFiles = + listOf( + "res/drawable/quantum_launchscreen_youtube.xml", + "res/drawable-sw600dp/quantum_launchscreen_youtube.xml", + ) + + splashScreenResourceFiles.forEach editSplashScreen@{ resourceFile -> + context.document[resourceFile].use { document -> + document.getElementsByTagName("layer-list").item(0).forEachChildElement { node -> + if (node.hasAttribute("android:drawable")) { + node.setAttribute("android:drawable", "@color/$splashBackgroundColor") + return@editSplashScreen + } + } + + throw PatchException("Failed to modify launch screen") + } + } + + // 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. + context.document["res/values-night/styles.xml"].use { document -> + // Create a night mode specific override for the splash screen background. + val style = document.createElement("style") + style.setAttribute("name", "Theme.YouTube.Home") + style.setAttribute("parent", "@style/Base.V23.Theme.YouTube.Home") + + val windowItem = document.createElement("item") + windowItem.setAttribute("name", "android:windowBackground") + windowItem.textContent = "@color/$splashBackgroundColor" + style.appendChild(windowItem) + + val resourcesNode = document.getElementsByTagName("resources").item(0) as Element + resourcesNode.appendChild(style) + } + } + } + }, + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val useGradientLoadingScreenMatch by useGradientLoadingScreenFingerprint() + val themeHelperLightColorMatch by themeHelperLightColorFingerprint() + val themeHelperDarkColorMatch by themeHelperDarkColorFingerprint() + + execute { + addResources("youtube", "layout.theme.themePatch") + + PreferenceScreen.GENERAL_LAYOUT.addPreferences( + SwitchPreference("revanced_gradient_loading_screen"), + ) + + useGradientLoadingScreenMatch.mutableMethod.apply { + + val isEnabledIndex = indexOfFirstWideLiteralInstructionValueOrThrow(GRADIENT_LOADING_SCREEN_AB_CONSTANT) + 3 + val isEnabledRegister = getInstruction(isEnabledIndex - 1).registerA + + addInstructions( + isEnabledIndex, + """ + invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->gradientLoadingScreenEnabled()Z + move-result v$isEnabledRegister + """, + ) + } + mapOf( + themeHelperLightColorMatch to lightThemeBackgroundColor, + themeHelperDarkColorMatch to darkThemeBackgroundColor, + ).forEach { (match, color) -> + match.mutableMethod.apply { + addInstructions( + 0, + """ + const-string v0, "$color" + return-object v0 + """, + ) + } + } + + lithoColorOverrideHook(EXTENSION_CLASS_DESCRIPTOR, "getValue") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/thumbnails/AlternativeThumbnailsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/thumbnails/AlternativeThumbnailsPatch.kt new file mode 100644 index 000000000..614ae9052 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/thumbnails/AlternativeThumbnailsPatch.kt @@ -0,0 +1,98 @@ +package app.revanced.patches.youtube.layout.thumbnails + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.ListPreference +import app.revanced.patches.shared.misc.settings.preference.NonInteractivePreference +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.shared.misc.settings.preference.TextPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.imageurlhook.addImageUrlErrorCallbackHook +import app.revanced.patches.youtube.misc.imageurlhook.addImageUrlHook +import app.revanced.patches.youtube.misc.imageurlhook.addImageUrlSuccessCallbackHook +import app.revanced.patches.youtube.misc.imageurlhook.cronetImageUrlHookPatch +import app.revanced.patches.youtube.misc.navigation.navigationBarHookPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/AlternativeThumbnailsPatch;" + +@Suppress("unused") +val alternativeThumbnailsPatch = bytecodePatch( + name = "Alternative thumbnails", + description = "Adds options to replace video thumbnails using the DeArrow API or image captures from the video.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + navigationBarHookPatch, + cronetImageUrlHookPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + execute { + addResources("youtube", "layout.thumbnails.alternativeThumbnailsPatch") + + val entries = "revanced_alt_thumbnail_options_entries" + val values = "revanced_alt_thumbnail_options_entry_values" + PreferenceScreen.ALTERNATIVE_THUMBNAILS.addPreferences( + ListPreference( + "revanced_alt_thumbnail_home", + summaryKey = null, + entriesKey = entries, + entryValuesKey = values, + ), + ListPreference( + "revanced_alt_thumbnail_subscription", + summaryKey = null, + entriesKey = entries, + entryValuesKey = values, + ), + ListPreference( + "revanced_alt_thumbnail_library", + summaryKey = null, + entriesKey = entries, + entryValuesKey = values, + ), + ListPreference( + "revanced_alt_thumbnail_player", + summaryKey = null, + entriesKey = entries, + entryValuesKey = values, + ), + ListPreference( + "revanced_alt_thumbnail_search", + summaryKey = null, + entriesKey = entries, + entryValuesKey = values, + ), + NonInteractivePreference( + "revanced_alt_thumbnail_dearrow_about", + // Custom about preference with link to the DeArrow website. + tag = "app.revanced.extension.youtube.settings.preference.AlternativeThumbnailsAboutDeArrowPreference", + selectable = true, + ), + SwitchPreference("revanced_alt_thumbnail_dearrow_connection_toast"), + TextPreference("revanced_alt_thumbnail_dearrow_api_url"), + NonInteractivePreference("revanced_alt_thumbnail_stills_about"), + SwitchPreference("revanced_alt_thumbnail_stills_fast"), + ListPreference("revanced_alt_thumbnail_stills_time", summaryKey = null), + ) + + addImageUrlHook(EXTENSION_CLASS_DESCRIPTOR) + addImageUrlSuccessCallbackHook(EXTENSION_CLASS_DESCRIPTOR) + addImageUrlErrorCallbackHook(EXTENSION_CLASS_DESCRIPTOR) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/thumbnails/BypassImageRegionRestrictionsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/thumbnails/BypassImageRegionRestrictionsPatch.kt new file mode 100644 index 000000000..cdb7dc4ed --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/thumbnails/BypassImageRegionRestrictionsPatch.kt @@ -0,0 +1,50 @@ +package app.revanced.patches.youtube.layout.thumbnails + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.imageurlhook.addImageUrlHook +import app.revanced.patches.youtube.misc.imageurlhook.cronetImageUrlHookPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/BypassImageRegionRestrictionsPatch;" + +@Suppress("unused") +val bypassImageRegionRestrictionsPatch = bytecodePatch( + name = "Bypass image region restrictions", + description = "Adds an option to use a different host for user avatar and channel images " + + "and can fix missing images that are blocked in some countries.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + cronetImageUrlHookPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + execute { + addResources("youtube", "layout.thumbnails.bypassImageRegionRestrictionsPatch") + + PreferenceScreen.MISC.addPreferences( + SwitchPreference("revanced_bypass_image_region_restrictions"), + ) + + // A priority hook is not needed, as the image urls of interest are not modified + // by AlternativeThumbnails or any other patch in this repo. + addImageUrlHook(EXTENSION_CLASS_DESCRIPTOR) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/announcements/AnnouncementsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/announcements/AnnouncementsPatch.kt new file mode 100644 index 000000000..00684b2f7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/announcements/AnnouncementsPatch.kt @@ -0,0 +1,43 @@ +package app.revanced.patches.youtube.misc.announcements + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.patches.youtube.shared.mainActivityOnCreateFingerprint + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/announcements/AnnouncementsPatch;" + +@Suppress("unused") +val announcementsPatch = bytecodePatch( + name = "Announcements", + description = "Adds an option to show announcements from ReVanced on app startup.", +) { + dependsOn( + settingsPatch, + addResourcesPatch, + ) + + compatibleWith("com.google.android.youtube") + + val mainActivityOnCreateMatch by mainActivityOnCreateFingerprint() + + execute { + addResources("youtube", "misc.announcements.announcementsPatch") + + PreferenceScreen.MISC.addPreferences( + SwitchPreference("revanced_announcements"), + ) + + mainActivityOnCreateMatch.mutableMethod.addInstructions( + // Insert index must be greater than the insert index used by GmsCoreSupport, + // as both patch the same method and GmsCore check should be first. + 1, + "invoke-static/range { p0 .. p0 }, $EXTENSION_CLASS_DESCRIPTOR->showAnnouncement(Landroid/app/Activity;)V", + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/autorepeat/AutoRepeatPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/autorepeat/AutoRepeatPatch.kt new file mode 100644 index 000000000..9558b965c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/autorepeat/AutoRepeatPatch.kt @@ -0,0 +1,68 @@ +package app.revanced.patches.youtube.misc.autorepeat + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.extensions.InstructionExtensions.instructions +import app.revanced.patcher.extensions.InstructionExtensions.removeInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.shared.autoRepeatFingerprint +import app.revanced.patches.youtube.shared.autoRepeatParentFingerprint +import app.revanced.util.matchOrThrow + +// TODO: Rename this patch to AlwaysRepeatPatch (as well as strings and references in the extension). +@Suppress("unused") +val autoRepeatPatch = bytecodePatch( + name = "Always repeat", + description = "Adds an option to always repeat videos when they end.", +) { + dependsOn( + sharedExtensionPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val autoRepeatParentMatch by autoRepeatParentFingerprint() + + execute { context -> + addResources("youtube", "misc.autorepeat.autoRepeatPatch") + + PreferenceScreen.MISC.addPreferences( + SwitchPreference("revanced_auto_repeat"), + ) + + autoRepeatFingerprint.apply { + match(context, autoRepeatParentMatch.classDef) + }.matchOrThrow.mutableMethod.apply { + val playMethod = autoRepeatParentMatch.mutableMethod + val index = instructions.lastIndex + + // Remove return-void. + removeInstruction(index) + // Add own instructions there. + addInstructionsWithLabels( + index, + """ + invoke-static {}, Lapp/revanced/extension/youtube/patches/AutoRepeatPatch;->shouldAutoRepeat()Z + move-result v0 + if-eqz v0, :noautorepeat + invoke-virtual { p0 }, $playMethod + :noautorepeat + return-void + """, + ) + } + } +} 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 000000000..b954b27e1 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/backgroundplayback/BackgroundPlaybackPatch.kt @@ -0,0 +1,103 @@ +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.instructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.playertype.playerTypeHookPatch +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.patches.youtube.video.information.videoInformationPatch +import app.revanced.util.addInstructionsAtControlFlowLabel +import app.revanced.util.findOpcodeIndicesReversed +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 + +internal var prefBackgroundAndOfflineCategoryId = -1L + private set + +private val backgroundPlaybackResourcePatch = resourcePatch { + dependsOn(resourceMappingPatch) + + execute { + prefBackgroundAndOfflineCategoryId = resourceMappings["string", "pref_background_and_offline_category"] + } +} + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/BackgroundPlaybackPatch;" + +@Suppress("unused") +val backgroundPlaybackPatch = bytecodePatch( + name = "Remove background playback restrictions", + description = "Removes restrictions on background playback, including playing kids videos in the background.", +) { + dependsOn( + backgroundPlaybackResourcePatch, + sharedExtensionPatch, + playerTypeHookPatch, + videoInformationPatch, + settingsPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val backgroundPlaybackManagerMatch by backgroundPlaybackManagerFingerprint() + val backgroundPlaybackSettingsMatch by backgroundPlaybackSettingsFingerprint() + val kidsBackgroundPlaybackPolicyControllerMatch by kidsBackgroundPlaybackPolicyControllerFingerprint() + + execute { context -> + backgroundPlaybackManagerMatch.mutableMethod.apply { + findOpcodeIndicesReversed(Opcode.RETURN).forEach { index -> + val register = getInstruction(index).registerA + + addInstructionsAtControlFlowLabel( + index, + """ + invoke-static { v$register }, $EXTENSION_CLASS_DESCRIPTOR->allowBackgroundPlayback(Z)Z + move-result v$register + """, + ) + } + } + + // Enable background playback option in YouTube settings + backgroundPlaybackSettingsMatch.mutableMethod.apply { + val booleanCalls = instructions.withIndex() + .filter { ((it.value as? ReferenceInstruction)?.reference as? MethodReference)?.returnType == "Z" } + + val settingsBooleanIndex = booleanCalls.elementAt(1).index + val settingsBooleanMethod = context.navigate(this).at(settingsBooleanIndex).mutable() + + settingsBooleanMethod.addInstructions( + 0, + """ + invoke-static {}, $EXTENSION_CLASS_DESCRIPTOR->overrideBackgroundPlaybackAvailable()Z + move-result v0 + return v0 + """, + ) + } + + // Force allowing background play for videos labeled for kids. + kidsBackgroundPlaybackPolicyControllerMatch.mutableMethod.addInstruction( + 0, + "return-void", + ) + } +} 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 000000000..aa4f6f922 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/backgroundplayback/Fingerprints.kt @@ -0,0 +1,72 @@ +package app.revanced.patches.youtube.misc.backgroundplayback + +import app.revanced.patcher.fingerprint +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val backgroundPlaybackManagerFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("Z") + parameters("L") + opcodes( + Opcode.CONST_4, + Opcode.IF_EQZ, + Opcode.IGET, + Opcode.AND_INT_LIT16, + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, + Opcode.IF_NEZ, + Opcode.SGET_OBJECT, + Opcode.IGET, + Opcode.CONST, + Opcode.IF_NE, + Opcode.IGET_OBJECT, + Opcode.IF_NEZ, + Opcode.SGET_OBJECT, + Opcode.IGET, + Opcode.IF_NE, + Opcode.IGET_OBJECT, + Opcode.CHECK_CAST, + Opcode.GOTO, + Opcode.SGET_OBJECT, + Opcode.GOTO, + Opcode.CONST_4, + Opcode.IF_EQZ, + Opcode.IGET_BOOLEAN, + Opcode.IF_EQZ, + ) +} + +internal val backgroundPlaybackSettingsFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Ljava/lang/String;") + parameters() + opcodes( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_EQZ, + Opcode.IF_NEZ, + Opcode.GOTO, + ) + literal { prefBackgroundAndOfflineCategoryId } +} + +internal val kidsBackgroundPlaybackPolicyControllerFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("I", "L", "L") + opcodes( + Opcode.CONST_4, + Opcode.IF_NE, + Opcode.SGET_OBJECT, + Opcode.IF_NE, + Opcode.IGET, + Opcode.CONST_4, + Opcode.IF_NE, + Opcode.IGET_OBJECT, + ) + literal { 5 } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/check/CheckEnvironmentPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/check/CheckEnvironmentPatch.kt new file mode 100644 index 000000000..f0cc13531 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/check/CheckEnvironmentPatch.kt @@ -0,0 +1,12 @@ +package app.revanced.patches.youtube.misc.check + +import app.revanced.patches.shared.misc.checks.checkEnvironmentPatch +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.shared.mainActivityOnCreateFingerprint + +@Suppress("unused") +val checkEnvironmentPatch = checkEnvironmentPatch( + mainActivityOnCreateFingerprint = mainActivityOnCreateFingerprint, + extensionPatch = sharedExtensionPatch, + "com.google.android.youtube", +) diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/debugging/EnableDebuggingPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/debugging/EnableDebuggingPatch.kt new file mode 100644 index 000000000..b354c75e1 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/debugging/EnableDebuggingPatch.kt @@ -0,0 +1,41 @@ +package app.revanced.patches.youtube.misc.debugging + +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.PreferenceScreenPreference +import app.revanced.patches.shared.misc.settings.preference.PreferenceScreenPreference.Sorting +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch + +val enableDebuggingPatch = resourcePatch( + name = "Enable debugging", + description = "Adds options for debugging.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith("com.google.android.youtube") + + execute { + addResources("youtube", "misc.debugging.enableDebuggingPatch") + + PreferenceScreen.MISC.addPreferences( + PreferenceScreenPreference( + key = "revanced_debug_screen", + sorting = Sorting.UNSORTED, + preferences = setOf( + SwitchPreference("revanced_debug"), + SwitchPreference("revanced_debug_protobuffer"), + SwitchPreference("revanced_debug_stacktrace"), + SwitchPreference("revanced_debug_toast_on_error"), + ), + ), + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/dimensions/spoof/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/dimensions/spoof/Fingerprints.kt new file mode 100644 index 000000000..4f99a4cf5 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/dimensions/spoof/Fingerprints.kt @@ -0,0 +1,8 @@ +package app.revanced.patches.youtube.misc.dimensions.spoof + +import app.revanced.patcher.fingerprint + +internal val deviceDimensionsModelToStringFingerprint = fingerprint { + returns("L") + strings("minh.", ";maxh.") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/dimensions/spoof/SpoofDeviceDimensionsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/dimensions/spoof/SpoofDeviceDimensionsPatch.kt new file mode 100644 index 000000000..c82d363e1 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/dimensions/spoof/SpoofDeviceDimensionsPatch.kt @@ -0,0 +1,63 @@ +package app.revanced.patches.youtube.misc.dimensions.spoof + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/spoof/SpoofDeviceDimensionsPatch;" + +@Suppress("unused") +val spoofDeviceDimensionsPatch = bytecodePatch( + name = "Spoof device dimensions", + description = "Adds an option to spoof the device dimensions which can unlock higher video qualities.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val deviceDimensionsModelToStringMatch by deviceDimensionsModelToStringFingerprint() + + execute { + addResources("youtube", "misc.dimensions.spoof.spoofDeviceDimensionsPatch") + + PreferenceScreen.MISC.addPreferences( + SwitchPreference("revanced_spoof_device_dimensions"), + ) + + deviceDimensionsModelToStringMatch + .mutableClass.methods.first { method -> method.name == "" } + // Override the parameters containing the dimensions. + .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_CLASS_DESCRIPTOR->get$method(I)I + move-result p$parameter + """ + }.joinToString("\n") { it }, + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/dns/CheckWatchHistoryDomainNameResolutionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/dns/CheckWatchHistoryDomainNameResolutionPatch.kt new file mode 100644 index 000000000..f4433fe29 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/dns/CheckWatchHistoryDomainNameResolutionPatch.kt @@ -0,0 +1,41 @@ +package app.revanced.patches.youtube.misc.dns + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.youtube.shared.mainActivityOnCreateFingerprint + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/CheckWatchHistoryDomainNameResolutionPatch;" + +@Suppress("unused") +val checkWatchHistoryDomainNameResolutionPatch = bytecodePatch( + name = "Check watch history domain name resolution", + description = "Checks if the device DNS server is preventing user watch history from being saved.", +) { + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val mainActivityOnCreateMatch by mainActivityOnCreateFingerprint() + + execute { + addResources("youtube", "misc.dns.checkWatchHistoryDomainNameResolutionPatch") + + mainActivityOnCreateMatch.mutableMethod.addInstructions( + // FIXME: Insert index must be greater than the insert index used by GmsCoreSupport, + // as both patch the same method and GmsCoreSupport check should be first, + // but the patch does not depend on GmsCoreSupport, so it should not be possible to enforce this + // unless a third patch is added that this patch and GmsCoreSupport depend on to manage + // the order of the patches. + 1, + "invoke-static/range { p0 .. p0 }, $EXTENSION_CLASS_DESCRIPTOR->checkDnsResolver(Landroid/app/Activity;)V", + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/extension/SharedExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/extension/SharedExtensionPatch.kt new file mode 100644 index 000000000..369e3ea74 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/extension/SharedExtensionPatch.kt @@ -0,0 +1,9 @@ +package app.revanced.patches.youtube.misc.extension + +import app.revanced.patches.shared.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.extension.hooks.* + +// 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/misc/extension/hooks/ApplicationInitHook.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/extension/hooks/ApplicationInitHook.kt new file mode 100644 index 000000000..6a0e7d1f4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/extension/hooks/ApplicationInitHook.kt @@ -0,0 +1,11 @@ +package app.revanced.patches.youtube.misc.extension.hooks + +import app.revanced.patches.shared.misc.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/misc/fix/backtoexitgesture/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/backtoexitgesture/Fingerprints.kt new file mode 100644 index 000000000..fed0199aa --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/backtoexitgesture/Fingerprints.kt @@ -0,0 +1,73 @@ +package app.revanced.patches.youtube.misc.fix.backtoexitgesture + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val onBackPressedFingerprint = fingerprint { + returns("V") + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + opcodes(Opcode.RETURN_VOID) + custom { method, classDef -> + method.name == "onBackPressed" && + // Old versions of YouTube called this class "WatchWhileActivity" instead. + (classDef.endsWith("MainActivity;") || classDef.endsWith("WatchWhileActivity;")) + } +} + +internal val recyclerViewScrollingFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.FINAL) + returns("V") + parameters() + opcodes( + Opcode.IGET_OBJECT, + Opcode.IGET_OBJECT, + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, + Opcode.CHECK_CAST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_LEZ, + Opcode.IGET_OBJECT, + Opcode.CONST_4, + ) +} + +internal val recyclerViewTopScrollingFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters() + opcodes( + Opcode.IGET_OBJECT, + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT, + Opcode.IF_EQZ, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + Opcode.CONST_4, + Opcode.INVOKE_VIRTUAL, + Opcode.GOTO, + Opcode.IGET_OBJECT, + Opcode.INVOKE_INTERFACE, + ) +} + +internal val recyclerViewTopScrollingParentFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + parameters("L", "L", "Landroid/view/ViewGroup;", "Landroid/view/ViewGroup;") + opcodes( + Opcode.INVOKE_DIRECT, + Opcode.IPUT_OBJECT, + Opcode.IPUT_OBJECT, + Opcode.IPUT_OBJECT, + Opcode.IPUT_OBJECT, + Opcode.CONST_16, + Opcode.INVOKE_VIRTUAL, + Opcode.NEW_INSTANCE, + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/backtoexitgesture/FixBackToExitGesturePatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/backtoexitgesture/FixBackToExitGesturePatch.kt new file mode 100644 index 000000000..ae5aee5e7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/backtoexitgesture/FixBackToExitGesturePatch.kt @@ -0,0 +1,61 @@ +package app.revanced.patches.youtube.misc.fix.backtoexitgesture + +import app.revanced.patcher.Match +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.util.matchOrThrow + +@Suppress("unused") +internal val fixBackToExitGesturePatch = bytecodePatch( + description = "Fixes the swipe back to exit gesture.", +) { + val recyclerViewTopScrollingParentMatch by recyclerViewTopScrollingParentFingerprint() + val recyclerViewScrollingMatch by recyclerViewScrollingFingerprint() + val onBackPressedMatch by onBackPressedFingerprint() + + execute { context -> + recyclerViewTopScrollingFingerprint.apply { + match(context, recyclerViewTopScrollingParentMatch.classDef) + } + + /** + * Inject a call to a method from the extension. + * + * @param targetMethod The target method to call. + */ + fun Match.injectCall(targetMethod: ExtensionMethod) = mutableMethod.addInstruction( + patternMatch!!.endIndex, + targetMethod.toString(), + ) + + mapOf( + recyclerViewTopScrollingFingerprint.matchOrThrow to ExtensionMethod( + methodName = "onTopView", + ), + recyclerViewScrollingMatch to ExtensionMethod( + methodName = "onScrollingViews", + ), + onBackPressedMatch to ExtensionMethod( + "p0", + "onBackPressed", + "Landroid/app/Activity;", + ), + ).forEach { (match, target) -> match.injectCall(target) } + } +} + +/** + * A reference to a method from the extension for [fixBackToExitGesturePatch]. + * + * @param register The method registers. + * @param methodName The method name. + * @param parameterTypes The parameters of the method. + */ +private class ExtensionMethod( + val register: String = "", + val methodName: String, + val parameterTypes: String = "", +) { + override fun toString() = + "invoke-static {$register}, Lapp/revanced/extension/youtube/patches/FixBackToExitGesturePatch;->$methodName($parameterTypes)V" +} diff --git a/src/main/kotlin/app/revanced/patches/youtube/misc/fix/cairo/DisableCairoSettingsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/cairo/DisableCairoSettingsPatch.kt similarity index 59% rename from src/main/kotlin/app/revanced/patches/youtube/misc/fix/cairo/DisableCairoSettingsPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/cairo/DisableCairoSettingsPatch.kt index b936d7bf9..902d65bf1 100644 --- a/src/main/kotlin/app/revanced/patches/youtube/misc/fix/cairo/DisableCairoSettingsPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/cairo/DisableCairoSettingsPatch.kt @@ -1,31 +1,27 @@ package app.revanced.patches.youtube.misc.fix.cairo -import app.revanced.patcher.data.BytecodeContext import app.revanced.patcher.extensions.InstructionExtensions.addInstruction import app.revanced.patcher.extensions.InstructionExtensions.getInstruction -import app.revanced.patcher.patch.BytecodePatch -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patches.youtube.misc.backgroundplayback.BackgroundPlaybackPatch -import app.revanced.patches.youtube.misc.fix.cairo.fingerprints.CarioFragmentConfigFingerprint -import app.revanced.patches.youtube.misc.playservice.VersionCheckPatch +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.misc.backgroundplayback.backgroundPlaybackPatch +import app.revanced.patches.youtube.misc.playservice.is_19_04_or_greater +import app.revanced.patches.youtube.misc.playservice.versionCheckPatch import app.revanced.util.indexOfFirstInstructionOrThrow import app.revanced.util.indexOfFirstWideLiteralInstructionValueOrThrow -import app.revanced.util.resultOrThrow import com.android.tools.smali.dexlib2.Opcode import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.sun.org.apache.bcel.internal.generic.InstructionConst.getInstruction -@Patch( +internal val disableCairoSettingsPatch = bytecodePatch( description = "Disables Cairo Fragment from being used.", - dependencies = [ - VersionCheckPatch::class - ] -) -internal object DisableCairoSettingsPatch : BytecodePatch( - setOf(CarioFragmentConfigFingerprint) ) { - override fun execute(context: BytecodeContext) { - if (!VersionCheckPatch.is_19_04_or_greater) { - return + dependsOn(versionCheckPatch) + + val cairoFragmentConfigMatch by cairoFragmentConfigFingerprint() + + execute { + if (!is_19_04_or_greater) { + return@execute } /** @@ -33,24 +29,25 @@ internal object DisableCairoSettingsPatch : BytecodePatch( * 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. + * 1. [backgroundPlaybackPatch] does not activate the Minimized playback setting of Cairo Fragment. * 2. Some patches do not yet support Cairo Fragments (ie: custom Seekbar color). * 3. Settings preferences added by ReVanced are missing. * * Screenshots of the Cairo Fragment: * uYouPlus#1468. */ - CarioFragmentConfigFingerprint.resultOrThrow().mutableMethod.apply { + cairoFragmentConfigMatch.mutableMethod.apply { val literalIndex = indexOfFirstWideLiteralInstructionValueOrThrow( - CarioFragmentConfigFingerprint.CAIRO_CONFIG_LITERAL_VALUE + CAIRO_CONFIG_LITERAL_VALUE, ) + val resultIndex = indexOfFirstInstructionOrThrow(literalIndex, Opcode.MOVE_RESULT) val register = getInstruction(resultIndex).registerA addInstruction( resultIndex + 1, - "const/16 v$register, 0x0" + "const/16 v$register, 0x0", ) } } -} \ No newline at end of file +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/cairo/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/cairo/Fingerprints.kt new file mode 100644 index 000000000..35ac5f7ae --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/cairo/Fingerprints.kt @@ -0,0 +1,19 @@ +package app.revanced.patches.youtube.misc.fix.cairo + +import app.revanced.patcher.fingerprint +import app.revanced.util.literal +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 the patches may be broken, so set this value to FALSE. + */ +const val CAIRO_CONFIG_LITERAL_VALUE = 45532100L + +internal val cairoFragmentConfigFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Z") + literal { CAIRO_CONFIG_LITERAL_VALUE } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/playback/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/playback/Fingerprints.kt new file mode 100644 index 000000000..71bd1c62c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/playback/Fingerprints.kt @@ -0,0 +1,261 @@ +package app.revanced.patches.youtube.misc.fix.playback + +import app.revanced.patcher.fingerprint +import app.revanced.util.containsWideLiteralInstructionValue +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.literal +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 buildInitPlaybackRequestFingerprint = fingerprint { + returns("Lorg/chromium/net/UrlRequest\$Builder;") + opcodes( + Opcode.MOVE_RESULT_OBJECT, + Opcode.IGET_OBJECT, // Moves the request URI string to a register to build the request with. + ) + strings( + "Content-Type", + "Range", + ) +} + +internal val buildPlayerRequestURIFingerprint = fingerprint { + returns("Ljava/lang/String;") + opcodes( + Opcode.INVOKE_VIRTUAL, // Register holds player request URI. + Opcode.MOVE_RESULT_OBJECT, + Opcode.IPUT_OBJECT, + Opcode.IGET_OBJECT, + Opcode.MONITOR_EXIT, + Opcode.RETURN_OBJECT, + ) + strings( + "youtubei/v1", + "key", + "asig", + ) +} + +internal val createPlaybackSpeedMenuItemFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + opcodes( + Opcode.IGET_OBJECT, // First instruction of the method + Opcode.IGET_OBJECT, + Opcode.IGET_OBJECT, + Opcode.CONST_4, + Opcode.IF_EQZ, + Opcode.INVOKE_INTERFACE, + null, // MOVE_RESULT or MOVE_RESULT_OBJECT, Return value controls the creation of the playback speed menu item. + ) + // 19.01 and earlier is missing the second parameter. + // Since this fingerprint is somewhat weak, work around by checking for both method parameter signatures. + custom { method, _ -> + // 19.01 and earlier parameters are: "[L" + // 19.02+ parameters are "[L", "F" + val parameterTypes = method.parameterTypes + val firstParameter = parameterTypes.firstOrNull() + + if (firstParameter == null || !firstParameter.startsWith("[L")) { + return@custom false + } + + parameterTypes.size == 1 || (parameterTypes.size == 2 && parameterTypes[1] == "F") + } +} + +internal val createPlayerRequestBodyFingerprint = fingerprint { + returns("V") + parameters("L") + opcodes( + Opcode.CHECK_CAST, + Opcode.IGET, + Opcode.AND_INT_LIT16, + ) + strings("ms") +} + +internal fun indexOfBuildModelInstruction(method: Method) = + method.indexOfFirstInstruction { + val reference = getReference() + reference?.definingClass == "Landroid/os/Build;" && + reference.name == "MODEL" && + reference.type == "Ljava/lang/String;" + } + +internal val createPlayerRequestBodyWithModelFingerprint = fingerprint { + returns("L") + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + parameters() + custom { method, _ -> + method.containsWideLiteralInstructionValue(1073741824) && indexOfBuildModelInstruction(method) >= 0 + } +} + +internal val playerGestureConfigSyntheticFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("Ljava/lang/Object;") + opcodes( + Opcode.SGET_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_EQZ, + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_VIRTUAL, // playerGestureConfig.downAndOutLandscapeAllowed. + Opcode.MOVE_RESULT, + Opcode.CHECK_CAST, + Opcode.IPUT_BOOLEAN, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_VIRTUAL, // playerGestureConfig.downAndOutPortraitAllowed. + Opcode.MOVE_RESULT, + Opcode.IPUT_BOOLEAN, + Opcode.RETURN_VOID, + ) + custom { method, classDef -> + fun indexOfDownAndOutAllowedInstruction() = + method.indexOfFirstInstruction { + val reference = getReference() + reference?.definingClass == "Lcom/google/android/libraries/youtube/innertube/model/media/PlayerConfigModel;" && + reference.parameterTypes.isEmpty() && + reference.returnType == "Z" + } + + // This method is always called "a" because this kind of class always has a single method. + method.name == "a" && + classDef.methods.count() == 2 && + indexOfDownAndOutAllowedInstruction() >= 0 + } +} + +internal val setPlayerRequestClientTypeFingerprint = fingerprint { + opcodes( + Opcode.IGET, + Opcode.IPUT, // Sets ClientInfo.clientId. + ) + strings("10.29") + literal { 134217728 } +} + +internal fun indexOfBuildVersionReleaseInstruction(methodDef: Method) = + methodDef.indexOfFirstInstruction { + val reference = getReference() + reference?.definingClass == "Landroid/os/Build\$VERSION;" && + reference.name == "RELEASE" && + reference.type == "Ljava/lang/String;" + } + +internal val createPlayerRequestBodyWithVersionReleaseFingerprint = fingerprint { + returns("L") + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + parameters() + custom { method, _ -> + method.containsWideLiteralInstructionValue(1073741824) && indexOfBuildVersionReleaseInstruction(method) >= 0 + } +} + +internal val buildRequestFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("Lorg/chromium/net/UrlRequest;") + custom { methodDef, _ -> + // Different targets have slightly different parameters + + // Earlier targets have parameters: + // L + // Ljava/util/Map; + // [B + // L + // L + // L + // Lorg/chromium/net/UrlRequest$Callback; + + // Later targets have parameters: + // L + // Ljava/util/Map; + // [B + // L + // L + // L + // Lorg/chromium/net/UrlRequest\$Callback; + // L + + val parameterTypes = methodDef.parameterTypes + (parameterTypes.size == 7 || parameterTypes.size == 8) && + parameterTypes[1] == "Ljava/util/Map;" // URL headers. + } +} + +internal val playerResponseModelBackgroundAudioPlaybackFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("Z") + parameters("Lcom/google/android/libraries/youtube/innertube/model/player/PlayerResponseModel;") + opcodes( + Opcode.CONST_4, + Opcode.IF_EQZ, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_NEZ, + Opcode.GOTO, + Opcode.RETURN, + null, // Opcode.CONST_4 or Opcode.MOVE + Opcode.RETURN, + ) +} + +internal val protobufClassParseByteBufferFingerprint = fingerprint { + accessFlags(AccessFlags.PROTECTED, AccessFlags.STATIC) + returns("L") + parameters("L", "Ljava/nio/ByteBuffer;") + opcodes( + Opcode.SGET_OBJECT, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.RETURN_OBJECT, + ) + custom { method, _ -> method.name == "parseFrom" } +} + +internal val createStreamingDataFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + returns("V") + parameters("L") + opcodes( + Opcode.IPUT_OBJECT, + Opcode.IGET_OBJECT, + Opcode.IF_NEZ, + Opcode.SGET_OBJECT, + Opcode.IPUT_OBJECT, + ) + custom { method, classDef -> + classDef.fields.any { field -> + field.name == "a" && field.type.endsWith("/StreamingDataOuterClass\$StreamingData;") + } + } +} + +internal val buildMediaDataSourceFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + returns("V") + parameters( + "Landroid/net/Uri;", + "J", + "I", + "[B", + "Ljava/util/Map;", + "J", + "J", + "Ljava/lang/String;", + "I", + "Ljava/lang/Object;", + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/playback/SpoofVideoStreamsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/playback/SpoofVideoStreamsPatch.kt new file mode 100644 index 000000000..c560d7e72 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/playback/SpoofVideoStreamsPatch.kt @@ -0,0 +1,248 @@ +package app.revanced.patches.youtube.misc.fix.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.extensions.InstructionExtensions.instructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.ListPreference +import app.revanced.patches.shared.misc.settings.preference.NonInteractivePreference +import app.revanced.patches.shared.misc.settings.preference.PreferenceScreenPreference +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +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.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.immutable.ImmutableMethodParameter + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/spoof/SpoofVideoStreamsPatch;" + +@Suppress("unused") +val spoofVideoStreamsPatch = bytecodePatch( + name = "Spoof video streams", + description = "Spoofs the client video streams to allow video playback.", +) { + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + dependsOn( + settingsPatch, + addResourcesPatch, + userAgentClientSpoofPatch, + ) + + val buildInitPlaybackRequestMatch by buildInitPlaybackRequestFingerprint() + val buildPlayerRequestURIMatch by buildPlayerRequestURIFingerprint() + val createStreamingDataMatch by createStreamingDataFingerprint() + val buildMediaDataSourceMatch by buildMediaDataSourceFingerprint() + val buildRequestMatch by buildRequestFingerprint() + val protobufClassParseByteBufferMatch by protobufClassParseByteBufferFingerprint() + + execute { + addResources("youtube", "misc.fix.playback.spoofVideoStreamsPatch") + + PreferenceScreen.MISC.addPreferences( + PreferenceScreenPreference( + key = "revanced_spoof_video_streams_screen", + sorting = PreferenceScreenPreference.Sorting.UNSORTED, + preferences = setOf( + SwitchPreference("revanced_spoof_video_streams"), + ListPreference( + "revanced_spoof_video_streams_client", + summaryKey = null, + ), + SwitchPreference( + "revanced_spoof_video_streams_ios_force_avc", + tag = "app.revanced.extension.youtube.settings.preference.ForceAVCSpoofingPreference", + ), + NonInteractivePreference("revanced_spoof_video_streams_about_android_vr"), + NonInteractivePreference("revanced_spoof_video_streams_about_ios"), + ), + ), + ) + + // region Block /initplayback requests to fall back to /get_watch requests. + + val moveUriStringIndex = buildInitPlaybackRequestMatch.patternMatch!!.startIndex + + buildInitPlaybackRequestMatch.mutableMethod.apply { + 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 Block /get_watch requests to fall back to /player requests. + + val invokeToStringIndex = buildPlayerRequestURIMatch.patternMatch!!.startIndex + + buildPlayerRequestURIMatch.mutableMethod.apply { + 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 Get replacement streams at player requests. + + buildRequestMatch.mutableMethod.apply { + val newRequestBuilderIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.name == "newUrlRequestBuilder" + } + val urlRegister = getInstruction(newRequestBuilderIndex).registerD + val freeRegister = getInstruction(newRequestBuilderIndex + 1).registerA + + addInstructions( + newRequestBuilderIndex, + """ + move-object v$freeRegister, p1 + invoke-static { v$urlRegister, v$freeRegister }, $EXTENSION_CLASS_DESCRIPTOR->fetchStreams(Ljava/lang/String;Ljava/util/Map;)V + """, + ) + } + + // endregion + + // region Replace the streaming data with the replacement streams. + + createStreamingDataMatch.mutableMethod.apply { + val setStreamDataMethodName = "patch_setStreamingData" + val resultMethodType = createStreamingDataMatch.mutableClass.type + val videoDetailsIndex = createStreamingDataMatch.patternMatch!!.endIndex + val videoDetailsRegister = getInstruction(videoDetailsIndex).registerA + val videoDetailsClass = getInstruction(videoDetailsIndex).getReference()!!.type + + addInstruction( + videoDetailsIndex + 1, + "invoke-direct { p0, v$videoDetailsRegister }, " + + "$resultMethodType->$setStreamDataMethodName($videoDetailsClass)V", + ) + + val protobufClass = protobufClassParseByteBufferMatch.mutableMethod.definingClass + val setStreamingDataIndex = createStreamingDataMatch.patternMatch!!.startIndex + + val playerProtoClass = getInstruction(setStreamingDataIndex + 1) + .getReference()!!.definingClass + + val setStreamingDataField = getInstruction(setStreamingDataIndex).getReference() + + val getStreamingDataField = getInstruction( + indexOfFirstInstructionOrThrow { + opcode == Opcode.IGET_OBJECT && getReference()?.definingClass == playerProtoClass + }, + ).getReference() + + // Use a helper method to avoid the need of picking out multiple free registers from the hooked code. + createStreamingDataMatch.mutableClass.methods.add( + ImmutableMethod( + resultMethodType, + setStreamDataMethodName, + listOf(ImmutableMethodParameter(videoDetailsClass, null, "videoDetails")), + "V", + AccessFlags.PRIVATE.value or AccessFlags.FINAL.value, + null, + null, + MutableMethodImplementation(9), + ).toMutable().apply { + addInstructionsWithLabels( + 0, + """ + invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->isSpoofingEnabled()Z + move-result v0 + if-eqz v0, :disabled + + # Get video id. + iget-object v2, p1, $videoDetailsClass->c:Ljava/lang/String; + if-eqz v2, :disabled + + # Get streaming data. + invoke-static { v2 }, $EXTENSION_CLASS_DESCRIPTOR->getStreamingData(Ljava/lang/String;)Ljava/nio/ByteBuffer; + move-result-object v3 + if-eqz v3, :disabled + + # Parse streaming data. + sget-object v4, $playerProtoClass->a:$playerProtoClass + invoke-static { v4, v3 }, $protobufClass->parseFrom(${protobufClass}Ljava/nio/ByteBuffer;)$protobufClass + move-result-object v5 + check-cast v5, $playerProtoClass + + # Set streaming data. + iget-object v6, v5, $getStreamingDataField + if-eqz v6, :disabled + iput-object v6, p0, $setStreamingDataField + + :disabled + return-void + """, + ) + }, + ) + } + + // endregion + + // region Remove /videoplayback request body to fix playback. + // It is assumed, YouTube makes a request with a body tuned for Android. + // Requesting streams intended for other platforms with a body tuned for Android could be the cause of 400 errors. + // A proper fix may include modifying the request body to match the platforms expected body. + + buildMediaDataSourceMatch.mutableMethod.apply { + val targetIndex = instructions.lastIndex + + // Instructions are added just before the method returns, + // so there's no concern of clobbering in-use registers. + addInstructions( + targetIndex, + """ + # Field a: Stream uri. + # Field c: Http method. + # Field d: Post data. + move-object v0, p0 # method has over 15 registers and must copy p0 to a lower register. + 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 + } +} diff --git a/src/main/kotlin/app/revanced/patches/youtube/misc/fix/playback/UserAgentClientSpoofPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/playback/UserAgentClientSpoofPatch.kt similarity index 54% rename from src/main/kotlin/app/revanced/patches/youtube/misc/fix/playback/UserAgentClientSpoofPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/playback/UserAgentClientSpoofPatch.kt index 2437dfda6..8bbd0ec77 100644 --- a/src/main/kotlin/app/revanced/patches/youtube/misc/fix/playback/UserAgentClientSpoofPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/fix/playback/UserAgentClientSpoofPatch.kt @@ -2,39 +2,30 @@ package app.revanced.patches.youtube.misc.fix.playback import app.revanced.patcher.extensions.InstructionExtensions.getInstruction import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction -import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod -import app.revanced.patches.all.misc.transformation.BaseTransformInstructionsPatch import app.revanced.patches.all.misc.transformation.IMethodCall -import app.revanced.patches.all.misc.transformation.Instruction35cInfo import app.revanced.patches.all.misc.transformation.filterMapInstruction35c +import app.revanced.patches.all.misc.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.ClassDef -import com.android.tools.smali.dexlib2.iface.Method -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.iface.reference.StringReference -object UserAgentClientSpoofPatch : BaseTransformInstructionsPatch() { - private const val ORIGINAL_PACKAGE_NAME = "com.google.android.youtube" - private const val USER_AGENT_STRING_BUILDER_APPEND_METHOD_REFERENCE = - "Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;" +private const val ORIGINAL_PACKAGE_NAME = "com.google.android.youtube" +private const val USER_AGENT_STRING_BUILDER_APPEND_METHOD_REFERENCE = + "Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;" - override fun filterMap( - classDef: ClassDef, - method: Method, - instruction: Instruction, - instructionIndex: Int, - ) = filterMapInstruction35c( - "Lapp/revanced/integrations", - classDef, - instruction, - instructionIndex, - ) - - override fun transform(mutableMethod: MutableMethod, entry: Instruction35cInfo) { +val userAgentClientSpoofPatch = 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. @@ -42,16 +33,16 @@ object UserAgentClientSpoofPatch : BaseTransformInstructionsPatch()?.toString() // Only replace string builder usage. if (referee != USER_AGENT_STRING_BUILDER_APPEND_METHOD_REFERENCE) { - return + return@transform } // Do not change the package name in methods that use resources, or for methods that use GmsCore. @@ -60,10 +51,10 @@ object UserAgentClientSpoofPatch : BaseTransformInstructionsPatch() opcode == Opcode.CONST_STRING && - (reference?.string == "android.resource://" || reference?.string == "gcore_") + (reference?.string == "android.resource://" || reference?.string == "gcore_") } if (resourceOrGmsStringInstructionIndex >= 0) { - return + return@transform } // Overwrite the result of context.getPackageName() with the original package name. @@ -72,20 +63,20 @@ object UserAgentClientSpoofPatch : BaseTransformInstructionsPatch, - override val returnType: String, - ) : IMethodCall { - GetPackageName( - "Landroid/content/Context;", - "getPackageName", - emptyArray(), - "Ljava/lang/String;", - ), - } +@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;", + ), } diff --git a/src/main/kotlin/app/revanced/patches/youtube/misc/gms/Constants.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/gms/Constants.kt similarity index 100% rename from src/main/kotlin/app/revanced/patches/youtube/misc/gms/Constants.kt rename to patches/src/main/kotlin/app/revanced/patches/youtube/misc/gms/Constants.kt diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/gms/GmsCoreSupportPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/gms/GmsCoreSupportPatch.kt new file mode 100644 index 000000000..1fea1f9bd --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/gms/GmsCoreSupportPatch.kt @@ -0,0 +1,70 @@ +package app.revanced.patches.youtube.misc.gms + +import app.revanced.patcher.patch.Option +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.castContextFetchFingerprint +import app.revanced.patches.shared.misc.gms.gmsCoreSupportPatch +import app.revanced.patches.shared.misc.settings.preference.IntentPreference +import app.revanced.patches.shared.primeMethodFingerprint +import app.revanced.patches.youtube.layout.buttons.overlay.hidePlayerOverlayButtonsPatch +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.fix.playback.spoofVideoStreamsPatch +import app.revanced.patches.youtube.misc.gms.Constants.REVANCED_YOUTUBE_PACKAGE_NAME +import app.revanced.patches.youtube.misc.gms.Constants.YOUTUBE_PACKAGE_NAME +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.patches.youtube.shared.mainActivityOnCreateFingerprint + +@Suppress("unused") +val gmsCoreSupportPatch = gmsCoreSupportPatch( + fromPackageName = YOUTUBE_PACKAGE_NAME, + toPackageName = REVANCED_YOUTUBE_PACKAGE_NAME, + primeMethodFingerprint = primeMethodFingerprint, + earlyReturnFingerprints = setOf( + castContextFetchFingerprint, + ), + mainActivityOnCreateFingerprint = mainActivityOnCreateFingerprint, + extensionPatch = sharedExtensionPatch, + gmsCoreSupportResourcePatchFactory = ::gmsCoreSupportResourcePatch, +) { + dependsOn( + hidePlayerOverlayButtonsPatch, // Hide non-functional cast button. + spoofVideoStreamsPatch, + ) + + compatibleWith( + YOUTUBE_PACKAGE_NAME( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) +} + +private fun gmsCoreSupportResourcePatch( + gmsCoreVendorGroupIdOption: Option, +) = app.revanced.patches.shared.misc.gms.gmsCoreSupportResourcePatch( + fromPackageName = YOUTUBE_PACKAGE_NAME, + toPackageName = REVANCED_YOUTUBE_PACKAGE_NAME, + gmsCoreVendorGroupIdOption = gmsCoreVendorGroupIdOption, + spoofedPackageSignature = "24bb24c05e47e0aefa68a58a766179d9b613a600", + executeBlock = { + addResources("youtube", "misc.gms.gmsCoreSupportResourcePatch") + + val gmsCoreVendorGroupId by gmsCoreVendorGroupIdOption + + PreferenceScreen.MISC.addPreferences( + IntentPreference( + "microg_settings", + intent = IntentPreference.Intent("", "org.microg.gms.ui.SettingsActivity") { + "$gmsCoreVendorGroupId.android.gms" + }, + ), + ) + }, +) { + dependsOn(settingsPatch, addResourcesPatch) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/imageurlhook/CronetImageUrlHook.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/imageurlhook/CronetImageUrlHook.kt new file mode 100644 index 000000000..8e21147f6 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/imageurlhook/CronetImageUrlHook.kt @@ -0,0 +1,114 @@ +package app.revanced.patches.youtube.misc.imageurlhook + +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.youtube.misc.extension.sharedExtensionPatch +import app.revanced.util.applyMatch +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 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 + +val cronetImageUrlHookPatch = bytecodePatch( + description = "Hooks Cronet image urls.", +) { + dependsOn(sharedExtensionPatch) + + val messageDigestImageUrlParentMatch by messageDigestImageUrlParentFingerprint() + val onResponseStartedMatch by onResponseStartedFingerprint() + val requestMatch by requestFingerprint() + + execute { context -> + loadImageUrlMethod = messageDigestImageUrlFingerprint + .applyMatch(context, messageDigestImageUrlParentMatch).mutableMethod + + loadImageSuccessCallbackMethod = onSucceededFingerprint + .applyMatch(context, onResponseStartedMatch).mutableMethod + + loadImageErrorCallbackMethod = onFailureFingerprint + .applyMatch(context, onResponseStartedMatch).mutableMethod + + // 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. + val urlFieldInstruction = requestMatch.mutableMethod.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" + requestMatch.mutableClass.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. + */ +fun addImageUrlHook(targetMethodClass: String, highPriority: Boolean = false) { + 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. + */ +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. + */ +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/youtube/misc/imageurlhook/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/imageurlhook/Fingerprints.kt new file mode 100644 index 000000000..fcd5298ac --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/imageurlhook/Fingerprints.kt @@ -0,0 +1,64 @@ +package app.revanced.patches.youtube.misc.imageurlhook + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val onFailureFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters( + "Lorg/chromium/net/UrlRequest;", + "Lorg/chromium/net/UrlResponseInfo;", + "Lorg/chromium/net/CronetException;" + ) + custom { method, _ -> + method.name == "onFailed" + } +} + +// Acts as a parent fingerprint. +internal val onResponseStartedFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("Lorg/chromium/net/UrlRequest;", "Lorg/chromium/net/UrlResponseInfo;") + strings( + "Content-Length", + "Content-Type", + "identity", + "application/x-protobuf", + ) + custom { method, _ -> + method.name == "onResponseStarted" + } +} + +internal val onSucceededFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("Lorg/chromium/net/UrlRequest;", "Lorg/chromium/net/UrlResponseInfo;") + custom { method, _ -> + method.name == "onSucceeded" + } +} + +internal const val CRONET_URL_REQUEST_CLASS_DESCRIPTOR = "Lorg/chromium/net/impl/CronetUrlRequest;" + +internal val requestFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + returns("V") + custom { _, classDef -> + classDef.type == CRONET_URL_REQUEST_CLASS_DESCRIPTOR + } +} + +internal val messageDigestImageUrlFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + parameters("Ljava/lang/String;", "L") +} + +internal val messageDigestImageUrlParentFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Ljava/lang/String;") + parameters() + strings("@#&=*+-_.,:!?()/~'%;\$") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/links/BypassURLRedirectsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/links/BypassURLRedirectsPatch.kt new file mode 100644 index 000000000..002c6e863 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/links/BypassURLRedirectsPatch.kt @@ -0,0 +1,86 @@ +package app.revanced.patches.youtube.misc.links + +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.playservice.is_19_33_or_greater +import app.revanced.patches.youtube.misc.playservice.versionCheckPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +@Suppress("unused") +val bypassURLRedirectsPatch = bytecodePatch( + name = "Bypass URL redirects", + description = "Adds an option to bypass URL redirects and open the original URL directly.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + versionCheckPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val abUriParserMatch by abUriParserFingerprint() + val abUriParserLegacyMatch by abUriParserLegacyFingerprint() + val httpUriParserMatch by httpUriParserFingerprint() + val httpUriParserLegacyMatch by httpUriParserLegacyFingerprint() + + execute { + addResources("youtube", "misc.links.bypassURLRedirectsPatch") + + PreferenceScreen.MISC.addPreferences( + SwitchPreference("revanced_bypass_url_redirects"), + ) + + val matches = if (is_19_33_or_greater) { + arrayOf( + abUriParserMatch, + httpUriParserMatch, + ) + } else { + arrayOf( + abUriParserLegacyMatch, + httpUriParserLegacyMatch, + ) + } + + matches.forEach { + it.mutableMethod.apply { + val insertIndex = findUriParseIndex() + val uriStringRegister = getInstruction(insertIndex).registerC + + replaceInstruction( + insertIndex, + "invoke-static {v$uriStringRegister}," + + "Lapp/revanced/extension/youtube/patches/BypassURLRedirectsPatch;" + + "->" + + "parseRedirectUri(Ljava/lang/String;)Landroid/net/Uri;", + ) + } + } + } +} + +internal fun Method.findUriParseIndex() = indexOfFirstInstruction { + val reference = getReference() + reference?.returnType == "Landroid/net/Uri;" && reference.name == "parse" +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/links/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/links/Fingerprints.kt new file mode 100644 index 000000000..73f6fae75 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/links/Fingerprints.kt @@ -0,0 +1,72 @@ +package app.revanced.patches.youtube.misc.links + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +/** + * Target 19.33+ + */ +internal val abUriParserFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Ljava/lang/Object") + parameters("Ljava/lang/Object") + strings( + "Found entityKey=`", + "` that does not contain a PlaylistVideoEntityId message as it's identifier.", + ) + custom { method, _ -> + method.findUriParseIndex() >= 0 + } +} + +internal val abUriParserLegacyFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Ljava/lang/Object") + parameters("Ljava/lang/Object") + opcodes( + Opcode.RETURN_OBJECT, + Opcode.CHECK_CAST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + Opcode.RETURN_OBJECT, + Opcode.CHECK_CAST, + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + Opcode.RETURN_OBJECT, + Opcode.CHECK_CAST, + ) + custom { methodDef, classDef -> + // This method is always called "a" because this kind of class always has a single (non-synthetic) method. + + if (methodDef.name != "a") return@custom false + + val count = classDef.methods.count() + count == 2 || count == 3 + } +} + +/** + * Target 19.33+ + */ +internal val httpUriParserFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("Landroid/net/Uri") + parameters("Ljava/lang/String") + strings("https", "https:", "://") + custom { methodDef, _ -> + methodDef.findUriParseIndex() >= 0 + } +} + +internal val httpUriParserLegacyFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("Landroid/net/Uri") + parameters("Ljava/lang/String") + opcodes( + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT_OBJECT, + ) + strings("://") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/links/OpenLinksExternallyPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/links/OpenLinksExternallyPatch.kt new file mode 100644 index 000000000..15e7b3c6e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/links/OpenLinksExternallyPatch.kt @@ -0,0 +1,60 @@ +package app.revanced.patches.youtube.misc.links + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.transformation.transformInstructionsPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +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( + name = "Open links externally", + description = "Adds an option to always open links in your browser instead of in the in-app-browser.", +) { + 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}, Lapp/revanced/extension/youtube/patches/OpenLinksExternallyPatch;->getIntent(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$register + """, + ) + }, + ), + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + execute { + addResources("youtube", "misc.links.openLinksExternallyPatch") + + PreferenceScreen.MISC.addPreferences( + SwitchPreference("revanced_external_browser"), + ) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/litho/filter/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/litho/filter/Fingerprints.kt new file mode 100644 index 000000000..5c0b0c816 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/litho/filter/Fingerprints.kt @@ -0,0 +1,57 @@ +package app.revanced.patches.youtube.misc.litho.filter + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +/** + * In 19.17 and earlier, this resolves to the same method as [readComponentIdentifierFingerprint]. + * In 19.18+ this resolves to a different method. + */ +internal val componentContextParserFingerprint = fingerprint { + strings("Component was not found %s because it was removed due to duplicate converter bindings.") +} + +internal val emptyComponentBuilderFingerprint = fingerprint { + opcodes( + Opcode.INVOKE_INTERFACE, + Opcode.INVOKE_STATIC_RANGE, + ) +} + +internal val lithoFilterFingerprint = fingerprint { + accessFlags(AccessFlags.STATIC, AccessFlags.CONSTRUCTOR) + returns("V") + custom { _, classDef -> + classDef.endsWith("LithoFilterPatch;") + } +} + +internal val protobufBufferReferenceFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("I", "Ljava/nio/ByteBuffer;") + opcodes( + Opcode.IPUT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.SUB_INT_2ADDR, + ) +} + +/** +* In 19.17 and earlier, this resolves to the same method as [componentContextParserFingerprint]. +* In 19.18+ this resolves to a different method. +*/ +internal val readComponentIdentifierFingerprint = fingerprint { + strings("Number of bits must be positive") +} + +internal val emptyComponentFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.CONSTRUCTOR) + parameters() + strings("EmptyComponent") + custom { _, classDef -> + classDef.methods.filter { AccessFlags.STATIC.isSet(it.accessFlags) }.size == 1 + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/litho/filter/LithoFilterPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/litho/filter/LithoFilterPatch.kt new file mode 100644 index 000000000..a3936d54d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/litho/filter/LithoFilterPatch.kt @@ -0,0 +1,243 @@ +@file:Suppress("SpellCheckingInspection") + +package app.revanced.patches.youtube.misc.litho.filter + +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.removeInstructions +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.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.playservice.is_19_18_or_greater +import app.revanced.patches.youtube.misc.playservice.versionCheckPatch +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.* +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +lateinit var addLithoFilter: (String) -> Unit + private set + +internal const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/youtube/patches/components/LithoFilterPatch;" + +val lithoFilterPatch = bytecodePatch( + description = "Hooks the method which parses the bytes into a ComponentContext to filter components.", +) { + dependsOn( + sharedExtensionPatch, + versionCheckPatch, + ) + + val componentContextParserMatch by componentContextParserFingerprint() + val lithoFilterMatch by lithoFilterFingerprint() + val protobufBufferReferenceMatch by protobufBufferReferenceFingerprint() + val readComponentIdentifierMatch by readComponentIdentifierFingerprint() + val emptyComponentMatch by emptyComponentFingerprint() + + var filterCount = 0 + + /** + * The following patch inserts a hook into the method that parses the bytes into a ComponentContext. + * This method contains a StringBuilder object that represents the pathBuilder of the component. + * The pathBuilder is used to filter components by their path. + * + * Additionally, the method contains a reference to the component's identifier. + * The identifier is used to filter components by their identifier. + * + * The protobuf buffer is passed along from a different injection point before the filtering occurs. + * The buffer is a large byte array that represents the component tree. + * This byte array is searched for strings that indicate the current component. + * + * The following pseudocode shows how the patch works: + * + * class SomeOtherClass { + * // Called before ComponentContextParser.parseBytesToComponentContext method. + * public void someOtherMethod(ByteBuffer byteBuffer) { + * ExtensionClass.setProtoBuffer(byteBuffer); // Inserted by this patch. + * ... + * } + * } + * + * When patching 19.17 and earlier: + * + * class ComponentContextParser { + * public ComponentContext ReadComponentIdentifierFingerprint(...) { + * ... + * if (extensionClass.filter(identifier, pathBuilder)); // Inserted by this patch. + * return emptyComponent; + * ... + * } + * } + * + * When patching 19.18 and later: + * + * class ComponentContextParser { + * public ComponentContext parseBytesToComponentContext(...) { + * ... + * if (ReadComponentIdentifierFingerprint() == null); // Inserted by this patch. + * return emptyComponent; + * ... + * } + * + * public ComponentIdentifierObj readComponentIdentifier(...) { + * ... + * if (extensionClass.filter(identifier, pathBuilder)); // Inserted by this patch. + * return null; + * ... + * } + * } + */ + execute { context -> + // Remove dummy filter from extenion static field + // and add the filters included during patching. + lithoFilterMatch.mutableMethod.apply { + removeInstructions(2, 4) // Remove dummy filter. + + addLithoFilter = { classDescriptor -> + addInstructions( + 2, + """ + new-instance v1, $classDescriptor + invoke-direct {v1}, $classDescriptor->()V + const/16 v2, ${filterCount++} + aput-object v1, v0, v2 + """, + ) + } + } + + // region Pass the buffer into extension. + + protobufBufferReferenceMatch.mutableMethod.addInstruction( + 0, + " invoke-static { p2 }, $EXTENSION_CLASS_DESCRIPTOR->setProtoBuffer(Ljava/nio/ByteBuffer;)V", + ) + + // endregion + + // region Hook the method that parses bytes into a ComponentContext. + + val readComponentMethod = readComponentIdentifierMatch.method + // Get the only static method in the class. + val builderMethodDescriptor = emptyComponentMatch.classDef.methods.first { method -> + AccessFlags.STATIC.isSet(method.accessFlags) + } + // Only one field. + val emptyComponentField = context.classBy { classDef -> + builderMethodDescriptor.returnType == classDef.type + }!!.immutableClass.fields.single() + + componentContextParserMatch.mutableMethod.apply { + // 19.18 and later require patching 2 methods instead of one. + // Otherwise, the patched code is the same. + if (is_19_18_or_greater) { + // Get the method name of the ReadComponentIdentifierFingerprint call. + val readComponentMethodCallIndex = indexOfFirstInstructionOrThrow { + val reference = getReference() + reference?.definingClass == readComponentMethod.definingClass && + reference.name == readComponentMethod.name + } + + // Result of read component, and also a free register. + val register = getInstruction(readComponentMethodCallIndex + 1).registerA + + // Insert after 'move-result-object' + val insertHookIndex = readComponentMethodCallIndex + 2 + + // Return an EmptyComponent instead of the original component if the filterState method returns true. + addInstructionsWithLabels( + insertHookIndex, + """ + if-nez v$register, :unfiltered + + # Component was filtered in ReadComponentIdentifierFingerprint hook + move-object/from16 v$register, p1 + invoke-static { v$register }, $builderMethodDescriptor + move-result-object v$register + iget-object v$register, v$register, $emptyComponentField + return-object v$register + """, + ExternalLabel("unfiltered", getInstruction(insertHookIndex)), + ) + } + } + + // endregion + + // region Read component then store the result. + + readComponentIdentifierMatch.mutableMethod.apply { + val insertHookIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.IPUT_OBJECT && + getReference()?.type == "Ljava/lang/StringBuilder;" + } + val stringBuilderRegister = getInstruction(insertHookIndex).registerA + + // Identifier is saved to a field just before the string builder. + val identifierRegister = getInstruction( + indexOfFirstInstructionReversedOrThrow(insertHookIndex) { + opcode == Opcode.IPUT_OBJECT && + getReference()?.type == "Ljava/lang/String;" + }, + ).registerA + + // Find a free temporary register. + val register = getInstruction( + // Immediately before is a StringBuilder append constant character. + indexOfFirstInstructionReversedOrThrow(insertHookIndex, Opcode.CONST_16), + ).registerA + + // Verify the temp register will not clobber the method result register. + if (stringBuilderRegister == register) { + throw PatchException("Free register will clobber StringBuilder register") + } + + val invokeFilterInstructions = """ + invoke-static { v$identifierRegister, v$stringBuilderRegister }, $EXTENSION_CLASS_DESCRIPTOR->filter(Ljava/lang/String;Ljava/lang/StringBuilder;)Z + move-result v$register + if-eqz v$register, :unfiltered + """ + + addInstructionsWithLabels( + insertHookIndex, + if (is_19_18_or_greater) { + """ + $invokeFilterInstructions + + # Return null, and the ComponentContextParserFingerprint hook + # handles returning an empty component. + const/4 v$register, 0x0 + return-object v$register + """ + } else { + """ + $invokeFilterInstructions + + # Exact same code as ComponentContextParserFingerprint hook, + # but with the free register of this method. + move-object/from16 v$register, p1 + invoke-static { v$register }, $builderMethodDescriptor + move-result-object v$register + iget-object v$register, v$register, $emptyComponentField + return-object v$register + """ + }, + ExternalLabel("unfiltered", getInstruction(insertHookIndex)), + ) + } + + // endregion + } + + finalize { + lithoFilterMatch.mutableMethod.replaceInstruction(0, "const/16 v0, $filterCount") + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/navigation/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/navigation/Fingerprints.kt new file mode 100644 index 000000000..0f5ade30a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/navigation/Fingerprints.kt @@ -0,0 +1,107 @@ +package app.revanced.patches.youtube.misc.navigation + +import app.revanced.patcher.fingerprint +import app.revanced.patches.youtube.layout.buttons.navigation.navigationButtonsPatch +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import app.revanced.util.literal +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 = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Landroid/view/View;") + literal { actionBarSearchResultsViewMicId } +} + +/** + * Matches to the class found in [pivotBarConstructorFingerprint]. + */ +internal val initializeButtonsFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + literal { imageOnlyTabResourceId } +} + +internal val mainActivityOnBackPressedFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters() + custom { method, classDef -> + val matchesClass = classDef.endsWith("MainActivity;") || + // Old versions of YouTube called this class "WatchWhileActivity" instead. + classDef.endsWith("WatchWhileActivity;") + + matchesClass && method.name == "onBackPressed" + } +} + +/** + * Extension method, used for callback into to other patches. + * Specifically, [navigationButtonsPatch]. + */ +internal val navigationBarHookCallbackFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.STATIC) + returns("V") + parameters(EXTENSION_NAVIGATION_BUTTON_DESCRIPTOR, "Landroid/view/View;") + custom { method, _ -> + method.name == "navigationTabCreatedCallback" && + method.definingClass == EXTENSION_CLASS_DESCRIPTOR + } +} + +/** + * Matches to the Enum class that looks up ordinal -> instance. + */ +internal val navigationEnumFingerprint = fingerprint { + accessFlags(AccessFlags.STATIC, AccessFlags.CONSTRUCTOR) + strings( + "PIVOT_HOME", + "TAB_SHORTS", + "CREATION_TAB_LARGE", + "PIVOT_SUBSCRIPTIONS", + "TAB_ACTIVITY", + "VIDEO_LIBRARY_WHITE", + "INCOGNITO_CIRCLE", + ) +} + +internal val pivotBarButtonsCreateDrawableViewFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Landroid/view/View;") + custom { method, _ -> + method.definingClass == "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 = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Landroid/view/View;") + parameters("L", "Z", "I", "L") + custom { method, _ -> + method.definingClass == "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 = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("I", "Z") + custom { method, _ -> + indexOfSetViewSelectedInstruction(method) >= 0 && + method.definingClass == "Lcom/google/android/libraries/youtube/rendering/ui/pivotbar/PivotBar;" + } +} + +internal val pivotBarConstructorFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + strings("com.google.android.apps.youtube.app.endpoint.flags") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/navigation/NavigationBarHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/navigation/NavigationBarHookPatch.kt new file mode 100644 index 000000000..fe9002a32 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/navigation/NavigationBarHookPatch.kt @@ -0,0 +1,163 @@ +package app.revanced.patches.youtube.misc.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.patch.resourcePatch +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.playertype.playerTypeHookPatch +import app.revanced.util.* +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 var imageOnlyTabResourceId = -1L + private set +internal var actionBarSearchResultsViewMicId = -1L + private set + +private val navigationBarHookResourcePatch = resourcePatch { + dependsOn(resourceMappingPatch) + + execute { + imageOnlyTabResourceId = resourceMappings["layout", "image_only_tab"] + actionBarSearchResultsViewMicId = resourceMappings["layout", "action_bar_search_results_view_mic"] + } +} + +internal const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/shared/NavigationBar;" +internal const val EXTENSION_NAVIGATION_BUTTON_DESCRIPTOR = + "Lapp/revanced/extension/youtube/shared/NavigationBar\$NavigationButton;" + +lateinit var hookNavigationButtonCreated: (String) -> Unit + +@Suppress("unused") +val navigationBarHookPatch = bytecodePatch(description = "Hooks the active navigation or search bar.") { + dependsOn( + sharedExtensionPatch, + navigationBarHookResourcePatch, + playerTypeHookPatch, // Required to detect the search bar in all situations. + ) + + val pivotBarConstructorMatch by pivotBarConstructorFingerprint() + val navigationEnumMatch by navigationEnumFingerprint() + val pivotBarButtonsCreateDrawableViewMatch by pivotBarButtonsCreateDrawableViewFingerprint() + val pivotBarButtonsCreateResourceViewMatch by pivotBarButtonsCreateResourceViewFingerprint() + val pivotBarButtonsViewSetSelectedMatch by pivotBarButtonsViewSetSelectedFingerprint() + val navigationBarHookCallbackMatch by navigationBarHookCallbackFingerprint() + val mainActivityOnBackPressedMatch by mainActivityOnBackPressedFingerprint() + val actionBarSearchResultsMatch by actionBarSearchResultsFingerprint() + + execute { context -> + 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.applyMatch(context, pivotBarConstructorMatch).mutableMethod.apply { + // Hook the current navigation bar enum value. Note, the 'You' tab does not have an enum value. + val navigationEnumClassName = navigationEnumMatch.mutableClass.type + addHook(Hook.SET_LAST_APP_NAVIGATION_ENUM) { + opcode == Opcode.INVOKE_STATIC && + getReference()?.definingClass == navigationEnumClassName + } + + // Hook the creation of navigation tab views. + val drawableTabMethod = pivotBarButtonsCreateDrawableViewMatch.mutableMethod + addHook(Hook.NAVIGATION_TAB_LOADED) predicate@{ + MethodUtil.methodSignaturesMatch( + getReference() ?: return@predicate false, + drawableTabMethod, + ) + } + + val imageResourceTabMethod = pivotBarButtonsCreateResourceViewMatch.method + addHook(Hook.NAVIGATION_IMAGE_RESOURCE_TAB_LOADED) predicate@{ + MethodUtil.methodSignaturesMatch( + getReference() ?: return@predicate false, + imageResourceTabMethod, + ) + } + } + + pivotBarButtonsViewSetSelectedMatch.mutableMethod.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", + ) + } + + // Hook onto back button pressed. Needed to fix race problem with + // Litho filtering based on navigation tab before the tab is updated. + mainActivityOnBackPressedMatch.mutableMethod.addInstruction( + 0, + "invoke-static { p0 }, " + + "$EXTENSION_CLASS_DESCRIPTOR->onBackPressed(Landroid/app/Activity;)V", + ) + + // Hook the search bar. + + // Two different layouts are used at the hooked code. + // Insert before the first ViewGroup method call after inflating, + // so this works regardless which layout is used. + actionBarSearchResultsMatch.mutableMethod.apply { + val searchBarResourceId = indexOfFirstWideLiteralInstructionValueOrThrow( + actionBarSearchResultsViewMicId, + ) + + val instructionIndex = indexOfFirstInstructionOrThrow(searchBarResourceId) { + opcode == Opcode.INVOKE_VIRTUAL && getReference()?.name == "setLayoutDirection" + } + + val viewRegister = getInstruction(instructionIndex).registerC + + addInstruction( + instructionIndex, + "invoke-static { v$viewRegister }, " + + "$EXTENSION_CLASS_DESCRIPTOR->searchBarResultsViewLoaded(Landroid/view/View;)V", + ) + } + + hookNavigationButtonCreated = { extensionClassDescriptor -> + navigationBarHookCallbackMatch.mutableMethod.addInstruction( + 0, + "invoke-static { p0, p1 }, " + + "$extensionClassDescriptor->navigationTabCreated" + + "(${EXTENSION_NAVIGATION_BUTTON_DESCRIPTOR}Landroid/view/View;)V", + ) + } + } +} + +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;"), + SEARCH_BAR_RESULTS_VIEW_LOADED("searchBarResultsViewLoaded", "Landroid/view/View;"), +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/playercontrols/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/playercontrols/Fingerprints.kt new file mode 100644 index 000000000..5bf4561f1 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/playercontrols/Fingerprints.kt @@ -0,0 +1,48 @@ +package app.revanced.patches.youtube.misc.playercontrols + +import app.revanced.patcher.fingerprint +import app.revanced.util.containsWideLiteralInstructionValue +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.AccessFlags + +internal val playerTopControlsInflateFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters() + literal { controlsLayoutStub } +} + +internal val playerControlsExtensionHookFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("V") + parameters("Z") + custom { methodDef, classDef -> + methodDef.name == "fullscreenButtonVisibilityChanged" && + classDef.type == "Lapp/revanced/extension/youtube/patches/PlayerControlsPatch;" + } +} + +internal val playerBottomControlsInflateFingerprint = fingerprint { + returns("Ljava/lang/Object;") + parameters() + literal { bottomUiContainerResourceId } +} + +internal val overlayViewInflateFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("Landroid/view/View;") + custom { methodDef, _ -> + methodDef.containsWideLiteralInstructionValue(fullscreenButton) && + methodDef.containsWideLiteralInstructionValue(heatseekerViewstub) + } +} + +/** + * Resolves to the class found in [playerTopControlsInflateFingerprint]. + */ +internal val controlsOverlayVisibilityFingerprint = fingerprint { + accessFlags(AccessFlags.PRIVATE, AccessFlags.FINAL) + returns("V") + parameters("Z", "Z") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/playercontrols/PlayerControlsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/playercontrols/PlayerControlsPatch.kt new file mode 100644 index 000000000..f682f6439 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/playercontrols/PlayerControlsPatch.kt @@ -0,0 +1,273 @@ +package app.revanced.patches.youtube.misc.playercontrols + +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.patch.resourcePatch +import app.revanced.patcher.util.Document +import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings +import app.revanced.util.* +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.TypeReference +import org.w3c.dom.Node + +/** + * 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 + +/** + * Add a new bottom to the bottom of the YouTube player. + * + * @param resourceDirectoryName The name of the directory containing the hosting resource. + */ +@Suppress("KDocUnresolvedReference") +lateinit var addBottomControl: (String) -> Unit + private set + +internal var bottomUiContainerResourceId = -1L + private set +internal var controlsLayoutStub = -1L + private set +internal var heatseekerViewstub = -1L + private set +internal var fullscreenButton = -1L + private set + +val playerControlsResourcePatch = resourcePatch { + dependsOn(resourceMappingPatch) + + /** + * The element to the left of the element being added. + */ + /** + * The element to the left of the element being added. + */ + var bottomLastLeftOf = "@id/fullscreen_button" + + lateinit var bottomTargetDocument: Document + + execute { context -> + val targetResourceName = "youtube_controls_bottom_ui_container.xml" + + bottomUiContainerResourceId = resourceMappings["id", "bottom_ui_container_stub"] + controlsLayoutStub = resourceMappings["id", "controls_layout_stub"] + heatseekerViewstub = resourceMappings["id", "heatseeker_viewstub"] + fullscreenButton = resourceMappings["id", "fullscreen_button"] + + bottomTargetDocument = context.document["res/layout/$targetResourceName"] + + val bottomTargetElement: Node = bottomTargetDocument.getElementsByTagName( + "android.support.constraint.ConstraintLayout", + ).item(0) + + var bottomInsertBeforeNode: Node = bottomTargetDocument.childNodes.findElementByAttributeValue( + "android:inflatedId", + bottomLastLeftOf, + ) ?: bottomTargetDocument.childNodes.findElementByAttributeValueOrThrow( + "android:id", // Older targets use non-inflated id. + bottomLastLeftOf, + ) + + addTopControl = { resourceDirectoryName -> + val resourceFileName = "host/layout/youtube_controls_layout.xml" + val hostingResourceStream = inputStreamFromBundledResource( + resourceDirectoryName, + resourceFileName + ) ?: throw PatchException("Could not find $resourceFileName") + + val document = context.document["res/layout/youtube_controls_layout.xml"] + + "RelativeLayout".copyXmlNode( + context.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 + } + } + + addBottomControl = { resourceDirectoryName -> + val resourceFileName = "host/layout/youtube_controls_bottom_ui_container.xml" + val sourceDocument = context.document[ + inputStreamFromBundledResource(resourceDirectoryName, resourceFileName) + ?: throw PatchException("Could not find $resourceFileName") + ] + + val sourceElements = sourceDocument.getElementsByTagName( + "android.support.constraint.ConstraintLayout", + ).item(0).childNodes + + // Copy the patch layout xml into the target layout file. + for (index in 1 until sourceElements.length) { + val element = sourceElements.item(index).cloneNode(true) + + // If the element has no attributes there's no point adding it to the destination. + if (!element.hasAttributes()) continue + + element.attributes.getNamedItem("yt:layout_constraintRight_toLeftOf").nodeValue = bottomLastLeftOf + bottomLastLeftOf = element.attributes.getNamedItem("android:id").nodeValue + + bottomTargetDocument.adoptNode(element) + // Elements do not need to be added in the layout order since a layout constraint is used, + // but in order is easier to make sense of while debugging. + bottomTargetElement.insertBefore(element, bottomInsertBeforeNode) + bottomInsertBeforeNode = element + } + + sourceDocument.close() + } + } + + finalize { + arrayOf( + "@id/bottom_end_container", + "@id/multiview_button", + ).forEach { + bottomTargetDocument.childNodes.findElementByAttributeValue( + "android:id", + it, + )?.setAttribute("yt:layout_constraintRight_toLeftOf", bottomLastLeftOf) + } + + bottomTargetDocument.close() + } +} + +/** + * Injects the code to initialize the controls. + * @param descriptor The descriptor of the method which should be called. + */ +internal fun initializeTopControl(descriptor: String) { + inflateTopControlMethod.addInstruction( + inflateTopControlInsertIndex++, + "invoke-static { v$inflateTopControlRegister }, $descriptor->initialize(Landroid/view/View;)V", + ) +} + +/** + * Injects the code to initialize the controls. + * @param descriptor The descriptor of the method which should be called. + */ +fun initializeBottomControl(descriptor: String) { + inflateBottomControlMethod.addInstruction( + inflateBottomControlInsertIndex++, + "invoke-static { v$inflateBottomControlRegister }, $descriptor->initializeButton(Landroid/view/View;)V", + ) +} + +/** + * Injects the code to change the visibility of controls. + * @param descriptor The descriptor of the method which should be called. + */ +fun injectVisibilityCheckCall(descriptor: String) { + visibilityMethod.addInstruction( + visibilityInsertIndex++, + "invoke-static { p1 , p2 }, $descriptor->changeVisibility(ZZ)V", + ) + + visibilityImmediateMethod.addInstruction( + visibilityImmediateInsertIndex++, + "invoke-static { p0 }, $descriptor->changeVisibilityImmediate(Z)V", + ) +} + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/PlayerControlsPatch;" + +private lateinit var inflateTopControlMethod: MutableMethod +private var inflateTopControlInsertIndex: Int = -1 +private var inflateTopControlRegister: Int = -1 + +private lateinit var inflateBottomControlMethod: MutableMethod +private var inflateBottomControlInsertIndex: Int = -1 +private var inflateBottomControlRegister: Int = -1 + +private lateinit var visibilityMethod: MutableMethod +private var visibilityInsertIndex: Int = 0 + +private lateinit var visibilityImmediateMethod: MutableMethod +private var visibilityImmediateInsertIndex: Int = 0 + +val playerControlsPatch = bytecodePatch( + description = "Manages the code for the player controls of the YouTube player.", +) { + dependsOn(playerControlsResourcePatch) + + val playerBottomControlsInflateMatch by playerBottomControlsInflateFingerprint() + val playerTopControlsInflateMatch by playerTopControlsInflateFingerprint() + val overlayViewInflateMatch by overlayViewInflateFingerprint() + val playerControlsExtensionHookMatch by playerControlsExtensionHookFingerprint() + + execute { context -> + fun MutableMethod.indexOfFirstViewInflateOrThrow() = + indexOfFirstInstructionOrThrow { + val reference = getReference() + reference?.definingClass == "Landroid/view/ViewStub;" && + reference.name == "inflate" + } + + playerBottomControlsInflateMatch.mutableMethod.apply { + inflateBottomControlMethod = this + + val inflateReturnObjectIndex = indexOfFirstViewInflateOrThrow() + 1 + inflateBottomControlRegister = getInstruction(inflateReturnObjectIndex).registerA + inflateBottomControlInsertIndex = inflateReturnObjectIndex + 1 + } + + playerTopControlsInflateMatch.mutableMethod.apply { + inflateTopControlMethod = this + + val inflateReturnObjectIndex = indexOfFirstViewInflateOrThrow() + 1 + inflateTopControlRegister = getInstruction(inflateReturnObjectIndex).registerA + inflateTopControlInsertIndex = inflateReturnObjectIndex + 1 + } + + controlsOverlayVisibilityFingerprint.applyMatch( + context, + playerTopControlsInflateMatch, + ).mutableMethod.apply { + visibilityMethod = this + } + + // Hook the fullscreen close button. Used to fix visibility + // when seeking and other situations. + overlayViewInflateMatch.mutableMethod.apply { + val resourceIndex = indexOfFirstWideLiteralInstructionValueReversedOrThrow(fullscreenButton) + + val index = indexOfFirstInstructionOrThrow(resourceIndex) { + opcode == Opcode.CHECK_CAST && + getReference()?.type == + "Landroid/widget/ImageView;" + } + val register = getInstruction(index).registerA + + addInstruction( + index + 1, + "invoke-static { v$register }, " + + "$EXTENSION_CLASS_DESCRIPTOR->setFullscreenCloseButton(Landroid/widget/ImageView;)V", + ) + } + + visibilityImmediateMethod = playerControlsExtensionHookMatch.mutableMethod + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/playertype/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/playertype/Fingerprints.kt new file mode 100644 index 000000000..2faae3acc --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/playertype/Fingerprints.kt @@ -0,0 +1,28 @@ +package app.revanced.patches.youtube.misc.playertype + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val playerTypeFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("L") + opcodes( + Opcode.IF_NE, + Opcode.RETURN_VOID, + ) + custom { _, classDef -> classDef.endsWith("/YouTubePlayerOverlaysLayout;") } +} + +internal val videoStateFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("Lcom/google/android/libraries/youtube/player/features/overlay/controls/ControlsState;") + opcodes( + Opcode.CONST_4, + Opcode.IF_EQZ, + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, // obfuscated parameter field name + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/playertype/PlayerTypeHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/playertype/PlayerTypeHookPatch.kt new file mode 100644 index 000000000..5ae0f6122 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/playertype/PlayerTypeHookPatch.kt @@ -0,0 +1,40 @@ +package app.revanced.patches.youtube.misc.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.bytecodePatch +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction + +internal const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/youtube/patches/PlayerTypeHookPatch;" + +@Suppress("unused") +val playerTypeHookPatch = bytecodePatch( + description = "Hook to get the current player type and video playback state.", +) { + dependsOn(sharedExtensionPatch) + + val playerTypeMatch by playerTypeFingerprint() + val videoStateMatch by videoStateFingerprint() + + execute { + playerTypeMatch.mutableMethod.addInstruction( + 0, + "invoke-static {p1}, $EXTENSION_CLASS_DESCRIPTOR->setPlayerType(Ljava/lang/Enum;)V", + ) + + videoStateMatch.mutableMethod.apply { + val endIndex = videoStateMatch.patternMatch!!.endIndex + val videoStateFieldName = getInstruction(endIndex).reference + + addInstructions( + 0, + """ + iget-object v0, p1, $videoStateFieldName # copy VideoState parameter field + invoke-static {v0}, $EXTENSION_CLASS_DESCRIPTOR->setVideoState(Ljava/lang/Enum;)V + """, + ) + } + } +} diff --git a/src/main/kotlin/app/revanced/patches/youtube/misc/playservice/VersionCheckPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/playservice/VersionCheckPatch.kt similarity index 55% rename from src/main/kotlin/app/revanced/patches/youtube/misc/playservice/VersionCheckPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/youtube/misc/playservice/VersionCheckPatch.kt index 4a704ca54..882e4537f 100644 --- a/src/main/kotlin/app/revanced/patches/youtube/misc/playservice/VersionCheckPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/playservice/VersionCheckPatch.kt @@ -1,37 +1,52 @@ +@file:Suppress("ktlint:standard:property-naming") + package app.revanced.patches.youtube.misc.playservice -import app.revanced.patcher.data.ResourceContext -import app.revanced.patcher.patch.ResourcePatch -import app.revanced.patcher.patch.annotation.Patch +import app.revanced.patcher.patch.resourcePatch import app.revanced.util.findElementByAttributeValueOrThrow -import kotlin.properties.Delegates -@Patch(description = "Uses the Play Store service version to find the major/minor version of the YouTube target app.") -internal object VersionCheckPatch : ResourcePatch() { - - var is_19_03_or_greater by Delegates.notNull() - var is_19_04_or_greater by Delegates.notNull() - var is_19_16_or_greater by Delegates.notNull() - var is_19_17_or_greater by Delegates.notNull() - var is_19_18_or_greater by Delegates.notNull() - var is_19_23_or_greater by Delegates.notNull() - var is_19_25_or_greater by Delegates.notNull() - var is_19_26_or_greater by Delegates.notNull() - var is_19_29_or_greater by Delegates.notNull() - var is_19_32_or_greater by Delegates.notNull() - var is_19_33_or_greater by Delegates.notNull() - var is_19_34_or_greater by Delegates.notNull() - var is_19_36_or_greater by Delegates.notNull() - var is_19_41_or_greater by Delegates.notNull() - - override fun execute(context: ResourceContext) { +var is_19_03_or_greater = false + private set +var is_19_04_or_greater = false + private set +var is_19_16_or_greater = false + private set +var is_19_17_or_greater = false + private set +var is_19_18_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_26_or_greater = false + private set +var is_19_29_or_greater = false + private set +var is_19_32_or_greater = false + private set +var is_19_33_or_greater = false + private set +var is_19_34_or_greater = false + private set +var is_19_36_or_greater = false + private set +var is_19_41_or_greater = false + private set +var is_19_43_or_greater = false + private set +@Suppress("unused") +val versionCheckPatch = resourcePatch( + description = "Uses the Play Store service version to find the major/minor version of the YouTube target app.", +) { + execute { context -> // The app version is missing from the decompiled manifest, // so instead use the Google Play services version and compare against specific releases. val playStoreServicesVersion = context.document["res/values/integers.xml"].use { document -> document.documentElement.childNodes.findElementByAttributeValueOrThrow( "name", - "google_play_services_version" + "google_play_services_version", ).textContent.toInt() } @@ -50,5 +65,6 @@ internal object VersionCheckPatch : ResourcePatch() { is_19_34_or_greater = 243499000 <= playStoreServicesVersion is_19_36_or_greater = 243705000 <= playStoreServicesVersion is_19_41_or_greater = 244305000 <= playStoreServicesVersion + is_19_43_or_greater = 244405000 <= playStoreServicesVersion } } diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/privacy/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/privacy/Fingerprints.kt new file mode 100644 index 000000000..72734bba7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/privacy/Fingerprints.kt @@ -0,0 +1,44 @@ +package app.revanced.patches.youtube.misc.privacy + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val copyTextFingerprint = fingerprint { + returns("V") + parameters("L", "Ljava/util/Map;") + opcodes( + Opcode.IGET_OBJECT, // Contains the text to copy to be sanitized. + Opcode.CONST_STRING, + Opcode.INVOKE_STATIC, // ClipData.newPlainText + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.IGET_OBJECT, + Opcode.IGET_OBJECT, + Opcode.INVOKE_INTERFACE, + Opcode.RETURN_VOID, + ) + strings("text/plain") +} + +internal val systemShareSheetFingerprint = fingerprint { + returns("V") + parameters("L", "Ljava/util/Map;") + opcodes( + Opcode.CHECK_CAST, + Opcode.GOTO, + ) + strings("YTShare_Logging_Share_Intent_Endpoint_Byte_Array") +} + +internal val youtubeShareSheetFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("L", "Ljava/util/Map;") + opcodes( + Opcode.CHECK_CAST, + Opcode.GOTO, + Opcode.MOVE_OBJECT, + Opcode.INVOKE_VIRTUAL, + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/privacy/RemoveTrackingQueryParameterPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/privacy/RemoveTrackingQueryParameterPatch.kt new file mode 100644 index 000000000..04da4a967 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/privacy/RemoveTrackingQueryParameterPatch.kt @@ -0,0 +1,82 @@ +package app.revanced.patches.youtube.misc.privacy + +import app.revanced.patcher.Match +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.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/RemoveTrackingQueryParameterPatch;" + +@Suppress("unused") +val removeTrackingQueryParameterPatch = bytecodePatch( + name = "Remove tracking query parameter", + description = "Adds an option to remove the tracking info from links you share.", +) { + dependsOn( + sharedExtensionPatch, + settingsPatch, + addResourcesPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val youTubeShareSheetMatch by youtubeShareSheetFingerprint() + val systemShareSheetMatch by systemShareSheetFingerprint() + val copyTextMatch by copyTextFingerprint() + + execute { + addResources("youtube", "misc.privacy.removeTrackingQueryParameterPatch") + + PreferenceScreen.MISC.addPreferences( + SwitchPreference("revanced_remove_tracking_query_parameter"), + ) + + fun Match.hook( + getInsertIndex: Match.PatternMatch.() -> Int, + getUrlRegister: MutableMethod.(insertIndex: Int) -> Int, + ) { + val insertIndex = patternMatch!!.getInsertIndex() + val urlRegister = mutableMethod.getUrlRegister(insertIndex) + + mutableMethod.addInstructions( + insertIndex, + """ + invoke-static {v$urlRegister}, $EXTENSION_CLASS_DESCRIPTOR->sanitize(Ljava/lang/String;)Ljava/lang/String; + move-result-object v$urlRegister + """, + ) + } + + // YouTube share sheet. + youTubeShareSheetMatch.hook(getInsertIndex = { startIndex + 1 }) { insertIndex -> + getInstruction(insertIndex - 1).registerA + } + + // Native system share sheet. + systemShareSheetMatch.hook(getInsertIndex = { endIndex }) { insertIndex -> + getInstruction(insertIndex - 1).registerA + } + + copyTextMatch.hook(getInsertIndex = { startIndex + 2 }) { insertIndex -> + getInstruction(insertIndex - 2).registerA + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/recyclerviewtree/hook/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/recyclerviewtree/hook/Fingerprints.kt new file mode 100644 index 000000000..09aa7bf4c --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/recyclerviewtree/hook/Fingerprints.kt @@ -0,0 +1,18 @@ +package app.revanced.patches.youtube.misc.recyclerviewtree.hook + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val recyclerViewTreeObserverFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + returns("V") + opcodes( + Opcode.CHECK_CAST, + Opcode.NEW_INSTANCE, + Opcode.INVOKE_DIRECT, + Opcode.INVOKE_VIRTUAL, + Opcode.NEW_INSTANCE, + ) + strings("LithoRVSLCBinder") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/recyclerviewtree/hook/RecyclerViewTreeHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/recyclerviewtree/hook/RecyclerViewTreeHookPatch.kt new file mode 100644 index 000000000..16863b200 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/recyclerviewtree/hook/RecyclerViewTreeHookPatch.kt @@ -0,0 +1,29 @@ +package app.revanced.patches.youtube.misc.recyclerviewtree.hook + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch + +lateinit var addRecyclerViewTreeHook: (String) -> Unit + private set + +val recyclerViewTreeHookPatch = bytecodePatch { + dependsOn(sharedExtensionPatch) + + val recyclerViewTreeObserverMatch by recyclerViewTreeObserverFingerprint() + + execute { + recyclerViewTreeObserverMatch.mutableMethod.apply { + val insertIndex = recyclerViewTreeObserverMatch.patternMatch!!.startIndex + 1 + val recyclerViewParameter = 2 + + addRecyclerViewTreeHook = { classDescriptor -> + addInstruction( + insertIndex, + "invoke-static/range { p$recyclerViewParameter .. p$recyclerViewParameter }, " + + "$classDescriptor->onFlyoutMenuCreate(Landroid/support/v7/widget/RecyclerView;)V", + ) + } + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/settings/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/settings/Fingerprints.kt new file mode 100644 index 000000000..42298d82f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/settings/Fingerprints.kt @@ -0,0 +1,23 @@ +package app.revanced.patches.youtube.misc.settings + +import app.revanced.patcher.fingerprint +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val licenseActivityOnCreateFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("L") + custom { method, classDef -> + classDef.endsWith("LicenseActivity;") && method.name == "onCreate" + } +} + +internal val setThemeFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("L") + parameters() + opcodes(Opcode.RETURN_OBJECT) + literal { appearanceStringId } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/settings/SettingsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/settings/SettingsPatch.kt new file mode 100644 index 000000000..5a731c905 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/settings/SettingsPatch.kt @@ -0,0 +1,272 @@ +package app.revanced.patches.youtube.misc.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.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.all.misc.packagename.setOrGetFallbackPackageName +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings +import app.revanced.patches.shared.misc.settings.preference.* +import app.revanced.patches.shared.misc.settings.preference.PreferenceScreenPreference.Sorting +import app.revanced.patches.shared.misc.settings.settingsPatch +import app.revanced.patches.youtube.misc.check.checkEnvironmentPatch +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.fix.cairo.disableCairoSettingsPatch +import app.revanced.util.* +import app.revanced.util.inputStreamFromBundledResource +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.util.MethodUtil + +// Used by a fingerprint() from SettingsPatch. +internal var appearanceStringId = -1L + private set + +private val preferences = mutableSetOf() + +fun addSettingPreference(screen: BasePreference) { + preferences += screen +} + +private val settingsResourcePatch = resourcePatch { + dependsOn( + resourceMappingPatch, + settingsPatch( + rootPreference = IntentPreference( + titleKey = "revanced_settings_title", + summaryKey = null, + intent = newIntent("revanced_settings_intent"), + ) to "settings_fragment", + preferences, + ), + ) + + execute { context -> + // Used for a fingerprint from SettingsPatch. + appearanceStringId = resourceMappings["string", "app_theme_appearance_dark"] + + arrayOf( + ResourceGroup("layout", "revanced_settings_with_toolbar.xml"), + ).forEach { resourceGroup -> + context.copyResources("settings", resourceGroup) + } + + // Copy style properties used to fix over-sized copy menu that appear in EditTextPreference. + // For a full explanation of how this fixes the issue, see the comments in this style file + // and the comments in the extension code. + val targetResource = "values/styles.xml" + inputStreamFromBundledResource( + "settings/host", + targetResource, + )!!.let { inputStream -> + "resources".copyXmlNode( + context.document[inputStream], + context.document["res/$targetResource"], + ).close() + } + + // Remove horizontal divider from the settings Preferences + // To better match the appearance of the stock YouTube settings. + context.document["res/values/styles.xml"].use { document -> + + arrayOf( + "Theme.YouTube.Settings", + "Theme.YouTube.Settings.Dark", + ).forEach { value -> + val listDividerNode = document.createElement("item") + listDividerNode.setAttribute("name", "android:listDivider") + listDividerNode.appendChild(document.createTextNode("@null")) + + document.childNodes.findElementByAttributeValueOrThrow( + "name", + value, + ).appendChild(listDividerNode) + } + } + + // Modify the manifest and add a data intent filter to the LicenseActivity. + // Some devices freak out if undeclared data is passed to an intent, + // and this change appears to fix the issue. + context.document["AndroidManifest.xml"].use { document -> + + val licenseElement = document.childNodes.findElementByAttributeValueOrThrow( + "android:name", + "com.google.android.libraries.social.licenses.LicenseActivity", + ) + + val mimeType = document.createElement("data") + mimeType.setAttribute("android:mimeType", "text/plain") + + val intentFilter = document.createElement("intent-filter") + intentFilter.appendChild(mimeType) + + licenseElement.appendChild(intentFilter) + } + } +} + +val settingsPatch = bytecodePatch( + description = "Adds settings for ReVanced to YouTube.", +) { + dependsOn( + sharedExtensionPatch, + settingsResourcePatch, + addResourcesPatch, + disableCairoSettingsPatch, + // Currently there is no easy way to make a mandatory patch, + // so for now this is a dependent of this patch. + checkEnvironmentPatch, + ) + + val setThemeMatch by setThemeFingerprint() + val licenseActivityOnCreateMatch by licenseActivityOnCreateFingerprint() + + val extensionPackage = "app/revanced/extension/youtube" + val activityHookClassDescriptor = "L$extensionPackage/settings/LicenseActivityHook;" + + val themeHelperDescriptor = "L$extensionPackage/ThemeHelper;" + val setThemeMethodName = "setTheme" + + execute { + addResources("youtube", "misc.settings.settingsPatch") + + // Add an "about" preference to the top. + preferences += NonInteractivePreference( + key = "revanced_settings_screen_00_about", + summaryKey = null, + tag = "app.revanced.extension.youtube.settings.preference.ReVancedYouTubeAboutPreference", + selectable = true, + ) + + PreferenceScreen.MISC.addPreferences( + TextPreference( + key = null, + titleKey = "revanced_pref_import_export_title", + summaryKey = "revanced_pref_import_export_summary", + inputType = InputType.TEXT_MULTI_LINE, + tag = "app.revanced.extension.shared.settings.preference.ImportExportPreference", + ), + ) + + setThemeMatch.mutableMethod.let { setThemeMethod -> + setThemeMethod.implementation!!.instructions.mapIndexedNotNull { i, instruction -> + if (instruction.opcode == Opcode.RETURN_OBJECT) i else null + }.asReversed().forEach { returnIndex -> + // The following strategy is to replace the return instruction with the setTheme instruction, + // then add a return instruction after the setTheme instruction. + // This is done because the return instruction is a target of another instruction. + + setThemeMethod.apply { + // This register is returned by the setTheme method. + val register = getInstruction(returnIndex).registerA + replaceInstruction( + returnIndex, + "invoke-static { v$register }, " + + "$themeHelperDescriptor->$setThemeMethodName(Ljava/lang/Enum;)V", + ) + addInstruction(returnIndex + 1, "return-object v$register") + } + } + } + + // Modify the license activity and remove all existing layout code. + // Must modify an existing activity and cannot add a new activity to the manifest, + // as that fails for root installations. + licenseActivityOnCreateMatch.mutableMethod.addInstructions( + 1, + """ + invoke-static { p0 }, $activityHookClassDescriptor->initialize(Landroid/app/Activity;)V + return-void + """, + ) + + // Remove other methods as they will break as the onCreate method is modified above. + licenseActivityOnCreateMatch.mutableClass.apply { + methods.removeIf { it.name != "onCreate" && !MethodUtil.isConstructor(it) } + } + } + + finalize { + PreferenceScreen.close() + } +} + +/** + * Creates an intent to open ReVanced settings. + */ +fun newIntent(settingsName: String) = IntentPreference.Intent( + data = settingsName, + targetClass = "com.google.android.libraries.social.licenses.LicenseActivity", +) { + // The package name change has to be reflected in the intent. + setOrGetFallbackPackageName("com.google.android.youtube") +} + +object PreferenceScreen : BasePreferenceScreen() { + // Sort screens in the root menu by key, to not scatter related items apart + // (sorting key is set in revanced_prefs.xml). + // If no preferences are added to a screen, the screen will not be added to the settings. + val ADS = Screen( + key = "revanced_settings_screen_01_ads", + summaryKey = null, + ) + val ALTERNATIVE_THUMBNAILS = Screen( + key = "revanced_settings_screen_02_alt_thumbnails", + summaryKey = null, + sorting = Sorting.UNSORTED, + ) + val FEED = Screen( + key = "revanced_settings_screen_03_feed", + summaryKey = null, + ) + val PLAYER = Screen( + key = "revanced_settings_screen_04_player", + summaryKey = null, + ) + val GENERAL_LAYOUT = Screen( + key = "revanced_settings_screen_05_general", + summaryKey = null, + ) + + // Don't sort, as related preferences are scattered apart. + // Can use title sorting after PreferenceCategory support is added. + val SHORTS = Screen( + key = "revanced_settings_screen_06_shorts", + summaryKey = null, + sorting = Sorting.UNSORTED, + ) + + // Don't sort, because title sorting scatters the custom color preferences. + val SEEKBAR = Screen( + key = "revanced_settings_screen_07_seekbar", + summaryKey = null, + sorting = Sorting.UNSORTED, + ) + val SWIPE_CONTROLS = Screen( + key = "revanced_settings_screen_08_swipe_controls", + summaryKey = null, + sorting = Sorting.UNSORTED, + ) + + // RYD and SB are items 9 and 10. + // Menus are added in their own patch because they use an Intent and not a Screen. + + val MISC = Screen( + key = "revanced_settings_screen_11_misc", + summaryKey = null, + ) + val VIDEO = Screen( + key = "revanced_settings_screen_12_video", + summaryKey = null, + ) + + override fun commit(screen: PreferenceScreenPreference) { + preferences += screen + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/zoomhaptics/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/zoomhaptics/Fingerprints.kt new file mode 100644 index 000000000..65cb70769 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/zoomhaptics/Fingerprints.kt @@ -0,0 +1,7 @@ +package app.revanced.patches.youtube.misc.zoomhaptics + +import app.revanced.patcher.fingerprint + +internal val zoomHapticsFingerprint = fingerprint { + strings("Failed to haptics vibrate for video zoom") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/misc/zoomhaptics/ZoomHapticsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/zoomhaptics/ZoomHapticsPatch.kt new file mode 100644 index 000000000..31cdccd8b --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/misc/zoomhaptics/ZoomHapticsPatch.kt @@ -0,0 +1,47 @@ +package app.revanced.patches.youtube.misc.zoomhaptics + +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.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch + +@Suppress("unused") +val zoomHapticsPatch = bytecodePatch( + name = "Disable zoom haptics", + description = "Adds an option to disable haptics when zooming.", +) { + dependsOn( + settingsPatch, + addResourcesPatch, + ) + + compatibleWith("com.google.android.youtube") + + val zoomHapticsMatch by zoomHapticsFingerprint() + + execute { + addResources("youtube", "misc.zoomhaptics.zoomHapticsPatch") + + PreferenceScreen.MISC.addPreferences( + SwitchPreference("revanced_disable_zoom_haptics"), + ) + + zoomHapticsMatch.mutableMethod.apply { + addInstructionsWithLabels( + 0, + """ + invoke-static { }, Lapp/revanced/extension/youtube/patches/ZoomHapticsPatch;->shouldVibrate()Z + move-result v0 + if-nez v0, :vibrate + return-void + """, + ExternalLabel("vibrate", getInstruction(0)), + ) + } + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/shared/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/shared/Fingerprints.kt new file mode 100644 index 000000000..a7c72504e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/shared/Fingerprints.kt @@ -0,0 +1,130 @@ +package app.revanced.patches.youtube.shared + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val autoRepeatFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters() + custom { method, _ -> + method.implementation!!.instructions.count() == 3 && method.annotations.isEmpty() + } +} + +internal val autoRepeatParentFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + strings( + "play() called when the player wasn't loaded.", + "play() blocked because Background Playability failed", + ) +} + +internal val layoutConstructorFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters() + strings("1.0x") +} + +internal val mainActivityFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + parameters() + custom { _, classDef -> + // Old versions of YouTube called this class "WatchWhileActivity" instead. + classDef.endsWith("MainActivity;") || classDef.endsWith("WatchWhileActivity;") + } +} + +internal val mainActivityOnCreateFingerprint = fingerprint { + returns("V") + parameters("Landroid/os/Bundle;") + custom { method, classDef -> + method.name == "onCreate" && + ( + classDef.endsWith("MainActivity;") || + // Old versions of YouTube called this class "WatchWhileActivity" instead. + classDef.endsWith("WatchWhileActivity;") + ) + } +} + +val rollingNumberTextViewAnimationUpdateFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("Landroid/graphics/Bitmap;") + opcodes( + Opcode.NEW_INSTANCE, // bitmap ImageSpan + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CONST_4, + Opcode.INVOKE_DIRECT, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.CONST_16, + Opcode.INVOKE_VIRTUAL, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.INT_TO_FLOAT, + Opcode.INVOKE_VIRTUAL, // set textview padding using bitmap width + ) + custom { _, classDef -> + classDef.superclass == "Landroid/support/v7/widget/AppCompatTextView;" || + classDef.superclass == + "Lcom/google/android/libraries/youtube/rendering/ui/spec/typography/YouTubeAppCompatTextView;" + } +} + +internal val seekbarFingerprint = fingerprint { + returns("V") + strings("timed_markers_width") +} + +internal val seekbarOnDrawFingerprint = fingerprint { + custom { method, _ -> method.name == "onDraw" } +} + +internal val subtitleButtonControllerFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("Lcom/google/android/libraries/youtube/player/subtitles/model/SubtitleTrack;") + opcodes( + Opcode.IGET_OBJECT, + Opcode.IF_NEZ, + Opcode.RETURN_VOID, + Opcode.IGET_BOOLEAN, + Opcode.CONST_4, + Opcode.IF_NEZ, + Opcode.CONST, + Opcode.INVOKE_VIRTUAL, + Opcode.IGET_OBJECT, + ) +} + +internal val newVideoQualityChangedFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("L") + parameters("L") + opcodes( + 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/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 000000000..d0c961c9d --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/information/Fingerprints.kt @@ -0,0 +1,132 @@ +package app.revanced.patches.youtube.video.information + +import app.revanced.patcher.fingerprint +import app.revanced.patches.youtube.shared.newVideoQualityChangedFingerprint +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.iface.reference.FieldReference + +internal val createVideoPlayerSeekbarFingerprint = fingerprint { + returns("V") + strings("timed_markers_width") +} + +internal val onPlaybackSpeedItemClickFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("L", "L", "I", "J") + custom { method, _ -> + method.name == "onItemClick" && + method.implementation?.instructions?.find { + it.opcode == Opcode.IGET_OBJECT && + it.getReference()!!.type == "Lcom/google/android/libraries/youtube/innertube/model/player/PlayerResponseModel;" + } != null + } +} + +internal val playerControllerSetTimeReferenceFingerprint = fingerprint { + opcodes(Opcode.INVOKE_DIRECT_RANGE, Opcode.IGET_OBJECT) + strings("Media progress reported outside media playback: ") +} + +internal val playerInitFingerprint = fingerprint { + strings("playVideo called on player response with no videoStreamingData.") +} + +/** + * Matched using class found in [playerInitFingerprint]. + */ +internal val seekFingerprint = fingerprint { + strings("Attempting to seek during an ad") +} + +internal val videoLengthFingerprint = fingerprint { + opcodes( + Opcode.MOVE_RESULT_WIDE, + Opcode.CMP_LONG, + Opcode.IF_LEZ, + Opcode.IGET_OBJECT, + Opcode.CHECK_CAST, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_WIDE, + Opcode.GOTO, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_WIDE, + Opcode.CONST_4, + Opcode.INVOKE_VIRTUAL, + ) +} + +/** + * Matches using class found in [mdxPlayerDirectorSetVideoStageFingerprint]. + */ +internal val mdxSeekFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Z") + parameters("J", "L") + opcodes( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.RETURN, + ) + custom { methodDef, _ -> + // The instruction count is necessary here to avoid matching the relative version + // of the seek method we're after, which has the same function signature as the + // regular one, is in the same class, and even has the exact same 3 opcodes pattern. + methodDef.implementation!!.instructions.count() == 3 + } +} + +internal val mdxPlayerDirectorSetVideoStageFingerprint = fingerprint { + strings("MdxDirector setVideoStage ad should be null when videoStage is not an Ad state ") +} + +/** + * Matches using class found in [mdxPlayerDirectorSetVideoStageFingerprint]. + */ +internal val mdxSeekRelativeFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + // Return type is boolean up to 19.39, and void with 19.39+. + parameters("J", "L") + opcodes( + + Opcode.IGET_OBJECT, + Opcode.INVOKE_INTERFACE, + ) +} + +/** + * Matches using class found in [playerInitFingerprint]. + */ +internal val seekRelativeFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + // Return type is boolean up to 19.39, and void with 19.39+. + parameters("J", "L") + opcodes( + Opcode.ADD_LONG_2ADDR, + Opcode.INVOKE_VIRTUAL, + ) +} + +/** + * Resolves with the class found in [newVideoQualityChangedFingerprint]. + */ +internal val playbackSpeedMenuSpeedChangedFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("L") + parameters("L") + opcodes( + 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, + Opcode.SGET_OBJECT, + Opcode.RETURN_OBJECT, + ) +} 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 000000000..a52a8e0d4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/information/VideoInformationPatch.kt @@ -0,0 +1,311 @@ +package app.revanced.patches.youtube.video.information + +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.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.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.shared.newVideoQualityChangedFingerprint +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.hookBackgroundPlayVideoId +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.applyMatch +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.builder.BuilderInstruction +import com.android.tools.smali.dexlib2.builder.MutableMethodImplementation +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.immutable.ImmutableMethod +import com.android.tools.smali.dexlib2.immutable.ImmutableMethodParameter +import com.android.tools.smali.dexlib2.util.MethodUtil +import com.sun.org.apache.bcel.internal.generic.InstructionConst.getInstruction + +private const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/youtube/patches/VideoInformation;" +private const val EXTENSION_PLAYER_INTERFACE = "Lapp/revanced/extension/youtube/patches/VideoInformation${'$'}PlaybackController;" + +private lateinit var playerInitMethod: MutableMethod +private var playerInitInsertIndex = -1 +private var playerInitInsertRegister = -1 + +private lateinit var mdxInitMethod: MutableMethod +private var mdxInitInsertIndex = -1 +private var mdxInitInsertRegister = -1 + +private lateinit var timeMethod: MutableMethod +private var timeInitInsertIndex = 2 + +// Old speed menu, where speeds are entries in a list. Method is also used by the player speed button. +private lateinit var legacySpeedSelectionInsertMethod: MutableMethod +private var legacySpeedSelectionInsertIndex = -1 +private var legacySpeedSelectionValueRegister = -1 + +// New speed menu, with preset buttons and 0.05x fine adjustments buttons. +private lateinit var speedSelectionInsertMethod: MutableMethod +private var speedSelectionInsertIndex = -1 +private var speedSelectionValueRegister = -1 + +// Used by other patches. +lateinit var setPlaybackSpeedContainerClassFieldReference: String + private set +lateinit var setPlaybackSpeedClassFieldReference: String + private set +lateinit var setPlaybackSpeedMethodReference: String + private set + +val videoInformationPatch = bytecodePatch( + description = "Hooks YouTube to get information about the current playing video.", +) { + dependsOn( + sharedExtensionPatch, + videoIdPatch, + playerResponseMethodHookPatch, + ) + + val playerInitMatch by playerInitFingerprint() + val mdxPlayerDirectorSetVideoStageMatch by mdxPlayerDirectorSetVideoStageFingerprint() + val createVideoPlayerSeekbarMatch by createVideoPlayerSeekbarFingerprint() + val playerControllerSetTimeReferenceMatch by playerControllerSetTimeReferenceFingerprint() + val onPlaybackSpeedItemClickMatch by onPlaybackSpeedItemClickFingerprint() + val newVideoQualityChangedMatch by newVideoQualityChangedFingerprint() + + execute { context -> + playerInitMethod = playerInitMatch.mutableClass.methods.first { MethodUtil.isConstructor(it) } + + // Find the location of the first invoke-direct call and extract the register storing the 'this' object reference. + val initThisIndex = playerInitMethod.indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_DIRECT && getReference()?.name == "" + } + playerInitInsertRegister = playerInitMethod.getInstruction(initThisIndex).registerC + playerInitInsertIndex = initThisIndex + 1 + + // Hook the player controller for use through the extension. + onCreateHook(EXTENSION_CLASS_DESCRIPTOR, "initialize") + + val seekFingerprintResultMethod = seekFingerprint.applyMatch(context, playerInitMatch).method + val seekRelativeFingerprintResultMethod = seekRelativeFingerprint.applyMatch(context, playerInitMatch).method + + // Create extension interface methods. + addSeekInterfaceMethods(playerInitMatch.mutableClass, seekFingerprintResultMethod, seekRelativeFingerprintResultMethod) + + with(mdxPlayerDirectorSetVideoStageMatch) { + mdxInitMethod = mutableClass.methods.first { MethodUtil.isConstructor(it) } + + val initThisIndex = mdxInitMethod.indexOfFirstInstructionOrThrow { + opcode == Opcode.INVOKE_DIRECT && getReference()?.name == "" + } + mdxInitInsertRegister = mdxInitMethod.getInstruction(initThisIndex).registerC + mdxInitInsertIndex = initThisIndex + 1 + + // Hook the MDX director for use through the extension. + onCreateHookMdx(EXTENSION_CLASS_DESCRIPTOR, "initializeMdx") + + val mdxSeekFingerprintResultMethod = + mdxSeekFingerprint.applyMatch(context, mdxPlayerDirectorSetVideoStageMatch).method + val mdxSeekRelativeFingerprintResultMethod = + mdxSeekRelativeFingerprint.applyMatch(context, mdxPlayerDirectorSetVideoStageMatch).method + + addSeekInterfaceMethods(mutableClass, mdxSeekFingerprintResultMethod, mdxSeekRelativeFingerprintResultMethod) + } + + with(createVideoPlayerSeekbarMatch) { + val videoLengthMethodMatch = videoLengthFingerprint.apply { match(context, classDef) }.match!! + + with(videoLengthMethodMatch.mutableMethod) { + val videoLengthRegisterIndex = videoLengthMethodMatch.patternMatch!!.endIndex - 2 + val videoLengthRegister = getInstruction(videoLengthRegisterIndex).registerA + val dummyRegisterForLong = videoLengthRegister + 1 // required for long values since they are wide + + addInstruction( + videoLengthMethodMatch.patternMatch!!.endIndex, + "invoke-static {v$videoLengthRegister, v$dummyRegisterForLong}, " + + "$EXTENSION_CLASS_DESCRIPTOR->setVideoLength(J)V", + ) + } + } + + /* + * Inject call for video ids + */ + val videoIdMethodDescriptor = "$EXTENSION_CLASS_DESCRIPTOR->setVideoId(Ljava/lang/String;)V" + hookVideoId(videoIdMethodDescriptor) + hookBackgroundPlayVideoId(videoIdMethodDescriptor) + 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.ProtoBufferParameterBeforeVideoId( + "$EXTENSION_CLASS_DESCRIPTOR->" + + "newPlayerResponseSignature(Ljava/lang/String;Ljava/lang/String;Z)Ljava/lang/String;", + ), + ) + + /* + * Set the video time method + */ + timeMethod = context.navigate(playerControllerSetTimeReferenceMatch.method) + .at(playerControllerSetTimeReferenceMatch.patternMatch!!.startIndex) + .mutable() + + /* + * Hook the methods which set the time + */ + videoTimeHook(EXTENSION_CLASS_DESCRIPTOR, "setVideoTime") + + /* + * Hook the user playback speed selection + */ + onPlaybackSpeedItemClickMatch.mutableMethod.apply { + speedSelectionInsertMethod = this + val speedSelectionMethodInstructions = this.implementation!!.instructions + val speedSelectionValueInstructionIndex = speedSelectionMethodInstructions.indexOfFirst { + it.opcode == Opcode.IGET + } + legacySpeedSelectionValueRegister = + getInstruction(speedSelectionValueInstructionIndex).registerA + setPlaybackSpeedClassFieldReference = + getInstruction(speedSelectionValueInstructionIndex + 1).reference.toString() + setPlaybackSpeedMethodReference = + getInstruction(speedSelectionValueInstructionIndex + 2).reference.toString() + setPlaybackSpeedContainerClassFieldReference = + getReference(speedSelectionMethodInstructions, -1, Opcode.IF_EQZ) + legacySpeedSelectionInsertIndex = speedSelectionValueInstructionIndex + 1 + } + + // Handle new playback speed menu. + playbackSpeedMenuSpeedChangedFingerprint.applyMatch( + context, + newVideoQualityChangedMatch, + ).mutableMethod.apply { + val index = indexOfFirstInstructionOrThrow(Opcode.IGET) + speedSelectionInsertMethod = this + speedSelectionInsertIndex = index + 1 + speedSelectionValueRegister = getInstruction(index).registerA + } + + userSelectedPlaybackSpeedHook(EXTENSION_CLASS_DESCRIPTOR, "userSelectedPlaybackSpeed") + } +} +private fun addSeekInterfaceMethods(targetClass: MutableClass, seekToMethod: Method, seekToRelativeMethod: Method) { + // Add the interface and methods that extension calls. + targetClass.interfaces.add(EXTENSION_PLAYER_INTERFACE) + + arrayOf( + Triple(seekToMethod, "seekTo", true), + Triple(seekToRelativeMethod, "seekToRelative", false), + ).forEach { (method, name, returnsBoolean) -> + // Add interface method. + // Get enum type for the seek helper method. + val seekSourceEnumType = method.parameterTypes[1].toString() + + val interfaceImplementation = ImmutableMethod( + targetClass.type, + name, + listOf(ImmutableMethodParameter("J", null, "time")), + if (returnsBoolean) "Z" else "V", + AccessFlags.PUBLIC.value or AccessFlags.FINAL.value, + null, + null, + MutableMethodImplementation(4), + ).toMutable() + + var instructions = """ + # First enum (field a) is SEEK_SOURCE_UNKNOWN. + sget-object v0, $seekSourceEnumType->a:$seekSourceEnumType + invoke-virtual { p0, p1, p2, v0 }, $method + """ + + instructions += if (returnsBoolean) { + """ + move-result p1 + return p1 + """ + } else { + "return-void" + } + + // Insert helper method instructions. + interfaceImplementation.addInstructions( + 0, + instructions, + ) + + targetClass.methods.add(interfaceImplementation) + } +} + +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) = + playerInitMethod.insert( + playerInitInsertIndex++, + "v$playerInitInsertRegister", + "$targetMethodClass->$targetMethodName($EXTENSION_PLAYER_INTERFACE)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) = + mdxInitMethod.insert( + mdxInitInsertIndex++, + "v$mdxInitInsertRegister", + "$targetMethodClass->$targetMethodName($EXTENSION_PLAYER_INTERFACE)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. + */ +fun videoTimeHook(targetMethodClass: String, targetMethodName: String) = + timeMethod.insertTimeHook( + timeInitInsertIndex++, + "$targetMethodClass->$targetMethodName(J)V", + ) + +private fun getReference(instructions: List, offset: Int, opcode: Opcode) = + (instructions[instructions.indexOfFirst { it.opcode == opcode } + offset] as ReferenceInstruction) + .reference.toString() + +/** + * Hook the video speed selected by the user. + */ +fun userSelectedPlaybackSpeedHook(targetMethodClass: String, targetMethodName: String) = + speedSelectionInsertMethod.addInstruction( + speedSelectionInsertIndex++, + "invoke-static {v$speedSelectionValueRegister}, $targetMethodClass->$targetMethodName(F)V", + ) 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 000000000..603329de7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/playerresponse/Fingerprints.kt @@ -0,0 +1,52 @@ +package app.revanced.patches.youtube.video.playerresponse + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +/** + * For targets 19.25 and later. + */ +internal val playerParameterBuilderFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("L") + parameters( + "Ljava/lang/String;", // VideoId. + "[B", + "Ljava/lang/String;", // Player parameters proto buffer. + "Ljava/lang/String;", + "I", + "I", + "L", // 19.25+ parameter + "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", + ) + strings("psps") +} + +/** + * For targets 19.24 and earlier. + */ +internal val playerParameterBuilderLegacyFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("L") + parameters( + "Ljava/lang/String;", // VideoId. + "[B", + "Ljava/lang/String;", // Player parameters proto buffer. + "Ljava/lang/String;", + "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", + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/playerresponse/PlayerResponseMethodHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/playerresponse/PlayerResponseMethodHookPatch.kt new file mode 100644 index 000000000..3febd4a73 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/playerresponse/PlayerResponseMethodHookPatch.kt @@ -0,0 +1,122 @@ +package app.revanced.patches.youtube.video.playerresponse + +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.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.playservice.is_19_23_or_greater +import app.revanced.patches.youtube.misc.playservice.versionCheckPatch + +private val hooks = mutableSetOf() + +fun addPlayerResponseMethodHook(hook: Hook) { + hooks += hook +} + +// Parameter numbers of the patched method. +private const val PARAMETER_VIDEO_ID = 1 +private const val PARAMETER_PROTO_BUFFER = 3 +private var parameterIsShortAndOpeningOrPlaying = -1 + +// Registers used to pass the parameters to the extension. +private var playerResponseMethodCopyRegisters = false +private lateinit var registerVideoId: String +private lateinit var registerProtoBuffer: String +private lateinit var registerIsShortAndOpeningOrPlaying: String + +private lateinit var playerResponseMethod: MutableMethod +private var numberOfInstructionsAdded = 0 + +val playerResponseMethodHookPatch = bytecodePatch { + dependsOn( + sharedExtensionPatch, + versionCheckPatch, + ) + + val playerParameterBuilderMatch by playerParameterBuilderFingerprint() + val playerParameterBuilderLegacyMatch by playerParameterBuilderLegacyFingerprint() + + execute { + if (is_19_23_or_greater) { + playerResponseMethod = playerParameterBuilderMatch.mutableMethod + parameterIsShortAndOpeningOrPlaying = 12 + } else { + playerResponseMethod = playerParameterBuilderLegacyMatch.mutableMethod + parameterIsShortAndOpeningOrPlaying = 11 + } + + // 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 the extension. + playerResponseMethodCopyRegisters = playerResponseMethod.implementation!!.registerCount - + playerResponseMethod.parameterTypes.size + parameterIsShortAndOpeningOrPlaying > 15 + + if (playerResponseMethodCopyRegisters) { + registerVideoId = "v0" + registerProtoBuffer = "v1" + registerIsShortAndOpeningOrPlaying = "v2" + } else { + registerVideoId = "p$PARAMETER_VIDEO_ID" + registerProtoBuffer = "p$PARAMETER_PROTO_BUFFER" + registerIsShortAndOpeningOrPlaying = "p$parameterIsShortAndOpeningOrPlaying" + } + } + + finalize { + fun hookVideoId(hook: Hook) { + playerResponseMethod.addInstruction( + 0, + "invoke-static {$registerVideoId, $registerIsShortAndOpeningOrPlaying}, $hook", + ) + numberOfInstructionsAdded++ + } + + fun hookProtoBufferParameter(hook: Hook) { + playerResponseMethod.addInstructions( + 0, + """ + invoke-static {$registerProtoBuffer, $registerVideoId, $registerIsShortAndOpeningOrPlaying}, $hook + move-result-object $registerProtoBuffer + """, + ) + 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(::hookProtoBufferParameter) + videoIdHooks.forEach(::hookVideoId) + beforeVideoIdHooks.forEach(::hookProtoBufferParameter) + + if (playerResponseMethodCopyRegisters) { + playerResponseMethod.addInstructions( + 0, + """ + move-object/from16 $registerVideoId, p$PARAMETER_VIDEO_ID + move-object/from16 $registerProtoBuffer, p$PARAMETER_PROTO_BUFFER + move/from16 $registerIsShortAndOpeningOrPlaying, p$parameterIsShortAndOpeningOrPlaying + """, + ) + numberOfInstructionsAdded += 3 + + // Move the modified register back. + playerResponseMethod.addInstruction( + numberOfInstructionsAdded, + "move-object/from16 p$PARAMETER_PROTO_BUFFER, $registerProtoBuffer", + ) + } + } +} + +sealed class Hook private constructor(private val methodDescriptor: String) { + class VideoId(methodDescriptor: String) : Hook(methodDescriptor) + + class ProtoBufferParameter(methodDescriptor: String) : Hook(methodDescriptor) + class ProtoBufferParameterBeforeVideoId(methodDescriptor: String) : Hook(methodDescriptor) + + override fun toString() = methodDescriptor +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/quality/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/quality/Fingerprints.kt new file mode 100644 index 000000000..e33674a38 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/quality/Fingerprints.kt @@ -0,0 +1,37 @@ +package app.revanced.patches.youtube.video.quality + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +/** + * Matches with the class found in [videoQualitySetterFingerprint]. + */ +internal val setQualityByIndexMethodClassFieldReferenceFingerprint = fingerprint { + returns("V") + parameters("L") + opcodes( + Opcode.IGET_OBJECT, + Opcode.IPUT_OBJECT, + Opcode.IGET_OBJECT, + ) +} + +internal val videoQualityItemOnClickParentFingerprint = fingerprint { + returns("V") + strings("VIDEO_QUALITIES_MENU_BOTTOM_SHEET_FRAGMENT") +} + +internal val videoQualitySetterFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("[L", "I", "Z") + opcodes( + Opcode.IF_EQZ, + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_VIRTUAL, + Opcode.IPUT_BOOLEAN, + ) + strings("menu_item_video_quality") +} diff --git a/src/main/kotlin/app/revanced/patches/youtube/video/quality/RememberVideoQualityPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/quality/RememberVideoQualityPatch.kt similarity index 51% rename from src/main/kotlin/app/revanced/patches/youtube/video/quality/RememberVideoQualityPatch.kt rename to patches/src/main/kotlin/app/revanced/patches/youtube/video/quality/RememberVideoQualityPatch.kt index 3e3c7000e..79ca86a4d 100644 --- a/src/main/kotlin/app/revanced/patches/youtube/video/quality/RememberVideoQualityPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/quality/RememberVideoQualityPatch.kt @@ -1,77 +1,71 @@ package app.revanced.patches.youtube.video.quality -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.patch.BytecodePatch import app.revanced.patcher.patch.PatchException -import app.revanced.patcher.patch.annotation.CompatiblePackage -import app.revanced.patcher.patch.annotation.Patch -import app.revanced.patches.all.misc.resources.AddResourcesPatch +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch import app.revanced.patches.shared.misc.settings.preference.ListPreference import app.revanced.patches.shared.misc.settings.preference.SwitchPreference -import app.revanced.patches.youtube.misc.integrations.IntegrationsPatch -import app.revanced.patches.youtube.misc.settings.SettingsPatch -import app.revanced.patches.youtube.shared.fingerprints.NewVideoQualityChangedFingerprint -import app.revanced.patches.youtube.video.information.VideoInformationPatch -import app.revanced.patches.youtube.video.quality.fingerprints.SetQualityByIndexMethodClassFieldReferenceFingerprint -import app.revanced.patches.youtube.video.quality.fingerprints.VideoQualityItemOnClickParentFingerprint -import app.revanced.patches.youtube.video.quality.fingerprints.VideoQualitySetterFingerprint -import app.revanced.util.exception +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.patches.youtube.shared.newVideoQualityChangedFingerprint +import app.revanced.patches.youtube.video.information.onCreateHook +import app.revanced.patches.youtube.video.information.videoInformationPatch +import app.revanced.util.applyMatch 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 -@Patch( +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/playback/quality/RememberVideoQualityPatch;" + +@Suppress("unused") +val rememberVideoQualityPatch = bytecodePatch( name = "Remember video quality", description = "Adds an option to remember the last video quality selected.", - dependencies = [ - IntegrationsPatch::class, - VideoInformationPatch::class, - SettingsPatch::class, - AddResourcesPatch::class - ], - compatiblePackages = [ - CompatiblePackage( - "com.google.android.youtube", [ - "18.38.44", - "18.49.37", - "19.16.39", - "19.25.37", - "19.34.42", - ] - ) - ] -) -@Suppress("unused") -object RememberVideoQualityPatch : BytecodePatch( - setOf( - VideoQualitySetterFingerprint, - VideoQualityItemOnClickParentFingerprint, - NewVideoQualityChangedFingerprint - ) ) { - private const val INTEGRATIONS_CLASS_DESCRIPTOR = - "Lapp/revanced/integrations/youtube/patches/playback/quality/RememberVideoQualityPatch;" + dependsOn( + sharedExtensionPatch, + videoInformationPatch, + settingsPatch, + addResourcesPatch, + ) - override fun execute(context: BytecodeContext) { - AddResourcesPatch(this::class) + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) - SettingsPatch.PreferenceScreen.VIDEO.addPreferences( + val videoQualitySetterMatch by videoQualitySetterFingerprint() + val videoQualityItemOnClickParentMatch by videoQualityItemOnClickParentFingerprint() + val newVideoQualityChangedMatch by newVideoQualityChangedFingerprint() + + execute { context -> + addResources("youtube", "video.quality.rememberVideoQualityPatch") + + PreferenceScreen.VIDEO.addPreferences( SwitchPreference("revanced_remember_video_quality_last_selected"), ListPreference( key = "revanced_video_quality_default_wifi", summaryKey = null, entriesKey = "revanced_video_quality_default_entries", - entryValuesKey = "revanced_video_quality_default_entry_values" + entryValuesKey = "revanced_video_quality_default_entry_values", ), ListPreference( key = "revanced_video_quality_default_mobile", summaryKey = null, entriesKey = "revanced_video_quality_default_entries", - entryValuesKey = "revanced_video_quality_default_entry_values" - ) + entryValuesKey = "revanced_video_quality_default_entry_values", + ), ) /* @@ -81,17 +75,15 @@ object RememberVideoQualityPatch : BytecodePatch( * It also hooks the method which is called when the video quality to set is determined. * Conveniently, at this point the video quality is overridden to the remembered playback speed. */ - VideoInformationPatch.onCreateHook(INTEGRATIONS_CLASS_DESCRIPTOR, "newVideoStarted") - + onCreateHook(EXTENSION_CLASS_DESCRIPTOR, "newVideoStarted") // Inject a call to set the remembered quality once a video loads. - VideoQualitySetterFingerprint.result?.also { - if (!SetQualityByIndexMethodClassFieldReferenceFingerprint.resolve(context, it.classDef)) - throw PatchException("Could not resolve fingerprint to find setQualityByIndex method") - }?.let { + setQualityByIndexMethodClassFieldReferenceFingerprint.applyMatch( + context, + videoQualitySetterMatch, + ).let { match -> // This instruction refers to the field with the type that contains the setQualityByIndex method. - val instructions = SetQualityByIndexMethodClassFieldReferenceFingerprint.result!! - .method.implementation!!.instructions + val instructions = match.method.implementation!!.instructions val getOnItemClickListenerClassReference = (instructions.elementAt(0) as ReferenceInstruction).reference @@ -109,7 +101,7 @@ object RememberVideoQualityPatch : BytecodePatch( .find { method -> method.parameterTypes.first() == "I" } ?: throw PatchException("Could not find setQualityByIndex method") - it.mutableMethod.addInstructions( + videoQualitySetterMatch.mutableMethod.addInstructions( 0, """ # Get the object instance to invoke the setQualityByIndex method on. @@ -124,39 +116,33 @@ object RememberVideoQualityPatch : BytecodePatch( # The second parameter is the index of the selected quality. # The register v0 stores the object instance to invoke the setQualityByIndex method on. # The register v1 stores the name of the setQualityByIndex method. - invoke-static {p1, p2, v0, v1}, $INTEGRATIONS_CLASS_DESCRIPTOR->setVideoQuality([Ljava/lang/Object;ILjava/lang/Object;Ljava/lang/String;)I + invoke-static { p1, p2, v0, v1 }, $EXTENSION_CLASS_DESCRIPTOR->setVideoQuality([Ljava/lang/Object;ILjava/lang/Object;Ljava/lang/String;)I move-result p2 """, ) - } ?: throw VideoQualitySetterFingerprint.exception - + } // Inject a call to remember the selected quality. - VideoQualityItemOnClickParentFingerprint.result?.let { - val onItemClickMethod = it.mutableClass.methods.find { method -> method.name == "onItemClick" } - - onItemClickMethod?.apply { - val listItemIndexParameter = 3 - - addInstruction( - 0, - "invoke-static {p$listItemIndexParameter}, $INTEGRATIONS_CLASS_DESCRIPTOR->userChangedQuality(I)V" - ) - } ?: throw PatchException("Failed to find onItemClick method") - } ?: throw VideoQualityItemOnClickParentFingerprint.exception + videoQualityItemOnClickParentMatch.mutableClass.methods.find { it.name == "onItemClick" }?.apply { + val listItemIndexParameter = 3 + addInstruction( + 0, + "invoke-static { p$listItemIndexParameter }, " + + "$EXTENSION_CLASS_DESCRIPTOR->userChangedQuality(I)V", + ) + } ?: throw PatchException("Failed to find onItemClick method") // Remember video quality if not using old layout menu. - NewVideoQualityChangedFingerprint.result?.apply { - mutableMethod.apply { - val index = scanResult.patternScanResult!!.startIndex - val qualityRegister = getInstruction(index).registerA + newVideoQualityChangedMatch.mutableMethod.apply { + val index = newVideoQualityChangedMatch.patternMatch!!.startIndex + val qualityRegister = getInstruction(index).registerA - addInstruction( - index + 1, - "invoke-static {v$qualityRegister}, $INTEGRATIONS_CLASS_DESCRIPTOR->userChangedQualityInNewFlyout(I)V" - ) - } - } ?: throw NewVideoQualityChangedFingerprint.exception + addInstruction( + index + 1, + "invoke-static { v$qualityRegister }, " + + "$EXTENSION_CLASS_DESCRIPTOR->userChangedQualityInNewFlyout(I)V", + ) + } } } diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/PlaybackSpeedPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/PlaybackSpeedPatch.kt new file mode 100644 index 000000000..592335bfc --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/PlaybackSpeedPatch.kt @@ -0,0 +1,29 @@ +package app.revanced.patches.youtube.video.speed + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.video.speed.button.playbackSpeedButtonPatch +import app.revanced.patches.youtube.video.speed.custom.customPlaybackSpeedPatch +import app.revanced.patches.youtube.video.speed.remember.rememberPlaybackSpeedPatch + +@Suppress("unused") +val playbackSpeedPatch = bytecodePatch( + name = "Playback speed", + description = "Adds options to customize available playback speeds, remember the last playback speed selected " + + "and show a speed dialog button to the video player.", +) { + dependsOn( + playbackSpeedButtonPatch, + customPlaybackSpeedPatch, + rememberPlaybackSpeedPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/button/PlaybackSpeedButtonPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/button/PlaybackSpeedButtonPatch.kt new file mode 100644 index 000000000..0537d0413 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/button/PlaybackSpeedButtonPatch.kt @@ -0,0 +1,56 @@ +package app.revanced.patches.youtube.video.speed.button + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patcher.patch.resourcePatch +import app.revanced.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.playercontrols.* +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.patches.youtube.video.speed.custom.customPlaybackSpeedPatch +import app.revanced.util.ResourceGroup +import app.revanced.util.copyResources + +private val playbackSpeedButtonResourcePatch = resourcePatch { + dependsOn(playerControlsResourcePatch) + + execute { context -> + context.copyResources( + "speedbutton", + ResourceGroup( + "drawable", + "revanced_playback_speed_dialog_button.xml", + ), + ) + + addBottomControl("speedbutton") + } +} + +private const val SPEED_BUTTON_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/videoplayer/PlaybackSpeedDialogButton;" + +@Suppress("unused") +val playbackSpeedButtonPatch = bytecodePatch( + description = "Adds the option to display playback speed dialog button in the video player.", +) { + dependsOn( + playbackSpeedButtonResourcePatch, + customPlaybackSpeedPatch, + playerControlsPatch, + settingsPatch, + addResourcesPatch, + ) + + execute { + addResources("youtube", "video.speed.button.playbackSpeedButtonPatch") + + PreferenceScreen.PLAYER.addPreferences( + SwitchPreference("revanced_playback_speed_dialog_button"), + ) + + initializeBottomControl(SPEED_BUTTON_CLASS_DESCRIPTOR) + injectVisibilityCheckCall(SPEED_BUTTON_CLASS_DESCRIPTOR) + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/custom/CustomPlaybackSpeedPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/custom/CustomPlaybackSpeedPatch.kt new file mode 100644 index 000000000..f6d2ac9de --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/custom/CustomPlaybackSpeedPatch.kt @@ -0,0 +1,173 @@ +package app.revanced.patches.youtube.video.speed.custom + +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.replaceInstruction +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.patches.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings +import app.revanced.patches.shared.misc.settings.preference.InputType +import app.revanced.patches.shared.misc.settings.preference.TextPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.litho.filter.addLithoFilter +import app.revanced.patches.youtube.misc.litho.filter.lithoFilterPatch +import app.revanced.patches.youtube.misc.recyclerviewtree.hook.addRecyclerViewTreeHook +import app.revanced.patches.youtube.misc.recyclerviewtree.hook.recyclerViewTreeHookPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.util.* +import com.android.tools.smali.dexlib2.AccessFlags +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 +import com.android.tools.smali.dexlib2.immutable.ImmutableField + +var speedUnavailableId = -1L + internal set + +private val customPlaybackSpeedResourcePatch = resourcePatch { + dependsOn(resourceMappingPatch) + + execute { + speedUnavailableId = resourceMappings[ + "string", + "varispeed_unavailable_message", + ] + } +} + +private const val FILTER_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/components/PlaybackSpeedMenuFilterPatch;" + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/playback/speed/CustomPlaybackSpeedPatch;" + +internal val customPlaybackSpeedPatch = bytecodePatch( + description = "Adds custom playback speed options.", +) { + dependsOn( + sharedExtensionPatch, + lithoFilterPatch, + settingsPatch, + recyclerViewTreeHookPatch, + customPlaybackSpeedResourcePatch, + addResourcesPatch, + ) + + val speedArrayGeneratorMatch by speedArrayGeneratorFingerprint() + val speedLimiterMatch by speedLimiterFingerprint() + val getOldPlaybackSpeedsMatch by getOldPlaybackSpeedsFingerprint() + val showOldPlaybackSpeedMenuExtensionMatch by showOldPlaybackSpeedMenuExtensionFingerprint() + + execute { context -> + addResources("youtube", "video.speed.custom.customPlaybackSpeedPatch") + + PreferenceScreen.VIDEO.addPreferences( + TextPreference("revanced_custom_playback_speeds", inputType = InputType.TEXT_MULTI_LINE), + ) + + // Replace the speeds float array with custom speeds. + speedArrayGeneratorMatch.mutableMethod.apply { + val sizeCallIndex = indexOfFirstInstructionOrThrow { getReference()?.name == "size" } + val sizeCallResultRegister = getInstruction(sizeCallIndex + 1).registerA + + replaceInstruction(sizeCallIndex + 1, "const/4 v$sizeCallResultRegister, 0x0") + + val arrayLengthConstIndex = indexOfFirstWideLiteralInstructionValueOrThrow(7) + val arrayLengthConstDestination = getInstruction(arrayLengthConstIndex).registerA + val playbackSpeedsArrayType = "$EXTENSION_CLASS_DESCRIPTOR->customPlaybackSpeeds:[F" + + addInstructions( + arrayLengthConstIndex + 1, + """ + sget-object v$arrayLengthConstDestination, $playbackSpeedsArrayType + array-length v$arrayLengthConstDestination, v$arrayLengthConstDestination + """, + ) + + val originalArrayFetchIndex = indexOfFirstInstructionOrThrow { + val reference = getReference() + reference?.type == "[F" && reference.definingClass.endsWith("/PlayerConfigModel;") + } + val originalArrayFetchDestination = + getInstruction(originalArrayFetchIndex).registerA + + replaceInstruction( + originalArrayFetchIndex, + "sget-object v$originalArrayFetchDestination, $playbackSpeedsArrayType", + ) + } + + // Override the min/max speeds that can be used. + speedLimiterMatch.mutableMethod.apply { + val limiterMinConstIndex = indexOfFirstWideLiteralInstructionValueOrThrow(0.25f.toRawBits().toLong()) + var limiterMaxConstIndex = indexOfFirstWideLiteralInstructionValue(2.0f.toRawBits().toLong()) + // Newer targets have 4x max speed. + if (limiterMaxConstIndex < 0) { + limiterMaxConstIndex = indexOfFirstWideLiteralInstructionValueOrThrow(4.0f.toRawBits().toLong()) + } + + val limiterMinConstDestination = getInstruction(limiterMinConstIndex).registerA + val limiterMaxConstDestination = getInstruction(limiterMaxConstIndex).registerA + + replaceInstruction(limiterMinConstIndex, "const/high16 v$limiterMinConstDestination, 0.0f") + replaceInstruction(limiterMaxConstIndex, "const/high16 v$limiterMaxConstDestination, 10.0f") + } + + // Add a static INSTANCE field to the class. + // This is later used to call "showOldPlaybackSpeedMenu" on the instance. + val instanceField = ImmutableField( + getOldPlaybackSpeedsMatch.classDef.type, + "INSTANCE", + getOldPlaybackSpeedsMatch.classDef.type, + AccessFlags.PUBLIC.value or AccessFlags.STATIC.value, + null, + null, + null, + ).toMutable() + + getOldPlaybackSpeedsMatch.mutableClass.staticFields.add(instanceField) + // Set the INSTANCE field to the instance of the class. + // In order to prevent a conflict with another patch, add the instruction at index 1. + getOldPlaybackSpeedsMatch.mutableMethod.addInstruction(1, "sput-object p0, $instanceField") + + // Get the "showOldPlaybackSpeedMenu" method. + // This is later called on the field INSTANCE. + val showOldPlaybackSpeedMenuMethod = showOldPlaybackSpeedMenuFingerprint.applyMatch( + context, + getOldPlaybackSpeedsMatch, + ).method.toString() + + // Insert the call to the "showOldPlaybackSpeedMenu" method on the field INSTANCE. + showOldPlaybackSpeedMenuExtensionMatch.mutableMethod.apply { + addInstructionsWithLabels( + instructions.lastIndex, + """ + sget-object v0, $instanceField + if-nez v0, :not_null + return-void + :not_null + invoke-virtual { v0 }, $showOldPlaybackSpeedMenuMethod + """, + ) + } + + // region Force old video quality menu. + // This is necessary, because there is no known way of adding custom playback speeds to the new menu. + + addRecyclerViewTreeHook(EXTENSION_CLASS_DESCRIPTOR) + + // Required to check if the playback speed menu is currently shown. + addLithoFilter(FILTER_CLASS_DESCRIPTOR) + + // endregion + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/custom/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/custom/Fingerprints.kt new file mode 100644 index 000000000..d3aa805d8 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/custom/Fingerprints.kt @@ -0,0 +1,50 @@ +package app.revanced.patches.youtube.video.speed.custom + +import app.revanced.patcher.fingerprint +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val getOldPlaybackSpeedsFingerprint = fingerprint { + parameters("[L", "I") + strings("menu_item_playback_speed") +} + +internal val showOldPlaybackSpeedMenuFingerprint = fingerprint { + literal { speedUnavailableId } +} + +internal val showOldPlaybackSpeedMenuExtensionFingerprint = fingerprint { + custom { method, _ -> method.name == "showOldPlaybackSpeedMenu" } +} + +internal val speedArrayGeneratorFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC) + returns("[L") + parameters("Lcom/google/android/libraries/youtube/innertube/model/player/PlayerResponseModel;") + opcodes( + Opcode.IF_NEZ, + Opcode.SGET_OBJECT, + Opcode.GOTO_16, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.IGET_OBJECT, + ) + strings("0.0#") +} + +internal val speedLimiterFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("F") + opcodes( + Opcode.INVOKE_STATIC, + Opcode.MOVE_RESULT, + Opcode.IF_EQZ, + Opcode.CONST_HIGH16, + Opcode.GOTO, + Opcode.CONST_HIGH16, + Opcode.CONST_HIGH16, + Opcode.INVOKE_STATIC, + ) +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/remember/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/remember/Fingerprints.kt new file mode 100644 index 000000000..3924588b4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/remember/Fingerprints.kt @@ -0,0 +1,8 @@ +package app.revanced.patches.youtube.video.speed.remember + +import app.revanced.patcher.fingerprint + +internal val initializePlaybackSpeedValuesFingerprint = fingerprint { + parameters("[L", "I") + strings("menu_item_playback_speed") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/remember/RememberPlaybackSpeedPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/remember/RememberPlaybackSpeedPatch.kt new file mode 100644 index 000000000..accc2498e --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/speed/remember/RememberPlaybackSpeedPatch.kt @@ -0,0 +1,87 @@ +package app.revanced.patches.youtube.video.speed.remember + +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.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.settings.preference.ListPreference +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import app.revanced.patches.youtube.video.information.* +import app.revanced.patches.youtube.video.speed.custom.customPlaybackSpeedPatch +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/playback/speed/RememberPlaybackSpeedPatch;" + +internal val rememberPlaybackSpeedPatch = bytecodePatch { + dependsOn( + sharedExtensionPatch, + settingsPatch, + videoInformationPatch, + customPlaybackSpeedPatch, + addResourcesPatch, + ) + + val initializePlaybackSpeedValuesMatch by initializePlaybackSpeedValuesFingerprint() + + execute { + addResources("youtube", "video.speed.remember.rememberPlaybackSpeedPatch") + + PreferenceScreen.VIDEO.addPreferences( + SwitchPreference("revanced_remember_playback_speed_last_selected"), + ListPreference( + key = "revanced_playback_speed_default", + summaryKey = null, + // Entries and values are set by the extension code based on the actual speeds available. + entriesKey = null, + entryValuesKey = null, + ), + ) + + onCreateHook(EXTENSION_CLASS_DESCRIPTOR, "newVideoStarted") + userSelectedPlaybackSpeedHook( + EXTENSION_CLASS_DESCRIPTOR, + "userSelectedPlaybackSpeed", + ) + + /* + * Hook the code that is called when the playback speeds are initialized, and sets the playback speed + */ + initializePlaybackSpeedValuesMatch.mutableMethod.apply { + // Infer everything necessary for calling the method setPlaybackSpeed(). + val onItemClickListenerClassFieldReference = getInstruction(0).reference + + // Registers are not used at index 0, so they can be freely used. + addInstructionsWithLabels( + 0, + """ + invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->getPlaybackSpeedOverride()F + move-result v0 + + # Check if the playback speed is not 1.0x. + const/high16 v1, 1.0f + cmpg-float v1, v0, v1 + if-eqz v1, :do_not_override + + # Get the instance of the class which has the container class field below. + iget-object v1, p0, $onItemClickListenerClassFieldReference + + # Get the container class field. + iget-object v1, v1, $setPlaybackSpeedContainerClassFieldReference + + # Get the field from its class. + iget-object v2, v1, $setPlaybackSpeedClassFieldReference + + # Invoke setPlaybackSpeed on that class. + invoke-virtual {v2, v0}, $setPlaybackSpeedMethodReference + """, + ExternalLabel("do_not_override", getInstruction(0)), + ) + } + } +} 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 000000000..c2b73fbee --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/videoid/Fingerprints.kt @@ -0,0 +1,43 @@ +package app.revanced.patches.youtube.video.videoid + +import app.revanced.patcher.fingerprint +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val videoIdFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("V") + parameters("L") + opcodes( + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + ) + custom { method, _ -> + method.indexOfPlayerResponseModelString() >= 0 + } +} + +internal val videoIdBackgroundPlayFingerprint = fingerprint { + accessFlags(AccessFlags.DECLARED_SYNCHRONIZED, AccessFlags.FINAL, AccessFlags.PUBLIC) + returns("V") + parameters("L") + opcodes( + Opcode.INVOKE_VIRTUAL, + Opcode.MOVE_RESULT, + Opcode.IF_EQZ, + Opcode.IGET_OBJECT, + Opcode.IF_EQZ, + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + ) +} + +internal val videoIdParentFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("[L") + parameters("L") + literal { 524288L } +} 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 000000000..35d95d8da --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/videoid/VideoIdPatch.kt @@ -0,0 +1,123 @@ +package app.revanced.patches.youtube.video.videoid + +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.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.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.applyMatch +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +/** + * 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", +) + +/** + * Alternate hook that supports only regular videos, but hook supports changing to new video + * during background play when no video is visible. + * + * _Does not support Shorts_. + * + * Be aware, the hook can be called multiple times for the same video id. + * + * @param methodDescriptor which method to call. Params have to be `Ljava/lang/String;` + */ +fun hookBackgroundPlayVideoId( + methodDescriptor: String, +) = backgroundPlaybackMethod.addInstruction( + backgroundPlaybackInsertIndex++, // move-result-object offset + "invoke-static {v$backgroundPlaybackVideoIdRegister}, $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] or [hookBackgroundPlayVideoId] 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) = addPlayerResponseMethodHook( + Hook.VideoId( + methodDescriptor, + ), +) + +private var videoIdRegister = 0 +private var videoIdInsertIndex = 0 +private lateinit var videoIdMethod: MutableMethod + +private var backgroundPlaybackVideoIdRegister = 0 +private var backgroundPlaybackInsertIndex = 0 +private lateinit var backgroundPlaybackMethod: MutableMethod + +val videoIdPatch = bytecodePatch( + description = "Hooks to detect when the video id changes.", +) { + dependsOn( + sharedExtensionPatch, + playerResponseMethodHookPatch, + ) + + val videoIdParentMatch by videoIdParentFingerprint() + val videoIdBackgroundPlayMatch by videoIdBackgroundPlayFingerprint() + + execute { context -> + videoIdFingerprint.applyMatch(context, videoIdParentMatch).mutableMethod.apply { + videoIdMethod = this + val index = indexOfPlayerResponseModelString() + videoIdRegister = getInstruction(index + 1).registerA + videoIdInsertIndex = index + 2 + } + + videoIdBackgroundPlayMatch.mutableMethod.apply { + backgroundPlaybackMethod = this + val index = indexOfPlayerResponseModelString() + backgroundPlaybackVideoIdRegister = getInstruction(index + 1).registerA + backgroundPlaybackInsertIndex = index + 2 + } + } +} + +internal fun Method.indexOfPlayerResponseModelString() = indexOfFirstInstruction { + val reference = getReference() + reference?.definingClass == "Lcom/google/android/libraries/youtube/innertube/model/player/PlayerResponseModel;" && + reference.returnType == "Ljava/lang/String;" +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/videoqualitymenu/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/videoqualitymenu/Fingerprints.kt new file mode 100644 index 000000000..7f5469da3 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/videoqualitymenu/Fingerprints.kt @@ -0,0 +1,43 @@ +package app.revanced.patches.youtube.video.videoqualitymenu + +import app.revanced.patcher.fingerprint +import app.revanced.util.literal +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal val videoQualityMenuOptionsFingerprint = fingerprint { + accessFlags(AccessFlags.STATIC) + returns("[L") + parameters("Landroid/content/Context", "L", "L") + opcodes( + Opcode.CONST_4, // First instruction of method. + Opcode.CONST_4, + Opcode.IF_EQZ, + Opcode.IGET_BOOLEAN, // Use the quality menu, that contains the advanced menu. + Opcode.IF_NEZ, + ) + literal { videoQualityQuickMenuAdvancedMenuDescription } +} + +internal val videoQualityMenuViewInflateFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("L") + parameters("L", "L", "L") + opcodes( + 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, + ) + literal { videoQualityBottomSheetListFragmentTitle } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/videoqualitymenu/RestoreOldVideoQualityMenuPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/videoqualitymenu/RestoreOldVideoQualityMenuPatch.kt new file mode 100644 index 000000000..182e88996 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/videoqualitymenu/RestoreOldVideoQualityMenuPatch.kt @@ -0,0 +1,136 @@ +package app.revanced.patches.youtube.video.videoqualitymenu + +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.all.misc.resources.addResources +import app.revanced.patches.all.misc.resources.addResourcesPatch +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings +import app.revanced.patches.shared.misc.settings.preference.SwitchPreference +import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch +import app.revanced.patches.youtube.misc.litho.filter.addLithoFilter +import app.revanced.patches.youtube.misc.litho.filter.lithoFilterPatch +import app.revanced.patches.youtube.misc.recyclerviewtree.hook.addRecyclerViewTreeHook +import app.revanced.patches.youtube.misc.recyclerviewtree.hook.recyclerViewTreeHookPatch +import app.revanced.patches.youtube.misc.settings.PreferenceScreen +import app.revanced.patches.youtube.misc.settings.settingsPatch +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +internal var videoQualityBottomSheetListFragmentTitle = -1L + private set +internal var videoQualityQuickMenuAdvancedMenuDescription = -1L + private set + +private val restoreOldVideoQualityMenuResourcePatch = resourcePatch { + dependsOn( + settingsPatch, + resourceMappingPatch, + addResourcesPatch, + ) + + execute { + addResources("youtube", "video.videoqualitymenu.restoreOldVideoQualityMenuResourcePatch") + + PreferenceScreen.VIDEO.addPreferences( + SwitchPreference("revanced_restore_old_video_quality_menu"), + ) + + // Used for the old type of the video quality menu. + videoQualityBottomSheetListFragmentTitle = resourceMappings[ + "layout", + "video_quality_bottom_sheet_list_fragment_title", + ] + + videoQualityQuickMenuAdvancedMenuDescription = resourceMappings[ + "string", + "video_quality_quick_menu_advanced_menu_description", + ] + } +} + +private const val FILTER_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/components/VideoQualityMenuFilterPatch;" + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/youtube/patches/playback/quality/RestoreOldVideoQualityMenuPatch;" + +@Suppress("unused") +val restoreOldVideoQualityMenuPatch = bytecodePatch( + name = "Restore old video quality menu", + description = "Adds an option to restore the old video quality menu with specific video resolution options.", + +) { + dependsOn( + sharedExtensionPatch, + restoreOldVideoQualityMenuResourcePatch, + lithoFilterPatch, + recyclerViewTreeHookPatch, + ) + + compatibleWith( + "com.google.android.youtube"( + "18.38.44", + "18.49.37", + "19.16.39", + "19.25.37", + "19.34.42", + ), + ) + + val videoQualityMenuViewInflateMatch by videoQualityMenuViewInflateFingerprint() + val videoQualityMenuOptionsMatch by videoQualityMenuOptionsFingerprint() + + execute { + // region Patch for the old type of the video quality menu. + // Used for regular videos when spoofing to old app version, + // and for the Shorts quality flyout on newer app versions. + + videoQualityMenuViewInflateMatch.mutableMethod.apply { + val checkCastIndex = videoQualityMenuViewInflateMatch.patternMatch!!.endIndex + val listViewRegister = getInstruction(checkCastIndex).registerA + + addInstruction( + checkCastIndex + 1, + "invoke-static { v$listViewRegister }, " + + "$EXTENSION_CLASS_DESCRIPTOR->" + + "showOldVideoQualityMenu(Landroid/widget/ListView;)V", + ) + } + + // Force YT to add the 'advanced' quality menu for Shorts. + val patternMatch = videoQualityMenuOptionsMatch.patternMatch!! + val startIndex = patternMatch.startIndex + if (startIndex != 0) throw PatchException("Unexpected opcode start index: $startIndex") + val insertIndex = patternMatch.endIndex + + videoQualityMenuOptionsMatch.mutableMethod.apply { + val register = getInstruction(insertIndex).registerA + + // A condition controls whether to show the three or four items quality menu. + // Force the four items quality menu to make the "Advanced" item visible, necessary for the patch. + addInstructions( + insertIndex, + """ + invoke-static { v$register }, $EXTENSION_CLASS_DESCRIPTOR->forceAdvancedVideoQualityMenuCreation(Z)Z + move-result v$register + """, + ) + } + + // endregion + + // region Patch for the new type of the video quality menu. + + addRecyclerViewTreeHook(EXTENSION_CLASS_DESCRIPTOR) + + // Required to check if the video quality menu is currently shown in order to click on the "Advanced" item. + addLithoFilter(FILTER_CLASS_DESCRIPTOR) + + // endregion + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/yuka/misc/unlockpremium/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/yuka/misc/unlockpremium/Fingerprints.kt new file mode 100644 index 000000000..af38f28f4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/yuka/misc/unlockpremium/Fingerprints.kt @@ -0,0 +1,20 @@ +package app.revanced.patches.yuka.misc.unlockpremium + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.AccessFlags +import app.revanced.patcher.fingerprint + +internal val isPremiumFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL) + returns("Z") + opcodes( + Opcode.IGET_BOOLEAN, + Opcode.RETURN, + ) +} + +internal val yukaUserConstructorFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR) + returns("V") + strings("premiumProvider") +} diff --git a/patches/src/main/kotlin/app/revanced/patches/yuka/misc/unlockpremium/UnlockPremiumPatch.kt b/patches/src/main/kotlin/app/revanced/patches/yuka/misc/unlockpremium/UnlockPremiumPatch.kt new file mode 100644 index 000000000..6ca1d5e57 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/yuka/misc/unlockpremium/UnlockPremiumPatch.kt @@ -0,0 +1,26 @@ +package app.revanced.patches.yuka.misc.unlockpremium + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.util.matchOrThrow + +@Suppress("unused") +val unlockPremiumPatch = bytecodePatch( + name = "Unlock premium", +) { + compatibleWith("io.yuka.android"("4.29")) + + val yukaUserConstructorMatch by yukaUserConstructorFingerprint() + + execute { context -> + isPremiumFingerprint.apply { + match(context, yukaUserConstructorMatch.classDef) + }.matchOrThrow.mutableMethod.addInstructions( + 0, + """ + const/4 v0, 0x1 + return v0 + """, + ) + } +} diff --git a/src/main/kotlin/app/revanced/util/BytecodeUtils.kt b/patches/src/main/kotlin/app/revanced/util/BytecodeUtils.kt similarity index 85% rename from src/main/kotlin/app/revanced/util/BytecodeUtils.kt rename to patches/src/main/kotlin/app/revanced/util/BytecodeUtils.kt index c9b146645..97ee4f2aa 100644 --- a/src/main/kotlin/app/revanced/util/BytecodeUtils.kt +++ b/patches/src/main/kotlin/app/revanced/util/BytecodeUtils.kt @@ -1,16 +1,21 @@ package app.revanced.util -import app.revanced.patcher.data.BytecodeContext +import app.revanced.patcher.Fingerprint +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.fingerprint.MethodFingerprint +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.patches.shared.misc.mapping.ResourceMappingPatch +import app.revanced.patches.shared.misc.mapping.get +import app.revanced.patches.shared.misc.mapping.resourceMappingPatch +import app.revanced.patches.shared.misc.mapping.resourceMappings import com.android.tools.smali.dexlib2.Opcode import com.android.tools.smali.dexlib2.iface.Method import com.android.tools.smali.dexlib2.iface.instruction.Instruction @@ -19,15 +24,16 @@ import com.android.tools.smali.dexlib2.iface.instruction.WideLiteralInstruction import com.android.tools.smali.dexlib2.iface.reference.Reference import com.android.tools.smali.dexlib2.util.MethodUtil -fun MethodFingerprint.resultOrThrow() = result ?: throw exception +val Fingerprint.matchOrThrow + get() = match ?: throw exception /** - * The [PatchException] of failing to resolve a [MethodFingerprint]. + * The [PatchException] of failing to match a [Fingerprint]. * * @return The [PatchException]. */ -val MethodFingerprint.exception - get() = PatchException("Failed to resolve ${this.javaClass.simpleName}") +val Fingerprint.exception + get() = PatchException("Failed to match the fingerprint: $this") /** * Find the [MutableMethod] from a given [Method] in a [MutableClass]. @@ -102,7 +108,7 @@ internal fun MutableMethod.addInstructionsAtControlFlowLabel( /** * Get the index of the first instruction with the id of the given resource name. * - * Requires [ResourceMappingPatch] as a dependency. + * 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. @@ -110,14 +116,14 @@ internal fun MutableMethod.addInstructionsAtControlFlowLabel( * @see [indexOfIdResourceOrThrow], [indexOfFirstWideLiteralInstructionValueReversed] */ fun Method.indexOfIdResource(resourceName: String): Int { - val resourceId = ResourceMappingPatch["id", resourceName] + val resourceId = resourceMappings["id", resourceName] return indexOfFirstWideLiteralInstructionValue(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. + * 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 [indexOfIdResource], [indexOfFirstWideLiteralInstructionValueReversedOrThrow] @@ -196,9 +202,12 @@ fun Method.containsWideLiteralInstructionValue(literal: Long) = * @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) { +fun BytecodePatchContext.traverseClassHierarchy(targetClass: MutableClass, callback: MutableClass.() -> Unit) { callback(targetClass) - this.findClass(targetClass.superclass ?: return)?.mutableClass?.let { + + targetClass.superclass ?: return + + classBy { targetClass.superclass == it.type }?.mutableClass?.let { traverseClassHierarchy(it, callback) } } @@ -368,11 +377,11 @@ fun Method.findOpcodeIndicesReversed(opcode: Opcode): List = * @return The list of indices of the opcode in reverse order. */ fun Method.findOpcodeIndicesReversed(filter: Instruction.() -> Boolean): List { - val indexes = implementation!!.instructions + val indexes = instructions .withIndex() .filter { (_, instruction) -> filter(instruction) } .map { (index, _) -> index } - .reversed() + .reversed() // TODO: Use asReversed here to avoid creating a new list. if (indexes.isEmpty()) throw PatchException("No matching instructions found in: $this") @@ -382,7 +391,7 @@ fun Method.findOpcodeIndicesReversed(filter: Instruction.() -> Boolean): List Unit, ) { @@ -401,47 +410,59 @@ fun BytecodeContext.forEachLiteralValueInstruction( } /** - * Return the resolved method early. + * Return the matched method early. */ -fun MethodFingerprint.returnEarly(bool: Boolean = false) { +fun Fingerprint.returnEarly(bool: Boolean = false) { val const = if (bool) "0x1" else "0x0" - result?.let { result -> - val stringInstructions = when (result.method.returnType.first()) { + match?.let { match -> + val stringInstructions = when (match.method.returnType.first()) { 'L' -> """ - const/4 v0, $const - return-object v0 - """ + const/4 v0, $const + return-object v0 + """ 'V' -> "return-void" 'I', 'Z' -> """ - const/4 v0, $const - return v0 - """ + const/4 v0, $const + return v0 + """ else -> throw Exception("This case should never happen.") } - result.mutableMethod.addInstructions(0, stringInstructions) + match.mutableMethod.addInstructions(0, stringInstructions) } ?: throw exception } /** - * Return the resolved methods early. + * Return the matched methods early. */ -fun Iterable.returnEarly(bool: Boolean = false) = forEach { fingerprint -> +fun Iterable.returnEarly(bool: Boolean = false) = forEach { fingerprint -> fingerprint.returnEarly(bool) } /** - * Return the resolved methods early. + * Return the matched methods early. */ @Deprecated("Use the Iterable version") -fun List.returnEarly(bool: Boolean = false) = forEach { fingerprint -> +fun List.returnEarly(bool: Boolean = false) = forEach { fingerprint -> fingerprint.returnEarly(bool) } /** - * Resolves this fingerprint using the classDef of a parent fingerprint. + * Matches this fingerprint using the classDef of a parent fingerprint match. */ -fun MethodFingerprint.alsoResolve(context: BytecodeContext, parentFingerprint: MethodFingerprint) = - also { resolve(context, parentFingerprint.resultOrThrow().classDef) }.resultOrThrow() +fun Fingerprint.applyMatch(context: BytecodePatchContext, parentMatch: Match) = + apply { match(context, parentMatch.classDef) }.matchOrThrow + +/** + * 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.containsWideLiteralInstructionValue(literalSupplier()) + } +} diff --git a/src/main/kotlin/app/revanced/util/ResourceUtils.kt b/patches/src/main/kotlin/app/revanced/util/ResourceUtils.kt similarity index 69% rename from src/main/kotlin/app/revanced/util/ResourceUtils.kt rename to patches/src/main/kotlin/app/revanced/util/ResourceUtils.kt index 2f0d93f84..02671bbd4 100644 --- a/src/main/kotlin/app/revanced/util/ResourceUtils.kt +++ b/patches/src/main/kotlin/app/revanced/util/ResourceUtils.kt @@ -1,8 +1,8 @@ package app.revanced.util -import app.revanced.patcher.data.ResourceContext +import app.revanced.patcher.patch.ResourcePatchContext import app.revanced.patcher.patch.PatchException -import app.revanced.patcher.util.DomFileEditor +import app.revanced.patcher.util.Document import app.revanced.util.resource.BaseResource import org.w3c.dom.Attr import org.w3c.dom.Element @@ -22,12 +22,13 @@ fun NodeList.asSequence() = (0 until this.length).asSequence().map { this.item(i /** * Returns a sequence for all child nodes. */ -fun Node.childElementsSequence() = this.childNodes.asSequence().filter { it.nodeType == Node.ELEMENT_NODE } +@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. */ -fun Node.forEachChildElement(action: (Node) -> Unit) = +inline fun Node.forEachChildElement(action: (Element) -> Unit) = childElementsSequence().forEach { action(it) } @@ -56,7 +57,7 @@ fun Node.insertFirst(node: Node) { * @param sourceResourceDirectory The source resource directory name. * @param resources The resources to copy. */ -fun ResourceContext.copyResources( +fun ResourcePatchContext.copyResources( sourceResourceDirectory: String, vararg resources: ResourceGroup, ) { @@ -92,27 +93,32 @@ class ResourceGroup(val resourceDirectoryName: String, vararg val resources: Str * @param targetTag The target xml node. * @param callback The callback to call when iterating over the nodes. */ -fun ResourceContext.iterateXmlNodeChildren( +fun ResourcePatchContext.iterateXmlNodeChildren( resource: String, targetTag: String, callback: (node: Node) -> Unit, -) = xmlEditor[classLoader.getResourceAsStream(resource)!!].use { editor -> - val document = editor.file - +) = 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)) } -// TODO: After the migration to the new patcher, remove the following code and replace it with the commented code below. -fun String.copyXmlNode(source: DomFileEditor, target: DomFileEditor): AutoCloseable { - val hostNodes = source.file.getElementsByTagName(this).item(0).childNodes +/** + * 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 destinationResourceFile = target.file - val destinationNode = destinationResourceFile.getElementsByTagName(this).item(0) + val destinationNode = target.getElementsByTagName(this).item(0) for (index in 0 until hostNodes.length) { val node = hostNodes.item(index).cloneNode(true) - destinationResourceFile.adoptNode(node) + target.adoptNode(node) destinationNode.appendChild(node) } @@ -122,45 +128,6 @@ fun String.copyXmlNode(source: DomFileEditor, target: DomFileEditor): AutoClosea } } -// /** -// * 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() -// } -// } - -// @Deprecated( -// "Use copyXmlNode(Document, Document) instead.", -// ReplaceWith( -// "this.copyXmlNode(source.file as Document, target.file as Document)", -// "app.revanced.patcher.util.Document", -// "app.revanced.patcher.util.Document", -// ), -// ) -// fun String.copyXmlNode( -// source: DomFileEditor, -// target: DomFileEditor, -// ) = this.copyXmlNode(source.file as Document, target.file as Document) - /** * Add a resource node child. * 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/src/main/kotlin/app/revanced/util/microg/MicroGBytecodeHelper.kt b/patches/src/main/kotlin/app/revanced/util/microg/MicroGBytecodeHelper.kt similarity index 100% rename from src/main/kotlin/app/revanced/util/microg/MicroGBytecodeHelper.kt rename to patches/src/main/kotlin/app/revanced/util/microg/MicroGBytecodeHelper.kt diff --git a/src/main/kotlin/app/revanced/util/microg/MicroGResourceHelper.kt b/patches/src/main/kotlin/app/revanced/util/microg/MicroGResourceHelper.kt similarity index 100% rename from src/main/kotlin/app/revanced/util/microg/MicroGResourceHelper.kt rename to patches/src/main/kotlin/app/revanced/util/microg/MicroGResourceHelper.kt diff --git a/src/main/kotlin/app/revanced/util/resource/ArrayResource.kt b/patches/src/main/kotlin/app/revanced/util/resource/ArrayResource.kt similarity index 100% rename from src/main/kotlin/app/revanced/util/resource/ArrayResource.kt rename to patches/src/main/kotlin/app/revanced/util/resource/ArrayResource.kt diff --git a/src/main/kotlin/app/revanced/util/resource/BaseResource.kt b/patches/src/main/kotlin/app/revanced/util/resource/BaseResource.kt similarity index 100% rename from src/main/kotlin/app/revanced/util/resource/BaseResource.kt rename to patches/src/main/kotlin/app/revanced/util/resource/BaseResource.kt diff --git a/src/main/kotlin/app/revanced/util/resource/StringResource.kt b/patches/src/main/kotlin/app/revanced/util/resource/StringResource.kt similarity index 100% rename from src/main/kotlin/app/revanced/util/resource/StringResource.kt rename to patches/src/main/kotlin/app/revanced/util/resource/StringResource.kt diff --git a/src/main/resources/addresources/values-af-rZA/strings.xml b/patches/src/main/resources/addresources/values-af-rZA/strings.xml similarity index 100% rename from src/main/resources/addresources/values-af-rZA/strings.xml rename to patches/src/main/resources/addresources/values-af-rZA/strings.xml diff --git a/src/main/resources/addresources/values-am-rET/strings.xml b/patches/src/main/resources/addresources/values-am-rET/strings.xml similarity index 100% rename from src/main/resources/addresources/values-am-rET/strings.xml rename to patches/src/main/resources/addresources/values-am-rET/strings.xml diff --git a/src/main/resources/addresources/values-ar-rSA/strings.xml b/patches/src/main/resources/addresources/values-ar-rSA/strings.xml similarity index 100% rename from src/main/resources/addresources/values-ar-rSA/strings.xml rename to patches/src/main/resources/addresources/values-ar-rSA/strings.xml diff --git a/src/main/resources/addresources/values-as-rIN/strings.xml b/patches/src/main/resources/addresources/values-as-rIN/strings.xml similarity index 100% rename from src/main/resources/addresources/values-as-rIN/strings.xml rename to patches/src/main/resources/addresources/values-as-rIN/strings.xml diff --git a/src/main/resources/addresources/values-az-rAZ/strings.xml b/patches/src/main/resources/addresources/values-az-rAZ/strings.xml similarity index 100% rename from src/main/resources/addresources/values-az-rAZ/strings.xml rename to patches/src/main/resources/addresources/values-az-rAZ/strings.xml diff --git a/src/main/resources/addresources/values-be-rBY/strings.xml b/patches/src/main/resources/addresources/values-be-rBY/strings.xml similarity index 100% rename from src/main/resources/addresources/values-be-rBY/strings.xml rename to patches/src/main/resources/addresources/values-be-rBY/strings.xml diff --git a/src/main/resources/addresources/values-bg-rBG/strings.xml b/patches/src/main/resources/addresources/values-bg-rBG/strings.xml similarity index 100% rename from src/main/resources/addresources/values-bg-rBG/strings.xml rename to patches/src/main/resources/addresources/values-bg-rBG/strings.xml diff --git a/src/main/resources/addresources/values-bn-rBD/strings.xml b/patches/src/main/resources/addresources/values-bn-rBD/strings.xml similarity index 100% rename from src/main/resources/addresources/values-bn-rBD/strings.xml rename to patches/src/main/resources/addresources/values-bn-rBD/strings.xml diff --git a/src/main/resources/addresources/values-bs-rBA/strings.xml b/patches/src/main/resources/addresources/values-bs-rBA/strings.xml similarity index 100% rename from src/main/resources/addresources/values-bs-rBA/strings.xml rename to patches/src/main/resources/addresources/values-bs-rBA/strings.xml diff --git a/src/main/resources/addresources/values-ca-rES/strings.xml b/patches/src/main/resources/addresources/values-ca-rES/strings.xml similarity index 100% rename from src/main/resources/addresources/values-ca-rES/strings.xml rename to patches/src/main/resources/addresources/values-ca-rES/strings.xml diff --git a/src/main/resources/addresources/values-cs-rCZ/strings.xml b/patches/src/main/resources/addresources/values-cs-rCZ/strings.xml similarity index 100% rename from src/main/resources/addresources/values-cs-rCZ/strings.xml rename to patches/src/main/resources/addresources/values-cs-rCZ/strings.xml diff --git a/src/main/resources/addresources/values-da-rDK/strings.xml b/patches/src/main/resources/addresources/values-da-rDK/strings.xml similarity index 100% rename from src/main/resources/addresources/values-da-rDK/strings.xml rename to patches/src/main/resources/addresources/values-da-rDK/strings.xml diff --git a/src/main/resources/addresources/values-de-rDE/strings.xml b/patches/src/main/resources/addresources/values-de-rDE/strings.xml similarity index 100% rename from src/main/resources/addresources/values-de-rDE/strings.xml rename to patches/src/main/resources/addresources/values-de-rDE/strings.xml diff --git a/src/main/resources/addresources/values-el-rGR/strings.xml b/patches/src/main/resources/addresources/values-el-rGR/strings.xml similarity index 100% rename from src/main/resources/addresources/values-el-rGR/strings.xml rename to patches/src/main/resources/addresources/values-el-rGR/strings.xml diff --git a/src/main/resources/addresources/values-es-rES/strings.xml b/patches/src/main/resources/addresources/values-es-rES/strings.xml similarity index 100% rename from src/main/resources/addresources/values-es-rES/strings.xml rename to patches/src/main/resources/addresources/values-es-rES/strings.xml diff --git a/src/main/resources/addresources/values-et-rEE/strings.xml b/patches/src/main/resources/addresources/values-et-rEE/strings.xml similarity index 100% rename from src/main/resources/addresources/values-et-rEE/strings.xml rename to patches/src/main/resources/addresources/values-et-rEE/strings.xml diff --git a/src/main/resources/addresources/values-eu-rES/strings.xml b/patches/src/main/resources/addresources/values-eu-rES/strings.xml similarity index 100% rename from src/main/resources/addresources/values-eu-rES/strings.xml rename to patches/src/main/resources/addresources/values-eu-rES/strings.xml diff --git a/src/main/resources/addresources/values-fa-rIR/strings.xml b/patches/src/main/resources/addresources/values-fa-rIR/strings.xml similarity index 100% rename from src/main/resources/addresources/values-fa-rIR/strings.xml rename to patches/src/main/resources/addresources/values-fa-rIR/strings.xml diff --git a/src/main/resources/addresources/values-fi-rFI/strings.xml b/patches/src/main/resources/addresources/values-fi-rFI/strings.xml similarity index 100% rename from src/main/resources/addresources/values-fi-rFI/strings.xml rename to patches/src/main/resources/addresources/values-fi-rFI/strings.xml diff --git a/src/main/resources/addresources/values-fil-rPH/strings.xml b/patches/src/main/resources/addresources/values-fil-rPH/strings.xml similarity index 100% rename from src/main/resources/addresources/values-fil-rPH/strings.xml rename to patches/src/main/resources/addresources/values-fil-rPH/strings.xml diff --git a/src/main/resources/addresources/values-fr-rFR/strings.xml b/patches/src/main/resources/addresources/values-fr-rFR/strings.xml similarity index 100% rename from src/main/resources/addresources/values-fr-rFR/strings.xml rename to patches/src/main/resources/addresources/values-fr-rFR/strings.xml diff --git a/src/main/resources/addresources/values-gl-rES/strings.xml b/patches/src/main/resources/addresources/values-gl-rES/strings.xml similarity index 100% rename from src/main/resources/addresources/values-gl-rES/strings.xml rename to patches/src/main/resources/addresources/values-gl-rES/strings.xml diff --git a/src/main/resources/addresources/values-gu-rIN/strings.xml b/patches/src/main/resources/addresources/values-gu-rIN/strings.xml similarity index 100% rename from src/main/resources/addresources/values-gu-rIN/strings.xml rename to patches/src/main/resources/addresources/values-gu-rIN/strings.xml diff --git a/src/main/resources/addresources/values-hi-rIN/strings.xml b/patches/src/main/resources/addresources/values-hi-rIN/strings.xml similarity index 100% rename from src/main/resources/addresources/values-hi-rIN/strings.xml rename to patches/src/main/resources/addresources/values-hi-rIN/strings.xml diff --git a/src/main/resources/addresources/values-hr-rHR/strings.xml b/patches/src/main/resources/addresources/values-hr-rHR/strings.xml similarity index 100% rename from src/main/resources/addresources/values-hr-rHR/strings.xml rename to patches/src/main/resources/addresources/values-hr-rHR/strings.xml diff --git a/src/main/resources/addresources/values-hu-rHU/strings.xml b/patches/src/main/resources/addresources/values-hu-rHU/strings.xml similarity index 100% rename from src/main/resources/addresources/values-hu-rHU/strings.xml rename to patches/src/main/resources/addresources/values-hu-rHU/strings.xml diff --git a/src/main/resources/addresources/values-hy-rAM/strings.xml b/patches/src/main/resources/addresources/values-hy-rAM/strings.xml similarity index 100% rename from src/main/resources/addresources/values-hy-rAM/strings.xml rename to patches/src/main/resources/addresources/values-hy-rAM/strings.xml diff --git a/src/main/resources/addresources/values-in-rID/strings.xml b/patches/src/main/resources/addresources/values-in-rID/strings.xml similarity index 100% rename from src/main/resources/addresources/values-in-rID/strings.xml rename to patches/src/main/resources/addresources/values-in-rID/strings.xml diff --git a/src/main/resources/addresources/values-is-rIS/strings.xml b/patches/src/main/resources/addresources/values-is-rIS/strings.xml similarity index 100% rename from src/main/resources/addresources/values-is-rIS/strings.xml rename to patches/src/main/resources/addresources/values-is-rIS/strings.xml diff --git a/src/main/resources/addresources/values-it-rIT/strings.xml b/patches/src/main/resources/addresources/values-it-rIT/strings.xml similarity index 100% rename from src/main/resources/addresources/values-it-rIT/strings.xml rename to patches/src/main/resources/addresources/values-it-rIT/strings.xml diff --git a/src/main/resources/addresources/values-iw-rIL/strings.xml b/patches/src/main/resources/addresources/values-iw-rIL/strings.xml similarity index 100% rename from src/main/resources/addresources/values-iw-rIL/strings.xml rename to patches/src/main/resources/addresources/values-iw-rIL/strings.xml diff --git a/src/main/resources/addresources/values-ja-rJP/strings.xml b/patches/src/main/resources/addresources/values-ja-rJP/strings.xml similarity index 100% rename from src/main/resources/addresources/values-ja-rJP/strings.xml rename to patches/src/main/resources/addresources/values-ja-rJP/strings.xml diff --git a/src/main/resources/addresources/values-ka-rGE/strings.xml b/patches/src/main/resources/addresources/values-ka-rGE/strings.xml similarity index 100% rename from src/main/resources/addresources/values-ka-rGE/strings.xml rename to patches/src/main/resources/addresources/values-ka-rGE/strings.xml diff --git a/src/main/resources/addresources/values-kk-rKZ/strings.xml b/patches/src/main/resources/addresources/values-kk-rKZ/strings.xml similarity index 100% rename from src/main/resources/addresources/values-kk-rKZ/strings.xml rename to patches/src/main/resources/addresources/values-kk-rKZ/strings.xml diff --git a/src/main/resources/addresources/values-km-rKH/strings.xml b/patches/src/main/resources/addresources/values-km-rKH/strings.xml similarity index 100% rename from src/main/resources/addresources/values-km-rKH/strings.xml rename to patches/src/main/resources/addresources/values-km-rKH/strings.xml diff --git a/src/main/resources/addresources/values-kn-rIN/strings.xml b/patches/src/main/resources/addresources/values-kn-rIN/strings.xml similarity index 100% rename from src/main/resources/addresources/values-kn-rIN/strings.xml rename to patches/src/main/resources/addresources/values-kn-rIN/strings.xml diff --git a/src/main/resources/addresources/values-ko-rKR/strings.xml b/patches/src/main/resources/addresources/values-ko-rKR/strings.xml similarity index 100% rename from src/main/resources/addresources/values-ko-rKR/strings.xml rename to patches/src/main/resources/addresources/values-ko-rKR/strings.xml diff --git a/src/main/resources/addresources/values-ky-rKG/strings.xml b/patches/src/main/resources/addresources/values-ky-rKG/strings.xml similarity index 100% rename from src/main/resources/addresources/values-ky-rKG/strings.xml rename to patches/src/main/resources/addresources/values-ky-rKG/strings.xml diff --git a/src/main/resources/addresources/values-lo-rLA/strings.xml b/patches/src/main/resources/addresources/values-lo-rLA/strings.xml similarity index 100% rename from src/main/resources/addresources/values-lo-rLA/strings.xml rename to patches/src/main/resources/addresources/values-lo-rLA/strings.xml diff --git a/src/main/resources/addresources/values-lt-rLT/strings.xml b/patches/src/main/resources/addresources/values-lt-rLT/strings.xml similarity index 100% rename from src/main/resources/addresources/values-lt-rLT/strings.xml rename to patches/src/main/resources/addresources/values-lt-rLT/strings.xml diff --git a/src/main/resources/addresources/values-lv-rLV/strings.xml b/patches/src/main/resources/addresources/values-lv-rLV/strings.xml similarity index 100% rename from src/main/resources/addresources/values-lv-rLV/strings.xml rename to patches/src/main/resources/addresources/values-lv-rLV/strings.xml diff --git a/src/main/resources/addresources/values-mk-rMK/strings.xml b/patches/src/main/resources/addresources/values-mk-rMK/strings.xml similarity index 100% rename from src/main/resources/addresources/values-mk-rMK/strings.xml rename to patches/src/main/resources/addresources/values-mk-rMK/strings.xml diff --git a/src/main/resources/addresources/values-ml-rIN/strings.xml b/patches/src/main/resources/addresources/values-ml-rIN/strings.xml similarity index 100% rename from src/main/resources/addresources/values-ml-rIN/strings.xml rename to patches/src/main/resources/addresources/values-ml-rIN/strings.xml diff --git a/src/main/resources/addresources/values-mn-rMN/strings.xml b/patches/src/main/resources/addresources/values-mn-rMN/strings.xml similarity index 100% rename from src/main/resources/addresources/values-mn-rMN/strings.xml rename to patches/src/main/resources/addresources/values-mn-rMN/strings.xml diff --git a/src/main/resources/addresources/values-mr-rIN/strings.xml b/patches/src/main/resources/addresources/values-mr-rIN/strings.xml similarity index 100% rename from src/main/resources/addresources/values-mr-rIN/strings.xml rename to patches/src/main/resources/addresources/values-mr-rIN/strings.xml diff --git a/src/main/resources/addresources/values-ms-rMY/strings.xml b/patches/src/main/resources/addresources/values-ms-rMY/strings.xml similarity index 100% rename from src/main/resources/addresources/values-ms-rMY/strings.xml rename to patches/src/main/resources/addresources/values-ms-rMY/strings.xml diff --git a/src/main/resources/addresources/values-my-rMM/strings.xml b/patches/src/main/resources/addresources/values-my-rMM/strings.xml similarity index 100% rename from src/main/resources/addresources/values-my-rMM/strings.xml rename to patches/src/main/resources/addresources/values-my-rMM/strings.xml diff --git a/src/main/resources/addresources/values-nb-rNO/strings.xml b/patches/src/main/resources/addresources/values-nb-rNO/strings.xml similarity index 100% rename from src/main/resources/addresources/values-nb-rNO/strings.xml rename to patches/src/main/resources/addresources/values-nb-rNO/strings.xml diff --git a/src/main/resources/addresources/values-ne-rIN/strings.xml b/patches/src/main/resources/addresources/values-ne-rIN/strings.xml similarity index 100% rename from src/main/resources/addresources/values-ne-rIN/strings.xml rename to patches/src/main/resources/addresources/values-ne-rIN/strings.xml diff --git a/src/main/resources/addresources/values-nl-rNL/strings.xml b/patches/src/main/resources/addresources/values-nl-rNL/strings.xml similarity index 100% rename from src/main/resources/addresources/values-nl-rNL/strings.xml rename to patches/src/main/resources/addresources/values-nl-rNL/strings.xml diff --git a/src/main/resources/addresources/values-or-rIN/strings.xml b/patches/src/main/resources/addresources/values-or-rIN/strings.xml similarity index 100% rename from src/main/resources/addresources/values-or-rIN/strings.xml rename to patches/src/main/resources/addresources/values-or-rIN/strings.xml diff --git a/src/main/resources/addresources/values-pa-rIN/strings.xml b/patches/src/main/resources/addresources/values-pa-rIN/strings.xml similarity index 100% rename from src/main/resources/addresources/values-pa-rIN/strings.xml rename to patches/src/main/resources/addresources/values-pa-rIN/strings.xml diff --git a/src/main/resources/addresources/values-pl-rPL/strings.xml b/patches/src/main/resources/addresources/values-pl-rPL/strings.xml similarity index 100% rename from src/main/resources/addresources/values-pl-rPL/strings.xml rename to patches/src/main/resources/addresources/values-pl-rPL/strings.xml diff --git a/src/main/resources/addresources/values-pt-rBR/strings.xml b/patches/src/main/resources/addresources/values-pt-rBR/strings.xml similarity index 100% rename from src/main/resources/addresources/values-pt-rBR/strings.xml rename to patches/src/main/resources/addresources/values-pt-rBR/strings.xml diff --git a/src/main/resources/addresources/values-pt-rPT/strings.xml b/patches/src/main/resources/addresources/values-pt-rPT/strings.xml similarity index 100% rename from src/main/resources/addresources/values-pt-rPT/strings.xml rename to patches/src/main/resources/addresources/values-pt-rPT/strings.xml diff --git a/src/main/resources/addresources/values-ro-rRO/strings.xml b/patches/src/main/resources/addresources/values-ro-rRO/strings.xml similarity index 100% rename from src/main/resources/addresources/values-ro-rRO/strings.xml rename to patches/src/main/resources/addresources/values-ro-rRO/strings.xml diff --git a/src/main/resources/addresources/values-ru-rRU/strings.xml b/patches/src/main/resources/addresources/values-ru-rRU/strings.xml similarity index 100% rename from src/main/resources/addresources/values-ru-rRU/strings.xml rename to patches/src/main/resources/addresources/values-ru-rRU/strings.xml diff --git a/src/main/resources/addresources/values-si-rLK/strings.xml b/patches/src/main/resources/addresources/values-si-rLK/strings.xml similarity index 100% rename from src/main/resources/addresources/values-si-rLK/strings.xml rename to patches/src/main/resources/addresources/values-si-rLK/strings.xml diff --git a/src/main/resources/addresources/values-sk-rSK/strings.xml b/patches/src/main/resources/addresources/values-sk-rSK/strings.xml similarity index 100% rename from src/main/resources/addresources/values-sk-rSK/strings.xml rename to patches/src/main/resources/addresources/values-sk-rSK/strings.xml diff --git a/src/main/resources/addresources/values-sl-rSI/strings.xml b/patches/src/main/resources/addresources/values-sl-rSI/strings.xml similarity index 100% rename from src/main/resources/addresources/values-sl-rSI/strings.xml rename to patches/src/main/resources/addresources/values-sl-rSI/strings.xml diff --git a/src/main/resources/addresources/values-sq-rAL/strings.xml b/patches/src/main/resources/addresources/values-sq-rAL/strings.xml similarity index 100% rename from src/main/resources/addresources/values-sq-rAL/strings.xml rename to patches/src/main/resources/addresources/values-sq-rAL/strings.xml diff --git a/src/main/resources/addresources/values-sr-rSP/strings.xml b/patches/src/main/resources/addresources/values-sr-rSP/strings.xml similarity index 100% rename from src/main/resources/addresources/values-sr-rSP/strings.xml rename to patches/src/main/resources/addresources/values-sr-rSP/strings.xml diff --git a/src/main/resources/addresources/values-sv-rSE/strings.xml b/patches/src/main/resources/addresources/values-sv-rSE/strings.xml similarity index 100% rename from src/main/resources/addresources/values-sv-rSE/strings.xml rename to patches/src/main/resources/addresources/values-sv-rSE/strings.xml diff --git a/src/main/resources/addresources/values-sw-rKE/strings.xml b/patches/src/main/resources/addresources/values-sw-rKE/strings.xml similarity index 100% rename from src/main/resources/addresources/values-sw-rKE/strings.xml rename to patches/src/main/resources/addresources/values-sw-rKE/strings.xml diff --git a/src/main/resources/addresources/values-ta-rIN/strings.xml b/patches/src/main/resources/addresources/values-ta-rIN/strings.xml similarity index 100% rename from src/main/resources/addresources/values-ta-rIN/strings.xml rename to patches/src/main/resources/addresources/values-ta-rIN/strings.xml diff --git a/src/main/resources/addresources/values-te-rIN/strings.xml b/patches/src/main/resources/addresources/values-te-rIN/strings.xml similarity index 100% rename from src/main/resources/addresources/values-te-rIN/strings.xml rename to patches/src/main/resources/addresources/values-te-rIN/strings.xml diff --git a/src/main/resources/addresources/values-th-rTH/strings.xml b/patches/src/main/resources/addresources/values-th-rTH/strings.xml similarity index 100% rename from src/main/resources/addresources/values-th-rTH/strings.xml rename to patches/src/main/resources/addresources/values-th-rTH/strings.xml diff --git a/src/main/resources/addresources/values-tr-rTR/strings.xml b/patches/src/main/resources/addresources/values-tr-rTR/strings.xml similarity index 100% rename from src/main/resources/addresources/values-tr-rTR/strings.xml rename to patches/src/main/resources/addresources/values-tr-rTR/strings.xml diff --git a/src/main/resources/addresources/values-uk-rUA/strings.xml b/patches/src/main/resources/addresources/values-uk-rUA/strings.xml similarity index 100% rename from src/main/resources/addresources/values-uk-rUA/strings.xml rename to patches/src/main/resources/addresources/values-uk-rUA/strings.xml diff --git a/src/main/resources/addresources/values-ur-rIN/strings.xml b/patches/src/main/resources/addresources/values-ur-rIN/strings.xml similarity index 100% rename from src/main/resources/addresources/values-ur-rIN/strings.xml rename to patches/src/main/resources/addresources/values-ur-rIN/strings.xml diff --git a/src/main/resources/addresources/values-uz-rUZ/strings.xml b/patches/src/main/resources/addresources/values-uz-rUZ/strings.xml similarity index 100% rename from src/main/resources/addresources/values-uz-rUZ/strings.xml rename to patches/src/main/resources/addresources/values-uz-rUZ/strings.xml diff --git a/src/main/resources/addresources/values-vi-rVN/strings.xml b/patches/src/main/resources/addresources/values-vi-rVN/strings.xml similarity index 100% rename from src/main/resources/addresources/values-vi-rVN/strings.xml rename to patches/src/main/resources/addresources/values-vi-rVN/strings.xml diff --git a/src/main/resources/addresources/values-zh-rCN/strings.xml b/patches/src/main/resources/addresources/values-zh-rCN/strings.xml similarity index 100% rename from src/main/resources/addresources/values-zh-rCN/strings.xml rename to patches/src/main/resources/addresources/values-zh-rCN/strings.xml diff --git a/src/main/resources/addresources/values-zh-rTW/strings.xml b/patches/src/main/resources/addresources/values-zh-rTW/strings.xml similarity index 100% rename from src/main/resources/addresources/values-zh-rTW/strings.xml rename to patches/src/main/resources/addresources/values-zh-rTW/strings.xml diff --git a/src/main/resources/addresources/values-zu-rZA/strings.xml b/patches/src/main/resources/addresources/values-zu-rZA/strings.xml similarity index 100% rename from src/main/resources/addresources/values-zu-rZA/strings.xml rename to patches/src/main/resources/addresources/values-zu-rZA/strings.xml diff --git a/src/main/resources/addresources/values/arrays.xml b/patches/src/main/resources/addresources/values/arrays.xml similarity index 91% rename from src/main/resources/addresources/values/arrays.xml rename to patches/src/main/resources/addresources/values/arrays.xml index 829f9533e..4a4715011 100644 --- a/src/main/resources/addresources/values/arrays.xml +++ b/patches/src/main/resources/addresources/values/arrays.xml @@ -1,18 +1,18 @@ - + Android VR iOS - + ANDROID_VR IOS - + @string/revanced_spoof_app_version_target_entry_1 @string/revanced_spoof_app_version_target_entry_2 @@ -28,7 +28,7 @@ 17.33.42 - + @string/revanced_miniplayer_type_entry_1 @string/revanced_miniplayer_type_entry_2 @@ -38,7 +38,7 @@ @string/revanced_miniplayer_type_entry_6 - + ORIGINAL PHONE TABLET @@ -57,7 +57,7 @@ TABLET - + @string/revanced_change_start_page_entry_default @string/revanced_change_start_page_entry_search @@ -77,7 +77,7 @@ @string/revanced_change_start_page_entry_browse - + ORIGINAL SEARCH @@ -98,7 +98,7 @@ BROWSE - + @string/revanced_alt_thumbnail_options_entry_1 @string/revanced_alt_thumbnail_options_entry_2 @@ -106,7 +106,7 @@ @string/revanced_alt_thumbnail_options_entry_4 - + ORIGINAL DEARROW DEARROW_STILL_IMAGES @@ -123,7 +123,7 @@ END - + @string/revanced_video_quality_default_entry_1 @string/revanced_video_quality_default_entry_2 @@ -149,7 +149,7 @@ - + @string/revanced_show_deleted_messages_entry_1 @string/revanced_show_deleted_messages_entry_2 @@ -161,10 +161,9 @@ cross-out - - + - + @string/revanced_block_embedded_ads_entry_1 @string/revanced_block_embedded_ads_entry_2 diff --git a/src/main/resources/addresources/values/strings.xml b/patches/src/main/resources/addresources/values/strings.xml similarity index 96% rename from src/main/resources/addresources/values/strings.xml rename to patches/src/main/resources/addresources/values/strings.xml index 295c84077..76310e0cd 100644 --- a/src/main/resources/addresources/values/strings.xml +++ b/patches/src/main/resources/addresources/values/strings.xml @@ -31,7 +31,7 @@ This is because Crowdin requires temporarily flattening this file and removing t --> - + Checks failed Open official website Ignore @@ -42,7 +42,7 @@ This is because Crowdin requires temporarily flattening this file and removing t Patched %s days ago APK build date is corrupted - + ReVanced Do you wish to proceed? Reset @@ -62,7 +62,7 @@ This is because Crowdin requires temporarily flattening this file and removing t Official links Donate - + MicroG GmsCore is not installed. Install it. Action needed @@ -73,7 +73,7 @@ This is because Crowdin requires temporarily flattening this file and removing t - + About Ads Alternative thumbnails @@ -86,7 +86,7 @@ This is because Crowdin requires temporarily flattening this file and removing t Misc Video - + Debugging Enable or disable debugging options Debug logging @@ -103,7 +103,7 @@ This is because Crowdin requires temporarily flattening this file and removing t Toast not shown if error occurs Turning off error toasts hides all ReVanced error notifications.\n\nYou will not be notified of any unexpected events. - + Disable like / subscribe button glow Like and subscribe button will not glow when mentioned Like and subscribe button will glow when mentioned @@ -317,7 +317,7 @@ This is because Crowdin requires temporarily flattening this file and removing t Keyword is too short and requires quotes: %s Keyword will hide all videos: %s - + Hide general ads General ads are hidden General ads are shown @@ -355,17 +355,17 @@ This is because Crowdin requires temporarily flattening this file and removing t Hide fullscreen ads only works with older devices - + Hide YouTube Premium promotions YouTube Premium promotions under video player are hidden YouTube Premium promotions under video player are shown - + Hide video ads Video ads are hidden Video ads are shown - + URL copied to clipboard URL with timestamp copied Show copy video URL button @@ -375,13 +375,13 @@ This is because Crowdin requires temporarily flattening this file and removing t Button is shown. Tap to copy video URL with timestamp. Tap and hold to copy video without timestamp Button is not shown - + Remove viewer discretion dialog Dialog will be removed Dialog will be shown This does not bypass the age restriction. It just accepts it automatically. - + External downloads Settings for using an external downloader Show external download button @@ -395,17 +395,17 @@ This is because Crowdin requires temporarily flattening this file and removing t Package name of your installed external downloader app, such as NewPipe or Seal %s is not installed. Please install it. - + Disable precise seeking gesture Gesture is disabled Gesture is enabled - + Enable seekbar tapping Seekbar tapping is enabled Seekbar tapping is disabled - + Enable brightness gesture Brightness swipe is enabled Brightness swipe is disabled @@ -434,12 +434,12 @@ This is because Crowdin requires temporarily flattening this file and removing t Swipe magnitude threshold The amount of threshold for swipe to occur - + Disable auto captions Auto captions are disabled Auto captions are enabled - + Action buttons Hide or show buttons under videos Hide Like and Dislike @@ -475,7 +475,7 @@ This is because Crowdin requires temporarily flattening this file and removing t Save to playlist button is hidden Save to playlist button is shown - + Navigation buttons Hide or change buttons in the navigation bar @@ -502,7 +502,7 @@ This is because Crowdin requires temporarily flattening this file and removing t Labels are hidden Labels are shown - + Flyout menu Hide or show player flyout menu items @@ -547,7 +547,7 @@ This is because Crowdin requires temporarily flattening this file and removing t Watch in VR menu is hidden Watch in VR menu is shown - + Hide previous & next video buttons Buttons are hidden Buttons are shown @@ -562,27 +562,27 @@ This is because Crowdin requires temporarily flattening this file and removing t Autoplay button is hidden Autoplay button is shown - + Hide end screen cards End screen cards are hidden End screen cards are shown - + Disable ambient mode in fullscreen Ambient mode disabled Ambient mode enabled - + Hide info cards Info cards are hidden Info cards are shown - + Disable rolling number animations Rolling numbers are not animated Rolling numbers are animated - + Hide seekbar in video player Video player seekbar is hidden Video player seekbar is shown @@ -590,7 +590,7 @@ This is because Crowdin requires temporarily flattening this file and removing t Thumbnail seekbar is hidden Thumbnail seekbar is shown - + Shorts player Hide or show components in the Shorts player @@ -690,27 +690,27 @@ This is because Crowdin requires temporarily flattening this file and removing t Navigation bar is hidden Navigation bar is shown - + Disable suggested video end screen Suggested videos will be disabled Suggested videos will be shown - + Hide video timestamp Timestamp is hidden Timestamp is shown - + Hide player popup panels Player popup panels are hidden Player popup panels are shown - + Player overlay opacity Opacity value between 0-100, where 0 is transparent Player overlay opacity must be between 0-100 - + Return YouTube Dislike Dislikes temporarily not available (API timed out) @@ -757,17 +757,17 @@ This is because Crowdin requires temporarily flattening this file and removing t Client rate limit encountered %d times %d milliseconds - + Enable wide search bar Wide search bar is enabled Wide search bar is disabled - + Restore old seekbar thumbnails Seekbar thumbnails will appear above the seekbar Seekbar thumbnails will appear in fullscreen - + SponsorBlock Enable SponsorBlock SponsorBlock is a crowd-sourced system for skipping annoying parts of YouTube videos @@ -950,7 +950,7 @@ This is because Crowdin requires temporarily flattening this file and removing t sponsor.ajay.app Data is provided by the SponsorBlock API. Tap here to learn more and see downloads for other platforms - + Spoof app version Version spoofed Version not spoofed @@ -965,7 +965,7 @@ This is because Crowdin requires temporarily flattening this file and removing t 17.41.37 - Restore old playlist shelf 17.33.42 - Restore old UI layout - + Set start page Default Browse channels @@ -983,12 +983,12 @@ This is because Crowdin requires temporarily flattening this file and removing t Trending Watch later - + Disable resuming Shorts player Shorts player will not resume on app startup Shorts player will resume on app startup - + Autoplay Shorts Shorts will autoplay Shorts will repeat @@ -996,13 +996,13 @@ This is because Crowdin requires temporarily flattening this file and removing t Shorts background play will autoplay Shorts background play will repeat - + Enable tablet layout Tablet layout is enabled Tablet layout is disabled Community posts do not show up on tablet layouts x - + Miniplayer Change the style of the in app minimized player Miniplayer type @@ -1040,12 +1040,12 @@ This is because Crowdin requires temporarily flattening this file and removing t Opacity value between 0-100, where 0 is transparent Miniplayer overlay opacity must be between 0-100 - + Enable gradient loading screen Loading screen will have a gradient background Loading screen will have a solid background - + Enable custom seekbar color Custom seekbar color is shown Original seekbar color is shown @@ -1053,12 +1053,12 @@ This is because Crowdin requires temporarily flattening this file and removing t The color of the seekbar Invalid seekbar color value - + Bypass image region restrictions Using image host yt4.ggpht.com Using original image host\n\nEnabling this can fix missing images that are blocked in some regions - + Home tab @@ -1091,7 +1091,7 @@ This is because Crowdin requires temporarily flattening this file and removing t DeArrow temporarily not available (status code: %s) DeArrow temporarily not available - + Show ReVanced announcements Announcements are shown on startup Announcements are not shown on startup @@ -1099,47 +1099,47 @@ This is because Crowdin requires temporarily flattening this file and removing t Failed connecting to announcements provider Dismiss - + Warning Your watch history is not being saved.<br><br>This most likely is caused by a DNS ad blocker or network proxy.<br><br>To fix this, whitelist <b>s.youtube.com</b> or turn off all DNS blockers and proxies. Do not show again - + Enable auto-repeat Auto-repeat is enabled Auto-repeat is disabled - + Spoof device dimensions Device dimensions spoofed\n\nHigher video qualities might be unlocked but you may experience video playback stuttering, worse battery life, and unknown side effects Device dimensions not spoofed\n\nEnabling this can unlock higher video qualities Enabling this can cause video playback stuttering, worse battery life, and unknown side effects. - + GmsCore Settings Settings for GmsCore - + Bypass URL redirects URL redirects are bypassed URL redirects are not bypassed - + Open links in browser Opening links externally Opening links in app - + Remove tracking query parameter Tracking query parameter is removed from links Tracking query parameter is not removed from links - + Disable zoom haptics Haptics are disabled Haptics are enabled - + Automatic quality 2160p 1440p @@ -1158,35 +1158,35 @@ This is because Crowdin requires temporarily flattening this file and removing t wifi Changed default %1$s quality to: %2$s - + Show speed dialog button Button is shown Button is not shown - + Custom playback speeds Add or change the available playback speeds Custom speeds must be less than %s. Using default values. Invalid custom playback speeds. Using default values. - + Remember playback speed changes Playback speed changes apply to all videos Playback speed changes only apply to the current video Default playback speed Changed default speed to: %s - + Restore old video quality menu Old video quality menu is shown Old video quality menu is not shown - + Enable slide to seek Slide to seek is enabled Slide to seek is not enabled - + Spoof video streams Spoof the client video streams to prevent playback issues Spoof video streams @@ -1204,20 +1204,14 @@ This is because Crowdin requires temporarily flattening this file and removing t Android VR spoofing side effects • Audio track menu is missing\n• Stable volume is not available - - - Enable auto HDR brightness - Auto HDR brightness is enabled - Auto HDR brightness is disabled - - + Block audio ads Audio ads are blocked Audio ads are unblocked - + %s is unavailable. Ads may show. Try switching to another ad block service in settings. %s server returned an error. Ads may show. Try switching to another ad block service in settings. Block embedded video ads @@ -1225,30 +1219,30 @@ This is because Crowdin requires temporarily flattening this file and removing t Luminous proxy PurpleAdBlock proxy - + Block video ads Video ads are blocked Video ads are unblocked - + message deleted Show deleted messages Do not show deleted messages Hide deleted messages behind a spoiler Show deleted messages as crossed-out text - + Automatically claim Channel Points Channel Points are claimed automatically Channel Points are not claimed automatically - + Enable Twitch debug mode Twitch debug mode is enabled (not recommended) Twitch debug mode is disabled - + ReVanced Settings Ads Ad blocking settings diff --git a/src/main/resources/change-header/revanced-borderless/drawable-hdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/change-header/revanced-borderless/drawable-hdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/change-header/revanced-borderless/drawable-hdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/change-header/revanced-borderless/drawable-hdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/change-header/revanced-borderless/drawable-hdpi/yt_wordmark_header_light.png b/patches/src/main/resources/change-header/revanced-borderless/drawable-hdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/change-header/revanced-borderless/drawable-hdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/change-header/revanced-borderless/drawable-hdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/change-header/revanced-borderless/drawable-mdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/change-header/revanced-borderless/drawable-mdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/change-header/revanced-borderless/drawable-mdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/change-header/revanced-borderless/drawable-mdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/change-header/revanced-borderless/drawable-mdpi/yt_wordmark_header_light.png b/patches/src/main/resources/change-header/revanced-borderless/drawable-mdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/change-header/revanced-borderless/drawable-mdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/change-header/revanced-borderless/drawable-mdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/change-header/revanced-borderless/drawable-xhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/change-header/revanced-borderless/drawable-xhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/change-header/revanced-borderless/drawable-xhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/change-header/revanced-borderless/drawable-xhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/change-header/revanced-borderless/drawable-xhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/change-header/revanced-borderless/drawable-xhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/change-header/revanced-borderless/drawable-xhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/change-header/revanced-borderless/drawable-xhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/change-header/revanced-borderless/drawable-xxhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/change-header/revanced-borderless/drawable-xxhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/change-header/revanced-borderless/drawable-xxhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/change-header/revanced-borderless/drawable-xxhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/change-header/revanced-borderless/drawable-xxhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/change-header/revanced-borderless/drawable-xxhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/change-header/revanced-borderless/drawable-xxhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/change-header/revanced-borderless/drawable-xxhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/change-header/revanced-borderless/drawable-xxxhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/change-header/revanced-borderless/drawable-xxxhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/change-header/revanced-borderless/drawable-xxxhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/change-header/revanced-borderless/drawable-xxxhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/change-header/revanced-borderless/drawable-xxxhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/change-header/revanced-borderless/drawable-xxxhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/change-header/revanced-borderless/drawable-xxxhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/change-header/revanced-borderless/drawable-xxxhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/change-header/revanced/drawable-hdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/change-header/revanced/drawable-hdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/change-header/revanced/drawable-hdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/change-header/revanced/drawable-hdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/change-header/revanced/drawable-hdpi/yt_wordmark_header_light.png b/patches/src/main/resources/change-header/revanced/drawable-hdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/change-header/revanced/drawable-hdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/change-header/revanced/drawable-hdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/change-header/revanced/drawable-mdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/change-header/revanced/drawable-mdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/change-header/revanced/drawable-mdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/change-header/revanced/drawable-mdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/change-header/revanced/drawable-mdpi/yt_wordmark_header_light.png b/patches/src/main/resources/change-header/revanced/drawable-mdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/change-header/revanced/drawable-mdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/change-header/revanced/drawable-mdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/change-header/revanced/drawable-xhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/change-header/revanced/drawable-xhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/change-header/revanced/drawable-xhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/change-header/revanced/drawable-xhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/change-header/revanced/drawable-xhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/change-header/revanced/drawable-xhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/change-header/revanced/drawable-xhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/change-header/revanced/drawable-xhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/change-header/revanced/drawable-xxhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/change-header/revanced/drawable-xxhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/change-header/revanced/drawable-xxhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/change-header/revanced/drawable-xxhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/change-header/revanced/drawable-xxhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/change-header/revanced/drawable-xxhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/change-header/revanced/drawable-xxhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/change-header/revanced/drawable-xxhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/change-header/revanced/drawable-xxxhdpi/yt_wordmark_header_dark.png b/patches/src/main/resources/change-header/revanced/drawable-xxxhdpi/yt_wordmark_header_dark.png similarity index 100% rename from src/main/resources/change-header/revanced/drawable-xxxhdpi/yt_wordmark_header_dark.png rename to patches/src/main/resources/change-header/revanced/drawable-xxxhdpi/yt_wordmark_header_dark.png diff --git a/src/main/resources/change-header/revanced/drawable-xxxhdpi/yt_wordmark_header_light.png b/patches/src/main/resources/change-header/revanced/drawable-xxxhdpi/yt_wordmark_header_light.png similarity index 100% rename from src/main/resources/change-header/revanced/drawable-xxxhdpi/yt_wordmark_header_light.png rename to patches/src/main/resources/change-header/revanced/drawable-xxxhdpi/yt_wordmark_header_light.png diff --git a/src/main/resources/copyvideourl/drawable/revanced_yt_copy.xml b/patches/src/main/resources/copyvideourl/drawable/revanced_yt_copy.xml similarity index 100% rename from src/main/resources/copyvideourl/drawable/revanced_yt_copy.xml rename to patches/src/main/resources/copyvideourl/drawable/revanced_yt_copy.xml diff --git a/src/main/resources/copyvideourl/drawable/revanced_yt_copy_timestamp.xml b/patches/src/main/resources/copyvideourl/drawable/revanced_yt_copy_timestamp.xml similarity index 100% rename from src/main/resources/copyvideourl/drawable/revanced_yt_copy_timestamp.xml rename to patches/src/main/resources/copyvideourl/drawable/revanced_yt_copy_timestamp.xml diff --git a/src/main/resources/copyvideourl/host/layout/youtube_controls_bottom_ui_container.xml b/patches/src/main/resources/copyvideourl/host/layout/youtube_controls_bottom_ui_container.xml similarity index 100% rename from src/main/resources/copyvideourl/host/layout/youtube_controls_bottom_ui_container.xml rename to patches/src/main/resources/copyvideourl/host/layout/youtube_controls_bottom_ui_container.xml diff --git a/src/main/resources/custom-branding/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/custom-branding/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/custom-branding/mipmap-hdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/custom-branding/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/custom-branding/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/custom-branding/mipmap-hdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/custom-branding/mipmap-hdpi/ic_launcher.png b/patches/src/main/resources/custom-branding/mipmap-hdpi/ic_launcher.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-hdpi/ic_launcher.png rename to patches/src/main/resources/custom-branding/mipmap-hdpi/ic_launcher.png diff --git a/src/main/resources/custom-branding/mipmap-hdpi/ic_launcher_round.png b/patches/src/main/resources/custom-branding/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-hdpi/ic_launcher_round.png rename to patches/src/main/resources/custom-branding/mipmap-hdpi/ic_launcher_round.png diff --git a/src/main/resources/custom-branding/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/custom-branding/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/custom-branding/mipmap-mdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/custom-branding/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/custom-branding/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/custom-branding/mipmap-mdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/custom-branding/mipmap-mdpi/ic_launcher.png b/patches/src/main/resources/custom-branding/mipmap-mdpi/ic_launcher.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-mdpi/ic_launcher.png rename to patches/src/main/resources/custom-branding/mipmap-mdpi/ic_launcher.png diff --git a/src/main/resources/custom-branding/mipmap-mdpi/ic_launcher_round.png b/patches/src/main/resources/custom-branding/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-mdpi/ic_launcher_round.png rename to patches/src/main/resources/custom-branding/mipmap-mdpi/ic_launcher_round.png diff --git a/src/main/resources/custom-branding/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/custom-branding/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/custom-branding/mipmap-xhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/custom-branding/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/custom-branding/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/custom-branding/mipmap-xhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/custom-branding/mipmap-xhdpi/ic_launcher.png b/patches/src/main/resources/custom-branding/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-xhdpi/ic_launcher.png rename to patches/src/main/resources/custom-branding/mipmap-xhdpi/ic_launcher.png diff --git a/src/main/resources/custom-branding/mipmap-xhdpi/ic_launcher_round.png b/patches/src/main/resources/custom-branding/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-xhdpi/ic_launcher_round.png rename to patches/src/main/resources/custom-branding/mipmap-xhdpi/ic_launcher_round.png diff --git a/src/main/resources/custom-branding/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/custom-branding/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/custom-branding/mipmap-xxhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/custom-branding/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/custom-branding/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/custom-branding/mipmap-xxhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/custom-branding/mipmap-xxhdpi/ic_launcher.png b/patches/src/main/resources/custom-branding/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-xxhdpi/ic_launcher.png rename to patches/src/main/resources/custom-branding/mipmap-xxhdpi/ic_launcher.png diff --git a/src/main/resources/custom-branding/mipmap-xxhdpi/ic_launcher_round.png b/patches/src/main/resources/custom-branding/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-xxhdpi/ic_launcher_round.png rename to patches/src/main/resources/custom-branding/mipmap-xxhdpi/ic_launcher_round.png diff --git a/src/main/resources/custom-branding/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png b/patches/src/main/resources/custom-branding/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png rename to patches/src/main/resources/custom-branding/mipmap-xxxhdpi/adaptiveproduct_youtube_background_color_108.png diff --git a/src/main/resources/custom-branding/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png b/patches/src/main/resources/custom-branding/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png rename to patches/src/main/resources/custom-branding/mipmap-xxxhdpi/adaptiveproduct_youtube_foreground_color_108.png diff --git a/src/main/resources/custom-branding/mipmap-xxxhdpi/ic_launcher.png b/patches/src/main/resources/custom-branding/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-xxxhdpi/ic_launcher.png rename to patches/src/main/resources/custom-branding/mipmap-xxxhdpi/ic_launcher.png diff --git a/src/main/resources/custom-branding/mipmap-xxxhdpi/ic_launcher_round.png b/patches/src/main/resources/custom-branding/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from src/main/resources/custom-branding/mipmap-xxxhdpi/ic_launcher_round.png rename to patches/src/main/resources/custom-branding/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/src/main/resources/downloads/drawable/revanced_yt_download_button.xml b/patches/src/main/resources/downloads/drawable/revanced_yt_download_button.xml similarity index 100% rename from src/main/resources/downloads/drawable/revanced_yt_download_button.xml rename to patches/src/main/resources/downloads/drawable/revanced_yt_download_button.xml diff --git a/src/main/resources/downloads/host/layout/youtube_controls_bottom_ui_container.xml b/patches/src/main/resources/downloads/host/layout/youtube_controls_bottom_ui_container.xml similarity index 100% rename from src/main/resources/downloads/host/layout/youtube_controls_bottom_ui_container.xml rename to patches/src/main/resources/downloads/host/layout/youtube_controls_bottom_ui_container.xml diff --git a/src/main/resources/settings/host/values/styles.xml b/patches/src/main/resources/settings/host/values/styles.xml similarity index 100% rename from src/main/resources/settings/host/values/styles.xml rename to patches/src/main/resources/settings/host/values/styles.xml diff --git a/src/main/resources/settings/layout/revanced_settings_with_toolbar.xml b/patches/src/main/resources/settings/layout/revanced_settings_with_toolbar.xml similarity index 100% rename from src/main/resources/settings/layout/revanced_settings_with_toolbar.xml rename to patches/src/main/resources/settings/layout/revanced_settings_with_toolbar.xml diff --git a/src/main/resources/settings/xml/revanced_prefs.xml b/patches/src/main/resources/settings/xml/revanced_prefs.xml similarity index 100% rename from src/main/resources/settings/xml/revanced_prefs.xml rename to patches/src/main/resources/settings/xml/revanced_prefs.xml diff --git a/src/main/resources/speedbutton/drawable/revanced_playback_speed_dialog_button.xml b/patches/src/main/resources/speedbutton/drawable/revanced_playback_speed_dialog_button.xml similarity index 100% rename from src/main/resources/speedbutton/drawable/revanced_playback_speed_dialog_button.xml rename to patches/src/main/resources/speedbutton/drawable/revanced_playback_speed_dialog_button.xml diff --git a/src/main/resources/speedbutton/host/layout/youtube_controls_bottom_ui_container.xml b/patches/src/main/resources/speedbutton/host/layout/youtube_controls_bottom_ui_container.xml similarity index 100% rename from src/main/resources/speedbutton/host/layout/youtube_controls_bottom_ui_container.xml rename to patches/src/main/resources/speedbutton/host/layout/youtube_controls_bottom_ui_container.xml diff --git a/src/main/resources/sponsorblock/drawable-xxxhdpi/quantum_ic_skip_next_white_24.png b/patches/src/main/resources/sponsorblock/drawable-xxxhdpi/quantum_ic_skip_next_white_24.png similarity index 100% rename from src/main/resources/sponsorblock/drawable-xxxhdpi/quantum_ic_skip_next_white_24.png rename to patches/src/main/resources/sponsorblock/drawable-xxxhdpi/quantum_ic_skip_next_white_24.png diff --git a/src/main/resources/sponsorblock/drawable/revanced_sb_adjust.xml b/patches/src/main/resources/sponsorblock/drawable/revanced_sb_adjust.xml similarity index 100% rename from src/main/resources/sponsorblock/drawable/revanced_sb_adjust.xml rename to patches/src/main/resources/sponsorblock/drawable/revanced_sb_adjust.xml diff --git a/src/main/resources/sponsorblock/drawable/revanced_sb_backward.xml b/patches/src/main/resources/sponsorblock/drawable/revanced_sb_backward.xml similarity index 100% rename from src/main/resources/sponsorblock/drawable/revanced_sb_backward.xml rename to patches/src/main/resources/sponsorblock/drawable/revanced_sb_backward.xml diff --git a/src/main/resources/sponsorblock/drawable/revanced_sb_compare.xml b/patches/src/main/resources/sponsorblock/drawable/revanced_sb_compare.xml similarity index 100% rename from src/main/resources/sponsorblock/drawable/revanced_sb_compare.xml rename to patches/src/main/resources/sponsorblock/drawable/revanced_sb_compare.xml diff --git a/src/main/resources/sponsorblock/drawable/revanced_sb_edit.xml b/patches/src/main/resources/sponsorblock/drawable/revanced_sb_edit.xml similarity index 100% rename from src/main/resources/sponsorblock/drawable/revanced_sb_edit.xml rename to patches/src/main/resources/sponsorblock/drawable/revanced_sb_edit.xml diff --git a/src/main/resources/sponsorblock/drawable/revanced_sb_forward.xml b/patches/src/main/resources/sponsorblock/drawable/revanced_sb_forward.xml similarity index 100% rename from src/main/resources/sponsorblock/drawable/revanced_sb_forward.xml rename to patches/src/main/resources/sponsorblock/drawable/revanced_sb_forward.xml diff --git a/src/main/resources/sponsorblock/drawable/revanced_sb_logo.xml b/patches/src/main/resources/sponsorblock/drawable/revanced_sb_logo.xml similarity index 100% rename from src/main/resources/sponsorblock/drawable/revanced_sb_logo.xml rename to patches/src/main/resources/sponsorblock/drawable/revanced_sb_logo.xml diff --git a/src/main/resources/sponsorblock/drawable/revanced_sb_publish.xml b/patches/src/main/resources/sponsorblock/drawable/revanced_sb_publish.xml similarity index 100% rename from src/main/resources/sponsorblock/drawable/revanced_sb_publish.xml rename to patches/src/main/resources/sponsorblock/drawable/revanced_sb_publish.xml diff --git a/src/main/resources/sponsorblock/drawable/revanced_sb_voting.xml b/patches/src/main/resources/sponsorblock/drawable/revanced_sb_voting.xml similarity index 100% rename from src/main/resources/sponsorblock/drawable/revanced_sb_voting.xml rename to patches/src/main/resources/sponsorblock/drawable/revanced_sb_voting.xml diff --git a/src/main/resources/sponsorblock/host/layout/youtube_controls_layout.xml b/patches/src/main/resources/sponsorblock/host/layout/youtube_controls_layout.xml similarity index 100% rename from src/main/resources/sponsorblock/host/layout/youtube_controls_layout.xml rename to patches/src/main/resources/sponsorblock/host/layout/youtube_controls_layout.xml diff --git a/src/main/resources/sponsorblock/layout/revanced_sb_inline_sponsor_overlay.xml b/patches/src/main/resources/sponsorblock/layout/revanced_sb_inline_sponsor_overlay.xml similarity index 86% rename from src/main/resources/sponsorblock/layout/revanced_sb_inline_sponsor_overlay.xml rename to patches/src/main/resources/sponsorblock/layout/revanced_sb_inline_sponsor_overlay.xml index fb57b7252..8d4f7b41a 100644 --- a/src/main/resources/sponsorblock/layout/revanced_sb_inline_sponsor_overlay.xml +++ b/patches/src/main/resources/sponsorblock/layout/revanced_sb_inline_sponsor_overlay.xml @@ -1,7 +1,7 @@ - - - - JsonPatch.Option( - option.key, - option.default, - option.values, - option.title, - option.description, - option.required, - ) - }, - ) - }.let { - File("patches.json").writeText(GsonBuilder().serializeNulls().create().toJson(it)) - } - - @Suppress("unused") - private class JsonPatch( - val name: String? = null, - val description: String? = null, - val compatiblePackages: Set? = null, - val use: Boolean = true, - val requiresIntegrations: Boolean = false, - val options: List