Compare commits
12 Commits
v4.0.0-dev
...
v4.0.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1482e7e1e8 | ||
|
|
806e94481c | ||
|
|
edcb6eac6d | ||
|
|
4dced95d54 | ||
|
|
62c9085096 | ||
|
|
50a9f09703 | ||
|
|
9633b4eef0 | ||
|
|
2a94ad681c | ||
|
|
9e79e9e72c | ||
|
|
429badef1a | ||
|
|
138b2f8960 | ||
|
|
60d70e5877 |
35
CHANGELOG.md
@@ -1,3 +1,38 @@
|
|||||||
|
# [4.0.0-dev.6](https://github.com/ReVanced/revanced-patches/compare/v4.0.0-dev.5...v4.0.0-dev.6) (2024-01-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **Tiktok - Playback speed:** Remember playback speed ([#2506](https://github.com/ReVanced/revanced-patches/issues/2506)) ([d2970e5](https://github.com/ReVanced/revanced-patches/commit/d2970e54fbbd7e4b1ae1d354ae2d5c4bbe9336b0))
|
||||||
|
|
||||||
|
# [4.0.0-dev.5](https://github.com/ReVanced/revanced-patches/compare/v4.0.0-dev.4...v4.0.0-dev.5) (2024-01-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **YouTube - Change header:** Improve patch descriptions ([#2581](https://github.com/ReVanced/revanced-patches/issues/2581)) ([43a5677](https://github.com/ReVanced/revanced-patches/commit/43a5677397380f14a049ae95532fd5096b94c938))
|
||||||
|
|
||||||
|
# [4.0.0-dev.4](https://github.com/ReVanced/revanced-patches/compare/v4.0.0-dev.3...v4.0.0-dev.4) (2024-01-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **MyFitnessPal:** Add `Hide ads` patch ([#2594](https://github.com/ReVanced/revanced-patches/issues/2594)) ([fd4b3c7](https://github.com/ReVanced/revanced-patches/commit/fd4b3c79a83f8de6256611629263d3e29e66f2c2))
|
||||||
|
|
||||||
|
# [4.0.0-dev.3](https://github.com/ReVanced/revanced-patches/compare/v4.0.0-dev.2...v4.0.0-dev.3) (2024-01-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **Change package name:** Mention caveat of the patch in the description ([427b81a](https://github.com/ReVanced/revanced-patches/commit/427b81a79a5a1de79f14d2261059fb098b22227f))
|
||||||
|
|
||||||
|
# [4.0.0-dev.2](https://github.com/ReVanced/revanced-patches/compare/v4.0.0-dev.1...v4.0.0-dev.2) (2024-01-02)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **YouTube - Change header:** Change to ReVanced borderless logo header by default ([#2512](https://github.com/ReVanced/revanced-patches/issues/2512)) ([75f785d](https://github.com/ReVanced/revanced-patches/commit/75f785d1ef6026cbbdf7073c10aace1b28d93a30))
|
||||||
|
|
||||||
# [4.0.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v3.3.0-dev.2...v4.0.0-dev.1) (2024-01-01)
|
# [4.0.0-dev.1](https://github.com/ReVanced/revanced-patches/compare/v3.3.0-dev.2...v4.0.0-dev.1) (2024-01-01)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -333,6 +333,20 @@ public final class app/revanced/patches/myexpenses/misc/pro/UnlockProPatch : app
|
|||||||
public synthetic fun execute (Lapp/revanced/patcher/data/Context;)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 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 static final field INSTANCE Lapp/revanced/patches/netguard/broadcasts/removerestriction/RemoveBroadcastsRestrictionPatch;
|
||||||
public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V
|
public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V
|
||||||
@@ -1128,6 +1142,12 @@ public final class app/revanced/patches/youtube/layout/branding/CustomBrandingPa
|
|||||||
public fun execute (Lapp/revanced/patcher/data/ResourceContext;)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 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 static final field INSTANCE Lapp/revanced/patches/youtube/layout/branding/header/PremiumHeadingPatch;
|
||||||
public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V
|
public synthetic fun execute (Lapp/revanced/patcher/data/Context;)V
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
org.gradle.parallel = true
|
org.gradle.parallel = true
|
||||||
org.gradle.caching = true
|
org.gradle.caching = true
|
||||||
kotlin.code.style = official
|
kotlin.code.style = official
|
||||||
version = 4.0.0-dev.1
|
version = 4.0.0-dev.6
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import java.io.Closeable
|
|||||||
|
|
||||||
@Patch(
|
@Patch(
|
||||||
name = "Change package name",
|
name = "Change package name",
|
||||||
description = "Appends \".revanced\" to the package name by default.",
|
description = "Appends \".revanced\" to the package name by default. Changing the package name of the app can lead to unexpected issues.",
|
||||||
use = false
|
use = false
|
||||||
)
|
)
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import com.android.tools.smali.dexlib2.iface.instruction.Instruction
|
|||||||
|
|
||||||
@Suppress("MemberVisibilityCanBePrivate")
|
@Suppress("MemberVisibilityCanBePrivate")
|
||||||
abstract class AbstractTransformInstructionsPatch<T> : BytecodePatch() {
|
abstract class AbstractTransformInstructionsPatch<T> : BytecodePatch() {
|
||||||
|
|
||||||
abstract fun filterMap(
|
abstract fun filterMap(
|
||||||
classDef: ClassDef,
|
classDef: ClassDef,
|
||||||
method: Method,
|
method: Method,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package app.revanced.patches.myfitnesspal.ads
|
||||||
|
|
||||||
|
import app.revanced.patcher.data.BytecodeContext
|
||||||
|
import app.revanced.patcher.extensions.InstructionExtensions.replaceInstructions
|
||||||
|
import app.revanced.patcher.patch.BytecodePatch
|
||||||
|
import app.revanced.patcher.patch.annotation.CompatiblePackage
|
||||||
|
import app.revanced.patcher.patch.annotation.Patch
|
||||||
|
import app.revanced.patches.myfitnesspal.ads.fingerprints.IsPremiumUseCaseImplFingerprint
|
||||||
|
import app.revanced.patches.myfitnesspal.ads.fingerprints.MainActivityNavigateToNativePremiumUpsellFingerprint
|
||||||
|
import app.revanced.util.exception
|
||||||
|
|
||||||
|
@Patch(
|
||||||
|
name = "Hide ads",
|
||||||
|
description = "Hides most of the ads across the app.",
|
||||||
|
compatiblePackages = [CompatiblePackage("com.myfitnesspal.android")]
|
||||||
|
)
|
||||||
|
@Suppress("unused")
|
||||||
|
object HideAdsPatch : BytecodePatch(
|
||||||
|
setOf(IsPremiumUseCaseImplFingerprint, MainActivityNavigateToNativePremiumUpsellFingerprint)
|
||||||
|
) {
|
||||||
|
override fun execute(context: BytecodeContext) {
|
||||||
|
// Overwrite the premium status specifically for ads.
|
||||||
|
IsPremiumUseCaseImplFingerprint.result?.mutableMethod?.replaceInstructions(
|
||||||
|
0,
|
||||||
|
"""
|
||||||
|
sget-object v0, Ljava/lang/Boolean;->TRUE:Ljava/lang/Boolean;
|
||||||
|
return-object v0
|
||||||
|
"""
|
||||||
|
) ?: throw IsPremiumUseCaseImplFingerprint.exception
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
MainActivityNavigateToNativePremiumUpsellFingerprint.result?.mutableMethod?.replaceInstructions(
|
||||||
|
0,
|
||||||
|
"return-void"
|
||||||
|
) ?: throw MainActivityNavigateToNativePremiumUpsellFingerprint.exception
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package app.revanced.patches.myfitnesspal.ads.fingerprints
|
||||||
|
|
||||||
|
import app.revanced.patcher.fingerprint.MethodFingerprint
|
||||||
|
import com.android.tools.smali.dexlib2.AccessFlags
|
||||||
|
|
||||||
|
object IsPremiumUseCaseImplFingerprint : MethodFingerprint(
|
||||||
|
accessFlags = AccessFlags.PUBLIC.value,
|
||||||
|
customFingerprint = { methodDef, classDef ->
|
||||||
|
classDef.type.endsWith("IsPremiumUseCaseImpl;") && methodDef.name == "doWork"
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package app.revanced.patches.myfitnesspal.ads.fingerprints
|
||||||
|
|
||||||
|
import app.revanced.patcher.extensions.or
|
||||||
|
import app.revanced.patcher.fingerprint.MethodFingerprint
|
||||||
|
import com.android.tools.smali.dexlib2.AccessFlags
|
||||||
|
|
||||||
|
object MainActivityNavigateToNativePremiumUpsellFingerprint : MethodFingerprint(
|
||||||
|
returnType = "V",
|
||||||
|
accessFlags = AccessFlags.PRIVATE or AccessFlags.FINAL,
|
||||||
|
customFingerprint = { methodDef, classDef ->
|
||||||
|
classDef.type.endsWith("MainActivity;") && methodDef.name == "navigateToNativePremiumUpsell"
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -1,49 +1,82 @@
|
|||||||
package app.revanced.patches.tiktok.interaction.speed
|
package app.revanced.patches.tiktok.interaction.speed
|
||||||
|
|
||||||
import app.revanced.patcher.data.BytecodeContext
|
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.addInstructions
|
||||||
|
import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
|
||||||
import app.revanced.patcher.patch.BytecodePatch
|
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.CompatiblePackage
|
||||||
import app.revanced.patcher.patch.annotation.Patch
|
import app.revanced.patcher.patch.annotation.Patch
|
||||||
import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod
|
import app.revanced.patches.tiktok.interaction.speed.fingerprints.GetSpeedFingerprint
|
||||||
import app.revanced.patches.tiktok.interaction.speed.fingerprints.SpeedControlParentFingerprint
|
import app.revanced.patches.tiktok.interaction.speed.fingerprints.OnRenderFirstFrameFingerprint
|
||||||
|
import app.revanced.patches.tiktok.interaction.speed.fingerprints.SetSpeedFingerprint
|
||||||
import app.revanced.util.exception
|
import app.revanced.util.exception
|
||||||
|
import app.revanced.util.getReference
|
||||||
import app.revanced.util.indexOfFirstInstruction
|
import app.revanced.util.indexOfFirstInstruction
|
||||||
import com.android.tools.smali.dexlib2.Opcode
|
import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction11x
|
||||||
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.iface.reference.MethodReference
|
||||||
|
|
||||||
@Patch(
|
@Patch(
|
||||||
name = "Playback speed",
|
name = "Playback speed",
|
||||||
description = "Enables the playback speed option for all videos.",
|
description = "Enables the playback speed option for all videos and " +
|
||||||
|
"retains the speed configurations in between videos.",
|
||||||
compatiblePackages = [
|
compatiblePackages = [
|
||||||
CompatiblePackage("com.ss.android.ugc.trill", ["32.5.3"]),
|
CompatiblePackage("com.ss.android.ugc.trill", ["32.5.3"]),
|
||||||
CompatiblePackage("com.zhiliaoapp.musically", ["32.5.3"])
|
CompatiblePackage("com.zhiliaoapp.musically", ["32.5.3"])
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
object PlaybackSpeedPatch : BytecodePatch(setOf(SpeedControlParentFingerprint)) {
|
object PlaybackSpeedPatch : BytecodePatch(
|
||||||
|
setOf(
|
||||||
|
GetSpeedFingerprint,
|
||||||
|
OnRenderFirstFrameFingerprint,
|
||||||
|
SetSpeedFingerprint
|
||||||
|
)
|
||||||
|
) {
|
||||||
override fun execute(context: BytecodeContext) {
|
override fun execute(context: BytecodeContext) {
|
||||||
SpeedControlParentFingerprint.result?.mutableMethod?.apply {
|
SetSpeedFingerprint.result?.let { onVideoSwiped ->
|
||||||
val targetMethodCallIndex = indexOfFirstInstruction {
|
// Remember the playback speed of the current video.
|
||||||
if (opcode == Opcode.INVOKE_STATIC) {
|
GetSpeedFingerprint.result?.mutableMethod?.apply {
|
||||||
val paramsTypes = ((this as Instruction35c).reference as MethodReference).parameterTypes
|
val injectIndex = indexOfFirstInstruction { getReference<MethodReference>()?.returnType == "F" } + 2
|
||||||
paramsTypes.size == 1 && paramsTypes[0].contains("/Aweme;")
|
val register = getInstruction<Instruction11x>(injectIndex - 1).registerA
|
||||||
} else false
|
|
||||||
}
|
|
||||||
|
|
||||||
val isSpeedEnableMethod = context
|
addInstruction(
|
||||||
.toMethodWalker(this)
|
injectIndex,
|
||||||
.nextMethod(targetMethodCallIndex, true)
|
"invoke-static { v$register }," +
|
||||||
.getMethod() as MutableMethod
|
" Lapp/revanced/tiktok/speed/SpeedPatch;->rememberPlaybackSpeed(F)V"
|
||||||
|
)
|
||||||
|
} ?: throw GetSpeedFingerprint.exception
|
||||||
|
|
||||||
isSpeedEnableMethod.addInstructions(
|
// By default, the playback speed will reset to 1.0 at the start of each video.
|
||||||
|
// Instead, override it with the desired playback speed.
|
||||||
|
OnRenderFirstFrameFingerprint.result?.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}, Lcom/ss/android/ugc/aweme/feed/panel/BaseListFragmentPanel;->getEnterFrom(Z)Ljava/lang/String;
|
||||||
|
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/tiktok/speed/SpeedPatch;->getPlaybackSpeed()F
|
||||||
|
move-result-object v2
|
||||||
|
invoke-static { v0, v1, v2 }, ${onVideoSwiped.method}
|
||||||
|
"""
|
||||||
|
) ?: throw OnRenderFirstFrameFingerprint.exception
|
||||||
|
|
||||||
|
// Force enable the playback speed option for all videos.
|
||||||
|
onVideoSwiped.mutableClass.methods.find { method -> method.returnType == "Z" }?.addInstructions(
|
||||||
0,
|
0,
|
||||||
"""
|
"""
|
||||||
const/4 v0, 0x1
|
const/4 v0, 0x1
|
||||||
return v0
|
return v0
|
||||||
"""
|
"""
|
||||||
)
|
) ?: throw PatchException("Failed to force enable the playback speed option.")
|
||||||
} ?: throw SpeedControlParentFingerprint.exception
|
} ?: throw SetSpeedFingerprint.exception
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package app.revanced.patches.tiktok.interaction.speed.fingerprints
|
||||||
|
|
||||||
|
import app.revanced.patcher.fingerprint.MethodFingerprint
|
||||||
|
|
||||||
|
internal object GetSpeedFingerprint : MethodFingerprint(
|
||||||
|
customFingerprint = { methodDef, _ ->
|
||||||
|
methodDef.definingClass.endsWith("/BaseListFragmentPanel;") && methodDef.name == "onFeedSpeedSelectedEvent"
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package app.revanced.patches.tiktok.interaction.speed.fingerprints
|
||||||
|
|
||||||
|
import app.revanced.patcher.fingerprint.MethodFingerprint
|
||||||
|
|
||||||
|
internal object OnRenderFirstFrameFingerprint : MethodFingerprint(
|
||||||
|
customFingerprint = { methodDef, _ ->
|
||||||
|
methodDef.definingClass.endsWith("/BaseListFragmentPanel;") && methodDef.name == "onRenderFirstFrame"
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package app.revanced.patches.tiktok.interaction.speed.fingerprints
|
||||||
|
|
||||||
|
import app.revanced.patcher.extensions.or
|
||||||
|
import app.revanced.patcher.fingerprint.MethodFingerprint
|
||||||
|
import com.android.tools.smali.dexlib2.AccessFlags
|
||||||
|
|
||||||
|
internal object SetSpeedFingerprint : MethodFingerprint(
|
||||||
|
returnType = "V",
|
||||||
|
accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC,
|
||||||
|
parameters = listOf(
|
||||||
|
"Ljava/lang/String;",
|
||||||
|
"Lcom/ss/android/ugc/aweme/feed/model/Aweme;",
|
||||||
|
"F"
|
||||||
|
),
|
||||||
|
strings = listOf("enterFrom")
|
||||||
|
)
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package app.revanced.patches.tiktok.interaction.speed.fingerprints
|
|
||||||
|
|
||||||
import app.revanced.patcher.extensions.or
|
|
||||||
import app.revanced.patcher.fingerprint.MethodFingerprint
|
|
||||||
import com.android.tools.smali.dexlib2.AccessFlags
|
|
||||||
|
|
||||||
internal object SpeedControlParentFingerprint : MethodFingerprint(
|
|
||||||
strings = listOf(
|
|
||||||
"onStopTrackingTouch, hasTouchMove=",
|
|
||||||
", isCurVideoPaused: ",
|
|
||||||
"already_shown_edge_speed_guide"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -93,7 +93,7 @@ object CustomBrandingPatch : ResourcePatch() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else resourceGroups.forEach { context.copyResources("branding", it) }
|
} else resourceGroups.forEach { context.copyResources("custom-branding", it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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.util.ResourceGroup
|
||||||
|
import app.revanced.util.copyResources
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@Patch(
|
||||||
|
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_NAME = "yt_wordmark_header"
|
||||||
|
private const val PREMIUM_HEADER_NAME = "yt_premium_wordmark_header"
|
||||||
|
private const val REVANCED_HEADER_NAME = "ReVanced"
|
||||||
|
private const val REVANCED_BORDERLESS_HEADER_NAME = "ReVanced (borderless logo)"
|
||||||
|
|
||||||
|
private val targetResourceDirectoryNames = arrayOf(
|
||||||
|
"xxxhdpi",
|
||||||
|
"xxhdpi",
|
||||||
|
"xhdpi",
|
||||||
|
"mdpi",
|
||||||
|
"hdpi",
|
||||||
|
).map { dpi ->
|
||||||
|
"drawable-$dpi"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val variants = arrayOf("light", "dark")
|
||||||
|
|
||||||
|
private val header by stringPatchOption(
|
||||||
|
key = "header",
|
||||||
|
default = REVANCED_BORDERLESS_HEADER_NAME,
|
||||||
|
values = mapOf(
|
||||||
|
"YouTube" to HEADER_NAME,
|
||||||
|
"YouTube Premium" to PREMIUM_HEADER_NAME,
|
||||||
|
"ReVanced" to REVANCED_HEADER_NAME,
|
||||||
|
"ReVanced (borderless logo)" to REVANCED_BORDERLESS_HEADER_NAME,
|
||||||
|
),
|
||||||
|
title = "Header",
|
||||||
|
description = """
|
||||||
|
Either a header name or a path to a custom header folder to use in the top bar.
|
||||||
|
The path to a folder must contain one or more of the following folders matching the DPI of your device:
|
||||||
|
|
||||||
|
${targetResourceDirectoryNames.joinToString("\n") { "- $it" }}
|
||||||
|
|
||||||
|
These folders must contain the following files:
|
||||||
|
|
||||||
|
${variants.joinToString("\n") { variant -> "- ${HEADER_NAME}_$variant.png" }}
|
||||||
|
""".trimIndent(),
|
||||||
|
required = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun execute(context: ResourceContext) {
|
||||||
|
// The directories to copy the header to.
|
||||||
|
val targetResourceDirectories = targetResourceDirectoryNames.mapNotNull {
|
||||||
|
context["res"].resolve(it).takeIf(File::exists)
|
||||||
|
}
|
||||||
|
// The files to replace in the target directories.
|
||||||
|
val targetResourceFiles = targetResourceDirectoryNames.map { directoryName ->
|
||||||
|
ResourceGroup(
|
||||||
|
directoryName,
|
||||||
|
*variants.map { variant -> "${HEADER_NAME}_$variant.png" }.toTypedArray()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function that overwrites both header variants from [from] to [to] in the target resource directories.
|
||||||
|
*/
|
||||||
|
val overwriteFromTo: (String, String) -> Unit = { from: String, to: String ->
|
||||||
|
targetResourceDirectories.forEach { directory ->
|
||||||
|
variants.forEach { variant ->
|
||||||
|
val fromPath = directory.resolve("${from}_$variant.png")
|
||||||
|
val toPath = directory.resolve("${to}_$variant.png")
|
||||||
|
|
||||||
|
fromPath.copyTo(toPath, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Functions to overwrite the header to the different variants.
|
||||||
|
val toPremium = { overwriteFromTo(PREMIUM_HEADER_NAME, HEADER_NAME) }
|
||||||
|
val toHeader = { overwriteFromTo(HEADER_NAME, PREMIUM_HEADER_NAME) }
|
||||||
|
val toReVanced = {
|
||||||
|
// Copy the ReVanced header to the resource directories.
|
||||||
|
targetResourceFiles.forEach { context.copyResources("change-header/revanced", it) }
|
||||||
|
|
||||||
|
// Overwrite the premium with the custom header as well.
|
||||||
|
toHeader()
|
||||||
|
}
|
||||||
|
val toReVancedBorderless = {
|
||||||
|
// Copy the ReVanced borderless header to the resource directories.
|
||||||
|
targetResourceFiles.forEach { context.copyResources("change-header/revanced-borderless", it) }
|
||||||
|
|
||||||
|
// Overwrite the premium with the custom header as well.
|
||||||
|
toHeader()
|
||||||
|
}
|
||||||
|
val toCustom = {
|
||||||
|
var copiedReplacementImages = false
|
||||||
|
// For all the resource groups in the custom header folder, copy them to the resource directories.
|
||||||
|
File(header!!).listFiles { file -> file.isDirectory }?.forEach { folder ->
|
||||||
|
val targetDirectory = context["res"].resolve(folder.name)
|
||||||
|
// Skip if the target directory (DPI) doesn't exist.
|
||||||
|
if (!targetDirectory.exists()) return@forEach
|
||||||
|
|
||||||
|
folder.listFiles { file -> file.isFile }?.forEach {
|
||||||
|
val targetResourceFile = targetDirectory.resolve(it.name)
|
||||||
|
|
||||||
|
it.copyTo(targetResourceFile, true)
|
||||||
|
copiedReplacementImages = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!copiedReplacementImages) throw PatchException("Could not find any custom images resources in directory: $header")
|
||||||
|
|
||||||
|
// Overwrite the premium with the custom header as well.
|
||||||
|
toHeader()
|
||||||
|
}
|
||||||
|
|
||||||
|
when (header) {
|
||||||
|
HEADER_NAME -> toHeader
|
||||||
|
PREMIUM_HEADER_NAME -> toPremium
|
||||||
|
REVANCED_HEADER_NAME -> toReVanced
|
||||||
|
REVANCED_BORDERLESS_HEADER_NAME -> toReVancedBorderless
|
||||||
|
else -> toCustom
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,62 +1,9 @@
|
|||||||
package app.revanced.patches.youtube.layout.branding.header
|
package app.revanced.patches.youtube.layout.branding.header
|
||||||
|
|
||||||
import app.revanced.patcher.data.ResourceContext
|
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 app.revanced.patcher.patch.annotation.CompatiblePackage
|
|
||||||
import app.revanced.patcher.patch.annotation.Patch
|
|
||||||
import app.revanced.patcher.patch.options.PatchOption.PatchExtensions.booleanPatchOption
|
|
||||||
import kotlin.io.path.copyTo
|
|
||||||
|
|
||||||
@Patch(
|
@Deprecated("Use PremiumHeadingPatch instead.")
|
||||||
name = "Premium heading",
|
|
||||||
description = "Adds or removes the YouTube Premium logo at the top of feeds.",
|
|
||||||
compatiblePackages = [
|
|
||||||
CompatiblePackage("com.google.android.youtube")
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@Suppress("unused")
|
|
||||||
object PremiumHeadingPatch : ResourcePatch() {
|
object PremiumHeadingPatch : ResourcePatch() {
|
||||||
private const val DEFAULT_HEADING_RES = "yt_wordmark_header"
|
override fun execute(context: ResourceContext) = ChangeHeaderPatch.execute(context)
|
||||||
private const val PREMIUM_HEADING_RES = "yt_premium_wordmark_header"
|
|
||||||
|
|
||||||
private val usePremiumHeading by booleanPatchOption(
|
|
||||||
key = "usePremiumHeading",
|
|
||||||
default = true,
|
|
||||||
title = "Use premium heading",
|
|
||||||
description = "Whether to use the YouTube Premium logo.",
|
|
||||||
required = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun execute(context: ResourceContext) {
|
|
||||||
val resDirectory = context["res"]
|
|
||||||
|
|
||||||
val (original, replacement) = if (usePremiumHeading!!)
|
|
||||||
PREMIUM_HEADING_RES to DEFAULT_HEADING_RES
|
|
||||||
else
|
|
||||||
DEFAULT_HEADING_RES to PREMIUM_HEADING_RES
|
|
||||||
|
|
||||||
val variants = arrayOf("light", "dark")
|
|
||||||
|
|
||||||
arrayOf(
|
|
||||||
"xxxhdpi",
|
|
||||||
"xxhdpi",
|
|
||||||
"xhdpi",
|
|
||||||
"hdpi",
|
|
||||||
"mdpi"
|
|
||||||
).mapNotNull { dpi ->
|
|
||||||
resDirectory.resolve("drawable-$dpi").takeIf { it.exists() }?.toPath()
|
|
||||||
}.also {
|
|
||||||
if (it.isEmpty())
|
|
||||||
throw PatchException("The drawable folder can not be found. Therefore, the patch can not be applied.")
|
|
||||||
}.forEach { path ->
|
|
||||||
|
|
||||||
variants.forEach { mode ->
|
|
||||||
val fromPath = path.resolve("${original}_$mode.png")
|
|
||||||
val toPath = path.resolve("${replacement}_$mode.png")
|
|
||||||
|
|
||||||
fromPath.copyTo(toPath, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package app.revanced.patches.youtube.layout.returnyoutubedislike
|
package app.revanced.patches.youtube.layout.returnyoutubedislike
|
||||||
|
|
||||||
import app.revanced.util.exception
|
|
||||||
import app.revanced.util.getReference
|
|
||||||
import app.revanced.util.indexOfFirstInstruction
|
|
||||||
import app.revanced.patcher.data.BytecodeContext
|
import app.revanced.patcher.data.BytecodeContext
|
||||||
import app.revanced.patcher.extensions.InstructionExtensions.addInstruction
|
import app.revanced.patcher.extensions.InstructionExtensions.addInstruction
|
||||||
import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
|
import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
|
||||||
@@ -11,19 +8,39 @@ import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
|
|||||||
import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction
|
import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction
|
||||||
import app.revanced.patcher.fingerprint.MethodFingerprint
|
import app.revanced.patcher.fingerprint.MethodFingerprint
|
||||||
import app.revanced.patcher.patch.BytecodePatch
|
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.CompatiblePackage
|
||||||
import app.revanced.patcher.patch.annotation.Patch
|
import app.revanced.patcher.patch.annotation.Patch
|
||||||
import app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints.*
|
import app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints.ConversionContextFingerprint
|
||||||
|
import app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints.DislikeFingerprint
|
||||||
|
import app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints.DislikesOldLayoutTextViewFingerprint
|
||||||
|
import app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints.LikeFingerprint
|
||||||
|
import app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints.RemoveLikeFingerprint
|
||||||
|
import app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints.RollingNumberMeasureAnimatedTextFingerprint
|
||||||
|
import app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints.RollingNumberMeasureStaticLabelFingerprint
|
||||||
|
import app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints.RollingNumberMeasureTextParentFingerprint
|
||||||
|
import app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints.RollingNumberSetterFingerprint
|
||||||
|
import app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints.RollingNumberTextViewFingerprint
|
||||||
|
import app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints.ShortsTextViewFingerprint
|
||||||
|
import app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints.TextComponentConstructorFingerprint
|
||||||
|
import app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints.TextComponentDataFingerprint
|
||||||
|
import app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints.TextComponentLookupFingerprint
|
||||||
import app.revanced.patches.youtube.misc.integrations.IntegrationsPatch
|
import app.revanced.patches.youtube.misc.integrations.IntegrationsPatch
|
||||||
import app.revanced.patches.youtube.misc.litho.filter.LithoFilterPatch
|
import app.revanced.patches.youtube.misc.litho.filter.LithoFilterPatch
|
||||||
import app.revanced.patches.youtube.misc.playertype.PlayerTypeHookPatch
|
import app.revanced.patches.youtube.misc.playertype.PlayerTypeHookPatch
|
||||||
import app.revanced.patches.youtube.shared.fingerprints.RollingNumberTextViewAnimationUpdateFingerprint
|
import app.revanced.patches.youtube.shared.fingerprints.RollingNumberTextViewAnimationUpdateFingerprint
|
||||||
import app.revanced.patches.youtube.video.videoid.VideoIdPatch
|
import app.revanced.patches.youtube.video.videoid.VideoIdPatch
|
||||||
|
import app.revanced.util.exception
|
||||||
|
import app.revanced.util.getReference
|
||||||
|
import app.revanced.util.indexOfFirstInstruction
|
||||||
|
import com.android.tools.smali.dexlib2.Opcode
|
||||||
import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction
|
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.OneRegisterInstruction
|
||||||
import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction
|
import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction
|
||||||
import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction
|
import com.android.tools.smali.dexlib2.iface.instruction.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.MethodReference
|
||||||
|
import com.android.tools.smali.dexlib2.iface.reference.TypeReference
|
||||||
|
|
||||||
@Patch(
|
@Patch(
|
||||||
name = "Return YouTube Dislike",
|
name = "Return YouTube Dislike",
|
||||||
@@ -38,6 +55,7 @@ import com.android.tools.smali.dexlib2.iface.reference.MethodReference
|
|||||||
compatiblePackages = [
|
compatiblePackages = [
|
||||||
CompatiblePackage(
|
CompatiblePackage(
|
||||||
"com.google.android.youtube", [
|
"com.google.android.youtube", [
|
||||||
|
"18.38.44",
|
||||||
"18.43.45",
|
"18.43.45",
|
||||||
"18.44.41",
|
"18.44.41",
|
||||||
"18.45.41",
|
"18.45.41",
|
||||||
@@ -49,7 +67,9 @@ import com.android.tools.smali.dexlib2.iface.reference.MethodReference
|
|||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
object ReturnYouTubeDislikePatch : BytecodePatch(
|
object ReturnYouTubeDislikePatch : BytecodePatch(
|
||||||
setOf(
|
setOf(
|
||||||
|
ConversionContextFingerprint,
|
||||||
TextComponentConstructorFingerprint,
|
TextComponentConstructorFingerprint,
|
||||||
|
TextComponentDataFingerprint,
|
||||||
ShortsTextViewFingerprint,
|
ShortsTextViewFingerprint,
|
||||||
DislikesOldLayoutTextViewFingerprint,
|
DislikesOldLayoutTextViewFingerprint,
|
||||||
LikeFingerprint,
|
LikeFingerprint,
|
||||||
@@ -67,6 +87,8 @@ object ReturnYouTubeDislikePatch : BytecodePatch(
|
|||||||
private const val FILTER_CLASS_DESCRIPTOR =
|
private const val FILTER_CLASS_DESCRIPTOR =
|
||||||
"Lapp/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch;"
|
"Lapp/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch;"
|
||||||
|
|
||||||
|
private fun MethodFingerprint.resultOrThrow() = result ?: throw exception
|
||||||
|
|
||||||
override fun execute(context: BytecodeContext) {
|
override fun execute(context: BytecodeContext) {
|
||||||
// region Inject newVideoLoaded event handler to update dislikes when a new video is loaded.
|
// region Inject newVideoLoaded event handler to update dislikes when a new video is loaded.
|
||||||
|
|
||||||
@@ -97,64 +119,132 @@ object ReturnYouTubeDislikePatch : BytecodePatch(
|
|||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Hook creation of Spans and the cached lookup of them.
|
// region Hook code for creation and cached lookup of text Spans.
|
||||||
|
|
||||||
// Alternatively the hook can be made at the creation of Spans in TextComponentSpec,
|
// Alternatively the hook can be made at the creation of Spans in TextComponentSpec,
|
||||||
// And it works in all situations except it fails to update the Span when the user dislikes,
|
// And it works in all situations except it fails to update the Span when the user dislikes,
|
||||||
// since the underlying (likes only) text did not change.
|
// since the underlying (likes only) text did not change.
|
||||||
// This hook handles all situations, as it's where the created Spans are stored and later reused.
|
// This hook handles all situations, as it's where the created Spans are stored and later reused.
|
||||||
TextComponentContextFingerprint.also {
|
TextComponentConstructorFingerprint.result?.let { textConstructorResult ->
|
||||||
if (!it.resolve(context, TextComponentConstructorFingerprint.result!!.classDef))
|
// Find the field name of the conversion context.
|
||||||
throw it.exception
|
val conversionContextClassType = ConversionContextFingerprint.resultOrThrow().classDef.type
|
||||||
}.result?.also { result ->
|
val conversionContextField = textConstructorResult.classDef.fields.find {
|
||||||
if (!TextComponentAtomicReferenceFingerprint.resolve(context, result.method, result.classDef))
|
it.type == conversionContextClassType
|
||||||
throw TextComponentAtomicReferenceFingerprint.exception
|
} ?: throw PatchException("Could not find conversion context field")
|
||||||
}?.let { textComponentContextFingerprintResult ->
|
|
||||||
val conversionContextIndex = textComponentContextFingerprintResult
|
|
||||||
.scanResult.patternScanResult!!.endIndex
|
|
||||||
val atomicReferenceStartIndex = TextComponentAtomicReferenceFingerprint.result!!
|
|
||||||
.scanResult.patternScanResult!!.startIndex
|
|
||||||
|
|
||||||
val insertIndex = atomicReferenceStartIndex + 9
|
TextComponentLookupFingerprint.resolve(context, textConstructorResult.classDef)
|
||||||
|
TextComponentLookupFingerprint.resultOrThrow().mutableMethod.apply {
|
||||||
|
// Find the instruction for creating the text data object.
|
||||||
|
val textDataClassType = TextComponentDataFingerprint.resultOrThrow().classDef.type
|
||||||
|
val insertIndex = indexOfFirstInstruction {
|
||||||
|
opcode == Opcode.NEW_INSTANCE &&
|
||||||
|
getReference<TypeReference>()?.type == textDataClassType
|
||||||
|
}
|
||||||
|
if (insertIndex < 0) throw PatchException("Could not find data creation instruction")
|
||||||
|
val tempRegister = getInstruction<OneRegisterInstruction>(insertIndex).registerA
|
||||||
|
|
||||||
textComponentContextFingerprintResult.mutableMethod.apply {
|
// Find the instruction that sets the span to an instance field.
|
||||||
// Get the conversion context obfuscated field name
|
// The instruction is only a few lines after the creation of the instance.
|
||||||
val conversionContextFieldReference =
|
// The method has multiple iput-object instructions using a CharSequence,
|
||||||
getInstruction<ReferenceInstruction>(conversionContextIndex).reference
|
// so verify the found instruction is in the expected location.
|
||||||
|
val putFieldInstruction = implementation!!.instructions
|
||||||
|
.subList(insertIndex, insertIndex + 20)
|
||||||
|
.find {
|
||||||
|
it.opcode == Opcode.IPUT_OBJECT &&
|
||||||
|
it.getReference<FieldReference>()?.type == "Ljava/lang/CharSequence;"
|
||||||
|
} ?: throw PatchException("Could not find put object instruction")
|
||||||
|
val charSequenceRegister = (putFieldInstruction as TwoRegisterInstruction).registerA
|
||||||
|
|
||||||
// Free register to hold the conversion context
|
|
||||||
val freeRegister =
|
|
||||||
getInstruction<TwoRegisterInstruction>(atomicReferenceStartIndex).registerB
|
|
||||||
|
|
||||||
val atomicReferenceRegister =
|
|
||||||
getInstruction<FiveRegisterInstruction>(atomicReferenceStartIndex + 6).registerC
|
|
||||||
|
|
||||||
// Instruction that is replaced, and also has the CharacterSequence register.
|
|
||||||
val moveCharSequenceInstruction = getInstruction<TwoRegisterInstruction>(insertIndex)
|
|
||||||
val charSequenceSourceRegister = moveCharSequenceInstruction.registerB
|
|
||||||
val charSequenceTargetRegister = moveCharSequenceInstruction.registerA
|
|
||||||
|
|
||||||
// Move the current instance to the free register, and get the conversion context from it.
|
|
||||||
// Must replace the instruction to preserve the control flow label.
|
|
||||||
replaceInstruction(insertIndex, "move-object/from16 v$freeRegister, p0")
|
|
||||||
addInstructions(
|
addInstructions(
|
||||||
insertIndex + 1,
|
insertIndex,
|
||||||
"""
|
"""
|
||||||
# Move context to free register
|
# Copy conversion context
|
||||||
iget-object v$freeRegister, v$freeRegister, $conversionContextFieldReference
|
move-object/from16 v$tempRegister, p0
|
||||||
invoke-static {v$freeRegister, v$atomicReferenceRegister, v$charSequenceSourceRegister}, $INTEGRATIONS_CLASS_DESCRIPTOR->onLithoTextLoaded(Ljava/lang/Object;Ljava/util/concurrent/atomic/AtomicReference;Ljava/lang/CharSequence;)Ljava/lang/CharSequence;
|
iget-object v$tempRegister, v$tempRegister, $conversionContextField
|
||||||
move-result-object v$freeRegister
|
invoke-static {v$tempRegister, v$charSequenceRegister}, $INTEGRATIONS_CLASS_DESCRIPTOR->onLithoTextLoaded(Ljava/lang/Object;Ljava/lang/CharSequence;)Ljava/lang/CharSequence;
|
||||||
# Replace the original instruction
|
move-result-object v$charSequenceRegister
|
||||||
move-object v${charSequenceTargetRegister}, v${freeRegister}
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} ?: throw TextComponentContextFingerprint.exception
|
} ?: throw TextComponentConstructorFingerprint.exception
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
|
// region Hook for non-litho Short videos.
|
||||||
|
|
||||||
|
ShortsTextViewFingerprint.result?.let {
|
||||||
|
it.mutableMethod.apply {
|
||||||
|
val patternResult = it.scanResult.patternScanResult!!
|
||||||
|
|
||||||
|
// If the field is true, the TextView is for a dislike button.
|
||||||
|
val isDisLikesBooleanReference = getInstruction<ReferenceInstruction>(patternResult.endIndex).reference
|
||||||
|
|
||||||
|
val textViewFieldReference = // Like/Dislike button TextView field
|
||||||
|
getInstruction<ReferenceInstruction>(patternResult.endIndex - 1).reference
|
||||||
|
|
||||||
|
// Check if the hooked TextView object is that of the dislike button.
|
||||||
|
// If RYD is disabled, or the TextView object is not that of the dislike button, the execution flow is not interrupted.
|
||||||
|
// Otherwise, the TextView object is modified, and the execution flow is interrupted to prevent it from being changed afterward.
|
||||||
|
val insertIndex = patternResult.startIndex + 6
|
||||||
|
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}, $INTEGRATIONS_CLASS_DESCRIPTOR->setShortsDislikes(Landroid/view/View;)Z
|
||||||
|
move-result v0
|
||||||
|
if-eqz v0, :ryd_disabled
|
||||||
|
return-void
|
||||||
|
|
||||||
|
:is_like
|
||||||
|
:ryd_disabled
|
||||||
|
nop
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?: throw ShortsTextViewFingerprint.exception
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Hook for litho Shorts
|
||||||
|
|
||||||
|
// Filter that parses the video id from the UI
|
||||||
|
LithoFilterPatch.addFilter(FILTER_CLASS_DESCRIPTOR)
|
||||||
|
|
||||||
|
// Player response video id is needed to search for the video ids in Shorts litho components.
|
||||||
|
VideoIdPatch.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.
|
||||||
|
|
||||||
|
DislikesOldLayoutTextViewFingerprint.result?.let {
|
||||||
|
it.mutableMethod.apply {
|
||||||
|
val startIndex = it.scanResult.patternScanResult!!.startIndex
|
||||||
|
|
||||||
|
val resourceIdentifierRegister = getInstruction<OneRegisterInstruction>(startIndex).registerA
|
||||||
|
val textViewRegister = getInstruction<OneRegisterInstruction>(startIndex + 4).registerA
|
||||||
|
|
||||||
|
addInstruction(
|
||||||
|
startIndex + 4,
|
||||||
|
"invoke-static {v$resourceIdentifierRegister, v$textViewRegister}, $INTEGRATIONS_CLASS_DESCRIPTOR->setOldUILayoutDislikes(ILandroid/widget/TextView;)V"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?: throw DislikesOldLayoutTextViewFingerprint.exception
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
|
||||||
// region Hook rolling numbers.
|
// 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 resolve and throw an exception,
|
||||||
|
// but everything will still work correctly anyways.
|
||||||
|
|
||||||
RollingNumberSetterFingerprint.result?.let {
|
RollingNumberSetterFingerprint.result?.let {
|
||||||
val dislikesIndex = it.scanResult.patternScanResult!!.endIndex
|
val dislikesIndex = it.scanResult.patternScanResult!!.endIndex
|
||||||
|
|
||||||
@@ -164,7 +254,7 @@ object ReturnYouTubeDislikePatch : BytecodePatch(
|
|||||||
val charSequenceInstanceRegister =
|
val charSequenceInstanceRegister =
|
||||||
getInstruction<OneRegisterInstruction>(0).registerA
|
getInstruction<OneRegisterInstruction>(0).registerA
|
||||||
val charSequenceFieldReference =
|
val charSequenceFieldReference =
|
||||||
getInstruction<ReferenceInstruction>(dislikesIndex).reference.toString()
|
getInstruction<ReferenceInstruction>(dislikesIndex).reference
|
||||||
|
|
||||||
val registerCount = implementation!!.registerCount
|
val registerCount = implementation!!.registerCount
|
||||||
|
|
||||||
@@ -268,73 +358,6 @@ object ReturnYouTubeDislikePatch : BytecodePatch(
|
|||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Hook for non-litho Short videos.
|
|
||||||
|
|
||||||
ShortsTextViewFingerprint.result?.let {
|
|
||||||
it.mutableMethod.apply {
|
|
||||||
val patternResult = it.scanResult.patternScanResult!!
|
|
||||||
|
|
||||||
// If the field is true, the TextView is for a dislike button.
|
|
||||||
val isDisLikesBooleanReference = getInstruction<ReferenceInstruction>(patternResult.endIndex).reference
|
|
||||||
|
|
||||||
val textViewFieldReference = // Like/Dislike button TextView field
|
|
||||||
getInstruction<ReferenceInstruction>(patternResult.endIndex - 1).reference
|
|
||||||
|
|
||||||
// Check if the hooked TextView object is that of the dislike button.
|
|
||||||
// If RYD is disabled, or the TextView object is not that of the dislike button, the execution flow is not interrupted.
|
|
||||||
// Otherwise, the TextView object is modified, and the execution flow is interrupted to prevent it from being changed afterward.
|
|
||||||
val insertIndex = patternResult.startIndex + 6
|
|
||||||
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}, $INTEGRATIONS_CLASS_DESCRIPTOR->setShortsDislikes(Landroid/view/View;)Z
|
|
||||||
move-result v0
|
|
||||||
if-eqz v0, :ryd_disabled
|
|
||||||
return-void
|
|
||||||
|
|
||||||
:is_like
|
|
||||||
:ryd_disabled
|
|
||||||
nop
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} ?: throw ShortsTextViewFingerprint.exception
|
|
||||||
|
|
||||||
// endregion
|
|
||||||
|
|
||||||
// region Hook for litho Shorts
|
|
||||||
|
|
||||||
// Filter that parses the video id from the UI
|
|
||||||
LithoFilterPatch.addFilter(FILTER_CLASS_DESCRIPTOR)
|
|
||||||
|
|
||||||
// Player response video id is needed to search for the video ids in Shorts litho components.
|
|
||||||
VideoIdPatch.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.
|
|
||||||
|
|
||||||
DislikesOldLayoutTextViewFingerprint.result?.let {
|
|
||||||
it.mutableMethod.apply {
|
|
||||||
val startIndex = it.scanResult.patternScanResult!!.startIndex
|
|
||||||
|
|
||||||
val resourceIdentifierRegister = getInstruction<OneRegisterInstruction>(startIndex).registerA
|
|
||||||
val textViewRegister = getInstruction<OneRegisterInstruction>(startIndex + 4).registerA
|
|
||||||
|
|
||||||
addInstruction(
|
|
||||||
startIndex + 4,
|
|
||||||
"invoke-static {v$resourceIdentifierRegister, v$textViewRegister}, $INTEGRATIONS_CLASS_DESCRIPTOR->setOldUILayoutDislikes(ILandroid/widget/TextView;)V"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} ?: throw DislikesOldLayoutTextViewFingerprint.exception
|
|
||||||
|
|
||||||
// endregion
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MethodFingerprint.toPatch(voteKind: Vote) = VotePatch(this, voteKind)
|
private fun MethodFingerprint.toPatch(voteKind: Vote) = VotePatch(this, voteKind)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints
|
||||||
|
|
||||||
|
import app.revanced.patcher.fingerprint.MethodFingerprint
|
||||||
|
|
||||||
|
internal object ConversionContextFingerprint : MethodFingerprint(
|
||||||
|
returnType = "Ljava/lang/String;",
|
||||||
|
parameters = listOf(),
|
||||||
|
strings = listOf(
|
||||||
|
", widthConstraint=",
|
||||||
|
", heightConstraint=",
|
||||||
|
", templateLoggerFactory=",
|
||||||
|
", rootDisposableContainer=",
|
||||||
|
// 18.37.36 and after this String is: ConversionContext{containerInternal=
|
||||||
|
// and before it is: ConversionContext{container=
|
||||||
|
// Use a partial string to match both.
|
||||||
|
"ConversionContext{container"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints
|
|
||||||
|
|
||||||
import app.revanced.patcher.extensions.or
|
|
||||||
import app.revanced.patcher.fingerprint.MethodFingerprint
|
|
||||||
import com.android.tools.smali.dexlib2.AccessFlags
|
|
||||||
import com.android.tools.smali.dexlib2.Opcode
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves against the same method that [TextComponentContextFingerprint] resolves to.
|
|
||||||
*/
|
|
||||||
internal object TextComponentAtomicReferenceFingerprint : MethodFingerprint(
|
|
||||||
returnType = "L",
|
|
||||||
accessFlags = AccessFlags.PROTECTED or AccessFlags.FINAL,
|
|
||||||
parameters = listOf("L"),
|
|
||||||
opcodes = listOf(
|
|
||||||
Opcode.MOVE_OBJECT, // Register B is free register
|
|
||||||
Opcode.MOVE_OBJECT_FROM16,
|
|
||||||
Opcode.MOVE_OBJECT_FROM16,
|
|
||||||
Opcode.MOVE_OBJECT_FROM16,
|
|
||||||
Opcode.MOVE_OBJECT_FROM16,
|
|
||||||
null,
|
|
||||||
Opcode.INVOKE_VIRTUAL, // Register C is atomic reference
|
|
||||||
Opcode.MOVE_RESULT_OBJECT, // Register A is char sequence
|
|
||||||
Opcode.CHECK_CAST,
|
|
||||||
Opcode.MOVE_OBJECT, // Replace this instruction with patch code
|
|
||||||
Opcode.INVOKE_INTERFACE,
|
|
||||||
Opcode.MOVE_RESULT,
|
|
||||||
Opcode.IF_EQZ,
|
|
||||||
Opcode.INVOKE_INTERFACE,
|
|
||||||
Opcode.MOVE_RESULT_OBJECT,
|
|
||||||
Opcode.GOTO
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints
|
package app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints
|
||||||
|
|
||||||
|
|
||||||
import app.revanced.patcher.extensions.or
|
import app.revanced.patcher.extensions.or
|
||||||
import app.revanced.patcher.fingerprint.MethodFingerprint
|
import app.revanced.patcher.fingerprint.MethodFingerprint
|
||||||
import com.android.tools.smali.dexlib2.AccessFlags
|
import com.android.tools.smali.dexlib2.AccessFlags
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
package app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints
|
|
||||||
|
|
||||||
import app.revanced.patcher.extensions.or
|
|
||||||
import app.revanced.patcher.fingerprint.MethodFingerprint
|
|
||||||
import com.android.tools.smali.dexlib2.AccessFlags
|
|
||||||
import com.android.tools.smali.dexlib2.Opcode
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves against the same class that [TextComponentConstructorFingerprint] resolves to.
|
|
||||||
*/
|
|
||||||
internal object TextComponentContextFingerprint : MethodFingerprint(
|
|
||||||
returnType = "L",
|
|
||||||
accessFlags = AccessFlags.PROTECTED or AccessFlags.FINAL,
|
|
||||||
parameters = listOf("L"),
|
|
||||||
opcodes = listOf(
|
|
||||||
Opcode.MOVE_OBJECT_FROM16,
|
|
||||||
Opcode.MOVE_OBJECT_FROM16,
|
|
||||||
Opcode.INVOKE_STATIC_RANGE,
|
|
||||||
Opcode.MOVE_RESULT_OBJECT,
|
|
||||||
Opcode.IGET_OBJECT,
|
|
||||||
Opcode.IGET_OBJECT,
|
|
||||||
Opcode.IGET_OBJECT,
|
|
||||||
Opcode.IGET_OBJECT,
|
|
||||||
Opcode.IGET_OBJECT,
|
|
||||||
Opcode.IGET_OBJECT, // conversion context field name
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints
|
||||||
|
|
||||||
|
import app.revanced.patcher.extensions.or
|
||||||
|
import app.revanced.patcher.fingerprint.MethodFingerprint
|
||||||
|
import com.android.tools.smali.dexlib2.AccessFlags
|
||||||
|
|
||||||
|
internal object TextComponentDataFingerprint : MethodFingerprint(
|
||||||
|
accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR,
|
||||||
|
parameters = listOf("L", "L"),
|
||||||
|
strings = listOf("text"),
|
||||||
|
customFingerprint = { _, classDef ->
|
||||||
|
val fields = classDef.fields
|
||||||
|
fields.find { it.type == "Ljava/util/BitSet;" } != null &&
|
||||||
|
fields.find { it.type == "[Ljava/lang/String;" } != null
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package app.revanced.patches.youtube.layout.returnyoutubedislike.fingerprints
|
||||||
|
|
||||||
|
import app.revanced.patcher.extensions.or
|
||||||
|
import app.revanced.patcher.fingerprint.MethodFingerprint
|
||||||
|
import com.android.tools.smali.dexlib2.AccessFlags
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves against the same class that [TextComponentConstructorFingerprint] resolves to.
|
||||||
|
*/
|
||||||
|
internal object TextComponentLookupFingerprint : MethodFingerprint(
|
||||||
|
returnType = "L",
|
||||||
|
accessFlags = AccessFlags.PROTECTED or AccessFlags.FINAL,
|
||||||
|
parameters = listOf("L"),
|
||||||
|
strings = listOf("…")
|
||||||
|
)
|
||||||
@@ -65,7 +65,7 @@ object SpoofAppVersionPatch : BytecodePatch(
|
|||||||
StringResource("revanced_spoof_app_version_target_entry_2", "18.20.39 - Restore wide video speed & quality menu"),
|
StringResource("revanced_spoof_app_version_target_entry_2", "18.20.39 - Restore wide video speed & quality menu"),
|
||||||
StringResource("revanced_spoof_app_version_target_entry_3", "17.08.35 - Restore old UI layout"),
|
StringResource("revanced_spoof_app_version_target_entry_3", "17.08.35 - Restore old UI layout"),
|
||||||
StringResource("revanced_spoof_app_version_target_entry_4", "16.08.35 - Restore explore tab"),
|
StringResource("revanced_spoof_app_version_target_entry_4", "16.08.35 - Restore explore tab"),
|
||||||
StringResource("revanced_spoof_app_version_target_entry_5", "16.01.35 - Restore old Shorts player"),
|
StringResource("revanced_spoof_app_version_target_entry_5", "16.01.35 - Restore fewer video player action buttons"),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
ArrayResource(
|
ArrayResource(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import app.revanced.patcher.util.DomFileEditor
|
|||||||
import app.revanced.patches.shared.settings.preference.impl.StringResource
|
import app.revanced.patches.shared.settings.preference.impl.StringResource
|
||||||
import app.revanced.patches.youtube.misc.settings.SettingsPatch
|
import app.revanced.patches.youtube.misc.settings.SettingsPatch
|
||||||
import org.w3c.dom.Node
|
import org.w3c.dom.Node
|
||||||
|
import java.io.InputStream
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.StandardCopyOption
|
import java.nio.file.StandardCopyOption
|
||||||
|
|
||||||
@@ -53,13 +54,18 @@ fun ResourceContext.copyResources(sourceResourceDirectory: String, vararg resour
|
|||||||
resourceGroup.resources.forEach { resource ->
|
resourceGroup.resources.forEach { resource ->
|
||||||
val resourceFile = "${resourceGroup.resourceDirectoryName}/$resource"
|
val resourceFile = "${resourceGroup.resourceDirectoryName}/$resource"
|
||||||
Files.copy(
|
Files.copy(
|
||||||
classLoader.getResourceAsStream("$sourceResourceDirectory/$resourceFile")!!,
|
inputStreamFromBundledResource(sourceResourceDirectory, resourceFile)!!,
|
||||||
targetResourceDirectory.resolve(resourceFile).toPath(), StandardCopyOption.REPLACE_EXISTING
|
targetResourceDirectory.resolve(resourceFile).toPath(), StandardCopyOption.REPLACE_EXISTING
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun inputStreamFromBundledResource(
|
||||||
|
sourceResourceDirectory: String,
|
||||||
|
resourceFile: String
|
||||||
|
): InputStream? = classLoader.getResourceAsStream("$sourceResourceDirectory/$resourceFile")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resource names mapped to their corresponding resource data.
|
* Resource names mapped to their corresponding resource data.
|
||||||
* @param resourceDirectoryName The name of the directory of the resource.
|
* @param resourceDirectoryName The name of the directory of the resource.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.6 KiB |