From 0158c5b4a071180f854e2de92a76af115207c849 Mon Sep 17 00:00:00 2001 From: ChsBuffer <33744752+chsbuffer@users.noreply.github.com> Date: Sun, 28 Jul 2024 14:16:34 +0800 Subject: [PATCH] Youtube: - VideoAds - BackgroundPlayback - RemoveTrackingQueryParameter - HideAds - LithoFilter --- README.md | 26 + app/build.gradle.kts | 1 + .../revanced/integrations/shared/Logger.java | 157 +++++ .../revanced/integrations/shared/Utils.java | 193 ++++++ .../shared/settings/BaseSettings.java | 11 + .../shared/settings/BooleanSetting.java | 13 + .../integrations/shared/settings/Setting.java | 18 + .../integrations/youtube/ByteTrieSearch.java | 45 ++ .../youtube/StringTrieSearch.java | 34 ++ .../integrations/youtube/TrieSearch.java | 412 +++++++++++++ .../youtube/patches/components/AdsFilter.java | 207 +++++++ .../patches/components/LithoFilterPatch.java | 557 ++++++++++++++++++ .../youtube/settings/Settings.java | 27 + .../github/chsbuffer/revancedxposed/Cache.kt | 109 ++++ .../github/chsbuffer/revancedxposed/Helper.kt | 23 + .../chsbuffer/revancedxposed/MainHook.kt | 9 + .../chsbuffer/revancedxposed/MusicHook.kt | 78 +-- .../chsbuffer/revancedxposed/YoutubeHook.kt | 357 +++++++++++ app/src/main/res/values/arrays.xml | 1 + gradle/libs.versions.toml | 4 +- 20 files changed, 2214 insertions(+), 68 deletions(-) create mode 100644 README.md create mode 100644 app/src/main/java/app/revanced/integrations/shared/Logger.java create mode 100644 app/src/main/java/app/revanced/integrations/shared/Utils.java create mode 100644 app/src/main/java/app/revanced/integrations/shared/settings/BaseSettings.java create mode 100644 app/src/main/java/app/revanced/integrations/shared/settings/BooleanSetting.java create mode 100644 app/src/main/java/app/revanced/integrations/shared/settings/Setting.java create mode 100644 app/src/main/java/app/revanced/integrations/youtube/ByteTrieSearch.java create mode 100644 app/src/main/java/app/revanced/integrations/youtube/StringTrieSearch.java create mode 100644 app/src/main/java/app/revanced/integrations/youtube/TrieSearch.java create mode 100644 app/src/main/java/app/revanced/integrations/youtube/patches/components/AdsFilter.java create mode 100644 app/src/main/java/app/revanced/integrations/youtube/patches/components/LithoFilterPatch.java create mode 100644 app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java create mode 100644 app/src/main/java/io/github/chsbuffer/revancedxposed/Cache.kt create mode 100644 app/src/main/java/io/github/chsbuffer/revancedxposed/Helper.kt create mode 100644 app/src/main/java/io/github/chsbuffer/revancedxposed/YoutubeHook.kt diff --git a/README.md b/README.md new file mode 100644 index 0000000..e2e5e2a --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Compatibility & Patches + +## Youtube Music +Youtube Music tested version: 7.11.50 +Patches: +- HideMusicVideoAds +- MinimizedPlayback +- RemoveUpgradeButton +- HideGetPremium +- EnableExclusiveAudioPlayback + +## Youtube +Youtube tested version: 19.29.37 +Patches: +- VideoAds +- BackgroundPlayback +- RemoveTrackingQueryParameter +- HideAds +- LithoFilter + +# Credit + +[DexKit](/~https://github.com/LuckyPray/DexKit) +[ReVanced Integrations](/~https://github.com/ReVanced/revanced-integrations) +[Revanced Patcher](/~https://github.com/ReVanced/revanced-patcher) +[Revanced Patches](/~https://github.com/ReVanced/revanced-patches) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4f2d96b..c7f05ab 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -51,5 +51,6 @@ android { dependencies { implementation(libs.dexkit) + implementation(libs.annotation) compileOnly(libs.xposed) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/integrations/shared/Logger.java b/app/src/main/java/app/revanced/integrations/shared/Logger.java new file mode 100644 index 0000000..2588505 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/shared/Logger.java @@ -0,0 +1,157 @@ +package app.revanced.integrations.shared; + +import static app.revanced.integrations.shared.settings.BaseSettings.DEBUG; +import static app.revanced.integrations.shared.settings.BaseSettings.DEBUG_STACKTRACE; +import static app.revanced.integrations.shared.settings.BaseSettings.DEBUG_TOAST_ON_ERROR; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import app.revanced.integrations.shared.settings.BaseSettings; + +public class Logger { + + /** + * Log messages using lambdas. + */ + public interface LogMessage { + @NonNull + String buildMessageString(); + + /** + * @return For outer classes, this returns {@link Class#getSimpleName()}. + * For static, inner, or anonymous classes, this returns the simple name of the enclosing class. + *
+ * For example, each of these classes return 'SomethingView': + * + * com.company.SomethingView + * com.company.SomethingView$StaticClass + * com.company.SomethingView$1 + * + */ + 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) { + if (DEBUG.get()) { + var messageString = message.buildMessageString(); + + if (DEBUG_STACKTRACE.get()) { + var builder = new StringBuilder(messageString); + var sw = new StringWriter(); + new Throwable().printStackTrace(new PrintWriter(sw)); + + builder.append('\n').append(sw); + messageString = builder.toString(); + } + + Log.d(REVANCED_LOG_PREFIX + message.findOuterClassSimpleName(), messageString); + } + } + + /** + * 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/app/src/main/java/app/revanced/integrations/shared/Utils.java b/app/src/main/java/app/revanced/integrations/shared/Utils.java new file mode 100644 index 0000000..281d89c --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/shared/Utils.java @@ -0,0 +1,193 @@ +package app.revanced.integrations.shared; + +import android.annotation.SuppressLint; +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.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; + +public class Utils { + + @SuppressLint("StaticFieldLeak") + private static Context context; + + public static void setContext(Context appContext) { + context = appContext; + } + + public static Context getContext() { + return context; + } + + /** + * 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); + } + + + /** + * 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"); + } + } + + + /** + * 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); + } + } +} diff --git a/app/src/main/java/app/revanced/integrations/shared/settings/BaseSettings.java b/app/src/main/java/app/revanced/integrations/shared/settings/BaseSettings.java new file mode 100644 index 0000000..aa24f15 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/shared/settings/BaseSettings.java @@ -0,0 +1,11 @@ +package app.revanced.integrations.shared.settings; + +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; + +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"); + public static final BooleanSetting DEBUG_PROTOBUFFER = new BooleanSetting("revanced_debug_protobuffer", FALSE/*, parent(DEBUG)*/); +} diff --git a/app/src/main/java/app/revanced/integrations/shared/settings/BooleanSetting.java b/app/src/main/java/app/revanced/integrations/shared/settings/BooleanSetting.java new file mode 100644 index 0000000..fe2910c --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/shared/settings/BooleanSetting.java @@ -0,0 +1,13 @@ +package app.revanced.integrations.shared.settings; + +public class BooleanSetting extends Setting { + + public BooleanSetting(String key, boolean value) { + super(key, value); + } + + public BooleanSetting(String key, boolean value, Object drop) { + super(key, value); + } + +} diff --git a/app/src/main/java/app/revanced/integrations/shared/settings/Setting.java b/app/src/main/java/app/revanced/integrations/shared/settings/Setting.java new file mode 100644 index 0000000..b3ddaab --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/shared/settings/Setting.java @@ -0,0 +1,18 @@ +package app.revanced.integrations.shared.settings; + +public class Setting { + T value; + public boolean rebootApp = false; + + public Setting(String key, T value) { + this.value = value; + } + + public T get() { + return value; + } + + public void save(T newValue){ + value = newValue; + } +} diff --git a/app/src/main/java/app/revanced/integrations/youtube/ByteTrieSearch.java b/app/src/main/java/app/revanced/integrations/youtube/ByteTrieSearch.java new file mode 100644 index 0000000..ef27382 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/ByteTrieSearch.java @@ -0,0 +1,45 @@ +package app.revanced.integrations.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); + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/integrations/youtube/StringTrieSearch.java b/app/src/main/java/app/revanced/integrations/youtube/StringTrieSearch.java new file mode 100644 index 0000000..618d9d6 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/StringTrieSearch.java @@ -0,0 +1,34 @@ +package app.revanced.integrations.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/app/src/main/java/app/revanced/integrations/youtube/TrieSearch.java b/app/src/main/java/app/revanced/integrations/youtube/TrieSearch.java new file mode 100644 index 0000000..12b385a --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/TrieSearch.java @@ -0,0 +1,412 @@ +package app.revanced.integrations.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/app/src/main/java/app/revanced/integrations/youtube/patches/components/AdsFilter.java b/app/src/main/java/app/revanced/integrations/youtube/patches/components/AdsFilter.java new file mode 100644 index 0000000..2e4a0fb --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/components/AdsFilter.java @@ -0,0 +1,207 @@ +package app.revanced.integrations.youtube.patches.components; + +import android.app.Instrumentation; +import android.view.KeyEvent; +import android.view.View; + +import androidx.annotation.Nullable; + +import app.revanced.integrations.youtube.settings.Settings; +import app.revanced.integrations.shared.Logger; +import app.revanced.integrations.shared.Utils; +import app.revanced.integrations.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 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", + "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" + ); + + 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 +// movieAds + ); + } + + @Override + boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray, + StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + 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); + } + + /** + * 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("revanced_hide_fullscreen_ads_feature_not_available_toast"); + } + }); + }, 1000); + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/components/LithoFilterPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/components/LithoFilterPatch.java new file mode 100644 index 0000000..3d66aeb --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/components/LithoFilterPatch.java @@ -0,0 +1,557 @@ +package app.revanced.integrations.youtube.patches.components; + +import android.os.Build; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Spliterator; +import java.util.function.Consumer; + +import app.revanced.integrations.shared.Logger; +import app.revanced.integrations.shared.settings.BooleanSetting; +import app.revanced.integrations.shared.settings.BaseSettings; +import app.revanced.integrations.youtube.ByteTrieSearch; +import app.revanced.integrations.youtube.StringTrieSearch; +import app.revanced.integrations.youtube.TrieSearch; +import app.revanced.integrations.youtube.settings.Settings; + +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); + } +} + + +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(); + } +} + +/** + * 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; + } +} + +/** + * Placeholder for actual filters. + */ +final class DummyFilter extends Filter { +} + +@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 (BaseSettings.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 AdsFilter() // 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 { + // It is assumed that protobufBuffer is empty as well in this case. + 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) { + if (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; + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java b/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java new file mode 100644 index 0000000..2db92f5 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java @@ -0,0 +1,27 @@ +package app.revanced.integrations.youtube.settings; + +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import app.revanced.integrations.shared.Logger; +import app.revanced.integrations.shared.settings.*; +@SuppressWarnings("deprecation") +public class Settings extends BaseSettings { + // 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_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); +} \ No newline at end of file diff --git a/app/src/main/java/io/github/chsbuffer/revancedxposed/Cache.kt b/app/src/main/java/io/github/chsbuffer/revancedxposed/Cache.kt new file mode 100644 index 0000000..c9bd860 --- /dev/null +++ b/app/src/main/java/io/github/chsbuffer/revancedxposed/Cache.kt @@ -0,0 +1,109 @@ +package io.github.chsbuffer.revancedxposed + +import android.annotation.SuppressLint +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import de.robv.android.xposed.XposedBridge +import org.luckypray.dexkit.result.ClassData +import org.luckypray.dexkit.result.FieldData +import org.luckypray.dexkit.result.MethodData +import org.luckypray.dexkit.wrap.DexClass +import org.luckypray.dexkit.wrap.DexField +import org.luckypray.dexkit.wrap.DexMethod + +@SuppressLint("CommitPrefEdits") +open class Cache(val app: Application) { + lateinit var pref: SharedPreferences + lateinit var map: MutableMap + + @Suppress("UNCHECKED_CAST") + fun loadCache(): Boolean { + pref = app.getSharedPreferences("xprevanced", Context.MODE_PRIVATE) + val packageInfo = app.packageManager.getPackageInfo(app.packageName, 0) + + val id = packageInfo.lastUpdateTime.toString() + val cachedId = pref.getString("id", null) + + XposedBridge.log("cache ID: $id") + XposedBridge.log("cached ID: ${cachedId ?: ""}") + + if (!cachedId.equals(id)) { + map = mutableMapOf("id" to id) + return false + } else { + map = pref.all.toMutableMap() as MutableMap + return true + } + } + + fun saveCache() { + val edit = pref.edit() + map.forEach { k, v -> + edit.putString(k, v) + } + edit.commit() + } + + fun getDexClass(key: String, f: () -> ClassData): DexClass { + return map[key]?.let { DexClass(it) } ?: f().apply { + map[key] = this.descriptor + XposedBridge.log("$key Matches: ${this.toDexType()}") + }.toDexType() + } + + fun getDexMethod(key: String, f: () -> MethodData): DexMethod { + return map[key]?.let { DexMethod(it) } ?: f().apply { + map[key] = this.descriptor + XposedBridge.log("$key Matches: ${this.toDexMethod()}") + }.toDexMethod() + } + + fun getDexField(key: String, f: () -> FieldData): DexField { + return map[key]?.let { DexField(it) } ?: f().apply { + map[key] = this.descriptor + XposedBridge.log("$key Matches: ${this.toDexField()}") + }.toDexField() + } + + fun getString(key: String, f: () -> String): String { + return map[key] ?: f().also { + map[key] = it + XposedBridge.log("$key Matches: ${it}") + } + } + + fun getNumber(key: String, f: () -> Number): Number { + return map[key]?.let { return Integer.parseInt(it) } ?: f().also { + map[key] = it.toString() + XposedBridge.log("$key Matches: ${it}") + } + } + + fun getDexMethod(key: String): DexMethod { + return DexMethod(map[key]!!) + } + + fun getDexClass(key: String): DexClass { + return DexClass(map[key]!!) + } + + fun getDexField(key: String): DexField { + return DexField(map[key]!!) + } + + fun getString(key: String): String { + return map[key]!! + } + + fun setString(key: String, value: Any) { + val str = when (value) { + is ClassData -> value.toDexType() + is MethodData -> value.toDexMethod() + is FieldData -> value.toDexField() + else -> value + } + XposedBridge.log("$key Matches: $str") + map[key] = str.toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/chsbuffer/revancedxposed/Helper.kt b/app/src/main/java/io/github/chsbuffer/revancedxposed/Helper.kt new file mode 100644 index 0000000..e020776 --- /dev/null +++ b/app/src/main/java/io/github/chsbuffer/revancedxposed/Helper.kt @@ -0,0 +1,23 @@ +package io.github.chsbuffer.revancedxposed + +import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam +import org.luckypray.dexkit.DexKitBridge +import org.luckypray.dexkit.util.OpCodeUtil + +fun createDexKit(lpparam: LoadPackageParam): DexKitBridge { + System.loadLibrary("dexkit") + return DexKitBridge.create(lpparam.classLoader, true) +} + + +fun opCodesOf( + vararg opNames: String?, +): Collection { + return opNames.map { + if (it == null) { + -1 + } else { + OpCodeUtil.getOpCode(it) + } + } +} diff --git a/app/src/main/java/io/github/chsbuffer/revancedxposed/MainHook.kt b/app/src/main/java/io/github/chsbuffer/revancedxposed/MainHook.kt index 73a28b3..f2a150c 100644 --- a/app/src/main/java/io/github/chsbuffer/revancedxposed/MainHook.kt +++ b/app/src/main/java/io/github/chsbuffer/revancedxposed/MainHook.kt @@ -19,6 +19,15 @@ class MainHook : IXposedHookLoadPackage { XposedBridge.log("Youtube Music handleLoadPackage: ${t}ms") } } + + "com.google.android.youtube" -> { + inContext(lpparam) { app -> + val t = measureTimeMillis { + YoutubeHook(app, lpparam).Hook() + } + XposedBridge.log("Youtube handleLoadPackage: ${t}ms") + } + } } } } diff --git a/app/src/main/java/io/github/chsbuffer/revancedxposed/MusicHook.kt b/app/src/main/java/io/github/chsbuffer/revancedxposed/MusicHook.kt index 4e1cd3b..1d6e92b 100644 --- a/app/src/main/java/io/github/chsbuffer/revancedxposed/MusicHook.kt +++ b/app/src/main/java/io/github/chsbuffer/revancedxposed/MusicHook.kt @@ -1,9 +1,6 @@ package io.github.chsbuffer.revancedxposed -import android.annotation.SuppressLint import android.app.Application -import android.content.Context -import android.content.SharedPreferences import android.view.View import de.robv.android.xposed.XC_MethodHook import de.robv.android.xposed.XC_MethodReplacement @@ -12,15 +9,11 @@ import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam import org.luckypray.dexkit.DexKitBridge import org.luckypray.dexkit.query.enums.StringMatchType -import org.luckypray.dexkit.result.MethodData -import org.luckypray.dexkit.wrap.DexMethod import java.lang.reflect.Modifier -@SuppressLint("CommitPrefEdits") -class MusicHook(val app: Application, val lpparam: LoadPackageParam) { +class MusicHook(app: Application, val lpparam: LoadPackageParam) : Cache(app) { lateinit var dexkit: DexKitBridge - lateinit var pref: SharedPreferences - lateinit var map: MutableMap + var cached: Boolean = false fun Hook() { @@ -28,8 +21,7 @@ class MusicHook(val app: Application, val lpparam: LoadPackageParam) { XposedBridge.log("Using cached keys: $cached") if (!cached) { - System.loadLibrary("dexkit") - dexkit = DexKitBridge.create(lpparam.classLoader, true) + dexkit = createDexKit(lpparam) } try { @@ -48,7 +40,7 @@ class MusicHook(val app: Application, val lpparam: LoadPackageParam) { } private fun EnableExclusiveAudioPlayback() { - getMethodData("AllowExclusiveAudioPlaybackFingerprint") { + getDexMethod("AllowExclusiveAudioPlaybackFingerprint") { dexkit.findMethod { matcher { returnType = "boolean" @@ -78,57 +70,9 @@ class MusicHook(val app: Application, val lpparam: LoadPackageParam) { } } - @Suppress("UNCHECKED_CAST") - fun loadCache(): Boolean { - pref = app.getSharedPreferences("xprevanced", Context.MODE_PRIVATE) - val packageInfo = app.packageManager.getPackageInfo(app.packageName, 0) - - val id = packageInfo.lastUpdateTime.toString() - val cachedId = pref.getString("id", null) - - XposedBridge.log("cache ID: $id") - XposedBridge.log("cached ID: ${cachedId ?: ""}") - - if (!cachedId.equals(id)) { - map = mutableMapOf("id" to id) - return false - } else { - map = pref.all.toMutableMap() as MutableMap - return true - } - } - - fun saveCache() { -// pref.edit(true) { -// map.forEach { k, v -> -// putString(k, v) -// } -// } - - val edit = pref.edit() - map.forEach { k, v -> - edit.putString(k, v) - } - edit.commit() - } - - fun getMethodData(key: String, f: () -> MethodData): DexMethod { - return map[key]?.let { DexMethod(it) } ?: f().apply { - map[key] = this.descriptor - XposedBridge.log("$key Matches: ${this.toDexMethod()}") - }.toDexMethod() - } - - fun getData(key: String, f: () -> String): String { - return map[key] ?: f().also { - map[key] = it - XposedBridge.log("$key Matches: ${it}") - } - } - fun HideGetPremium() { // HideGetPremiumFingerprint - getMethodData("HideGetPremiumFingerprint") { + getDexMethod("HideGetPremiumFingerprint") { dexkit.findMethod { matcher { modifiers = Modifier.FINAL or Modifier.PUBLIC @@ -164,7 +108,7 @@ class MusicHook(val app: Application, val lpparam: LoadPackageParam) { private fun RemoveUpgradeButton() { // PivotBarConstructorFingerprint - getMethodData("PivotBarConstructorFingerprint") { + getDexMethod("PivotBarConstructorFingerprint") { val result = dexkit.findMethod { matcher { name = "" @@ -180,13 +124,13 @@ class MusicHook(val app: Application, val lpparam: LoadPackageParam) { ) } }.single() - getData("pivotBarElementField") { + getString("pivotBarElementField") { result.declaredClass!!.fields.single { f -> f.typeName == "java.util.List" }.fieldName } result }.let { val pivotBarElementField = - getData("pivotBarElementField") { throw Exception("WTF, Shouldn't i already searched?") } + getString("pivotBarElementField") { throw Exception("WTF, Shouldn't i already searched?") } XposedBridge.hookMethod(it.getConstructorInstance(lpparam.classLoader), object : XC_MethodHook() { override fun afterHookedMethod(param: MethodHookParam) { @@ -200,7 +144,7 @@ class MusicHook(val app: Application, val lpparam: LoadPackageParam) { } private fun MinimizedPlayback() { - getMethodData("BackgroundPlaybackDisableFingerprint") { + getDexMethod("BackgroundPlaybackDisableFingerprint") { dexkit.findMethod { matcher { returnType = "boolean" @@ -226,7 +170,7 @@ class MusicHook(val app: Application, val lpparam: LoadPackageParam) { } // KidsMinimizedPlaybackPolicyControllerFingerprint - getMethodData("KidsMinimizedPlaybackPolicyControllerFingerprint") { + getDexMethod("KidsMinimizedPlaybackPolicyControllerFingerprint") { dexkit.findMethod { matcher { @@ -258,7 +202,7 @@ class MusicHook(val app: Application, val lpparam: LoadPackageParam) { private fun HideMusicVideoAds() { // ShowMusicVideoAdsParentFingerprint - getMethodData("ShowMusicVideoAdsMethod") { + getDexMethod("ShowMusicVideoAdsMethod") { dexkit.findMethod { matcher { usingStrings( diff --git a/app/src/main/java/io/github/chsbuffer/revancedxposed/YoutubeHook.kt b/app/src/main/java/io/github/chsbuffer/revancedxposed/YoutubeHook.kt new file mode 100644 index 0000000..bd55c61 --- /dev/null +++ b/app/src/main/java/io/github/chsbuffer/revancedxposed/YoutubeHook.kt @@ -0,0 +1,357 @@ +package io.github.chsbuffer.revancedxposed + +import android.app.Application +import android.content.ClipData +import android.content.Intent +import android.view.View +import app.revanced.integrations.shared.Utils +import app.revanced.integrations.youtube.patches.components.LithoFilterPatch +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XC_MethodReplacement +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.XposedHelpers +import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam +import org.luckypray.dexkit.DexKitBridge +import org.luckypray.dexkit.result.FieldUsingType +import java.lang.reflect.Member +import java.lang.reflect.Modifier +import java.nio.ByteBuffer + +class YoutubeHook(app: Application, val lpparam: LoadPackageParam) : Cache(app) { + lateinit var dexkit: DexKitBridge + val classLoader: ClassLoader = lpparam.classLoader + + fun Hook() { + val cached = loadCache() + if (!cached) + dexkit = createDexKit(lpparam) + + try { + VideoAds() + BackgroundPlayback() + RemoveTrackingQueryParameter() + HideAds() + LithoFilter() + saveCache() + } catch (err: Exception) { + pref.edit().clear().apply() + throw err + } finally { + if (!cached) dexkit.close() + } + } + + fun VideoAds() { + + val LoadVideoAds = getDexMethod("LoadVideoAds") { + dexkit.findMethod { + matcher { + usingEqStrings( + listOf( + "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: ", + ) + ) + } + }.single() + } + + XposedBridge.hookMethod( + LoadVideoAds.getMethodInstance(classLoader), XC_MethodReplacement.DO_NOTHING + ) + } + + fun BackgroundPlayback() { + + val prefBackgroundAndOfflineCategoryId = getNumber("prefBackgroundAndOfflineCategoryId") { + app.resources.getIdentifier( + "pref_background_and_offline_category", "string", app.packageName + ) + } + + val BackgroundPlaybackManagerFingerprint = + getDexMethod("BackgroundPlaybackManagerFingerprint") { + dexkit.findMethod { + matcher { + returnType = "boolean" + modifiers = Modifier.PUBLIC or Modifier.STATIC + paramTypes = listOf(null) + opNames = listOf( + "const/4", + "if-eqz", + "iget", + "and-int/lit16", + "if-eqz", + "iget-object", + "if-nez", + "sget-object", + "iget", + "const", + "if-ne", + "iget-object", + "if-nez", + "sget-object", + "iget", + "if-ne", + "iget-object", + "check-cast", + "goto", + "sget-object", + "goto", + "const/4", + "if-eqz", + "iget-boolean", + "if-eqz" + ) + } + }.single() + } + + val BackgroundPlaybackSettingsFingerprint = + getDexMethod("BackgroundPlaybackSettingsBoolean") { + dexkit.findMethod { + matcher { + returnType = "java.lang.String" + modifiers = Modifier.PUBLIC or Modifier.FINAL + paramCount = 0 + opNames = listOf( + "invoke-virtual", + "move-result", + "invoke-virtual", + "move-result", + "if-eqz", + "if-nez", + "goto" + ) + usingNumbers(prefBackgroundAndOfflineCategoryId) + } + }.single().invokes.filter { it.returnTypeName == "boolean" }[1] + } + + XposedBridge.hookMethod( + BackgroundPlaybackManagerFingerprint.getMethodInstance(classLoader), + XC_MethodReplacement.returnConstant(true) + ) + XposedBridge.hookMethod( + BackgroundPlaybackSettingsFingerprint.getMethodInstance(classLoader), + XC_MethodReplacement.returnConstant(true) + ) + } + + fun RemoveTrackingQueryParameter() { + val CopyTextFingerprint = getDexMethod("CopyTextFingerprint") { + dexkit.findMethod { + matcher { + returnType = "void" + paramTypes = listOf(null, "java.util.Map") + opNames = listOf( + "iget-object", + "const-string", + "invoke-static", + "move-result-object", + "invoke-virtual", + "iget-object", + "iget-object", + "invoke-interface", + "return-void", + ) + } + }.single() + } + + val sanitizeArg1 = object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + val url = param.args[1] as String + param.args[1] = + url.replace(".si=.+".toRegex(), "").replace(".feature=.+".toRegex(), "") + } + } + + XposedBridge.hookMethod( + CopyTextFingerprint.getMethodInstance(classLoader), ScopedHook( + XposedHelpers.findMethodExact( + ClipData::class.java.name, + lpparam.classLoader, + "newPlainText", + CharSequence::class.java, + CharSequence::class.java + ), sanitizeArg1 + ) + ) + + val intentSanitizeHook = ScopedHook( + XposedHelpers.findMethodExact( + Intent::class.java.name, + lpparam.classLoader, + "putExtra", + String::class.java, + String::class.java + ), sanitizeArg1 + ) + + val YouTubeShareSheetFingerprint = getDexMethod("YouTubeShareSheetFingerprint") { + dexkit.findMethod { + matcher { + modifiers = Modifier.PUBLIC or Modifier.FINAL + returnType = "void" + paramTypes = listOf(null, "java.util.Map") + opNames = listOf( + "check-cast", + "goto", + "move-object", + "invoke-virtual", + ) + addInvoke("Landroid/content/Intent;->putExtra(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;") + } + }.single() + } + + XposedBridge.hookMethod( + YouTubeShareSheetFingerprint.getMethodInstance(classLoader), intentSanitizeHook + ) + + val SystemShareSheetFingerprint = getDexMethod("SystemShareSheetFingerprint") { + dexkit.findMethod { + matcher { + returnType = "void" + paramTypes = listOf(null, "java.util.Map") + opNames = listOf( + "check-cast", + "goto", + ) + addEqString("YTShare_Logging_Share_Intent_Endpoint_Byte_Array") + } + }.single() + } + + XposedBridge.hookMethod( + SystemShareSheetFingerprint.getMethodInstance(classLoader), intentSanitizeHook + ) + } + + fun LithoFilter() { + val ComponentContextParserFingerprint = getDexMethod("ComponentContextParserFingerprint") { + dexkit.findMethod { + matcher { + addEqString("Component was not found %s because it was removed due to duplicate converter bindings.") + } + }.single() + } + + val ProtobufBufferReferenceFingerprint = + getDexMethod("ProtobufBufferReferenceFingerprint") { + dexkit.findMethod { + matcher { + returnType = "void" + modifiers = Modifier.PUBLIC or Modifier.FINAL + paramTypes = listOf("int", "java.nio.ByteBuffer") + opNames = listOf( + "iput", "invoke-virtual", "move-result", "sub-int/2addr" + ) + } + }.single() + } + + // Pass the buffer into Integrations. + XposedBridge.hookMethod(ProtobufBufferReferenceFingerprint.getMethodInstance(classLoader), + object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + LithoFilterPatch.setProtoBuffer(param.args[1] as ByteBuffer) + } + }) + + // Hook the method that parses bytes into a ComponentContext. + val emptyComponentClass = getDexClass("emptyComponentClass") { + dexkit.findClass { + matcher { + addMethod { + name = "" + addEqString("EmptyComponent") + } + } + }.single() + } + + val parseBytesToConversionContext = getDexMethod("parseBytesToConversionContext") { + val method = dexkit.findMethod { + matcher { + usingEqStrings( + "Failed to parse Element proto.", + "Cannot read theme key from model.", + "Number of bits must be positive", + "Failed to parse LoggingProperties", + "Found an Element with missing debugger id." + ) + } + }.single() + val conversionContextClass = method.returnType!! + setString("conversionContextClass", conversionContextClass) + setString("identifierFieldData", + conversionContextClass.methods.single { it.methodName == "toString" }.usingFields.filter { it.usingType == FieldUsingType.Read && it.field.typeSign == "Ljava/lang/String;" }[1].field + ) + setString("pathBuilderFieldData", + conversionContextClass.fields.single { it.typeSign == "Ljava/lang/StringBuilder;" }) + method + } + + val conversionContextClass = getDexClass("conversionContextClass") + + val identifierField = getDexField("identifierFieldData").getFieldInstance(classLoader) + val pathBuilderField = getDexField("pathBuilderFieldData").getFieldInstance(classLoader) + + val filtered = ThreadLocal() + + XposedBridge.hookMethod(parseBytesToConversionContext.getMethodInstance(classLoader), + object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + val conversion = param.result + + val identifier = identifierField.get(conversion) as String + val pathBuilder = pathBuilderField.get(conversion) as StringBuilder + filtered.set(LithoFilterPatch.filter(identifier, pathBuilder)) + } + }) + + XposedBridge.hookMethod(ComponentContextParserFingerprint.getMethodInstance(classLoader), + object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + if (filtered.get() == true) { + param.result = + emptyComponentClass.getInstance(classLoader).declaredConstructors.single() + .newInstance() + } + } + }) + } + + fun HideAds() { + val adAttributionId = getNumber("adAttributionId") { + app.resources.getIdentifier("ad_attribution", "id", app.packageName) + } + + XposedHelpers.findAndHookMethod(View::class.java.name, + lpparam.classLoader, + "findViewById", + Int::class.java.name, + object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + if (param.args[0].equals(adAttributionId)) { + XposedBridge.log("Hide Ad Attribution View") + Utils.hideViewByLayoutParams(param.result as View) + } + } + }) + } +} + +class ScopedHook(val hookMethod: Member, val callback: XC_MethodHook) : XC_MethodHook() { + lateinit var Unhook: XC_MethodHook.Unhook + override fun beforeHookedMethod(param: MethodHookParam?) { + Unhook = XposedBridge.hookMethod(hookMethod, callback) + } + + override fun afterHookedMethod(param: MethodHookParam?) { + Unhook.unhook() + } +} diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 4874473..0552e6f 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -2,5 +2,6 @@ com.google.android.apps.youtube.music + com.google.android.youtube \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9ca1bea..61c10fe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,14 @@ [versions] -agp = "8.5.0" +agp = "8.5.1" kotlin = "1.9.0" dexkit = "2.0.2" xposed = "82" +annotation = "1.8.1" [libraries] dexkit = { group = "org.luckypray", name = "dexkit", version.ref = "dexkit" } xposed = { group = "de.robv.android.xposed", name = "api", version.ref = "xposed" } +annotation = { group = "androidx.annotation", name = "annotation", version.ref = "annotation" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }