filteredList = new ArrayList<>();
+
+ for (Object item : links) {
+ if (item instanceof ILink && ((ILink) item).getPromoted()) continue;
+
+ filteredList.add(item);
+ }
+
+ return filteredList;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/GmsCoreSupport.java b/extensions/shared/src/main/java/app/revanced/extension/shared/GmsCoreSupport.java
new file mode 100644
index 000000000..1e2586b2b
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/GmsCoreSupport.java
@@ -0,0 +1,158 @@
+package app.revanced.extension.shared;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.SearchManager;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.PowerManager;
+import android.provider.Settings;
+
+import androidx.annotation.RequiresApi;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+
+/**
+ * @noinspection unused
+ */
+public class GmsCoreSupport {
+ public static final String ORIGINAL_UNPATCHED_PACKAGE_NAME = "com.google.android.youtube";
+ private static final String GMS_CORE_PACKAGE_NAME
+ = getGmsCoreVendorGroupId() + ".android.gms";
+ private static final Uri GMS_CORE_PROVIDER
+ = Uri.parse("content://" + getGmsCoreVendorGroupId() + ".android.gsf.gservices/prefix");
+ private static final String DONT_KILL_MY_APP_LINK
+ = "https://dontkillmyapp.com";
+
+ private static void open(String queryOrLink) {
+ Intent intent;
+ try {
+ // Check if queryOrLink is a valid URL.
+ new URL(queryOrLink);
+
+ intent = new Intent(Intent.ACTION_VIEW, Uri.parse(queryOrLink));
+ } catch (MalformedURLException e) {
+ intent = new Intent(Intent.ACTION_WEB_SEARCH);
+ intent.putExtra(SearchManager.QUERY, queryOrLink);
+ }
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ Utils.getContext().startActivity(intent);
+
+ // Gracefully exit, otherwise the broken app will continue to run.
+ System.exit(0);
+ }
+
+ private static void showBatteryOptimizationDialog(Activity context,
+ String dialogMessageRef,
+ String positiveButtonStringRef,
+ DialogInterface.OnClickListener onPositiveClickListener) {
+ // Do not set cancelable to false, to allow using back button to skip the action,
+ // just in case the check can never be satisfied.
+ var dialog = new AlertDialog.Builder(context)
+ .setIconAttribute(android.R.attr.alertDialogIcon)
+ .setTitle(str("gms_core_dialog_title"))
+ .setMessage(str(dialogMessageRef))
+ .setPositiveButton(str(positiveButtonStringRef), onPositiveClickListener)
+ .create();
+ Utils.showDialog(context, dialog);
+ }
+
+ /**
+ * Injection point.
+ */
+ @RequiresApi(api = Build.VERSION_CODES.N)
+ public static void checkGmsCore(Activity context) {
+ try {
+ // Verify the user has not included GmsCore for a root installation.
+ // GmsCore Support changes the package name, but with a mounted installation
+ // all manifest changes are ignored and the original package name is used.
+ if (context.getPackageName().equals(ORIGINAL_UNPATCHED_PACKAGE_NAME)) {
+ Logger.printInfo(() -> "App is mounted with root, but GmsCore patch was included");
+ // Cannot use localize text here, since the app will load
+ // resources from the unpatched app and all patch strings are missing.
+ Utils.showToastLong("The 'GmsCore support' patch breaks mount installations");
+
+ // Do not exit. If the app exits before launch completes (and without
+ // opening another activity), then on some devices such as Pixel phone Android 10
+ // no toast will be shown and the app will continually be relaunched
+ // with the appearance of a hung app.
+ }
+
+ // Verify GmsCore is installed.
+ try {
+ PackageManager manager = context.getPackageManager();
+ manager.getPackageInfo(GMS_CORE_PACKAGE_NAME, PackageManager.GET_ACTIVITIES);
+ } catch (PackageManager.NameNotFoundException exception) {
+ Logger.printInfo(() -> "GmsCore was not found");
+ // Cannot show a dialog and must show a toast,
+ // because on some installations the app crashes before a dialog can be displayed.
+ Utils.showToastLong(str("gms_core_toast_not_installed_message"));
+ open(getGmsCoreDownload());
+ return;
+ }
+
+ // Check if GmsCore is running in the background.
+ try (var client = context.getContentResolver().acquireContentProviderClient(GMS_CORE_PROVIDER)) {
+ if (client == null) {
+ Logger.printInfo(() -> "GmsCore is not running in the background");
+
+ showBatteryOptimizationDialog(context,
+ "gms_core_dialog_not_whitelisted_not_allowed_in_background_message",
+ "gms_core_dialog_open_website_text",
+ (dialog, id) -> open(DONT_KILL_MY_APP_LINK));
+ return;
+ }
+ }
+
+ // Check if GmsCore is whitelisted from battery optimizations.
+ if (batteryOptimizationsEnabled(context)) {
+ Logger.printInfo(() -> "GmsCore is not whitelisted from battery optimizations");
+ showBatteryOptimizationDialog(context,
+ "gms_core_dialog_not_whitelisted_using_battery_optimizations_message",
+ "gms_core_dialog_continue_text",
+ (dialog, id) -> openGmsCoreDisableBatteryOptimizationsIntent(context));
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "checkGmsCore failure", ex);
+ }
+ }
+
+ @SuppressLint("BatteryLife") // Permission is part of GmsCore
+ private static void openGmsCoreDisableBatteryOptimizationsIntent(Activity activity) {
+ Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
+ intent.setData(Uri.fromParts("package", GMS_CORE_PACKAGE_NAME, null));
+ activity.startActivityForResult(intent, 0);
+ }
+
+ /**
+ * @return If GmsCore is not whitelisted from battery optimizations.
+ */
+ private static boolean batteryOptimizationsEnabled(Context context) {
+ var powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
+ return !powerManager.isIgnoringBatteryOptimizations(GMS_CORE_PACKAGE_NAME);
+ }
+
+ private static String getGmsCoreDownload() {
+ final var vendorGroupId = getGmsCoreVendorGroupId();
+ //noinspection SwitchStatementWithTooFewBranches
+ switch (vendorGroupId) {
+ case "app.revanced":
+ return "https://github.com/revanced/gmscore/releases/latest";
+ default:
+ return vendorGroupId + ".android.gms";
+ }
+ }
+
+ // Modified by a patch. Do not touch.
+ private static String getGmsCoreVendorGroupId() {
+ return "app.revanced";
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/Logger.java b/extensions/shared/src/main/java/app/revanced/extension/shared/Logger.java
new file mode 100644
index 000000000..3ac7438b9
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/Logger.java
@@ -0,0 +1,168 @@
+package app.revanced.extension.shared;
+
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import app.revanced.extension.shared.settings.BaseSettings;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+
+import static app.revanced.extension.shared.settings.BaseSettings.*;
+
+public class Logger {
+
+ /**
+ * Log messages using lambdas.
+ */
+ @FunctionalInterface
+ public interface LogMessage {
+ @NonNull
+ String buildMessageString();
+
+ /**
+ * @return For outer classes, this returns {@link Class#getSimpleName()}.
+ * For static, inner, or anonymous classes, this returns the simple name of the enclosing class.
+ *
+ * For example, each of these classes return 'SomethingView':
+ *
+ * com.company.SomethingView
+ * com.company.SomethingView$StaticClass
+ * com.company.SomethingView$1
+ *
+ */
+ private String findOuterClassSimpleName() {
+ var selfClass = this.getClass();
+
+ String fullClassName = selfClass.getName();
+ final int dollarSignIndex = fullClassName.indexOf('$');
+ if (dollarSignIndex < 0) {
+ return selfClass.getSimpleName(); // Already an outer class.
+ }
+
+ // Class is inner, static, or anonymous.
+ // Parse the simple name full name.
+ // A class with no package returns index of -1, but incrementing gives index zero which is correct.
+ final int simpleClassNameStartIndex = fullClassName.lastIndexOf('.') + 1;
+ return fullClassName.substring(simpleClassNameStartIndex, dollarSignIndex);
+ }
+ }
+
+ private static final String REVANCED_LOG_PREFIX = "revanced: ";
+
+ /**
+ * Logs debug messages under the outer class name of the code calling this method.
+ * Whenever possible, the log string should be constructed entirely inside {@link LogMessage#buildMessageString()}
+ * so the performance cost of building strings is paid only if {@link BaseSettings#DEBUG} is enabled.
+ */
+ public static void printDebug(@NonNull LogMessage message) {
+ printDebug(message, null);
+ }
+
+ /**
+ * Logs debug messages under the outer class name of the code calling this method.
+ * Whenever possible, the log string should be constructed entirely inside {@link LogMessage#buildMessageString()}
+ * so the performance cost of building strings is paid only if {@link BaseSettings#DEBUG} is enabled.
+ */
+ public static void printDebug(@NonNull LogMessage message, @Nullable Exception ex) {
+ if (DEBUG.get()) {
+ String logMessage = message.buildMessageString();
+ String logTag = REVANCED_LOG_PREFIX + message.findOuterClassSimpleName();
+
+ if (DEBUG_STACKTRACE.get()) {
+ var builder = new StringBuilder(logMessage);
+ var sw = new StringWriter();
+ new Throwable().printStackTrace(new PrintWriter(sw));
+
+ builder.append('\n').append(sw);
+ logMessage = builder.toString();
+ }
+
+ if (ex == null) {
+ Log.d(logTag, logMessage);
+ } else {
+ Log.d(logTag, logMessage, ex);
+ }
+ }
+ }
+
+ /**
+ * Logs information messages using the outer class name of the code calling this method.
+ */
+ public static void printInfo(@NonNull LogMessage message) {
+ printInfo(message, null);
+ }
+
+ /**
+ * Logs information messages using the outer class name of the code calling this method.
+ */
+ public static void printInfo(@NonNull LogMessage message, @Nullable Exception ex) {
+ String logTag = REVANCED_LOG_PREFIX + message.findOuterClassSimpleName();
+ String logMessage = message.buildMessageString();
+ if (ex == null) {
+ Log.i(logTag, logMessage);
+ } else {
+ Log.i(logTag, logMessage, ex);
+ }
+ }
+
+ /**
+ * Logs exceptions under the outer class name of the code calling this method.
+ */
+ public static void printException(@NonNull LogMessage message) {
+ printException(message, null, null);
+ }
+
+ /**
+ * Logs exceptions under the outer class name of the code calling this method.
+ */
+ public static void printException(@NonNull LogMessage message, @Nullable Throwable ex) {
+ printException(message, ex, null);
+ }
+
+ /**
+ * Logs exceptions under the outer class name of the code calling this method.
+ *
+ * If the calling code is showing it's own error toast,
+ * instead use {@link #printInfo(LogMessage, Exception)}
+ *
+ * @param message log message
+ * @param ex exception (optional)
+ * @param userToastMessage user specific toast message to show instead of the log message (optional)
+ */
+ public static void printException(@NonNull LogMessage message, @Nullable Throwable ex,
+ @Nullable String userToastMessage) {
+ String messageString = message.buildMessageString();
+ String outerClassSimpleName = message.findOuterClassSimpleName();
+ String logMessage = REVANCED_LOG_PREFIX + outerClassSimpleName;
+ if (ex == null) {
+ Log.e(logMessage, messageString);
+ } else {
+ Log.e(logMessage, messageString, ex);
+ }
+ if (DEBUG_TOAST_ON_ERROR.get()) {
+ String toastMessageToDisplay = (userToastMessage != null)
+ ? userToastMessage
+ : outerClassSimpleName + ": " + messageString;
+ Utils.showToastLong(toastMessageToDisplay);
+ }
+ }
+
+ /**
+ * Logging to use if {@link BaseSettings#DEBUG} or {@link Utils#getContext()} may not be initialized.
+ * Normally this method should not be used.
+ */
+ public static void initializationInfo(@NonNull Class> callingClass, @NonNull String message) {
+ Log.i(REVANCED_LOG_PREFIX + callingClass.getSimpleName(), message);
+ }
+
+ /**
+ * Logging to use if {@link BaseSettings#DEBUG} or {@link Utils#getContext()} may not be initialized.
+ * Normally this method should not be used.
+ */
+ public static void initializationException(@NonNull Class> callingClass, @NonNull String message,
+ @Nullable Exception ex) {
+ Log.e(REVANCED_LOG_PREFIX + callingClass.getSimpleName(), message, ex);
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/StringRef.java b/extensions/shared/src/main/java/app/revanced/extension/shared/StringRef.java
new file mode 100644
index 000000000..4390137de
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/StringRef.java
@@ -0,0 +1,122 @@
+package app.revanced.extension.shared;
+
+import android.content.Context;
+import android.content.res.Resources;
+
+import androidx.annotation.NonNull;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+public class StringRef {
+ private static Resources resources;
+ private static String packageName;
+
+ // must use a thread safe map, as this class is used both on and off the main thread
+ private static final Map strings = Collections.synchronizedMap(new HashMap<>());
+
+ /**
+ * Returns a cached instance.
+ * Should be used if the same String could be loaded more than once.
+ *
+ * @param id string resource name/id
+ * @see #sf(String)
+ */
+ @NonNull
+ public static StringRef sfc(@NonNull String id) {
+ StringRef ref = strings.get(id);
+ if (ref == null) {
+ ref = new StringRef(id);
+ strings.put(id, ref);
+ }
+ return ref;
+ }
+
+ /**
+ * Creates a new instance, but does not cache the value.
+ * Should be used for Strings that are loaded exactly once.
+ *
+ * @param id string resource name/id
+ * @see #sfc(String)
+ */
+ @NonNull
+ public static StringRef sf(@NonNull String id) {
+ return new StringRef(id);
+ }
+
+ /**
+ * Gets string value by string id, shorthand for sfc(id).toString()
+ *
+ * @param id string resource name/id
+ * @return String value from string.xml
+ */
+ @NonNull
+ public static String str(@NonNull String id) {
+ return sfc(id).toString();
+ }
+
+ /**
+ * Gets string value by string id, shorthand for sfc(id).toString()
and formats the string
+ * with given args.
+ *
+ * @param id string resource name/id
+ * @param args the args to format the string with
+ * @return String value from string.xml formatted with given args
+ */
+ @NonNull
+ public static String str(@NonNull String id, Object... args) {
+ return String.format(str(id), args);
+ }
+
+ /**
+ * Creates a StringRef object that'll not change it's value
+ *
+ * @param value value which toString() method returns when invoked on returned object
+ * @return Unique StringRef instance, its value will never change
+ */
+ @NonNull
+ public static StringRef constant(@NonNull String value) {
+ final StringRef ref = new StringRef(value);
+ ref.resolved = true;
+ return ref;
+ }
+
+ /**
+ * Shorthand for constant("")
+ * Its value always resolves to empty string
+ */
+ @NonNull
+ public static final StringRef empty = constant("");
+
+ @NonNull
+ private String value;
+ private boolean resolved;
+
+ public StringRef(@NonNull String resName) {
+ this.value = resName;
+ }
+
+ @Override
+ @NonNull
+ public String toString() {
+ if (!resolved) {
+ if (resources == null || packageName == null) {
+ Context context = Utils.getContext();
+ resources = context.getResources();
+ packageName = context.getPackageName();
+ }
+ resolved = true;
+ if (resources != null) {
+ final int identifier = resources.getIdentifier(value, "string", packageName);
+ if (identifier == 0)
+ Logger.printException(() -> "Resource not found: " + value);
+ else
+ value = resources.getString(identifier);
+ } else {
+ Logger.printException(() -> "Could not resolve resources!");
+ }
+ }
+ return value;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/Utils.java b/extensions/shared/src/main/java/app/revanced/extension/shared/Utils.java
new file mode 100644
index 000000000..45cf5616e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/Utils.java
@@ -0,0 +1,741 @@
+package app.revanced.extension.shared;
+
+import android.annotation.SuppressLint;
+import android.app.*;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.res.Resources;
+import android.net.ConnectivityManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.preference.Preference;
+import android.preference.PreferenceGroup;
+import android.preference.PreferenceScreen;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+import android.widget.Toast;
+import android.widget.Toolbar;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.text.Bidi;
+import java.util.*;
+import java.util.regex.Pattern;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.settings.preference.ReVancedAboutPreference;
+
+public class Utils {
+
+ @SuppressLint("StaticFieldLeak")
+ private static Context context;
+
+ private static String versionName;
+
+ private Utils() {
+ } // utility class
+
+ /**
+ * Injection point.
+ *
+ * @return The manifest 'Version' entry of the patches.jar used during patching.
+ */
+ @SuppressWarnings("SameReturnValue")
+ public static String getPatchesReleaseVersion() {
+ return ""; // Value is replaced during patching.
+ }
+
+ /**
+ * @return The version name of the app, such as 19.11.43
+ */
+ public static String getAppVersionName() {
+ if (versionName == null) {
+ try {
+ final var packageName = Objects.requireNonNull(getContext()).getPackageName();
+
+ PackageManager packageManager = context.getPackageManager();
+ PackageInfo packageInfo;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ packageInfo = packageManager.getPackageInfo(
+ packageName,
+ PackageManager.PackageInfoFlags.of(0)
+ );
+ } else {
+ packageInfo = packageManager.getPackageInfo(
+ packageName,
+ 0
+ );
+ }
+ versionName = packageInfo.versionName;
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to get package info", ex);
+ versionName = "Unknown";
+ }
+ }
+
+ return versionName;
+ }
+
+
+ /**
+ * Hide a view by setting its layout height and width to 1dp.
+ *
+ * @param condition The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static void hideViewBy0dpUnderCondition(BooleanSetting condition, View view) {
+ if (hideViewBy0dpUnderCondition(condition.get(), view)) {
+ Logger.printDebug(() -> "View hidden by setting: " + condition);
+ }
+ }
+
+ /**
+ * Hide a view by setting its layout height and width to 0dp.
+ *
+ * @param condition The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static boolean hideViewBy0dpUnderCondition(boolean condition, View view) {
+ if (condition) {
+ hideViewByLayoutParams(view);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Hide a view by setting its visibility to GONE.
+ *
+ * @param condition The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static void hideViewUnderCondition(BooleanSetting condition, View view) {
+ if (hideViewUnderCondition(condition.get(), view)) {
+ Logger.printDebug(() -> "View hidden by setting: " + condition);
+ }
+ }
+
+ /**
+ * Hide a view by setting its visibility to GONE.
+ *
+ * @param condition The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static boolean hideViewUnderCondition(boolean condition, View view) {
+ if (condition) {
+ view.setVisibility(View.GONE);
+ return true;
+ }
+
+ return false;
+ }
+
+ public static void hideViewByRemovingFromParentUnderCondition(BooleanSetting condition, View view) {
+ if (hideViewByRemovingFromParentUnderCondition(condition.get(), view)) {
+ Logger.printDebug(() -> "View hidden by setting: " + condition);
+ }
+ }
+
+ public static boolean hideViewByRemovingFromParentUnderCondition(boolean setting, View view) {
+ if (setting) {
+ ViewParent parent = view.getParent();
+ if (parent instanceof ViewGroup) {
+ ((ViewGroup) parent).removeView(view);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * General purpose pool for network calls and other background tasks.
+ * All tasks run at max thread priority.
+ */
+ private static final ThreadPoolExecutor backgroundThreadPool = new ThreadPoolExecutor(
+ 3, // 3 threads always ready to go
+ Integer.MAX_VALUE,
+ 10, // For any threads over the minimum, keep them alive 10 seconds after they go idle
+ TimeUnit.SECONDS,
+ new SynchronousQueue<>(),
+ r -> { // ThreadFactory
+ Thread t = new Thread(r);
+ t.setPriority(Thread.MAX_PRIORITY); // run at max priority
+ return t;
+ });
+
+ public static void runOnBackgroundThread(@NonNull Runnable task) {
+ backgroundThreadPool.execute(task);
+ }
+
+ @NonNull
+ public static Future submitOnBackgroundThread(@NonNull Callable call) {
+ return backgroundThreadPool.submit(call);
+ }
+
+ /**
+ * Simulates a delay by doing meaningless calculations.
+ * Used for debugging to verify UI timeout logic.
+ */
+ @SuppressWarnings("UnusedReturnValue")
+ public static long doNothingForDuration(long amountOfTimeToWaste) {
+ final long timeCalculationStarted = System.currentTimeMillis();
+ Logger.printDebug(() -> "Artificially creating delay of: " + amountOfTimeToWaste + "ms");
+
+ long meaninglessValue = 0;
+ while (System.currentTimeMillis() - timeCalculationStarted < amountOfTimeToWaste) {
+ // could do a thread sleep, but that will trigger an exception if the thread is interrupted
+ meaninglessValue += Long.numberOfLeadingZeros((long) Math.exp(Math.random()));
+ }
+ // return the value, otherwise the compiler or VM might optimize and remove the meaningless time wasting work,
+ // leaving an empty loop that hammers on the System.currentTimeMillis native call
+ return meaninglessValue;
+ }
+
+
+ public static boolean containsAny(@NonNull String value, @NonNull String... targets) {
+ return indexOfFirstFound(value, targets) >= 0;
+ }
+
+ public static int indexOfFirstFound(@NonNull String value, @NonNull String... targets) {
+ for (String string : targets) {
+ if (!string.isEmpty()) {
+ final int indexOf = value.indexOf(string);
+ if (indexOf >= 0) return indexOf;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * @return zero, if the resource is not found
+ */
+ @SuppressLint("DiscouragedApi")
+ public static int getResourceIdentifier(@NonNull Context context, @NonNull String resourceIdentifierName, @NonNull String type) {
+ return context.getResources().getIdentifier(resourceIdentifierName, type, context.getPackageName());
+ }
+
+ /**
+ * @return zero, if the resource is not found
+ */
+ public static int getResourceIdentifier(@NonNull String resourceIdentifierName, @NonNull String type) {
+ return getResourceIdentifier(getContext(), resourceIdentifierName, type);
+ }
+
+ public static int getResourceInteger(@NonNull String resourceIdentifierName) throws Resources.NotFoundException {
+ return getContext().getResources().getInteger(getResourceIdentifier(resourceIdentifierName, "integer"));
+ }
+
+ @NonNull
+ public static Animation getResourceAnimation(@NonNull String resourceIdentifierName) throws Resources.NotFoundException {
+ return AnimationUtils.loadAnimation(getContext(), getResourceIdentifier(resourceIdentifierName, "anim"));
+ }
+
+ public static int getResourceColor(@NonNull String resourceIdentifierName) throws Resources.NotFoundException {
+ //noinspection deprecation
+ return getContext().getResources().getColor(getResourceIdentifier(resourceIdentifierName, "color"));
+ }
+
+ public static int getResourceDimensionPixelSize(@NonNull String resourceIdentifierName) throws Resources.NotFoundException {
+ return getContext().getResources().getDimensionPixelSize(getResourceIdentifier(resourceIdentifierName, "dimen"));
+ }
+
+ public static float getResourceDimension(@NonNull String resourceIdentifierName) throws Resources.NotFoundException {
+ return getContext().getResources().getDimension(getResourceIdentifier(resourceIdentifierName, "dimen"));
+ }
+
+ public interface MatchFilter {
+ boolean matches(T object);
+ }
+
+ /**
+ * Includes sub children.
+ *
+ * @noinspection unchecked
+ */
+ public static R getChildViewByResourceName(@NonNull View view, @NonNull String str) {
+ var child = view.findViewById(Utils.getResourceIdentifier(str, "id"));
+ if (child != null) {
+ return (R) child;
+ }
+
+ throw new IllegalArgumentException("View with resource name '" + str + "' not found");
+ }
+
+ /**
+ * @param searchRecursively If children ViewGroups should also be
+ * recursively searched using depth first search.
+ * @return The first child view that matches the filter.
+ */
+ @Nullable
+ public static T getChildView(@NonNull ViewGroup viewGroup, boolean searchRecursively,
+ @NonNull MatchFilter filter) {
+ for (int i = 0, childCount = viewGroup.getChildCount(); i < childCount; i++) {
+ View childAt = viewGroup.getChildAt(i);
+
+ if (filter.matches(childAt)) {
+ //noinspection unchecked
+ return (T) childAt;
+ }
+ // Must do recursive after filter check, in case the filter is looking for a ViewGroup.
+ if (searchRecursively && childAt instanceof ViewGroup) {
+ T match = getChildView((ViewGroup) childAt, true, filter);
+ if (match != null) return match;
+ }
+ }
+
+ return null;
+ }
+
+ @Nullable
+ public static ViewParent getParentView(@NonNull View view, int nthParent) {
+ ViewParent parent = view.getParent();
+
+ int currentDepth = 0;
+ while (++currentDepth < nthParent && parent != null) {
+ parent = parent.getParent();
+ }
+
+ if (currentDepth == nthParent) {
+ return parent;
+ }
+
+ final int currentDepthLog = currentDepth;
+ Logger.printDebug(() -> "Could not find parent view of depth: " + nthParent
+ + " and instead found at: " + currentDepthLog + " view: " + view);
+ return null;
+ }
+
+ public static void restartApp(@NonNull Context context) {
+ String packageName = context.getPackageName();
+ Intent intent = context.getPackageManager().getLaunchIntentForPackage(packageName);
+ Intent mainIntent = Intent.makeRestartActivityTask(intent.getComponent());
+ // Required for API 34 and later
+ // Ref: https://developer.android.com/about/versions/14/behavior-changes-14#safer-intents
+ mainIntent.setPackage(packageName);
+ context.startActivity(mainIntent);
+ System.exit(0);
+ }
+
+ public static Context getContext() {
+ if (context == null) {
+ Logger.initializationException(Utils.class, "Context is null, returning null!", null);
+ }
+ return context;
+ }
+
+ public static void setContext(Context appContext) {
+ context = appContext;
+ // In some apps like TikTok, the Setting classes can load in weird orders due to cyclic class dependencies.
+ // Calling the regular printDebug method here can cause a Settings context null pointer exception,
+ // even though the context is already set before the call.
+ //
+ // The initialization logger methods do not directly or indirectly
+ // reference the Context or any Settings and are unaffected by this problem.
+ //
+ // Info level also helps debug if a patch hook is called before
+ // the context is set since debug logging is off by default.
+ Logger.initializationInfo(Utils.class, "Set context: " + appContext);
+ }
+
+ public static void setClipboard(@NonNull String text) {
+ android.content.ClipboardManager clipboard = (android.content.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
+ android.content.ClipData clip = android.content.ClipData.newPlainText("ReVanced", text);
+ clipboard.setPrimaryClip(clip);
+ }
+
+ public static boolean isTablet() {
+ return context.getResources().getConfiguration().smallestScreenWidthDp >= 600;
+ }
+
+ @Nullable
+ private static Boolean isRightToLeftTextLayout;
+
+ /**
+ * If the device language uses right to left text layout (hebrew, arabic, etc)
+ */
+ public static boolean isRightToLeftTextLayout() {
+ if (isRightToLeftTextLayout == null) {
+ String displayLanguage = Locale.getDefault().getDisplayLanguage();
+ isRightToLeftTextLayout = new Bidi(displayLanguage, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT).isRightToLeft();
+ }
+ return isRightToLeftTextLayout;
+ }
+
+ /**
+ * @return if the text contains at least 1 number character,
+ * including any unicode numbers such as Arabic.
+ */
+ @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+ public static boolean containsNumber(@NonNull CharSequence text) {
+ for (int index = 0, length = text.length(); index < length;) {
+ final int codePoint = Character.codePointAt(text, index);
+ if (Character.isDigit(codePoint)) {
+ return true;
+ }
+ index += Character.charCount(codePoint);
+ }
+
+ return false;
+ }
+
+ /**
+ * Ignore this class. It must be public to satisfy Android requirements.
+ */
+ @SuppressWarnings("deprecation")
+ public static final class DialogFragmentWrapper extends DialogFragment {
+
+ private Dialog dialog;
+ @Nullable
+ private DialogFragmentOnStartAction onStartAction;
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ // Do not call super method to prevent state saving.
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ return dialog;
+ }
+
+ @Override
+ public void onStart() {
+ try {
+ super.onStart();
+
+ if (onStartAction != null) {
+ onStartAction.onStart((AlertDialog) getDialog());
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onStart failure: " + dialog.getClass().getSimpleName(), ex);
+ }
+ }
+ }
+
+ /**
+ * Interface for {@link #showDialog(Activity, AlertDialog, boolean, DialogFragmentOnStartAction)}.
+ */
+ @FunctionalInterface
+ public interface DialogFragmentOnStartAction {
+ void onStart(AlertDialog dialog);
+ }
+
+ public static void showDialog(Activity activity, AlertDialog dialog) {
+ showDialog(activity, dialog, true, null);
+ }
+
+ /**
+ * Utility method to allow showing an AlertDialog on top of other alert dialogs.
+ * Calling this will always display the dialog on top of all other dialogs
+ * previously called using this method.
+ *
+ * Be aware the on start action can be called multiple times for some situations,
+ * such as the user switching apps without dismissing the dialog then switching back to this app.
+ *
+ * This method is only useful during app startup and multiple patches may show their own dialog,
+ * and the most important dialog can be called last (using a delay) so it's always on top.
+ *
+ * For all other situations it's better to not use this method and
+ * call {@link AlertDialog#show()} on the dialog.
+ */
+ @SuppressWarnings("deprecation")
+ public static void showDialog(Activity activity,
+ AlertDialog dialog,
+ boolean isCancelable,
+ @Nullable DialogFragmentOnStartAction onStartAction) {
+ verifyOnMainThread();
+
+ DialogFragmentWrapper fragment = new DialogFragmentWrapper();
+ fragment.dialog = dialog;
+ fragment.onStartAction = onStartAction;
+ fragment.setCancelable(isCancelable);
+
+ fragment.show(activity.getFragmentManager(), null);
+ }
+
+ /**
+ * Safe to call from any thread
+ */
+ public static void showToastShort(@NonNull String messageToToast) {
+ showToast(messageToToast, Toast.LENGTH_SHORT);
+ }
+
+ /**
+ * Safe to call from any thread
+ */
+ public static void showToastLong(@NonNull String messageToToast) {
+ showToast(messageToToast, Toast.LENGTH_LONG);
+ }
+
+ private static void showToast(@NonNull String messageToToast, int toastDuration) {
+ Objects.requireNonNull(messageToToast);
+ runOnMainThreadNowOrLater(() -> {
+ if (context == null) {
+ Logger.initializationException(Utils.class, "Cannot show toast (context is null): " + messageToToast, null);
+ } else {
+ Logger.printDebug(() -> "Showing toast: " + messageToToast);
+ Toast.makeText(context, messageToToast, toastDuration).show();
+ }
+ }
+ );
+ }
+
+ /**
+ * Automatically logs any exceptions the runnable throws.
+ *
+ * @see #runOnMainThreadNowOrLater(Runnable)
+ */
+ public static void runOnMainThread(@NonNull Runnable runnable) {
+ runOnMainThreadDelayed(runnable, 0);
+ }
+
+ /**
+ * Automatically logs any exceptions the runnable throws
+ */
+ public static void runOnMainThreadDelayed(@NonNull Runnable runnable, long delayMillis) {
+ Runnable loggingRunnable = () -> {
+ try {
+ runnable.run();
+ } catch (Exception ex) {
+ Logger.printException(() -> runnable.getClass().getSimpleName() + ": " + ex.getMessage(), ex);
+ }
+ };
+ new Handler(Looper.getMainLooper()).postDelayed(loggingRunnable, delayMillis);
+ }
+
+ /**
+ * If called from the main thread, the code is run immediately.
+ * If called off the main thread, this is the same as {@link #runOnMainThread(Runnable)}.
+ */
+ public static void runOnMainThreadNowOrLater(@NonNull Runnable runnable) {
+ if (isCurrentlyOnMainThread()) {
+ runnable.run();
+ } else {
+ runOnMainThread(runnable);
+ }
+ }
+
+ /**
+ * @return if the calling thread is on the main thread
+ */
+ public static boolean isCurrentlyOnMainThread() {
+ return Looper.getMainLooper().isCurrentThread();
+ }
+
+ /**
+ * @throws IllegalStateException if the calling thread is _off_ the main thread
+ */
+ public static void verifyOnMainThread() throws IllegalStateException {
+ if (!isCurrentlyOnMainThread()) {
+ throw new IllegalStateException("Must call _on_ the main thread");
+ }
+ }
+
+ /**
+ * @throws IllegalStateException if the calling thread is _on_ the main thread
+ */
+ public static void verifyOffMainThread() throws IllegalStateException {
+ if (isCurrentlyOnMainThread()) {
+ throw new IllegalStateException("Must call _off_ the main thread");
+ }
+ }
+
+ public enum NetworkType {
+ NONE,
+ MOBILE,
+ OTHER,
+ }
+
+ public static boolean isNetworkConnected() {
+ NetworkType networkType = getNetworkType();
+ return networkType == NetworkType.MOBILE
+ || networkType == NetworkType.OTHER;
+ }
+
+ @SuppressLint("MissingPermission") // permission already included in YouTube
+ public static NetworkType getNetworkType() {
+ Context networkContext = getContext();
+ if (networkContext == null) {
+ return NetworkType.NONE;
+ }
+ ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ var networkInfo = cm.getActiveNetworkInfo();
+
+ if (networkInfo == null || !networkInfo.isConnected()) {
+ return NetworkType.NONE;
+ }
+ var type = networkInfo.getType();
+ return (type == ConnectivityManager.TYPE_MOBILE)
+ || (type == ConnectivityManager.TYPE_BLUETOOTH) ? NetworkType.MOBILE : NetworkType.OTHER;
+ }
+
+ /**
+ * Hide a view by setting its layout params to 0x0
+ * @param view The view to hide.
+ */
+ public static void hideViewByLayoutParams(View view) {
+ if (view instanceof LinearLayout) {
+ LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(0, 0);
+ view.setLayoutParams(layoutParams);
+ } else if (view instanceof FrameLayout) {
+ FrameLayout.LayoutParams layoutParams2 = new FrameLayout.LayoutParams(0, 0);
+ view.setLayoutParams(layoutParams2);
+ } else if (view instanceof RelativeLayout) {
+ RelativeLayout.LayoutParams layoutParams3 = new RelativeLayout.LayoutParams(0, 0);
+ view.setLayoutParams(layoutParams3);
+ } else if (view instanceof Toolbar) {
+ Toolbar.LayoutParams layoutParams4 = new Toolbar.LayoutParams(0, 0);
+ view.setLayoutParams(layoutParams4);
+ } else if (view instanceof ViewGroup) {
+ ViewGroup.LayoutParams layoutParams5 = new ViewGroup.LayoutParams(0, 0);
+ view.setLayoutParams(layoutParams5);
+ } else {
+ ViewGroup.LayoutParams params = view.getLayoutParams();
+ params.width = 0;
+ params.height = 0;
+ view.setLayoutParams(params);
+ }
+ }
+
+ /**
+ * {@link PreferenceScreen} and {@link PreferenceGroup} sorting styles.
+ */
+ private enum Sort {
+ /**
+ * Sort by the localized preference title.
+ */
+ BY_TITLE("_sort_by_title"),
+
+ /**
+ * Sort by the preference keys.
+ */
+ BY_KEY("_sort_by_key"),
+
+ /**
+ * Unspecified sorting.
+ */
+ UNSORTED("_sort_by_unsorted");
+
+ final String keySuffix;
+
+ Sort(String keySuffix) {
+ this.keySuffix = keySuffix;
+ }
+
+ @NonNull
+ static Sort fromKey(@Nullable String key, @NonNull Sort defaultSort) {
+ if (key != null) {
+ for (Sort sort : values()) {
+ if (key.endsWith(sort.keySuffix)) {
+ return sort;
+ }
+ }
+ }
+ return defaultSort;
+ }
+ }
+
+ private static final Pattern punctuationPattern = Pattern.compile("\\p{P}+");
+
+ /**
+ * Strips all punctuation and converts to lower case. A null parameter returns an empty string.
+ */
+ public static String removePunctuationConvertToLowercase(@Nullable CharSequence original) {
+ if (original == null) return "";
+ return punctuationPattern.matcher(original).replaceAll("").toLowerCase();
+ }
+
+ /**
+ * Sort a PreferenceGroup and all it's sub groups by title or key.
+ *
+ * Sort order is determined by the preferences key {@link Sort} suffix.
+ *
+ * If a preference has no key or no {@link Sort} suffix,
+ * then the preferences are left unsorted.
+ */
+ @SuppressWarnings("deprecation")
+ public static void sortPreferenceGroups(@NonNull PreferenceGroup group) {
+ Sort groupSort = Sort.fromKey(group.getKey(), Sort.UNSORTED);
+ SortedMap preferences = new TreeMap<>();
+
+ for (int i = 0, prefCount = group.getPreferenceCount(); i < prefCount; i++) {
+ Preference preference = group.getPreference(i);
+
+ final Sort preferenceSort;
+ if (preference instanceof PreferenceGroup) {
+ sortPreferenceGroups((PreferenceGroup) preference);
+ preferenceSort = groupSort; // Sort value for groups is for it's content, not itself.
+ } else {
+ // Allow individual preferences to set a key sorting.
+ // Used to force a preference to the top or bottom of a group.
+ preferenceSort = Sort.fromKey(preference.getKey(), groupSort);
+ }
+
+ final String sortValue;
+ switch (preferenceSort) {
+ case BY_TITLE:
+ sortValue = removePunctuationConvertToLowercase(preference.getTitle());
+ break;
+ case BY_KEY:
+ sortValue = preference.getKey();
+ break;
+ case UNSORTED:
+ continue; // Keep original sorting.
+ default:
+ throw new IllegalStateException();
+ }
+
+ preferences.put(sortValue, preference);
+ }
+
+ int index = 0;
+ for (Preference pref : preferences.values()) {
+ int order = index++;
+
+ // Move any screens, intents, and the one off About preference to the top.
+ if (pref instanceof PreferenceScreen || pref instanceof ReVancedAboutPreference
+ || pref.getIntent() != null) {
+ // Arbitrary high number.
+ order -= 1000;
+ }
+
+ pref.setOrder(order);
+ }
+ }
+
+ /**
+ * If {@link Fragment} uses [Android library] rather than [AndroidX library],
+ * the Dialog theme corresponding to [Android library] should be used.
+ *
+ * If not, the following issues will occur:
+ * ReVanced/revanced-patches#3061
+ *
+ * To prevent these issues, apply the Dialog theme corresponding to [Android library].
+ */
+ public static void setEditTextDialogTheme(AlertDialog.Builder builder) {
+ final int editTextDialogStyle = getResourceIdentifier(
+ "revanced_edit_text_dialog_style", "style");
+ if (editTextDialogStyle != 0) {
+ builder.getContext().setTheme(editTextDialogStyle);
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/checks/Check.java b/extensions/shared/src/main/java/app/revanced/extension/shared/checks/Check.java
new file mode 100644
index 000000000..855e6003b
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/checks/Check.java
@@ -0,0 +1,164 @@
+package app.revanced.extension.shared.checks;
+
+import static android.text.Html.FROM_HTML_MODE_COMPACT;
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.Utils.DialogFragmentOnStartAction;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.net.Uri;
+import android.text.Html;
+import android.widget.Button;
+
+import androidx.annotation.Nullable;
+
+import java.util.Collection;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+
+abstract class Check {
+ private static final int NUMBER_OF_TIMES_TO_IGNORE_WARNING_BEFORE_DISABLING = 2;
+
+ private static final int SECONDS_BEFORE_SHOWING_IGNORE_BUTTON = 15;
+ private static final int SECONDS_BEFORE_SHOWING_WEBSITE_BUTTON = 10;
+
+ private static final Uri GOOD_SOURCE = Uri.parse("https://revanced.app");
+
+ /**
+ * @return If the check conclusively passed or failed. A null value indicates it neither passed nor failed.
+ */
+ @Nullable
+ protected abstract Boolean check();
+
+ protected abstract String failureReason();
+
+ /**
+ * Specifies a sorting order for displaying the checks that failed.
+ * A lower value indicates to show first before other checks.
+ */
+ public abstract int uiSortingValue();
+
+ /**
+ * For debugging and development only.
+ * Forces all checks to be performed and the check failed dialog to be shown.
+ * Can be enabled by importing settings text with {@link Settings#CHECK_ENVIRONMENT_WARNINGS_ISSUED}
+ * set to -1.
+ */
+ static boolean debugAlwaysShowWarning() {
+ final boolean alwaysShowWarning = Settings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get() < 0;
+ if (alwaysShowWarning) {
+ Logger.printInfo(() -> "Debug forcing environment check warning to show");
+ }
+
+ return alwaysShowWarning;
+ }
+
+ static boolean shouldRun() {
+ return Settings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get()
+ < NUMBER_OF_TIMES_TO_IGNORE_WARNING_BEFORE_DISABLING;
+ }
+
+ static void disableForever() {
+ Logger.printInfo(() -> "Environment checks disabled forever");
+
+ Settings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.save(Integer.MAX_VALUE);
+ }
+
+ @SuppressLint("NewApi")
+ static void issueWarning(Activity activity, Collection failedChecks) {
+ final var reasons = new StringBuilder();
+
+ reasons.append("");
+ for (var check : failedChecks) {
+ // Add a non breaking space to fix bullet points spacing issue.
+ reasons.append(" ").append(check.failureReason());
+ }
+ reasons.append(" ");
+
+ var message = Html.fromHtml(
+ str("revanced_check_environment_failed_message", reasons.toString()),
+ FROM_HTML_MODE_COMPACT
+ );
+
+ Utils.runOnMainThreadDelayed(() -> {
+ AlertDialog alert = new AlertDialog.Builder(activity)
+ .setCancelable(false)
+ .setIconAttribute(android.R.attr.alertDialogIcon)
+ .setTitle(str("revanced_check_environment_failed_title"))
+ .setMessage(message)
+ .setPositiveButton(
+ " ",
+ (dialog, which) -> {
+ final var intent = new Intent(Intent.ACTION_VIEW, GOOD_SOURCE);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ activity.startActivity(intent);
+
+ // Shutdown to prevent the user from navigating back to this app,
+ // which is no longer showing a warning dialog.
+ activity.finishAffinity();
+ System.exit(0);
+ }
+ ).setNegativeButton(
+ " ",
+ (dialog, which) -> {
+ // Cleanup data if the user incorrectly imported a huge negative number.
+ final int current = Math.max(0, Settings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get());
+ Settings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.save(current + 1);
+
+ dialog.dismiss();
+ }
+ ).create();
+
+ Utils.showDialog(activity, alert, false, new DialogFragmentOnStartAction() {
+ boolean hasRun;
+ @Override
+ public void onStart(AlertDialog dialog) {
+ // Only run this once, otherwise if the user changes to a different app
+ // then changes back, this handler will run again and disable the buttons.
+ if (hasRun) {
+ return;
+ }
+ hasRun = true;
+
+ var openWebsiteButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE);
+ openWebsiteButton.setEnabled(false);
+
+ var dismissButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE);
+ dismissButton.setEnabled(false);
+
+ getCountdownRunnable(dismissButton, openWebsiteButton).run();
+ }
+ });
+ }, 1000); // Use a delay, so this dialog is shown on top of any other startup dialogs.
+ }
+
+ private static Runnable getCountdownRunnable(Button dismissButton, Button openWebsiteButton) {
+ return new Runnable() {
+ private int secondsRemaining = SECONDS_BEFORE_SHOWING_IGNORE_BUTTON;
+
+ @Override
+ public void run() {
+ Utils.verifyOnMainThread();
+
+ if (secondsRemaining > 0) {
+ if (secondsRemaining - SECONDS_BEFORE_SHOWING_WEBSITE_BUTTON == 0) {
+ openWebsiteButton.setText(str("revanced_check_environment_dialog_open_official_source_button"));
+ openWebsiteButton.setEnabled(true);
+ }
+
+ secondsRemaining--;
+
+ Utils.runOnMainThreadDelayed(this, 1000);
+ } else {
+ dismissButton.setText(str("revanced_check_environment_dialog_ignore_button"));
+ dismissButton.setEnabled(true);
+ }
+ }
+ };
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/checks/CheckEnvironmentPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/checks/CheckEnvironmentPatch.java
new file mode 100644
index 000000000..d63f8b7e3
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/checks/CheckEnvironmentPatch.java
@@ -0,0 +1,341 @@
+package app.revanced.extension.shared.checks;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.util.Base64;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.*;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.checks.Check.debugAlwaysShowWarning;
+import static app.revanced.extension.shared.checks.PatchInfo.Build.*;
+
+/**
+ * This class is used to check if the app was patched by the user
+ * and not downloaded pre-patched, because pre-patched apps are difficult to trust.
+ *
+ * Various indicators help to detect if the app was patched by the user.
+ */
+@SuppressWarnings("unused")
+public final class CheckEnvironmentPatch {
+ private static final boolean DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG = debugAlwaysShowWarning();
+
+ private enum InstallationType {
+ /**
+ * CLI patching, manual installation of a previously patched using adb,
+ * or root installation if stock app is first installed using adb.
+ */
+ ADB((String) null),
+ ROOT_MOUNT_ON_APP_STORE("com.android.vending"),
+ MANAGER("app.revanced.manager.flutter",
+ "app.revanced.manager",
+ "app.revanced.manager.debug");
+
+ @Nullable
+ static InstallationType installTypeFromPackageName(@Nullable String packageName) {
+ for (InstallationType type : values()) {
+ for (String installPackageName : type.packageNames) {
+ if (Objects.equals(installPackageName, packageName)) {
+ return type;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Array elements can be null.
+ */
+ final String[] packageNames;
+
+ InstallationType(String... packageNames) {
+ this.packageNames = packageNames;
+ }
+ }
+
+ /**
+ * Check if the app is installed by the manager, the app store, or through adb/CLI.
+ *
+ * Does not conclusively
+ * If the app is installed by the manager or the app store, it is likely, the app was patched using the manager,
+ * or installed manually via ADB (in the case of ReVanced CLI for example).
+ *
+ * If the app is not installed by the manager or the app store, then the app was likely downloaded pre-patched
+ * and installed by the browser or another unknown app.
+ */
+ private static class CheckExpectedInstaller extends Check {
+ @Nullable
+ InstallationType installerFound;
+
+ @NonNull
+ @Override
+ protected Boolean check() {
+ final var context = Utils.getContext();
+
+ final var installerPackageName =
+ context.getPackageManager().getInstallerPackageName(context.getPackageName());
+
+ Logger.printInfo(() -> "Installed by: " + installerPackageName);
+
+ installerFound = InstallationType.installTypeFromPackageName(installerPackageName);
+ final boolean passed = (installerFound != null);
+
+ Logger.printInfo(() -> passed
+ ? "Apk was not installed from an unknown source"
+ : "Apk was installed from an unknown source");
+
+ return passed;
+ }
+
+ @Override
+ protected String failureReason() {
+ return str("revanced_check_environment_manager_not_expected_installer");
+ }
+
+ @Override
+ public int uiSortingValue() {
+ return -100; // Show first.
+ }
+ }
+
+ /**
+ * Check if the build properties are the same as during the patch.
+ *
+ * If the build properties are the same as during the patch, it is likely, the app was patched on the same device.
+ *
+ * If the build properties are different, the app was likely downloaded pre-patched or patched on another device.
+ */
+ private static class CheckWasPatchedOnSameDevice extends Check {
+ @SuppressLint({"NewApi", "HardwareIds"})
+ @Override
+ protected Boolean check() {
+ if (PATCH_BOARD.isEmpty()) {
+ // Did not patch with Manager, and cannot conclusively say where this was from.
+ Logger.printInfo(() -> "APK does not contain a hardware signature and cannot compare to current device");
+ return null;
+ }
+
+ //noinspection deprecation
+ final var passed = buildFieldEqualsHash("BOARD", Build.BOARD, PATCH_BOARD) &
+ buildFieldEqualsHash("BOOTLOADER", Build.BOOTLOADER, PATCH_BOOTLOADER) &
+ buildFieldEqualsHash("BRAND", Build.BRAND, PATCH_BRAND) &
+ buildFieldEqualsHash("CPU_ABI", Build.CPU_ABI, PATCH_CPU_ABI) &
+ buildFieldEqualsHash("CPU_ABI2", Build.CPU_ABI2, PATCH_CPU_ABI2) &
+ buildFieldEqualsHash("DEVICE", Build.DEVICE, PATCH_DEVICE) &
+ buildFieldEqualsHash("DISPLAY", Build.DISPLAY, PATCH_DISPLAY) &
+ buildFieldEqualsHash("FINGERPRINT", Build.FINGERPRINT, PATCH_FINGERPRINT) &
+ buildFieldEqualsHash("HARDWARE", Build.HARDWARE, PATCH_HARDWARE) &
+ buildFieldEqualsHash("HOST", Build.HOST, PATCH_HOST) &
+ buildFieldEqualsHash("ID", Build.ID, PATCH_ID) &
+ buildFieldEqualsHash("MANUFACTURER", Build.MANUFACTURER, PATCH_MANUFACTURER) &
+ buildFieldEqualsHash("MODEL", Build.MODEL, PATCH_MODEL) &
+ buildFieldEqualsHash("PRODUCT", Build.PRODUCT, PATCH_PRODUCT) &
+ buildFieldEqualsHash("RADIO", Build.RADIO, PATCH_RADIO) &
+ buildFieldEqualsHash("TAGS", Build.TAGS, PATCH_TAGS) &
+ buildFieldEqualsHash("TYPE", Build.TYPE, PATCH_TYPE) &
+ buildFieldEqualsHash("USER", Build.USER, PATCH_USER);
+
+ Logger.printInfo(() -> passed
+ ? "Device hardware signature matches current device"
+ : "Device hardware signature does not match current device");
+
+ return passed;
+ }
+
+ @Override
+ protected String failureReason() {
+ return str("revanced_check_environment_not_same_patching_device");
+ }
+
+ @Override
+ public int uiSortingValue() {
+ return 0; // Show in the middle.
+ }
+ }
+
+ /**
+ * Check if the app was installed within the last 30 minutes after being patched.
+ *
+ * If the app was installed within the last 30 minutes, it is likely, the app was patched by the user.
+ *
+ * If the app was installed much later than the patch time, it is likely the app was
+ * downloaded pre-patched or the user waited too long to install the app.
+ */
+ private static class CheckIsNearPatchTime extends Check {
+ /**
+ * How soon after patching the app must be installed to pass.
+ */
+ static final int INSTALL_AFTER_PATCHING_DURATION_THRESHOLD = 30 * 60 * 1000; // 30 minutes.
+
+ /**
+ * Milliseconds between the time the app was patched, and when it was installed/updated.
+ */
+ long durationBetweenPatchingAndInstallation;
+
+ @NonNull
+ @Override
+ protected Boolean check() {
+ try {
+ Context context = Utils.getContext();
+ PackageManager packageManager = context.getPackageManager();
+ PackageInfo packageInfo = packageManager.getPackageInfo(context.getPackageName(), 0);
+
+ // Duration since initial install or last update, which ever is sooner.
+ durationBetweenPatchingAndInstallation = packageInfo.lastUpdateTime - PatchInfo.PATCH_TIME;
+ Logger.printInfo(() -> "App was installed/updated: "
+ + (durationBetweenPatchingAndInstallation / (60 * 1000) + " minutes after patching"));
+
+ if (durationBetweenPatchingAndInstallation < 0) {
+ // Patch time is in the future and clearly wrong.
+ return false;
+ }
+
+ if (durationBetweenPatchingAndInstallation < INSTALL_AFTER_PATCHING_DURATION_THRESHOLD) {
+ return true;
+ }
+ } catch (PackageManager.NameNotFoundException ex) {
+ Logger.printException(() -> "Package name not found exception", ex); // Will never happen.
+ }
+
+ // User installed more than 30 minutes after patching.
+ return false;
+ }
+
+ @Override
+ protected String failureReason() {
+ if (durationBetweenPatchingAndInstallation < 0) {
+ // Could happen if the user has their device clock incorrectly set in the past,
+ // but assume that isn't the case and the apk was patched on a device with the wrong system time.
+ return str("revanced_check_environment_not_near_patch_time_invalid");
+ }
+
+ // If patched over 1 day ago, show how old this pre-patched apk is.
+ // Showing the age can help convey it's better to patch yourself and know it's the latest.
+ final long oneDay = 24 * 60 * 60 * 1000;
+ final long daysSincePatching = durationBetweenPatchingAndInstallation / oneDay;
+ if (daysSincePatching > 1) { // Use over 1 day to avoid singular vs plural strings.
+ return str("revanced_check_environment_not_near_patch_time_days", daysSincePatching);
+ }
+
+ return str("revanced_check_environment_not_near_patch_time");
+ }
+
+ @Override
+ public int uiSortingValue() {
+ return 100; // Show last.
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void check(Activity context) {
+ // If the warning was already issued twice, or if the check was successful in the past,
+ // do not run the checks again.
+ if (!Check.shouldRun() && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
+ Logger.printDebug(() -> "Environment checks are disabled");
+ return;
+ }
+
+ Utils.runOnBackgroundThread(() -> {
+ try {
+ Logger.printInfo(() -> "Running environment checks");
+ List failedChecks = new ArrayList<>();
+
+ CheckWasPatchedOnSameDevice sameHardware = new CheckWasPatchedOnSameDevice();
+ Boolean hardwareCheckPassed = sameHardware.check();
+ if (hardwareCheckPassed != null) {
+ if (hardwareCheckPassed && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
+ // Patched on the same device using Manager,
+ // and no further checks are needed.
+ Check.disableForever();
+ return;
+ }
+
+ failedChecks.add(sameHardware);
+ }
+
+ CheckExpectedInstaller installerCheck = new CheckExpectedInstaller();
+ if (installerCheck.check() && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
+ // If the installer package is Manager but this code is reached,
+ // that means it must not be the right Manager otherwise the hardware hash
+ // signatures would be present and this check would not have run.
+ if (installerCheck.installerFound == InstallationType.MANAGER) {
+ failedChecks.add(installerCheck);
+ // Also could not have been patched on this device.
+ failedChecks.add(sameHardware);
+ } else if (failedChecks.isEmpty()) {
+ // ADB install of CLI build. Allow even if patched a long time ago.
+ Check.disableForever();
+ return;
+ }
+ } else {
+ failedChecks.add(installerCheck);
+ }
+
+ CheckIsNearPatchTime nearPatchTime = new CheckIsNearPatchTime();
+ Boolean timeCheckPassed = nearPatchTime.check();
+ if (timeCheckPassed && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
+ // Allow installing recently patched apks,
+ // even if the install source is not Manager or ADB.
+ Check.disableForever();
+ return;
+ } else {
+ failedChecks.add(nearPatchTime);
+ }
+
+ if (DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
+ // Show all failures for debugging layout.
+ failedChecks = Arrays.asList(
+ sameHardware,
+ nearPatchTime,
+ installerCheck
+ );
+ }
+
+ //noinspection ComparatorCombinators
+ Collections.sort(failedChecks, (o1, o2) -> o1.uiSortingValue() - o2.uiSortingValue());
+
+ Check.issueWarning(
+ context,
+ failedChecks
+ );
+ } catch (Exception ex) {
+ Logger.printException(() -> "check failure", ex);
+ }
+ });
+ }
+
+ private static boolean buildFieldEqualsHash(String buildFieldName, String buildFieldValue, @Nullable String hash) {
+ try {
+ final var sha1 = MessageDigest.getInstance("SHA-1")
+ .digest(buildFieldValue.getBytes(StandardCharsets.UTF_8));
+
+ // Must be careful to use same base64 encoding Kotlin uses.
+ String runtimeHash = new String(Base64.encode(sha1, Base64.NO_WRAP), StandardCharsets.ISO_8859_1);
+ final boolean equals = runtimeHash.equals(hash);
+ if (!equals) {
+ Logger.printInfo(() -> "Hashes do not match. " + buildFieldName + ": '" + buildFieldValue
+ + "' runtimeHash: '" + runtimeHash + "' patchTimeHash: '" + hash + "'");
+ }
+
+ return equals;
+ } catch (NoSuchAlgorithmException ex) {
+ Logger.printException(() -> "buildFieldEqualsHash failure", ex); // Will never happen.
+
+ return false;
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/checks/PatchInfo.java b/extensions/shared/src/main/java/app/revanced/extension/shared/checks/PatchInfo.java
new file mode 100644
index 000000000..62144d753
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/checks/PatchInfo.java
@@ -0,0 +1,28 @@
+package app.revanced.extension.shared.checks;
+
+// Fields are set by the patch. Do not modify.
+// Fields are not final, because the compiler is inlining them.
+final class PatchInfo {
+ static long PATCH_TIME = 0L;
+
+ final static class Build {
+ static String PATCH_BOARD = "";
+ static String PATCH_BOOTLOADER = "";
+ static String PATCH_BRAND = "";
+ static String PATCH_CPU_ABI = "";
+ static String PATCH_CPU_ABI2 = "";
+ static String PATCH_DEVICE = "";
+ static String PATCH_DISPLAY = "";
+ static String PATCH_FINGERPRINT = "";
+ static String PATCH_HARDWARE = "";
+ static String PATCH_HOST = "";
+ static String PATCH_ID = "";
+ static String PATCH_MANUFACTURER = "";
+ static String PATCH_MODEL = "";
+ static String PATCH_PRODUCT = "";
+ static String PATCH_RADIO = "";
+ static String PATCH_TAGS = "";
+ static String PATCH_TYPE = "";
+ static String PATCH_USER = "";
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/fixes/slink/BaseFixSLinksPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/fixes/slink/BaseFixSLinksPatch.java
new file mode 100644
index 000000000..a8a6bf504
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/fixes/slink/BaseFixSLinksPatch.java
@@ -0,0 +1,208 @@
+package app.revanced.extension.shared.fixes.slink;
+
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import androidx.annotation.NonNull;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.SocketTimeoutException;
+import java.net.URL;
+import java.util.Objects;
+
+import static app.revanced.extension.shared.Utils.getContext;
+
+
+/**
+ * Base class to implement /s/ link resolution in 3rd party Reddit apps.
+ *
+ *
+ * Usage:
+ *
+ *
+ * An implementation of this class must have two static methods that are called by the app:
+ *
+ * public static boolean patchResolveSLink(String link)
+ * public static void patchSetAccessToken(String accessToken)
+ *
+ * The static methods must call the instance methods of the base class.
+ *
+ * The singleton pattern can be used to access the instance of the class:
+ *
+ * {@code
+ * {
+ * INSTANCE = new FixSLinksPatch();
+ * }
+ * }
+ *
+ * Set the app's web view activity class as a fallback to open /s/ links if the resolution fails:
+ *
+ * {@code
+ * private FixSLinksPatch() {
+ * webViewActivityClass = WebViewActivity.class;
+ * }
+ * }
+ *
+ * Hook the app's navigation handler to call this method before doing any of its own resolution:
+ *
+ * {@code
+ * public static boolean patchResolveSLink(Context context, String link) {
+ * return INSTANCE.resolveSLink(context, link);
+ * }
+ * }
+ *
+ * If this method returns true, the app should early return and not do any of its own resolution.
+ *
+ *
+ * Hook the app's access token so that this class can use it to resolve /s/ links:
+ *
+ * {@code
+ * public static void patchSetAccessToken(String accessToken) {
+ * INSTANCE.setAccessToken(access_token);
+ * }
+ * }
+ *
+ */
+public abstract class BaseFixSLinksPatch {
+ /**
+ * The class of the activity used to open links in a web view if resolving them fails.
+ */
+ protected Class extends Activity> webViewActivityClass;
+
+ /**
+ * The access token used to resolve the /s/ link.
+ */
+ protected String accessToken;
+
+ /**
+ * The URL that was trying to be resolved before the access token was set.
+ * If this is not null, the URL will be resolved right after the access token is set.
+ */
+ protected String pendingUrl;
+
+ /**
+ * The singleton instance of the class.
+ */
+ protected static BaseFixSLinksPatch INSTANCE;
+
+ public boolean resolveSLink(String link) {
+ switch (resolveLink(link)) {
+ case ACCESS_TOKEN_START: {
+ pendingUrl = link;
+ return true;
+ }
+ case DO_NOTHING:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private ResolveResult resolveLink(String link) {
+ Context context = getContext();
+ if (link.matches(".*reddit\\.com/r/[^/]+/s/[^/]+")) {
+ // A link ends with #bypass if it failed to resolve below.
+ // resolveLink is called with the same link again but this time with #bypass
+ // so that the link is opened in the app browser instead of trying to resolve it again.
+ if (link.endsWith("#bypass")) {
+ openInAppBrowser(context, link);
+
+ return ResolveResult.DO_NOTHING;
+ }
+
+ Logger.printDebug(() -> "Resolving " + link);
+
+ if (accessToken == null) {
+ // This is not optimal.
+ // However, an accessToken is necessary to make an authenticated request to Reddit.
+ // in case Reddit has banned the IP - e.g. VPN.
+ Intent startIntent = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
+ context.startActivity(startIntent);
+
+ return ResolveResult.ACCESS_TOKEN_START;
+ }
+
+
+ Utils.runOnBackgroundThread(() -> {
+ String bypassLink = link + "#bypass";
+
+ String finalLocation = bypassLink;
+ try {
+ HttpURLConnection connection = getHttpURLConnection(link, accessToken);
+ connection.connect();
+ String location = connection.getHeaderField("location");
+ connection.disconnect();
+
+ Objects.requireNonNull(location, "Location is null");
+
+ finalLocation = location;
+ Logger.printDebug(() -> "Resolved " + link + " to " + location);
+ } catch (SocketTimeoutException e) {
+ Logger.printException(() -> "Timeout when trying to resolve " + link, e);
+ finalLocation = bypassLink;
+ } catch (Exception e) {
+ Logger.printException(() -> "Failed to resolve " + link, e);
+ finalLocation = bypassLink;
+ } finally {
+ Intent startIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(finalLocation));
+ startIntent.setPackage(context.getPackageName());
+ startIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(startIntent);
+ }
+ });
+
+ return ResolveResult.DO_NOTHING;
+ }
+
+ return ResolveResult.CONTINUE;
+ }
+
+ public void setAccessToken(String accessToken) {
+ Logger.printDebug(() -> "Setting access token");
+
+ this.accessToken = accessToken;
+
+ // In case a link was trying to be resolved before access token was set.
+ // The link is resolved now, after the access token is set.
+ if (pendingUrl != null) {
+ String link = pendingUrl;
+ pendingUrl = null;
+
+ Logger.printDebug(() -> "Opening pending URL");
+
+ resolveLink(link);
+ }
+ }
+
+ private void openInAppBrowser(Context context, String link) {
+ Intent intent = new Intent(context, webViewActivityClass);
+ intent.putExtra("url", link);
+ context.startActivity(intent);
+ }
+
+ @NonNull
+ private HttpURLConnection getHttpURLConnection(String link, String accessToken) throws IOException {
+ URL url = new URL(link);
+
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ connection.setInstanceFollowRedirects(false);
+ connection.setRequestMethod("HEAD");
+ connection.setConnectTimeout(2000);
+ connection.setReadTimeout(2000);
+
+ if (accessToken != null) {
+ Logger.printDebug(() -> "Setting access token to make /s/ request");
+
+ connection.setRequestProperty("Authorization", "Bearer " + accessToken);
+ } else {
+ Logger.printDebug(() -> "Not setting access token to make /s/ request, because it is null");
+ }
+
+ return connection;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/fixes/slink/ResolveResult.java b/extensions/shared/src/main/java/app/revanced/extension/shared/fixes/slink/ResolveResult.java
new file mode 100644
index 000000000..8026c2058
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/fixes/slink/ResolveResult.java
@@ -0,0 +1,10 @@
+package app.revanced.extension.shared.fixes.slink;
+
+public enum ResolveResult {
+ // Let app handle rest of stuff
+ CONTINUE,
+ // Start app, to make it cache its access_token
+ ACCESS_TOKEN_START,
+ // Don't do anything - we started resolving
+ DO_NOTHING
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BaseSettings.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BaseSettings.java
new file mode 100644
index 000000000..70d7589e8
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BaseSettings.java
@@ -0,0 +1,17 @@
+package app.revanced.extension.shared.settings;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+import static app.revanced.extension.shared.settings.Setting.parent;
+
+/**
+ * Settings shared across multiple apps.
+ *
+ * To ensure this class is loaded when the UI is created, app specific setting bundles should extend
+ * or reference this class.
+ */
+public class BaseSettings {
+ public static final BooleanSetting DEBUG = new BooleanSetting("revanced_debug", FALSE);
+ public static final BooleanSetting DEBUG_STACKTRACE = new BooleanSetting("revanced_debug_stacktrace", FALSE, parent(DEBUG));
+ public static final BooleanSetting DEBUG_TOAST_ON_ERROR = new BooleanSetting("revanced_debug_toast_on_error", TRUE, "revanced_debug_toast_on_error_user_dialog_message");
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BooleanSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BooleanSetting.java
new file mode 100644
index 000000000..7e84034d0
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BooleanSetting.java
@@ -0,0 +1,79 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Objects;
+
+@SuppressWarnings("unused")
+public class BooleanSetting extends Setting {
+ public BooleanSetting(String key, Boolean defaultValue) {
+ super(key, defaultValue);
+ }
+ public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+ public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+ public BooleanSetting(String key, Boolean defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+ public BooleanSetting(String key, Boolean defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+ public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+ public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+ public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+ public BooleanSetting(@NonNull String key, @NonNull Boolean defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ /**
+ * Sets, but does _not_ persistently save the value.
+ * This method is only to be used by the Settings preference code.
+ *
+ * This intentionally is a static method to deter
+ * accidental usage when {@link #save(Boolean)} was intnded.
+ */
+ public static void privateSetValue(@NonNull BooleanSetting setting, @NonNull Boolean newValue) {
+ setting.value = Objects.requireNonNull(newValue);
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getBoolean(key, defaultValue);
+ }
+
+ @Override
+ protected Boolean readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ return json.getBoolean(importExportKey);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = Boolean.valueOf(Objects.requireNonNull(newValue));
+ }
+
+ @Override
+ public void save(@NonNull Boolean newValue) {
+ // Must set before saving to preferences (otherwise importing fails to update UI correctly).
+ value = Objects.requireNonNull(newValue);
+ preferences.saveBoolean(key, newValue);
+ }
+
+ @NonNull
+ @Override
+ public Boolean get() {
+ return value;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/EnumSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/EnumSetting.java
new file mode 100644
index 000000000..a2b82dd21
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/EnumSetting.java
@@ -0,0 +1,117 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Locale;
+import java.util.Objects;
+
+import app.revanced.extension.shared.Logger;
+
+/**
+ * If an Enum value is removed or changed, any saved or imported data using the
+ * non-existent value will be reverted to the default value
+ * (the event is logged, but no user error is displayed).
+ *
+ * All saved JSON text is converted to lowercase to keep the output less obnoxious.
+ */
+@SuppressWarnings("unused")
+public class EnumSetting> extends Setting {
+ public EnumSetting(String key, T defaultValue) {
+ super(key, defaultValue);
+ }
+ public EnumSetting(String key, T defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+ public EnumSetting(String key, T defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+ public EnumSetting(String key, T defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+ public EnumSetting(String key, T defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+ public EnumSetting(String key, T defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+ public EnumSetting(String key, T defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+ public EnumSetting(String key, T defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+ public EnumSetting(@NonNull String key, @NonNull T defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getEnum(key, defaultValue);
+ }
+
+ @Override
+ protected T readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ String enumName = json.getString(importExportKey);
+ try {
+ return getEnumFromString(enumName);
+ } catch (IllegalArgumentException ex) {
+ // Info level to allow removing enum values in the future without showing any user errors.
+ Logger.printInfo(() -> "Using default, and ignoring unknown enum value: " + enumName, ex);
+ return defaultValue;
+ }
+ }
+
+ @Override
+ protected void writeToJSON(JSONObject json, String importExportKey) throws JSONException {
+ // Use lowercase to keep the output less ugly.
+ json.put(importExportKey, value.name().toLowerCase(Locale.ENGLISH));
+ }
+
+ @NonNull
+ private T getEnumFromString(String enumName) {
+ //noinspection ConstantConditions
+ for (Enum> value : defaultValue.getClass().getEnumConstants()) {
+ if (value.name().equalsIgnoreCase(enumName)) {
+ // noinspection unchecked
+ return (T) value;
+ }
+ }
+ throw new IllegalArgumentException("Unknown enum value: " + enumName);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = getEnumFromString(Objects.requireNonNull(newValue));
+ }
+
+ @Override
+ public void save(@NonNull T newValue) {
+ // Must set before saving to preferences (otherwise importing fails to update UI correctly).
+ value = Objects.requireNonNull(newValue);
+ preferences.saveEnumAsString(key, newValue);
+ }
+
+ @NonNull
+ @Override
+ public T get() {
+ return value;
+ }
+
+ /**
+ * Availability based on if this setting is currently set to any of the provided types.
+ */
+ @SafeVarargs
+ public final Setting.Availability availability(@NonNull T... types) {
+ return () -> {
+ T currentEnumType = get();
+ for (T enumType : types) {
+ if (currentEnumType == enumType) return true;
+ }
+ return false;
+ };
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/FloatSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/FloatSetting.java
new file mode 100644
index 000000000..7419741e0
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/FloatSetting.java
@@ -0,0 +1,69 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Objects;
+
+@SuppressWarnings("unused")
+public class FloatSetting extends Setting {
+
+ public FloatSetting(String key, Float defaultValue) {
+ super(key, defaultValue);
+ }
+ public FloatSetting(String key, Float defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+ public FloatSetting(String key, Float defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+ public FloatSetting(String key, Float defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+ public FloatSetting(String key, Float defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+ public FloatSetting(String key, Float defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+ public FloatSetting(String key, Float defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+ public FloatSetting(String key, Float defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+ public FloatSetting(@NonNull String key, @NonNull Float defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getFloatString(key, defaultValue);
+ }
+
+ @Override
+ protected Float readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ return (float) json.getDouble(importExportKey);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = Float.valueOf(Objects.requireNonNull(newValue));
+ }
+
+ @Override
+ public void save(@NonNull Float newValue) {
+ // Must set before saving to preferences (otherwise importing fails to update UI correctly).
+ value = Objects.requireNonNull(newValue);
+ preferences.saveFloatString(key, newValue);
+ }
+
+ @NonNull
+ @Override
+ public Float get() {
+ return value;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/IntegerSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/IntegerSetting.java
new file mode 100644
index 000000000..58f39a910
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/IntegerSetting.java
@@ -0,0 +1,69 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Objects;
+
+@SuppressWarnings("unused")
+public class IntegerSetting extends Setting {
+
+ public IntegerSetting(String key, Integer defaultValue) {
+ super(key, defaultValue);
+ }
+ public IntegerSetting(String key, Integer defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+ public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+ public IntegerSetting(String key, Integer defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+ public IntegerSetting(String key, Integer defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+ public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+ public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+ public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+ public IntegerSetting(@NonNull String key, @NonNull Integer defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getIntegerString(key, defaultValue);
+ }
+
+ @Override
+ protected Integer readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ return json.getInt(importExportKey);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = Integer.valueOf(Objects.requireNonNull(newValue));
+ }
+
+ @Override
+ public void save(@NonNull Integer newValue) {
+ // Must set before saving to preferences (otherwise importing fails to update UI correctly).
+ value = Objects.requireNonNull(newValue);
+ preferences.saveIntegerString(key, newValue);
+ }
+
+ @NonNull
+ @Override
+ public Integer get() {
+ return value;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/LongSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/LongSetting.java
new file mode 100644
index 000000000..4d7f8114f
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/LongSetting.java
@@ -0,0 +1,69 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Objects;
+
+@SuppressWarnings("unused")
+public class LongSetting extends Setting {
+
+ public LongSetting(String key, Long defaultValue) {
+ super(key, defaultValue);
+ }
+ public LongSetting(String key, Long defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+ public LongSetting(String key, Long defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+ public LongSetting(String key, Long defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+ public LongSetting(String key, Long defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+ public LongSetting(String key, Long defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+ public LongSetting(String key, Long defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+ public LongSetting(String key, Long defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+ public LongSetting(@NonNull String key, @NonNull Long defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getLongString(key, defaultValue);
+ }
+
+ @Override
+ protected Long readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ return json.getLong(importExportKey);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = Long.valueOf(Objects.requireNonNull(newValue));
+ }
+
+ @Override
+ public void save(@NonNull Long newValue) {
+ // Must set before saving to preferences (otherwise importing fails to update UI correctly).
+ value = Objects.requireNonNull(newValue);
+ preferences.saveLongString(key, newValue);
+ }
+
+ @NonNull
+ @Override
+ public Long get() {
+ return value;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/Setting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/Setting.java
new file mode 100644
index 000000000..7507d802a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/Setting.java
@@ -0,0 +1,437 @@
+package app.revanced.extension.shared.settings;
+
+import android.content.Context;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.StringRef;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.preference.SharedPrefCategory;
+import app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings;
+import org.jetbrains.annotations.NotNull;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.*;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+@SuppressWarnings("unused")
+public abstract class Setting {
+
+ /**
+ * Indicates if a {@link Setting} is available to edit and use.
+ * Typically this is dependent upon other BooleanSetting(s) set to 'true',
+ * but this can be used to call into extension code and check other conditions.
+ */
+ public interface Availability {
+ boolean isAvailable();
+ }
+
+ /**
+ * Availability based on a single parent setting being enabled.
+ */
+ @NonNull
+ public static Availability parent(@NonNull BooleanSetting parent) {
+ return parent::get;
+ }
+
+ /**
+ * Availability based on all parents being enabled.
+ */
+ @NonNull
+ public static Availability parentsAll(@NonNull BooleanSetting... parents) {
+ return () -> {
+ for (BooleanSetting parent : parents) {
+ if (!parent.get()) return false;
+ }
+ return true;
+ };
+ }
+
+ /**
+ * Availability based on any parent being enabled.
+ */
+ @NonNull
+ public static Availability parentsAny(@NonNull BooleanSetting... parents) {
+ return () -> {
+ for (BooleanSetting parent : parents) {
+ if (parent.get()) return true;
+ }
+ return false;
+ };
+ }
+
+ /**
+ * All settings that were instantiated.
+ * When a new setting is created, it is automatically added to this list.
+ */
+ private static final List> SETTINGS = new ArrayList<>();
+
+ /**
+ * Map of setting path to setting object.
+ */
+ private static final Map> PATH_TO_SETTINGS = new HashMap<>();
+
+ /**
+ * Preference all instances are saved to.
+ */
+ public static final SharedPrefCategory preferences = new SharedPrefCategory("revanced_prefs");
+
+ @Nullable
+ public static Setting> getSettingFromPath(@NonNull String str) {
+ return PATH_TO_SETTINGS.get(str);
+ }
+
+ /**
+ * @return All settings that have been created.
+ */
+ @NonNull
+ public static List> allLoadedSettings() {
+ return Collections.unmodifiableList(SETTINGS);
+ }
+
+ /**
+ * @return All settings that have been created, sorted by keys.
+ */
+ @NonNull
+ private static List> allLoadedSettingsSorted() {
+ Collections.sort(SETTINGS, (Setting> o1, Setting> o2) -> o1.key.compareTo(o2.key));
+ return allLoadedSettings();
+ }
+
+ /**
+ * The key used to store the value in the shared preferences.
+ */
+ @NonNull
+ public final String key;
+
+ /**
+ * The default value of the setting.
+ */
+ @NonNull
+ public final T defaultValue;
+
+ /**
+ * If the app should be rebooted, if this setting is changed
+ */
+ public final boolean rebootApp;
+
+ /**
+ * If this setting should be included when importing/exporting settings.
+ */
+ public final boolean includeWithImportExport;
+
+ /**
+ * If this setting is available to edit and use.
+ * Not to be confused with it's status returned from {@link #get()}.
+ */
+ @Nullable
+ private final Availability availability;
+
+ /**
+ * Confirmation message to display, if the user tries to change the setting from the default value.
+ * Currently this works only for Boolean setting types.
+ */
+ @Nullable
+ public final StringRef userDialogMessage;
+
+ // Must be volatile, as some settings are read/write from different threads.
+ // Of note, the object value is persistently stored using SharedPreferences (which is thread safe).
+ /**
+ * The value of the setting.
+ */
+ @NonNull
+ protected volatile T value;
+
+ public Setting(String key, T defaultValue) {
+ this(key, defaultValue, false, true, null, null);
+ }
+ public Setting(String key, T defaultValue, boolean rebootApp) {
+ this(key, defaultValue, rebootApp, true, null, null);
+ }
+ public Setting(String key, T defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ this(key, defaultValue, rebootApp, includeWithImportExport, null, null);
+ }
+ public Setting(String key, T defaultValue, String userDialogMessage) {
+ this(key, defaultValue, false, true, userDialogMessage, null);
+ }
+ public Setting(String key, T defaultValue, Availability availability) {
+ this(key, defaultValue, false, true, null, availability);
+ }
+ public Setting(String key, T defaultValue, boolean rebootApp, String userDialogMessage) {
+ this(key, defaultValue, rebootApp, true, userDialogMessage, null);
+ }
+ public Setting(String key, T defaultValue, boolean rebootApp, Availability availability) {
+ this(key, defaultValue, rebootApp, true, null, availability);
+ }
+ public Setting(String key, T defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ this(key, defaultValue, rebootApp, true, userDialogMessage, availability);
+ }
+
+ /**
+ * A setting backed by a shared preference.
+ *
+ * @param key The key used to store the value in the shared preferences.
+ * @param defaultValue The default value of the setting.
+ * @param rebootApp If the app should be rebooted, if this setting is changed.
+ * @param includeWithImportExport If this setting should be shown in the import/export dialog.
+ * @param userDialogMessage Confirmation message to display, if the user tries to change the setting from the default value.
+ * @param availability Condition that must be true, for this setting to be available to configure.
+ */
+ public Setting(@NonNull String key,
+ @NonNull T defaultValue,
+ boolean rebootApp,
+ boolean includeWithImportExport,
+ @Nullable String userDialogMessage,
+ @Nullable Availability availability
+ ) {
+ this.key = Objects.requireNonNull(key);
+ this.value = this.defaultValue = Objects.requireNonNull(defaultValue);
+ this.rebootApp = rebootApp;
+ this.includeWithImportExport = includeWithImportExport;
+ this.userDialogMessage = (userDialogMessage == null) ? null : new StringRef(userDialogMessage);
+ this.availability = availability;
+
+ SETTINGS.add(this);
+ if (PATH_TO_SETTINGS.put(key, this) != null) {
+ // Debug setting may not be created yet so using Logger may cause an initialization crash.
+ // Show a toast instead.
+ Utils.showToastLong(this.getClass().getSimpleName()
+ + " error: Duplicate Setting key found: " + key);
+ }
+
+ load();
+ }
+
+ /**
+ * Migrate a setting value if the path is renamed but otherwise the old and new settings are identical.
+ */
+ public static void migrateOldSettingToNew(@NonNull Setting oldSetting, @NonNull Setting newSetting) {
+ if (oldSetting == newSetting) throw new IllegalArgumentException();
+
+ if (!oldSetting.isSetToDefault()) {
+ Logger.printInfo(() -> "Migrating old setting value: " + oldSetting + " into replacement setting: " + newSetting);
+ newSetting.save(oldSetting.value);
+ oldSetting.resetToDefault();
+ }
+ }
+
+ /**
+ * Migrate an old Setting value previously stored in a different SharedPreference.
+ *
+ * This method will be deleted in the future.
+ */
+ public static void migrateFromOldPreferences(@NonNull SharedPrefCategory oldPrefs, @NonNull Setting setting, String settingKey) {
+ if (!oldPrefs.preferences.contains(settingKey)) {
+ return; // Nothing to do.
+ }
+
+ Object newValue = setting.get();
+ final Object migratedValue;
+ if (setting instanceof BooleanSetting) {
+ migratedValue = oldPrefs.getBoolean(settingKey, (Boolean) newValue);
+ } else if (setting instanceof IntegerSetting) {
+ migratedValue = oldPrefs.getIntegerString(settingKey, (Integer) newValue);
+ } else if (setting instanceof LongSetting) {
+ migratedValue = oldPrefs.getLongString(settingKey, (Long) newValue);
+ } else if (setting instanceof FloatSetting) {
+ migratedValue = oldPrefs.getFloatString(settingKey, (Float) newValue);
+ } else if (setting instanceof StringSetting) {
+ migratedValue = oldPrefs.getString(settingKey, (String) newValue);
+ } else {
+ Logger.printException(() -> "Unknown setting: " + setting);
+ // Remove otherwise it'll show a toast on every launch
+ oldPrefs.preferences.edit().remove(settingKey).apply();
+ return;
+ }
+
+ oldPrefs.preferences.edit().remove(settingKey).apply(); // Remove the old setting.
+ if (migratedValue.equals(newValue)) {
+ Logger.printDebug(() -> "Value does not need migrating: " + settingKey);
+ return; // Old value is already equal to the new setting value.
+ }
+
+ Logger.printDebug(() -> "Migrating old preference value into current preference: " + settingKey);
+ //noinspection unchecked
+ setting.save(migratedValue);
+ }
+
+ /**
+ * Sets, but does _not_ persistently save the value.
+ * This method is only to be used by the Settings preference code.
+ *
+ * This intentionally is a static method to deter
+ * accidental usage when {@link #save(Object)} was intended.
+ */
+ public static void privateSetValueFromString(@NonNull Setting> setting, @NonNull String newValue) {
+ setting.setValueFromString(newValue);
+ }
+
+ /**
+ * Sets the value of {@link #value}, but do not save to {@link #preferences}.
+ */
+ protected abstract void setValueFromString(@NonNull String newValue);
+
+ /**
+ * Load and set the value of {@link #value}.
+ */
+ protected abstract void load();
+
+ /**
+ * Persistently saves the value.
+ */
+ public abstract void save(@NonNull T newValue);
+
+ @NonNull
+ public abstract T get();
+
+ /**
+ * Identical to calling {@link #save(Object)} using {@link #defaultValue}.
+ */
+ public void resetToDefault() {
+ save(defaultValue);
+ }
+
+ /**
+ * @return if this setting can be configured and used.
+ */
+ public boolean isAvailable() {
+ return availability == null || availability.isAvailable();
+ }
+
+ /**
+ * @return if the currently set value is the same as {@link #defaultValue}
+ */
+ public boolean isSetToDefault() {
+ return value.equals(defaultValue);
+ }
+
+ @NotNull
+ @Override
+ public String toString() {
+ return key + "=" + get();
+ }
+
+ // region Import / export
+
+ /**
+ * If a setting path has this prefix, then remove it before importing/exporting.
+ */
+ private static final String OPTIONAL_REVANCED_SETTINGS_PREFIX = "revanced_";
+
+ /**
+ * The path, minus any 'revanced' prefix to keep json concise.
+ */
+ private String getImportExportKey() {
+ if (key.startsWith(OPTIONAL_REVANCED_SETTINGS_PREFIX)) {
+ return key.substring(OPTIONAL_REVANCED_SETTINGS_PREFIX.length());
+ }
+ return key;
+ }
+
+ /**
+ * @param importExportKey The JSON key. The JSONObject parameter will contain data for this key.
+ * @return the value stored using the import/export key. Do not set any values in this method.
+ */
+ protected abstract T readFromJSON(JSONObject json, String importExportKey) throws JSONException;
+
+ /**
+ * Saves this instance to JSON.
+ *
+ * To keep the JSON simple and readable,
+ * subclasses should not write out any embedded types (such as JSON Array or Dictionaries).
+ *
+ * If this instance is not a type supported natively by JSON (ie: it's not a String/Integer/Float/Long),
+ * then subclasses can override this method and write out a String value representing the value.
+ */
+ protected void writeToJSON(JSONObject json, String importExportKey) throws JSONException {
+ json.put(importExportKey, value);
+ }
+
+ @NonNull
+ public static String exportToJson(@Nullable Context alertDialogContext) {
+ try {
+ JSONObject json = new JSONObject();
+ for (Setting> setting : allLoadedSettingsSorted()) {
+ String importExportKey = setting.getImportExportKey();
+ if (json.has(importExportKey)) {
+ throw new IllegalArgumentException("duplicate key found: " + importExportKey);
+ }
+
+ final boolean exportDefaultValues = false; // Enable to see what all settings looks like in the UI.
+ //noinspection ConstantValue
+ if (setting.includeWithImportExport && (!setting.isSetToDefault() || exportDefaultValues)) {
+ setting.writeToJSON(json, importExportKey);
+ }
+ }
+ SponsorBlockSettings.showExportWarningIfNeeded(alertDialogContext);
+
+ if (json.length() == 0) {
+ return "";
+ }
+
+ String export = json.toString(0);
+
+ // Remove the outer JSON braces to make the output more compact,
+ // and leave less chance of the user forgetting to copy it
+ return export.substring(2, export.length() - 2);
+ } catch (JSONException e) {
+ Logger.printException(() -> "Export failure", e); // should never happen
+ return "";
+ }
+ }
+
+ /**
+ * @return if any settings that require a reboot were changed.
+ */
+ public static boolean importFromJSON(@NonNull String settingsJsonString) {
+ try {
+ if (!settingsJsonString.matches("[\\s\\S]*\\{")) {
+ settingsJsonString = '{' + settingsJsonString + '}'; // Restore outer JSON braces
+ }
+ JSONObject json = new JSONObject(settingsJsonString);
+
+ boolean rebootSettingChanged = false;
+ int numberOfSettingsImported = 0;
+ for (Setting setting : SETTINGS) {
+ String key = setting.getImportExportKey();
+ if (json.has(key)) {
+ Object value = setting.readFromJSON(json, key);
+ if (!setting.get().equals(value)) {
+ rebootSettingChanged |= setting.rebootApp;
+ //noinspection unchecked
+ setting.save(value);
+ }
+ numberOfSettingsImported++;
+ } else if (setting.includeWithImportExport && !setting.isSetToDefault()) {
+ Logger.printDebug(() -> "Resetting to default: " + setting);
+ rebootSettingChanged |= setting.rebootApp;
+ setting.resetToDefault();
+ }
+ }
+
+ // SB Enum categories are saved using StringSettings.
+ // Which means they need to reload again if changed by other code (such as here).
+ // This call could be removed by creating a custom Setting class that manages the
+ // "String <-> Enum" logic or by adding an event hook of when settings are imported.
+ // But for now this is simple and works.
+ SponsorBlockSettings.updateFromImportedSettings();
+
+ Utils.showToastLong(numberOfSettingsImported == 0
+ ? str("revanced_settings_import_reset")
+ : str("revanced_settings_import_success", numberOfSettingsImported));
+
+ return rebootSettingChanged;
+ } catch (JSONException | IllegalArgumentException ex) {
+ Utils.showToastLong(str("revanced_settings_import_failure_parse", ex.getMessage()));
+ Logger.printInfo(() -> "", ex);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Import failure: " + ex.getMessage(), ex); // should never happen
+ }
+ return false;
+ }
+
+ // End import / export
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/StringSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/StringSetting.java
new file mode 100644
index 000000000..0fa5e03fc
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/StringSetting.java
@@ -0,0 +1,69 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Objects;
+
+@SuppressWarnings("unused")
+public class StringSetting extends Setting {
+
+ public StringSetting(String key, String defaultValue) {
+ super(key, defaultValue);
+ }
+ public StringSetting(String key, String defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+ public StringSetting(String key, String defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+ public StringSetting(String key, String defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+ public StringSetting(String key, String defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+ public StringSetting(String key, String defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+ public StringSetting(String key, String defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+ public StringSetting(String key, String defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+ public StringSetting(@NonNull String key, @NonNull String defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getString(key, defaultValue);
+ }
+
+ @Override
+ protected String readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ return json.getString(importExportKey);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = Objects.requireNonNull(newValue);
+ }
+
+ @Override
+ public void save(@NonNull String newValue) {
+ // Must set before saving to preferences (otherwise importing fails to update UI correctly).
+ value = Objects.requireNonNull(newValue);
+ preferences.saveString(key, newValue);
+ }
+
+ @NonNull
+ @Override
+ public String get() {
+ return value;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java
new file mode 100644
index 000000000..3c1ad706a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java
@@ -0,0 +1,274 @@
+package app.revanced.extension.shared.settings.preference;
+
+import android.annotation.SuppressLint;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.preference.*;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.settings.Setting;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+@SuppressWarnings({"unused", "deprecation"})
+public abstract class AbstractPreferenceFragment extends PreferenceFragment {
+ /**
+ * Indicates that if a preference changes,
+ * to apply the change from the Setting to the UI component.
+ */
+ public static boolean settingImportInProgress;
+
+ /**
+ * Confirm and restart dialog button text and title.
+ * Set by subclasses if Strings cannot be added as a resource.
+ */
+ @Nullable
+ protected static String restartDialogButtonText, restartDialogTitle, confirmDialogTitle;
+
+ /**
+ * Used to prevent showing reboot dialog, if user cancels a setting user dialog.
+ */
+ private boolean showingUserDialogMessage;
+
+ private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> {
+ try {
+ Setting> setting = Setting.getSettingFromPath(str);
+ if (setting == null) {
+ return;
+ }
+ Preference pref = findPreference(str);
+ if (pref == null) {
+ return;
+ }
+ Logger.printDebug(() -> "Preference changed: " + setting.key);
+
+ // Apply 'Setting <- Preference', unless during importing when it needs to be 'Setting -> Preference'.
+ updatePreference(pref, setting, true, settingImportInProgress);
+ // Update any other preference availability that may now be different.
+ updateUIAvailability();
+
+ if (settingImportInProgress) {
+ return;
+ }
+
+ if (!showingUserDialogMessage) {
+ if (setting.userDialogMessage != null && ((SwitchPreference) pref).isChecked() != (Boolean) setting.defaultValue) {
+ showSettingUserDialogConfirmation((SwitchPreference) pref, (BooleanSetting) setting);
+ } else if (setting.rebootApp) {
+ showRestartDialog(getContext());
+ }
+ }
+
+ } catch (Exception ex) {
+ Logger.printException(() -> "OnSharedPreferenceChangeListener failure", ex);
+ }
+ };
+
+ /**
+ * Initialize this instance, and do any custom behavior.
+ *
+ * To ensure all {@link Setting} instances are correctly synced to the UI,
+ * it is important that subclasses make a call or otherwise reference their Settings class bundle
+ * so all app specific {@link Setting} instances are loaded before this method returns.
+ */
+ protected void initialize() {
+ final var identifier = Utils.getResourceIdentifier("revanced_prefs", "xml");
+
+ if (identifier == 0) return;
+ addPreferencesFromResource(identifier);
+ Utils.sortPreferenceGroups(getPreferenceScreen());
+ }
+
+ private void showSettingUserDialogConfirmation(SwitchPreference switchPref, BooleanSetting setting) {
+ Utils.verifyOnMainThread();
+
+ final var context = getContext();
+ if (confirmDialogTitle == null) {
+ confirmDialogTitle = str("revanced_settings_confirm_user_dialog_title");
+ }
+ showingUserDialogMessage = true;
+ new AlertDialog.Builder(context)
+ .setTitle(confirmDialogTitle)
+ .setMessage(setting.userDialogMessage.toString())
+ .setPositiveButton(android.R.string.ok, (dialog, id) -> {
+ if (setting.rebootApp) {
+ showRestartDialog(context);
+ }
+ })
+ .setNegativeButton(android.R.string.cancel, (dialog, id) -> {
+ switchPref.setChecked(setting.defaultValue); // Recursive call that resets the Setting value.
+ })
+ .setOnDismissListener(dialog -> {
+ showingUserDialogMessage = false;
+ })
+ .setCancelable(false)
+ .show();
+ }
+
+ /**
+ * Updates all Preferences values and their availability using the current values in {@link Setting}.
+ */
+ protected void updateUIToSettingValues() {
+ updatePreferenceScreen(getPreferenceScreen(), true,true);
+ }
+
+ /**
+ * Updates Preferences availability only using the status of {@link Setting}.
+ */
+ protected void updateUIAvailability() {
+ updatePreferenceScreen(getPreferenceScreen(), false, false);
+ }
+
+ /**
+ * Syncs all UI Preferences to any {@link Setting} they represent.
+ */
+ private void updatePreferenceScreen(@NonNull PreferenceScreen screen,
+ boolean syncSettingValue,
+ boolean applySettingToPreference) {
+ // Alternatively this could iterate thru all Settings and check for any matching Preferences,
+ // but there are many more Settings than UI preferences so it's more efficient to only check
+ // the Preferences.
+ for (int i = 0, prefCount = screen.getPreferenceCount(); i < prefCount; i++) {
+ Preference pref = screen.getPreference(i);
+ if (pref instanceof PreferenceScreen) {
+ updatePreferenceScreen((PreferenceScreen) pref, syncSettingValue, applySettingToPreference);
+ } else if (pref.hasKey()) {
+ String key = pref.getKey();
+ Setting> setting = Setting.getSettingFromPath(key);
+
+ if (setting != null) {
+ updatePreference(pref, setting, syncSettingValue, applySettingToPreference);
+ } else if (BaseSettings.DEBUG.get() && (pref instanceof SwitchPreference
+ || pref instanceof EditTextPreference || pref instanceof ListPreference)) {
+ // Probably a typo in the patches preference declaration.
+ Logger.printException(() -> "Preference key has no setting: " + key);
+ }
+ }
+ }
+ }
+
+ /**
+ * Handles syncing a UI Preference with the {@link Setting} that backs it.
+ * If needed, subclasses can override this to handle additional UI Preference types.
+ *
+ * @param applySettingToPreference If true, then apply {@link Setting} -> Preference.
+ * If false, then apply {@link Setting} <- Preference.
+ */
+ protected void syncSettingWithPreference(@NonNull Preference pref,
+ @NonNull Setting> setting,
+ boolean applySettingToPreference) {
+ if (pref instanceof SwitchPreference) {
+ SwitchPreference switchPref = (SwitchPreference) pref;
+ BooleanSetting boolSetting = (BooleanSetting) setting;
+ if (applySettingToPreference) {
+ switchPref.setChecked(boolSetting.get());
+ } else {
+ BooleanSetting.privateSetValue(boolSetting, switchPref.isChecked());
+ }
+ } else if (pref instanceof EditTextPreference) {
+ EditTextPreference editPreference = (EditTextPreference) pref;
+ if (applySettingToPreference) {
+ editPreference.setText(setting.get().toString());
+ } else {
+ Setting.privateSetValueFromString(setting, editPreference.getText());
+ }
+ } else if (pref instanceof ListPreference) {
+ ListPreference listPref = (ListPreference) pref;
+ if (applySettingToPreference) {
+ listPref.setValue(setting.get().toString());
+ } else {
+ Setting.privateSetValueFromString(setting, listPref.getValue());
+ }
+ updateListPreferenceSummary(listPref, setting);
+ } else {
+ Logger.printException(() -> "Setting cannot be handled: " + pref.getClass() + ": " + pref);
+ }
+ }
+
+ /**
+ * Updates a UI Preference with the {@link Setting} that backs it.
+ *
+ * @param syncSetting If the UI should be synced {@link Setting} <-> Preference
+ * @param applySettingToPreference If true, then apply {@link Setting} -> Preference.
+ * If false, then apply {@link Setting} <- Preference.
+ */
+ private void updatePreference(@NonNull Preference pref, @NonNull Setting> setting,
+ boolean syncSetting, boolean applySettingToPreference) {
+ if (!syncSetting && applySettingToPreference) {
+ throw new IllegalArgumentException();
+ }
+
+ if (syncSetting) {
+ syncSettingWithPreference(pref, setting, applySettingToPreference);
+ }
+
+ updatePreferenceAvailability(pref, setting);
+ }
+
+ protected void updatePreferenceAvailability(@NonNull Preference pref, @NonNull Setting> setting) {
+ pref.setEnabled(setting.isAvailable());
+ }
+
+ protected void updateListPreferenceSummary(ListPreference listPreference, Setting> setting) {
+ String objectStringValue = setting.get().toString();
+ final int entryIndex = listPreference.findIndexOfValue(objectStringValue);
+ if (entryIndex >= 0) {
+ listPreference.setSummary(listPreference.getEntries()[entryIndex]);
+ } else {
+ // Value is not an available option.
+ // User manually edited import data, or options changed and current selection is no longer available.
+ // Still show the value in the summary, so it's clear that something is selected.
+ listPreference.setSummary(objectStringValue);
+ }
+ }
+
+ public static void showRestartDialog(@NonNull final Context context) {
+ Utils.verifyOnMainThread();
+ if (restartDialogTitle == null) {
+ restartDialogTitle = str("revanced_settings_restart_title");
+ }
+ if (restartDialogButtonText == null) {
+ restartDialogButtonText = str("revanced_settings_restart");
+ }
+ new AlertDialog.Builder(context)
+ .setMessage(restartDialogTitle)
+ .setPositiveButton(restartDialogButtonText, (dialog, id)
+ -> Utils.restartApp(context))
+ .setNegativeButton(android.R.string.cancel, null)
+ .setCancelable(false)
+ .show();
+ }
+
+ @SuppressLint("ResourceType")
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ try {
+ PreferenceManager preferenceManager = getPreferenceManager();
+ preferenceManager.setSharedPreferencesName(Setting.preferences.name);
+
+ // Must initialize before adding change listener,
+ // otherwise the syncing of Setting -> UI
+ // causes a callback to the listener even though nothing changed.
+ initialize();
+ updateUIToSettingValues();
+
+ preferenceManager.getSharedPreferences().registerOnSharedPreferenceChangeListener(listener);
+ } catch (Exception ex) {
+ Logger.printException(() -> "onCreate() failure", ex);
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ getPreferenceManager().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(listener);
+ super.onDestroy();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java
new file mode 100644
index 000000000..c750ca3f1
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java
@@ -0,0 +1,99 @@
+package app.revanced.extension.shared.settings.preference;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.os.Build;
+import android.preference.EditTextPreference;
+import android.preference.Preference;
+import android.text.InputType;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.widget.EditText;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class ImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener {
+
+ private String existingSettings;
+
+ private void init() {
+ setSelectable(true);
+
+ EditText editText = getEditText();
+ editText.setTextIsSelectable(true);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ editText.setAutofillHints((String) null);
+ }
+ editText.setInputType(editText.getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ editText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 7); // Use a smaller font to reduce text wrap.
+
+ setOnPreferenceClickListener(this);
+ }
+
+ public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init();
+ }
+ public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+ public ImportExportPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+ public ImportExportPreference(Context context) {
+ super(context);
+ init();
+ }
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ try {
+ // Must set text before preparing dialog, otherwise text is non selectable if this preference is later reopened.
+ existingSettings = Setting.exportToJson(getContext());
+ getEditText().setText(existingSettings);
+ } catch (Exception ex) {
+ Logger.printException(() -> "showDialog failure", ex);
+ }
+ return true;
+ }
+
+ @Override
+ protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
+ try {
+ Utils.setEditTextDialogTheme(builder);
+
+ // Show the user the settings in JSON format.
+ builder.setNeutralButton(str("revanced_settings_import_copy"), (dialog, which) -> {
+ Utils.setClipboard(getEditText().getText().toString());
+ }).setPositiveButton(str("revanced_settings_import"), (dialog, which) -> {
+ importSettings(getEditText().getText().toString());
+ });
+ } catch (Exception ex) {
+ Logger.printException(() -> "onPrepareDialogBuilder failure", ex);
+ }
+ }
+
+ private void importSettings(String replacementSettings) {
+ try {
+ if (replacementSettings.equals(existingSettings)) {
+ return;
+ }
+ AbstractPreferenceFragment.settingImportInProgress = true;
+ final boolean rebootNeeded = Setting.importFromJSON(replacementSettings);
+ if (rebootNeeded) {
+ AbstractPreferenceFragment.showRestartDialog(getContext());
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "importSettings failure", ex);
+ } finally {
+ AbstractPreferenceFragment.settingImportInProgress = false;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ReVancedAboutPreference.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ReVancedAboutPreference.java
new file mode 100644
index 000000000..89fbe80e9
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ReVancedAboutPreference.java
@@ -0,0 +1,325 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.StringRef.sf;
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.youtube.requests.Route.Method.GET;
+
+import android.annotation.SuppressLint;
+import android.app.Dialog;
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.graphics.Color;
+import android.net.Uri;
+import android.os.Bundle;
+import android.preference.Preference;
+import android.util.AttributeSet;
+import android.view.Window;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.net.HttpURLConnection;
+import java.net.SocketTimeoutException;
+import java.util.ArrayList;
+import java.util.List;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.requests.Requester;
+import app.revanced.extension.youtube.requests.Route;
+
+/**
+ * Opens a dialog showing the links from {@link SocialLinksRoutes}.
+ */
+@SuppressWarnings({"unused", "deprecation"})
+public class ReVancedAboutPreference extends Preference {
+
+ private static String useNonBreakingHyphens(String text) {
+ // Replace any dashes with non breaking dashes, so the English text 'pre-release'
+ // and the dev release number does not break and cover two lines.
+ return text.replace("-", "‑"); // #8209 = non breaking hyphen.
+ }
+
+ private static String getColorHexString(int color) {
+ return String.format("#%06X", (0x00FFFFFF & color));
+ }
+
+ protected boolean isDarkModeEnabled() {
+ Configuration config = getContext().getResources().getConfiguration();
+ final int currentNightMode = config.uiMode & Configuration.UI_MODE_NIGHT_MASK;
+ return currentNightMode == Configuration.UI_MODE_NIGHT_YES;
+ }
+
+ /**
+ * Subclasses can override this and provide a themed color.
+ */
+ protected int getLightColor() {
+ return Color.WHITE;
+ }
+
+ /**
+ * Subclasses can override this and provide a themed color.
+ */
+ protected int getDarkColor() {
+ return Color.BLACK;
+ }
+
+ private String createDialogHtml(WebLink[] socialLinks) {
+ final boolean isNetworkConnected = Utils.isNetworkConnected();
+
+ StringBuilder builder = new StringBuilder();
+ builder.append("");
+ builder.append("
");
+
+ final boolean isDarkMode = isDarkModeEnabled();
+ String backgroundColorHex = getColorHexString(isDarkMode ? getDarkColor() : getLightColor());
+ String foregroundColorHex = getColorHexString(isDarkMode ? getLightColor() : getDarkColor());
+ // Apply light/dark mode colors.
+ builder.append(String.format(
+ "",
+ backgroundColorHex, foregroundColorHex, foregroundColorHex));
+
+ if (isNetworkConnected) {
+ builder.append(" ");
+ }
+
+ String patchesVersion = Utils.getPatchesReleaseVersion();
+
+ // Add the title.
+ builder.append("")
+ .append("ReVanced")
+ .append(" ");
+
+ builder.append("")
+ // Replace hyphens with non breaking dashes so the version number does not break lines.
+ .append(useNonBreakingHyphens(str("revanced_settings_about_links_body", patchesVersion)))
+ .append("
");
+
+ // Add a disclaimer if using a dev release.
+ if (patchesVersion.contains("dev")) {
+ builder.append("")
+ // English text 'Pre-release' can break lines.
+ .append(useNonBreakingHyphens(str("revanced_settings_about_links_dev_header")))
+ .append(" ");
+
+ builder.append("")
+ .append(str("revanced_settings_about_links_dev_body"))
+ .append("
");
+ }
+
+ builder.append("")
+ .append(str("revanced_settings_about_links_header"))
+ .append(" ");
+
+ builder.append("");
+ for (WebLink social : socialLinks) {
+ builder.append("
");
+ builder.append(String.format("
%s ", social.url, social.name));
+ builder.append("
");
+ }
+ builder.append("
");
+
+ builder.append("");
+ return builder.toString();
+ }
+
+ {
+ setOnPreferenceClickListener(pref -> {
+ // Show a progress spinner if the social links are not fetched yet.
+ if (!SocialLinksRoutes.hasFetchedLinks() && Utils.isNetworkConnected()) {
+ ProgressDialog progress = new ProgressDialog(getContext());
+ progress.setProgressStyle(ProgressDialog.STYLE_SPINNER);
+ progress.show();
+ Utils.runOnBackgroundThread(() -> fetchLinksAndShowDialog(progress));
+ } else {
+ // No network call required and can run now.
+ fetchLinksAndShowDialog(null);
+ }
+
+ return false;
+ });
+ }
+
+ private void fetchLinksAndShowDialog(@Nullable ProgressDialog progress) {
+ WebLink[] socialLinks = SocialLinksRoutes.fetchSocialLinks();
+ String htmlDialog = createDialogHtml(socialLinks);
+
+ Utils.runOnMainThreadNowOrLater(() -> {
+ if (progress != null) {
+ progress.dismiss();
+ }
+ new WebViewDialog(getContext(), htmlDialog).show();
+ });
+ }
+
+ public ReVancedAboutPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+ public ReVancedAboutPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+ public ReVancedAboutPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public ReVancedAboutPreference(Context context) {
+ super(context);
+ }
+}
+
+/**
+ * Displays html content as a dialog. Any links a user taps on are opened in an external browser.
+ */
+class WebViewDialog extends Dialog {
+
+ private final String htmlContent;
+
+ public WebViewDialog(@NonNull Context context, @NonNull String htmlContent) {
+ super(context);
+ this.htmlContent = htmlContent;
+ }
+
+ // JS required to hide any broken images. No remote javascript is ever loaded.
+ @SuppressLint("SetJavaScriptEnabled")
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+
+ WebView webView = new WebView(getContext());
+ webView.getSettings().setJavaScriptEnabled(true);
+ webView.setWebViewClient(new OpenLinksExternallyWebClient());
+ webView.loadDataWithBaseURL(null, htmlContent, "text/html", "utf-8", null);
+
+ setContentView(webView);
+ }
+
+ private class OpenLinksExternallyWebClient extends WebViewClient {
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, String url) {
+ try {
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
+ getContext().startActivity(intent);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Open link failure", ex);
+ }
+ // Dismiss the about dialog using a delay,
+ // otherwise without a delay the UI looks hectic with the dialog dismissing
+ // to show the settings while simultaneously a web browser is opening.
+ Utils.runOnMainThreadDelayed(WebViewDialog.this::dismiss, 500);
+ return true;
+ }
+ }
+}
+
+class WebLink {
+ final boolean preferred;
+ final String name;
+ final String url;
+
+ WebLink(JSONObject json) throws JSONException {
+ this(json.getBoolean("preferred"),
+ json.getString("name"),
+ json.getString("url")
+ );
+ }
+
+ WebLink(boolean preferred, String name, String url) {
+ this.preferred = preferred;
+ this.name = name;
+ this.url = url;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "ReVancedSocialLink{" +
+ "preferred=" + preferred +
+ ", name='" + name + '\'' +
+ ", url='" + url + '\'' +
+ '}';
+ }
+}
+
+class SocialLinksRoutes {
+ /**
+ * Simple link to the website donate page,
+ * rather than fetching and parsing the donation links using the API.
+ */
+ public static final WebLink DONATE_LINK = new WebLink(true,
+ sf("revanced_settings_about_links_donate").toString(),
+ "https://revanced.app/donate");
+
+ /**
+ * Links to use if fetch links api call fails.
+ */
+ private static final WebLink[] NO_CONNECTION_STATIC_LINKS = {
+ new WebLink(true, "ReVanced.app", "https://revanced.app"),
+ DONATE_LINK,
+ };
+
+ private static final String SOCIAL_LINKS_PROVIDER = "https://api.revanced.app/v2";
+ private static final Route.CompiledRoute GET_SOCIAL = new Route(GET, "/socials").compile();
+
+ @Nullable
+ private static volatile WebLink[] fetchedLinks;
+
+ static boolean hasFetchedLinks() {
+ return fetchedLinks != null;
+ }
+
+ static WebLink[] fetchSocialLinks() {
+ try {
+ if (hasFetchedLinks()) return fetchedLinks;
+
+ // Check if there is no internet connection.
+ if (!Utils.isNetworkConnected()) return NO_CONNECTION_STATIC_LINKS;
+
+ HttpURLConnection connection = Requester.getConnectionFromCompiledRoute(SOCIAL_LINKS_PROVIDER, GET_SOCIAL);
+ connection.setConnectTimeout(5000);
+ connection.setReadTimeout(5000);
+ Logger.printDebug(() -> "Fetching social links from: " + connection.getURL());
+
+ // Do not show an exception toast if the server is down
+ final int responseCode = connection.getResponseCode();
+ if (responseCode != 200) {
+ Logger.printDebug(() -> "Failed to get social links. Response code: " + responseCode);
+ return NO_CONNECTION_STATIC_LINKS;
+ }
+
+ JSONObject json = Requester.parseJSONObjectAndDisconnect(connection);
+ JSONArray socials = json.getJSONArray("socials");
+
+ List links = new ArrayList<>();
+
+ links.add(DONATE_LINK); // Show donate link first.
+ for (int i = 0, length = socials.length(); i < length; i++) {
+ WebLink link = new WebLink(socials.getJSONObject(i));
+ links.add(link);
+ }
+
+ Logger.printDebug(() -> "links: " + links);
+
+ return fetchedLinks = links.toArray(new WebLink[0]);
+
+ } catch (SocketTimeoutException ex) {
+ Logger.printInfo(() -> "Could not fetch social links", ex); // No toast.
+ } catch (JSONException ex) {
+ Logger.printException(() -> "Could not parse about information", ex);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to get about information", ex);
+ }
+
+ return NO_CONNECTION_STATIC_LINKS;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ResettableEditTextPreference.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ResettableEditTextPreference.java
new file mode 100644
index 000000000..3e9a96961
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ResettableEditTextPreference.java
@@ -0,0 +1,67 @@
+package app.revanced.extension.shared.settings.preference;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.preference.EditTextPreference;
+import android.util.AttributeSet;
+import android.widget.Button;
+import android.widget.EditText;
+
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.Logger;
+
+import java.util.Objects;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class ResettableEditTextPreference extends EditTextPreference {
+
+ public ResettableEditTextPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+ public ResettableEditTextPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+ public ResettableEditTextPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public ResettableEditTextPreference(Context context) {
+ super(context);
+ }
+
+ @Override
+ protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
+ super.onPrepareDialogBuilder(builder);
+ Utils.setEditTextDialogTheme(builder);
+
+ Setting> setting = Setting.getSettingFromPath(getKey());
+ if (setting != null) {
+ builder.setNeutralButton(str("revanced_settings_reset"), null);
+ }
+ }
+
+ @Override
+ protected void showDialog(Bundle state) {
+ super.showDialog(state);
+
+ // Override the button click listener to prevent dismissing the dialog.
+ Button button = ((AlertDialog) getDialog()).getButton(AlertDialog.BUTTON_NEUTRAL);
+ if (button == null) {
+ return;
+ }
+ button.setOnClickListener(v -> {
+ try {
+ Setting> setting = Objects.requireNonNull(Setting.getSettingFromPath(getKey()));
+ String defaultStringValue = setting.defaultValue.toString();
+ EditText editText = getEditText();
+ editText.setText(defaultStringValue);
+ editText.setSelection(defaultStringValue.length()); // move cursor to end of text
+ } catch (Exception ex) {
+ Logger.printException(() -> "reset failure", ex);
+ }
+ });
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/SharedPrefCategory.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/SharedPrefCategory.java
new file mode 100644
index 000000000..4e9c1f2e0
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/SharedPrefCategory.java
@@ -0,0 +1,190 @@
+package app.revanced.extension.shared.settings.preference;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.preference.PreferenceFragment;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+
+import java.util.Objects;
+
+/**
+ * Shared categories, and helper methods.
+ *
+ * The various save methods store numbers as Strings,
+ * which is required if using {@link PreferenceFragment}.
+ *
+ * If saved numbers will not be used with a preference fragment,
+ * then store the primitive numbers using the {@link #preferences} itself.
+ */
+public class SharedPrefCategory {
+ @NonNull
+ public final String name;
+ @NonNull
+ public final SharedPreferences preferences;
+
+ public SharedPrefCategory(@NonNull String name) {
+ this.name = Objects.requireNonNull(name);
+ preferences = Objects.requireNonNull(Utils.getContext()).getSharedPreferences(name, Context.MODE_PRIVATE);
+ }
+
+ private void removeConflictingPreferenceKeyValue(@NonNull String key) {
+ Logger.printException(() -> "Found conflicting preference: " + key);
+ removeKey(key);
+ }
+
+ private void saveObjectAsString(@NonNull String key, @Nullable Object value) {
+ preferences.edit().putString(key, (value == null ? null : value.toString())).apply();
+ }
+
+ /**
+ * Removes any preference data type that has the specified key.
+ */
+ public void removeKey(@NonNull String key) {
+ preferences.edit().remove(Objects.requireNonNull(key)).apply();
+ }
+
+ public void saveBoolean(@NonNull String key, boolean value) {
+ preferences.edit().putBoolean(key, value).apply();
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveEnumAsString(@NonNull String key, @Nullable Enum> value) {
+ saveObjectAsString(key, value);
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveIntegerString(@NonNull String key, @Nullable Integer value) {
+ saveObjectAsString(key, value);
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveLongString(@NonNull String key, @Nullable Long value) {
+ saveObjectAsString(key, value);
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveFloatString(@NonNull String key, @Nullable Float value) {
+ saveObjectAsString(key, value);
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveString(@NonNull String key, @Nullable String value) {
+ saveObjectAsString(key, value);
+ }
+
+ @NonNull
+ public String getString(@NonNull String key, @NonNull String _default) {
+ Objects.requireNonNull(_default);
+ try {
+ return preferences.getString(key, _default);
+ } catch (ClassCastException ex) {
+ // Value stored is a completely different type (should never happen).
+ removeConflictingPreferenceKeyValue(key);
+ return _default;
+ }
+ }
+
+ @NonNull
+ public > T getEnum(@NonNull String key, @NonNull T _default) {
+ Objects.requireNonNull(_default);
+ try {
+ String enumName = preferences.getString(key, null);
+ if (enumName != null) {
+ try {
+ // noinspection unchecked
+ return (T) Enum.valueOf(_default.getClass(), enumName);
+ } catch (IllegalArgumentException ex) {
+ // Info level to allow removing enum values in the future without showing any user errors.
+ Logger.printInfo(() -> "Using default, and ignoring unknown enum value: " + enumName);
+ removeKey(key);
+ }
+ }
+ } catch (ClassCastException ex) {
+ // Value stored is a completely different type (should never happen).
+ removeConflictingPreferenceKeyValue(key);
+ }
+ return _default;
+ }
+
+ public boolean getBoolean(@NonNull String key, boolean _default) {
+ try {
+ return preferences.getBoolean(key, _default);
+ } catch (ClassCastException ex) {
+ // Value stored is a completely different type (should never happen).
+ removeConflictingPreferenceKeyValue(key);
+ return _default;
+ }
+ }
+
+ @NonNull
+ public Integer getIntegerString(@NonNull String key, @NonNull Integer _default) {
+ try {
+ String value = preferences.getString(key, null);
+ if (value != null) {
+ return Integer.valueOf(value);
+ }
+ } catch (ClassCastException | NumberFormatException ex) {
+ try {
+ // Old data previously stored as primitive.
+ return preferences.getInt(key, _default);
+ } catch (ClassCastException ex2) {
+ // Value stored is a completely different type (should never happen).
+ removeConflictingPreferenceKeyValue(key);
+ }
+ }
+ return _default;
+ }
+
+ @NonNull
+ public Long getLongString(@NonNull String key, @NonNull Long _default) {
+ try {
+ String value = preferences.getString(key, null);
+ if (value != null) {
+ return Long.valueOf(value);
+ }
+ } catch (ClassCastException | NumberFormatException ex) {
+ try {
+ return preferences.getLong(key, _default);
+ } catch (ClassCastException ex2) {
+ removeConflictingPreferenceKeyValue(key);
+ }
+ }
+ return _default;
+ }
+
+ @NonNull
+ public Float getFloatString(@NonNull String key, @NonNull Float _default) {
+ try {
+ String value = preferences.getString(key, null);
+ if (value != null) {
+ return Float.valueOf(value);
+ }
+ } catch (ClassCastException | NumberFormatException ex) {
+ try {
+ return preferences.getFloat(key, _default);
+ } catch (ClassCastException ex2) {
+ removeConflictingPreferenceKeyValue(key);
+ }
+ }
+ return _default;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return name;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/syncforreddit/FixRedditVideoDownloadPatch.java b/extensions/shared/src/main/java/app/revanced/extension/syncforreddit/FixRedditVideoDownloadPatch.java
new file mode 100644
index 000000000..e006e31e6
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/syncforreddit/FixRedditVideoDownloadPatch.java
@@ -0,0 +1,77 @@
+package app.revanced.extension.syncforreddit;
+
+import android.util.Pair;
+import androidx.annotation.Nullable;
+import org.w3c.dom.Element;
+import org.xml.sax.SAXException;
+
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+
+/**
+ * @noinspection unused
+ */
+public class FixRedditVideoDownloadPatch {
+ private static @Nullable Pair getBestMpEntry(Element element) {
+ var representations = element.getElementsByTagName("Representation");
+ var entries = new ArrayList>();
+
+ for (int i = 0; i < representations.getLength(); i++) {
+ Element representation = (Element) representations.item(i);
+ var bandwidthStr = representation.getAttribute("bandwidth");
+ try {
+ var bandwidth = Integer.parseInt(bandwidthStr);
+ var baseUrl = representation.getElementsByTagName("BaseURL").item(0);
+ if (baseUrl != null) {
+ entries.add(new Pair<>(bandwidth, baseUrl.getTextContent()));
+ }
+ } catch (NumberFormatException ignored) {
+ }
+ }
+
+ if (entries.isEmpty()) {
+ return null;
+ }
+
+ Collections.sort(entries, (e1, e2) -> e2.first - e1.first);
+ return entries.get(0);
+ }
+
+ private static String[] parse(byte[] data) throws ParserConfigurationException, IOException, SAXException {
+ var adaptionSets = DocumentBuilderFactory
+ .newInstance()
+ .newDocumentBuilder()
+ .parse(new ByteArrayInputStream(data))
+ .getElementsByTagName("AdaptationSet");
+
+ String videoUrl = null;
+ String audioUrl = null;
+
+ for (int i = 0; i < adaptionSets.getLength(); i++) {
+ Element element = (Element) adaptionSets.item(i);
+ var contentType = element.getAttribute("contentType");
+ var bestEntry = getBestMpEntry(element);
+ if (bestEntry == null) continue;
+
+ if (contentType.equalsIgnoreCase("video")) {
+ videoUrl = bestEntry.second;
+ } else if (contentType.equalsIgnoreCase("audio")) {
+ audioUrl = bestEntry.second;
+ }
+ }
+
+ return new String[]{videoUrl, audioUrl};
+ }
+
+ public static String[] getLinks(byte[] data) {
+ try {
+ return parse(data);
+ } catch (ParserConfigurationException | IOException | SAXException e) {
+ return new String[]{null, null};
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/syncforreddit/FixSLinksPatch.java b/extensions/shared/src/main/java/app/revanced/extension/syncforreddit/FixSLinksPatch.java
new file mode 100644
index 000000000..de6a96c12
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/syncforreddit/FixSLinksPatch.java
@@ -0,0 +1,24 @@
+package app.revanced.extension.syncforreddit;
+
+import com.laurencedawson.reddit_sync.ui.activities.WebViewActivity;
+
+import app.revanced.extension.shared.fixes.slink.BaseFixSLinksPatch;
+
+/** @noinspection unused*/
+public class FixSLinksPatch extends BaseFixSLinksPatch {
+ static {
+ INSTANCE = new FixSLinksPatch();
+ }
+
+ private FixSLinksPatch() {
+ webViewActivityClass = WebViewActivity.class;
+ }
+
+ public static boolean patchResolveSLink(String link) {
+ return INSTANCE.resolveSLink(link);
+ }
+
+ public static void patchSetAccessToken(String accessToken) {
+ INSTANCE.setAccessToken(accessToken);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/Utils.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/Utils.java
new file mode 100644
index 000000000..277965921
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/Utils.java
@@ -0,0 +1,25 @@
+package app.revanced.extension.tiktok;
+
+import app.revanced.extension.shared.settings.StringSetting;
+
+public class Utils {
+
+ // Edit: This could be handled using a custom Setting class
+ // that saves its value to preferences and JSON using the formatted String created here.
+ public static long[] parseMinMax(StringSetting setting) {
+ final String[] minMax = setting.get().split("-");
+ if (minMax.length == 2) {
+ try {
+ final long min = Long.parseLong(minMax[0]);
+ final long max = Long.parseLong(minMax[1]);
+
+ if (min <= max && min >= 0) return new long[]{min, max};
+
+ } catch (NumberFormatException ignored) {
+ }
+ }
+
+ setting.save("0-" + Long.MAX_VALUE);
+ return new long[]{0L, Long.MAX_VALUE};
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/cleardisplay/RememberClearDisplayPatch.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/cleardisplay/RememberClearDisplayPatch.java
new file mode 100644
index 000000000..e436b5dcd
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/cleardisplay/RememberClearDisplayPatch.java
@@ -0,0 +1,13 @@
+package app.revanced.extension.tiktok.cleardisplay;
+
+import app.revanced.extension.tiktok.settings.Settings;
+
+@SuppressWarnings("unused")
+public class RememberClearDisplayPatch {
+ public static boolean getClearDisplayState() {
+ return Settings.CLEAR_DISPLAY.get();
+ }
+ public static void rememberClearDisplayState(boolean newState) {
+ Settings.CLEAR_DISPLAY.save(newState);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/download/DownloadsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/download/DownloadsPatch.java
new file mode 100644
index 000000000..c55d62878
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/download/DownloadsPatch.java
@@ -0,0 +1,14 @@
+package app.revanced.extension.tiktok.download;
+
+import app.revanced.extension.tiktok.settings.Settings;
+
+@SuppressWarnings("unused")
+public class DownloadsPatch {
+ public static String getDownloadPath() {
+ return Settings.DOWNLOAD_PATH.get();
+ }
+
+ public static boolean shouldRemoveWatermark() {
+ return Settings.DOWNLOAD_WATERMARK.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/AdsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/AdsFilter.java
new file mode 100644
index 000000000..31a982c68
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/AdsFilter.java
@@ -0,0 +1,16 @@
+package app.revanced.extension.tiktok.feedfilter;
+
+import app.revanced.extension.tiktok.settings.Settings;
+import com.ss.android.ugc.aweme.feed.model.Aweme;
+
+public class AdsFilter implements IFilter {
+ @Override
+ public boolean getEnabled() {
+ return Settings.REMOVE_ADS.get();
+ }
+
+ @Override
+ public boolean getFiltered(Aweme item) {
+ return item.isAd() || item.isWithPromotionalMusic();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/FeedItemsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/FeedItemsFilter.java
new file mode 100644
index 000000000..e1e0add8e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/FeedItemsFilter.java
@@ -0,0 +1,34 @@
+package app.revanced.extension.tiktok.feedfilter;
+
+import com.ss.android.ugc.aweme.feed.model.Aweme;
+import com.ss.android.ugc.aweme.feed.model.FeedItemList;
+
+import java.util.Iterator;
+import java.util.List;
+
+public final class FeedItemsFilter {
+ private static final List FILTERS = List.of(
+ new AdsFilter(),
+ new LiveFilter(),
+ new StoryFilter(),
+ new ImageVideoFilter(),
+ new ViewCountFilter(),
+ new LikeCountFilter()
+ );
+
+ public static void filter(FeedItemList feedItemList) {
+ Iterator feedItemListIterator = feedItemList.items.iterator();
+ while (feedItemListIterator.hasNext()) {
+ Aweme item = feedItemListIterator.next();
+ if (item == null) continue;
+
+ for (IFilter filter : FILTERS) {
+ boolean enabled = filter.getEnabled();
+ if (enabled && filter.getFiltered(item)) {
+ feedItemListIterator.remove();
+ break;
+ }
+ }
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/IFilter.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/IFilter.java
new file mode 100644
index 000000000..57639258d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/IFilter.java
@@ -0,0 +1,9 @@
+package app.revanced.extension.tiktok.feedfilter;
+
+import com.ss.android.ugc.aweme.feed.model.Aweme;
+
+public interface IFilter {
+ boolean getEnabled();
+
+ boolean getFiltered(Aweme item);
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/ImageVideoFilter.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/ImageVideoFilter.java
new file mode 100644
index 000000000..ed3e7cdb9
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/ImageVideoFilter.java
@@ -0,0 +1,16 @@
+package app.revanced.extension.tiktok.feedfilter;
+
+import app.revanced.extension.tiktok.settings.Settings;
+import com.ss.android.ugc.aweme.feed.model.Aweme;
+
+public class ImageVideoFilter implements IFilter {
+ @Override
+ public boolean getEnabled() {
+ return Settings.HIDE_IMAGE.get();
+ }
+
+ @Override
+ public boolean getFiltered(Aweme item) {
+ return item.isImage() || item.isPhotoMode();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/LikeCountFilter.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/LikeCountFilter.java
new file mode 100644
index 000000000..57eb665ea
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/LikeCountFilter.java
@@ -0,0 +1,32 @@
+package app.revanced.extension.tiktok.feedfilter;
+
+import app.revanced.extension.tiktok.settings.Settings;
+import com.ss.android.ugc.aweme.feed.model.Aweme;
+import com.ss.android.ugc.aweme.feed.model.AwemeStatistics;
+
+import static app.revanced.extension.tiktok.Utils.parseMinMax;
+
+public final class LikeCountFilter implements IFilter {
+ final long minLike;
+ final long maxLike;
+
+ LikeCountFilter() {
+ long[] minMax = parseMinMax(Settings.MIN_MAX_LIKES);
+ minLike = minMax[0];
+ maxLike = minMax[1];
+ }
+
+ @Override
+ public boolean getEnabled() {
+ return true;
+ }
+
+ @Override
+ public boolean getFiltered(Aweme item) {
+ AwemeStatistics statistics = item.getStatistics();
+ if (statistics == null) return false;
+
+ long likeCount = statistics.getDiggCount();
+ return likeCount < minLike || likeCount > maxLike;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/LiveFilter.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/LiveFilter.java
new file mode 100644
index 000000000..db6ab0af0
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/LiveFilter.java
@@ -0,0 +1,16 @@
+package app.revanced.extension.tiktok.feedfilter;
+
+import app.revanced.extension.tiktok.settings.Settings;
+import com.ss.android.ugc.aweme.feed.model.Aweme;
+
+public class LiveFilter implements IFilter {
+ @Override
+ public boolean getEnabled() {
+ return Settings.HIDE_LIVE.get();
+ }
+
+ @Override
+ public boolean getFiltered(Aweme item) {
+ return item.isLive() || item.isLiveReplay();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/StoryFilter.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/StoryFilter.java
new file mode 100644
index 000000000..85d0a7088
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/StoryFilter.java
@@ -0,0 +1,16 @@
+package app.revanced.extension.tiktok.feedfilter;
+
+import app.revanced.extension.tiktok.settings.Settings;
+import com.ss.android.ugc.aweme.feed.model.Aweme;
+
+public class StoryFilter implements IFilter {
+ @Override
+ public boolean getEnabled() {
+ return Settings.HIDE_STORY.get();
+ }
+
+ @Override
+ public boolean getFiltered(Aweme item) {
+ return item.getIsTikTokStory();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/ViewCountFilter.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/ViewCountFilter.java
new file mode 100644
index 000000000..ca9156f84
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/feedfilter/ViewCountFilter.java
@@ -0,0 +1,32 @@
+package app.revanced.extension.tiktok.feedfilter;
+
+import app.revanced.extension.tiktok.settings.Settings;
+import com.ss.android.ugc.aweme.feed.model.Aweme;
+import com.ss.android.ugc.aweme.feed.model.AwemeStatistics;
+
+import static app.revanced.extension.tiktok.Utils.parseMinMax;
+
+public class ViewCountFilter implements IFilter {
+ final long minView;
+ final long maxView;
+
+ ViewCountFilter() {
+ long[] minMax = parseMinMax(Settings.MIN_MAX_VIEWS);
+ minView = minMax[0];
+ maxView = minMax[1];
+ }
+
+ @Override
+ public boolean getEnabled() {
+ return true;
+ }
+
+ @Override
+ public boolean getFiltered(Aweme item) {
+ AwemeStatistics statistics = item.getStatistics();
+ if (statistics == null) return false;
+
+ long playCount = statistics.getPlayCount();
+ return playCount < minView || playCount > maxView;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/AdPersonalizationActivityHook.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/AdPersonalizationActivityHook.java
new file mode 100644
index 000000000..11304eb1e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/AdPersonalizationActivityHook.java
@@ -0,0 +1,82 @@
+package app.revanced.extension.tiktok.settings;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.preference.PreferenceFragment;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.tiktok.settings.preference.ReVancedPreferenceFragment;
+import com.bytedance.ies.ugc.aweme.commercialize.compliance.personalization.AdPersonalizationActivity;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+
+/**
+ * Hooks AdPersonalizationActivity.
+ *
+ * This class is responsible for injecting our own fragment by replacing the AdPersonalizationActivity.
+ *
+ * @noinspection unused
+ */
+public class AdPersonalizationActivityHook {
+ public static Object createSettingsEntry(String entryClazzName, String entryInfoClazzName) {
+ try {
+ Class> entryClazz = Class.forName(entryClazzName);
+ Class> entryInfoClazz = Class.forName(entryInfoClazzName);
+ Constructor> entryConstructor = entryClazz.getConstructor(entryInfoClazz);
+ Constructor> entryInfoConstructor = entryInfoClazz.getDeclaredConstructors()[0];
+ Object buttonInfo = entryInfoConstructor.newInstance("ReVanced settings", null, (View.OnClickListener) view -> startSettingsActivity(), "revanced");
+ return entryConstructor.newInstance(buttonInfo);
+ } catch (ClassNotFoundException | NoSuchMethodException | InvocationTargetException | IllegalAccessException |
+ InstantiationException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /***
+ * Initialize the settings menu.
+ * @param base The activity to initialize the settings menu on.
+ * @return Whether the settings menu should be initialized.
+ */
+ public static boolean initialize(AdPersonalizationActivity base) {
+ Bundle extras = base.getIntent().getExtras();
+ if (extras != null && !extras.getBoolean("revanced", false)) return false;
+
+ SettingsStatus.load();
+
+ LinearLayout linearLayout = new LinearLayout(base);
+ linearLayout.setLayoutParams(new LinearLayout.LayoutParams(-1, -1));
+ linearLayout.setOrientation(LinearLayout.VERTICAL);
+ linearLayout.setFitsSystemWindows(true);
+ linearLayout.setTransitionGroup(true);
+
+ FrameLayout fragment = new FrameLayout(base);
+ fragment.setLayoutParams(new FrameLayout.LayoutParams(-1, -1));
+ int fragmentId = View.generateViewId();
+ fragment.setId(fragmentId);
+
+ linearLayout.addView(fragment);
+ base.setContentView(linearLayout);
+
+ PreferenceFragment preferenceFragment = new ReVancedPreferenceFragment();
+ base.getFragmentManager().beginTransaction().replace(fragmentId, preferenceFragment).commit();
+
+ return true;
+ }
+
+ private static void startSettingsActivity() {
+ Context appContext = Utils.getContext();
+ if (appContext != null) {
+ Intent intent = new Intent(appContext, AdPersonalizationActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ intent.putExtra("revanced", true);
+ appContext.startActivity(intent);
+ } else {
+ Logger.printDebug(() -> "Utils.getContext() return null");
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/Settings.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/Settings.java
new file mode 100644
index 000000000..22a2d84d9
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/Settings.java
@@ -0,0 +1,26 @@
+package app.revanced.extension.tiktok.settings;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.settings.FloatSetting;
+import app.revanced.extension.shared.settings.StringSetting;
+
+public class Settings extends BaseSettings {
+ public static final BooleanSetting REMOVE_ADS = new BooleanSetting("remove_ads", TRUE, true);
+ public static final BooleanSetting HIDE_LIVE = new BooleanSetting("hide_live", FALSE, true);
+ public static final BooleanSetting HIDE_STORY = new BooleanSetting("hide_story", FALSE, true);
+ public static final BooleanSetting HIDE_IMAGE = new BooleanSetting("hide_image", FALSE, true);
+ public static final StringSetting MIN_MAX_VIEWS = new StringSetting("min_max_views", "0-" + Long.MAX_VALUE, true);
+ public static final StringSetting MIN_MAX_LIKES = new StringSetting("min_max_likes", "0-" + Long.MAX_VALUE, true);
+ public static final StringSetting DOWNLOAD_PATH = new StringSetting("down_path", "DCIM/TikTok");
+ public static final BooleanSetting DOWNLOAD_WATERMARK = new BooleanSetting("down_watermark", TRUE);
+ public static final BooleanSetting CLEAR_DISPLAY = new BooleanSetting("clear_display", FALSE);
+ public static final FloatSetting REMEMBERED_SPEED = new FloatSetting("REMEMBERED_SPEED", 1.0f);
+ public static final BooleanSetting SIM_SPOOF = new BooleanSetting("simspoof", TRUE, true);
+ public static final StringSetting SIM_SPOOF_ISO = new StringSetting("simspoof_iso", "us");
+ public static final StringSetting SIMSPOOF_MCCMNC = new StringSetting("simspoof_mccmnc", "310160");
+ public static final StringSetting SIMSPOOF_OP_NAME = new StringSetting("simspoof_op_name", "T-Mobile");
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/SettingsStatus.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/SettingsStatus.java
new file mode 100644
index 000000000..7333b1798
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/SettingsStatus.java
@@ -0,0 +1,23 @@
+package app.revanced.extension.tiktok.settings;
+
+public class SettingsStatus {
+ public static boolean feedFilterEnabled = false;
+ public static boolean downloadEnabled = false;
+ public static boolean simSpoofEnabled = false;
+
+ public static void enableFeedFilter() {
+ feedFilterEnabled = true;
+ }
+
+ public static void enableDownload() {
+ downloadEnabled = true;
+ }
+
+ public static void enableSimSpoof() {
+ simSpoofEnabled = true;
+ }
+
+ public static void load() {
+
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/DownloadPathPreference.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/DownloadPathPreference.java
new file mode 100644
index 000000000..ae4759c79
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/DownloadPathPreference.java
@@ -0,0 +1,124 @@
+package app.revanced.extension.tiktok.settings.preference;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Environment;
+import android.preference.DialogPreference;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+
+import app.revanced.extension.shared.settings.StringSetting;
+
+@SuppressWarnings("deprecation")
+public class DownloadPathPreference extends DialogPreference {
+ private final Context context;
+ private final String[] entryValues = {"DCIM", "Movies", "Pictures"};
+ private String mValue;
+
+ private boolean mValueSet;
+ private int mediaPathIndex;
+ private String childDownloadPath;
+
+ public DownloadPathPreference(Context context, String title, StringSetting setting) {
+ super(context);
+ this.context = context;
+ this.setTitle(title);
+ this.setSummary(Environment.getExternalStorageDirectory().getPath() + "/" + setting.get());
+ this.setKey(setting.key);
+ this.setValue(setting.get());
+ }
+
+ public String getValue() {
+ return this.mValue;
+ }
+
+ public void setValue(String value) {
+ final boolean changed = !TextUtils.equals(mValue, value);
+ if (changed || !mValueSet) {
+ mValue = value;
+ mValueSet = true;
+ persistString(value);
+ if (changed) {
+ notifyDependencyChange(shouldDisableDependents());
+ notifyChanged();
+ }
+ }
+ }
+
+ @Override
+ protected View onCreateDialogView() {
+ String currentMedia = getValue().split("/")[0];
+ childDownloadPath = getValue().substring(getValue().indexOf("/") + 1);
+ mediaPathIndex = findIndexOf(currentMedia);
+
+ LinearLayout dialogView = new LinearLayout(context);
+ RadioGroup mediaPath = new RadioGroup(context);
+ mediaPath.setLayoutParams(new RadioGroup.LayoutParams(-1, -2));
+ for (String entryValue : entryValues) {
+ RadioButton radioButton = new RadioButton(context);
+ radioButton.setText(entryValue);
+ radioButton.setId(View.generateViewId());
+ mediaPath.addView(radioButton);
+ }
+ mediaPath.setOnCheckedChangeListener((radioGroup, id) -> {
+ RadioButton radioButton = radioGroup.findViewById(id);
+ mediaPathIndex = findIndexOf(radioButton.getText().toString());
+ });
+ mediaPath.check(mediaPath.getChildAt(mediaPathIndex).getId());
+ EditText downloadPath = new EditText(context);
+ downloadPath.setInputType(InputType.TYPE_CLASS_TEXT);
+ downloadPath.setText(childDownloadPath);
+ downloadPath.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {
+
+ }
+
+ @Override
+ public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
+
+ }
+
+ @Override
+ public void afterTextChanged(Editable editable) {
+ childDownloadPath = editable.toString();
+ }
+ });
+ dialogView.setLayoutParams(new LinearLayout.LayoutParams(-1, -1));
+ dialogView.setOrientation(LinearLayout.VERTICAL);
+ dialogView.addView(mediaPath);
+ dialogView.addView(downloadPath);
+ return dialogView;
+ }
+
+ @Override
+ protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
+ builder.setTitle("Download Path");
+ builder.setPositiveButton(android.R.string.ok, (dialog, which) -> this.onClick(dialog, DialogInterface.BUTTON_POSITIVE));
+ builder.setNegativeButton(android.R.string.cancel, null);
+ }
+
+ @Override
+ protected void onDialogClosed(boolean positiveResult) {
+ if (positiveResult && mediaPathIndex >= 0) {
+ String newValue = entryValues[mediaPathIndex] + "/" + childDownloadPath;
+ setSummary(Environment.getExternalStorageDirectory().getPath() + "/" + newValue);
+ setValue(newValue);
+ }
+ }
+
+ private int findIndexOf(String str) {
+ for (int i = 0; i < entryValues.length; i++) {
+ if (str.equals(entryValues[i])) return i;
+ }
+ return -1;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/InputTextPreference.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/InputTextPreference.java
new file mode 100644
index 000000000..b80380e51
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/InputTextPreference.java
@@ -0,0 +1,17 @@
+package app.revanced.extension.tiktok.settings.preference;
+
+import android.content.Context;
+import android.preference.EditTextPreference;
+
+import app.revanced.extension.shared.settings.StringSetting;
+
+public class InputTextPreference extends EditTextPreference {
+
+ public InputTextPreference(Context context, String title, String summary, StringSetting setting) {
+ super(context);
+ this.setTitle(title);
+ this.setSummary(summary);
+ this.setKey(setting.key);
+ this.setText(setting.get());
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/RangeValuePreference.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/RangeValuePreference.java
new file mode 100644
index 000000000..8eaf98ac5
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/RangeValuePreference.java
@@ -0,0 +1,130 @@
+package app.revanced.extension.tiktok.settings.preference;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.preference.DialogPreference;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import app.revanced.extension.shared.settings.StringSetting;
+
+@SuppressWarnings("deprecation")
+public class RangeValuePreference extends DialogPreference {
+ private final Context context;
+
+ private String minValue;
+
+ private String maxValue;
+
+ private String mValue;
+
+ private boolean mValueSet;
+
+ public RangeValuePreference(Context context, String title, String summary, StringSetting setting) {
+ super(context);
+ this.context = context;
+ setTitle(title);
+ setSummary(summary);
+ setKey(setting.key);
+ setValue(setting.get());
+ }
+
+ public void setValue(String value) {
+ final boolean changed = !TextUtils.equals(mValue, value);
+ if (changed || !mValueSet) {
+ mValue = value;
+ mValueSet = true;
+ persistString(value);
+ if (changed) {
+ notifyDependencyChange(shouldDisableDependents());
+ notifyChanged();
+ }
+ }
+ }
+
+ public String getValue() {
+ return mValue;
+ }
+
+ @Override
+ protected View onCreateDialogView() {
+ minValue = getValue().split("-")[0];
+ maxValue = getValue().split("-")[1];
+ LinearLayout dialogView = new LinearLayout(context);
+ dialogView.setOrientation(LinearLayout.VERTICAL);
+ LinearLayout minView = new LinearLayout(context);
+ minView.setOrientation(LinearLayout.HORIZONTAL);
+ TextView min = new TextView(context);
+ min.setText("Min: ");
+ minView.addView(min);
+ EditText minEditText = new EditText(context);
+ minEditText.setInputType(InputType.TYPE_CLASS_NUMBER);
+ minEditText.setText(minValue);
+ minView.addView(minEditText);
+ dialogView.addView(minView);
+ LinearLayout maxView = new LinearLayout(context);
+ maxView.setOrientation(LinearLayout.HORIZONTAL);
+ TextView max = new TextView(context);
+ max.setText("Max: ");
+ maxView.addView(max);
+ EditText maxEditText = new EditText(context);
+ maxEditText.setInputType(InputType.TYPE_CLASS_NUMBER);
+ maxEditText.setText(maxValue);
+ maxView.addView(maxEditText);
+ dialogView.addView(maxView);
+ minEditText.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {
+
+ }
+
+ @Override
+ public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
+
+ }
+
+ @Override
+ public void afterTextChanged(Editable editable) {
+ minValue = editable.toString();
+ }
+ });
+ maxEditText.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {
+
+ }
+
+ @Override
+ public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
+
+ }
+
+ @Override
+ public void afterTextChanged(Editable editable) {
+ maxValue = editable.toString();
+ }
+ });
+ return dialogView;
+ }
+
+ @Override
+ protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
+ builder.setPositiveButton(android.R.string.ok, (dialog, which) -> this.onClick(dialog, DialogInterface.BUTTON_POSITIVE));
+ builder.setNegativeButton(android.R.string.cancel, null);
+ }
+
+ @Override
+ protected void onDialogClosed(boolean positiveResult) {
+ if (positiveResult) {
+ String newValue = minValue + "-" + maxValue;
+ setValue(newValue);
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/ReVancedPreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/ReVancedPreferenceFragment.java
new file mode 100644
index 000000000..43ab69297
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/ReVancedPreferenceFragment.java
@@ -0,0 +1,54 @@
+package app.revanced.extension.tiktok.settings.preference;
+
+import android.preference.Preference;
+import android.preference.PreferenceScreen;
+import androidx.annotation.NonNull;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment;
+import app.revanced.extension.tiktok.settings.preference.categories.DownloadsPreferenceCategory;
+import app.revanced.extension.tiktok.settings.preference.categories.FeedFilterPreferenceCategory;
+import app.revanced.extension.tiktok.settings.preference.categories.ExtensionPreferenceCategory;
+import app.revanced.extension.tiktok.settings.preference.categories.SimSpoofPreferenceCategory;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Preference fragment for ReVanced settings
+ */
+@SuppressWarnings("deprecation")
+public class ReVancedPreferenceFragment extends AbstractPreferenceFragment {
+
+ @Override
+ protected void syncSettingWithPreference(@NonNull @NotNull Preference pref,
+ @NonNull @NotNull Setting> setting,
+ boolean applySettingToPreference) {
+ if (pref instanceof RangeValuePreference) {
+ RangeValuePreference rangeValuePref = (RangeValuePreference) pref;
+ Setting.privateSetValueFromString(setting, rangeValuePref.getValue());
+ } else if (pref instanceof DownloadPathPreference) {
+ DownloadPathPreference downloadPathPref = (DownloadPathPreference) pref;
+ Setting.privateSetValueFromString(setting, downloadPathPref.getValue());
+ } else {
+ super.syncSettingWithPreference(pref, setting, applySettingToPreference);
+ }
+ }
+
+ @Override
+ protected void initialize() {
+ final var context = getContext();
+
+ // Currently no resources can be compiled for TikTok (fails with aapt error).
+ // So all TikTok Strings are hard coded in the extension.
+ restartDialogTitle = "Refresh and restart";
+ restartDialogButtonText = "Restart";
+ confirmDialogTitle = "Do you wish to proceed?";
+
+ PreferenceScreen preferenceScreen = getPreferenceManager().createPreferenceScreen(context);
+ setPreferenceScreen(preferenceScreen);
+
+ // Custom categories reference app specific Settings class.
+ new FeedFilterPreferenceCategory(context, preferenceScreen);
+ new DownloadsPreferenceCategory(context, preferenceScreen);
+ new SimSpoofPreferenceCategory(context, preferenceScreen);
+ new ExtensionPreferenceCategory(context, preferenceScreen);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/TogglePreference.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/TogglePreference.java
new file mode 100644
index 000000000..788b0d67d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/TogglePreference.java
@@ -0,0 +1,17 @@
+package app.revanced.extension.tiktok.settings.preference;
+
+import android.content.Context;
+import android.preference.SwitchPreference;
+
+import app.revanced.extension.shared.settings.BooleanSetting;
+
+@SuppressWarnings("deprecation")
+public class TogglePreference extends SwitchPreference {
+ public TogglePreference(Context context, String title, String summary, BooleanSetting setting) {
+ super(context);
+ this.setTitle(title);
+ this.setSummary(summary);
+ this.setKey(setting.key);
+ this.setChecked(setting.get());
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/ConditionalPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/ConditionalPreferenceCategory.java
new file mode 100644
index 000000000..d9f865ee9
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/ConditionalPreferenceCategory.java
@@ -0,0 +1,22 @@
+package app.revanced.extension.tiktok.settings.preference.categories;
+
+import android.content.Context;
+import android.preference.PreferenceCategory;
+import android.preference.PreferenceScreen;
+
+@SuppressWarnings("deprecation")
+public abstract class ConditionalPreferenceCategory extends PreferenceCategory {
+ public ConditionalPreferenceCategory(Context context, PreferenceScreen screen) {
+ super(context);
+
+ if (getSettingsStatus()) {
+ screen.addPreference(this);
+ addPreferences(context);
+ }
+ }
+
+ public abstract boolean getSettingsStatus();
+
+ public abstract void addPreferences(Context context);
+}
+
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/DownloadsPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/DownloadsPreferenceCategory.java
new file mode 100644
index 000000000..1ba3defa4
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/DownloadsPreferenceCategory.java
@@ -0,0 +1,35 @@
+package app.revanced.extension.tiktok.settings.preference.categories;
+
+import android.content.Context;
+import android.preference.PreferenceScreen;
+import app.revanced.extension.tiktok.settings.Settings;
+import app.revanced.extension.tiktok.settings.SettingsStatus;
+import app.revanced.extension.tiktok.settings.preference.DownloadPathPreference;
+import app.revanced.extension.tiktok.settings.preference.TogglePreference;
+
+@SuppressWarnings("deprecation")
+public class DownloadsPreferenceCategory extends ConditionalPreferenceCategory {
+ public DownloadsPreferenceCategory(Context context, PreferenceScreen screen) {
+ super(context, screen);
+ setTitle("Downloads");
+ }
+
+ @Override
+ public boolean getSettingsStatus() {
+ return SettingsStatus.downloadEnabled;
+ }
+
+ @Override
+ public void addPreferences(Context context) {
+ addPreference(new DownloadPathPreference(
+ context,
+ "Download path",
+ Settings.DOWNLOAD_PATH
+ ));
+ addPreference(new TogglePreference(
+ context,
+ "Remove watermark", "",
+ Settings.DOWNLOAD_WATERMARK
+ ));
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/ExtensionPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/ExtensionPreferenceCategory.java
new file mode 100644
index 000000000..ad49df688
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/ExtensionPreferenceCategory.java
@@ -0,0 +1,29 @@
+package app.revanced.extension.tiktok.settings.preference.categories;
+
+import android.content.Context;
+import android.preference.PreferenceScreen;
+
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.tiktok.settings.preference.TogglePreference;
+
+@SuppressWarnings("deprecation")
+public class ExtensionPreferenceCategory extends ConditionalPreferenceCategory {
+ public ExtensionPreferenceCategory(Context context, PreferenceScreen screen) {
+ super(context, screen);
+ setTitle("Extension");
+ }
+
+ @Override
+ public boolean getSettingsStatus() {
+ return true;
+ }
+
+ @Override
+ public void addPreferences(Context context) {
+ addPreference(new TogglePreference(context,
+ "Enable debug log",
+ "Show extension debug log.",
+ BaseSettings.DEBUG
+ ));
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/FeedFilterPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/FeedFilterPreferenceCategory.java
new file mode 100644
index 000000000..bcd56bc7e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/FeedFilterPreferenceCategory.java
@@ -0,0 +1,55 @@
+package app.revanced.extension.tiktok.settings.preference.categories;
+
+import android.content.Context;
+import android.preference.PreferenceScreen;
+import app.revanced.extension.tiktok.settings.preference.RangeValuePreference;
+import app.revanced.extension.tiktok.settings.Settings;
+import app.revanced.extension.tiktok.settings.SettingsStatus;
+import app.revanced.extension.tiktok.settings.preference.TogglePreference;
+
+@SuppressWarnings("deprecation")
+public class FeedFilterPreferenceCategory extends ConditionalPreferenceCategory {
+ public FeedFilterPreferenceCategory(Context context, PreferenceScreen screen) {
+ super(context, screen);
+ setTitle("Feed filter");
+ }
+
+ @Override
+ public boolean getSettingsStatus() {
+ return SettingsStatus.feedFilterEnabled;
+ }
+
+ @Override
+ public void addPreferences(Context context) {
+ addPreference(new TogglePreference(
+ context,
+ "Remove feed ads", "Remove ads from feed.",
+ Settings.REMOVE_ADS
+ ));
+ addPreference(new TogglePreference(
+ context,
+ "Hide livestreams", "Hide livestreams from feed.",
+ Settings.HIDE_LIVE
+ ));
+ addPreference(new TogglePreference(
+ context,
+ "Hide story", "Hide story from feed.",
+ Settings.HIDE_STORY
+ ));
+ addPreference(new TogglePreference(
+ context,
+ "Hide image video", "Hide image video from feed.",
+ Settings.HIDE_IMAGE
+ ));
+ addPreference(new RangeValuePreference(
+ context,
+ "Min/Max views", "The minimum or maximum views of a video to show.",
+ Settings.MIN_MAX_VIEWS
+ ));
+ addPreference(new RangeValuePreference(
+ context,
+ "Min/Max likes", "The minimum or maximum likes of a video to show.",
+ Settings.MIN_MAX_LIKES
+ ));
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/SimSpoofPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/SimSpoofPreferenceCategory.java
new file mode 100644
index 000000000..0a820dc39
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/settings/preference/categories/SimSpoofPreferenceCategory.java
@@ -0,0 +1,47 @@
+package app.revanced.extension.tiktok.settings.preference.categories;
+
+import android.content.Context;
+import android.preference.PreferenceScreen;
+import app.revanced.extension.tiktok.settings.Settings;
+import app.revanced.extension.tiktok.settings.SettingsStatus;
+import app.revanced.extension.tiktok.settings.preference.InputTextPreference;
+import app.revanced.extension.tiktok.settings.preference.TogglePreference;
+
+@SuppressWarnings("deprecation")
+public class SimSpoofPreferenceCategory extends ConditionalPreferenceCategory {
+ public SimSpoofPreferenceCategory(Context context, PreferenceScreen screen) {
+ super(context, screen);
+ setTitle("Bypass regional restriction");
+ }
+
+
+ @Override
+ public boolean getSettingsStatus() {
+ return SettingsStatus.simSpoofEnabled;
+ }
+
+ @Override
+ public void addPreferences(Context context) {
+ addPreference(new TogglePreference(
+ context,
+ "Fake sim card info",
+ "Bypass regional restriction by fake sim card information.",
+ Settings.SIM_SPOOF
+ ));
+ addPreference(new InputTextPreference(
+ context,
+ "Country ISO", "us, uk, jp, ...",
+ Settings.SIM_SPOOF_ISO
+ ));
+ addPreference(new InputTextPreference(
+ context,
+ "Operator mcc+mnc", "mcc+mnc",
+ Settings.SIMSPOOF_MCCMNC
+ ));
+ addPreference(new InputTextPreference(
+ context,
+ "Operator name", "Name of the operator.",
+ Settings.SIMSPOOF_OP_NAME
+ ));
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/speed/PlaybackSpeedPatch.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/speed/PlaybackSpeedPatch.java
new file mode 100644
index 000000000..3b078ab89
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/speed/PlaybackSpeedPatch.java
@@ -0,0 +1,13 @@
+package app.revanced.extension.tiktok.speed;
+
+import app.revanced.extension.tiktok.settings.Settings;
+
+public class PlaybackSpeedPatch {
+ public static void rememberPlaybackSpeed(float newSpeed) {
+ Settings.REMEMBERED_SPEED.save(newSpeed);
+ }
+
+ public static float getPlaybackSpeed() {
+ return Settings.REMEMBERED_SPEED.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tiktok/spoof/sim/SpoofSimPatch.java b/extensions/shared/src/main/java/app/revanced/extension/tiktok/spoof/sim/SpoofSimPatch.java
new file mode 100644
index 000000000..94910bdb6
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tiktok/spoof/sim/SpoofSimPatch.java
@@ -0,0 +1,37 @@
+package app.revanced.extension.tiktok.spoof.sim;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.tiktok.settings.Settings;
+
+@SuppressWarnings("unused")
+public class SpoofSimPatch {
+
+ private static final boolean ENABLED = Settings.SIM_SPOOF.get();
+
+ public static String getCountryIso(String value) {
+ if (ENABLED) {
+ String iso = Settings.SIM_SPOOF_ISO.get();
+ Logger.printDebug(() -> "Spoofing sim ISO from: " + value + " to: " + iso);
+ return iso;
+ }
+ return value;
+ }
+
+ public static String getOperator(String value) {
+ if (ENABLED) {
+ String mcc_mnc = Settings.SIMSPOOF_MCCMNC.get();
+ Logger.printDebug(() -> "Spoofing sim MCC-MNC from: " + value + " to: " + mcc_mnc);
+ return mcc_mnc;
+ }
+ return value;
+ }
+
+ public static String getOperatorName(String value) {
+ if (ENABLED) {
+ String operator = Settings.SIMSPOOF_OP_NAME.get();
+ Logger.printDebug(() -> "Spoofing sim operator from: " + value + " to: " + operator);
+ return operator;
+ }
+ return value;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tudortmund/lockscreen/ShowOnLockscreenPatch.java b/extensions/shared/src/main/java/app/revanced/extension/tudortmund/lockscreen/ShowOnLockscreenPatch.java
new file mode 100644
index 000000000..f2868cf4e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tudortmund/lockscreen/ShowOnLockscreenPatch.java
@@ -0,0 +1,46 @@
+package app.revanced.extension.tudortmund.lockscreen;
+
+import android.content.Context;
+import android.hardware.display.DisplayManager;
+import android.os.Build;
+import android.view.Display;
+import android.view.Window;
+import androidx.appcompat.app.AppCompatActivity;
+
+import static android.view.WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD;
+import static android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED;
+
+public class ShowOnLockscreenPatch {
+ /**
+ * @noinspection deprecation
+ */
+ public static Window getWindow(AppCompatActivity activity, float brightness) {
+ Window window = activity.getWindow();
+
+ if (brightness >= 0) {
+ // High brightness set, therefore show on lockscreen.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) activity.setShowWhenLocked(true);
+ else window.addFlags(FLAG_SHOW_WHEN_LOCKED | FLAG_DISMISS_KEYGUARD);
+ } else {
+ // Ignore brightness reset when the screen is turned off.
+ DisplayManager displayManager = (DisplayManager) activity.getSystemService(Context.DISPLAY_SERVICE);
+
+ boolean isScreenOn = false;
+ for (Display display : displayManager.getDisplays()) {
+ if (display.getState() == Display.STATE_OFF) continue;
+
+ isScreenOn = true;
+ break;
+ }
+
+ if (isScreenOn) {
+ // Hide on lockscreen.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) activity.setShowWhenLocked(false);
+ else window.clearFlags(FLAG_SHOW_WHEN_LOCKED | FLAG_DISMISS_KEYGUARD);
+ }
+ }
+
+ return window;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/tumblr/patches/TimelineFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/tumblr/patches/TimelineFilterPatch.java
new file mode 100644
index 000000000..bb3a2473d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/tumblr/patches/TimelineFilterPatch.java
@@ -0,0 +1,32 @@
+package app.revanced.extension.tumblr.patches;
+
+import com.tumblr.rumblr.model.TimelineObject;
+import com.tumblr.rumblr.model.Timelineable;
+
+import java.util.HashSet;
+import java.util.List;
+
+public final class TimelineFilterPatch {
+ private static final HashSet blockedObjectTypes = new HashSet<>();
+
+ static {
+ // This dummy gets removed by the TimelineFilterPatch and in its place,
+ // equivalent instructions with a different constant string
+ // will be inserted for each Timeline object type filter.
+ // Modifying this line may break the patch.
+ blockedObjectTypes.add("BLOCKED_OBJECT_DUMMY");
+ }
+
+ // Calls to this method are injected where the list of Timeline objects is first received.
+ // We modify the list filter out elements that we want to hide.
+ public static void filterTimeline(final List> timelineObjects) {
+ final var iterator = timelineObjects.iterator();
+ while (iterator.hasNext()) {
+ var timelineElement = iterator.next();
+ if (timelineElement == null) continue;
+
+ String elementType = timelineElement.getData().getTimelineObjectType().toString();
+ if (blockedObjectTypes.contains(elementType)) iterator.remove();
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/Utils.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/Utils.java
new file mode 100644
index 000000000..73c363ff8
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/Utils.java
@@ -0,0 +1,14 @@
+package app.revanced.extension.twitch;
+
+public class Utils {
+
+ /* Called from SettingsPatch smali */
+ public static int getStringId(String name) {
+ return app.revanced.extension.shared.Utils.getResourceIdentifier(name, "string");
+ }
+
+ /* Called from SettingsPatch smali */
+ public static int getDrawableId(String name) {
+ return app.revanced.extension.shared.Utils.getResourceIdentifier(name, "drawable");
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/IAdblockService.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/IAdblockService.java
new file mode 100644
index 000000000..457ecdf97
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/IAdblockService.java
@@ -0,0 +1,26 @@
+package app.revanced.extension.twitch.adblock;
+
+import okhttp3.Request;
+
+public interface IAdblockService {
+ String friendlyName();
+
+ Integer maxAttempts();
+
+ Boolean isAvailable();
+
+ Request rewriteHlsRequest(Request originalRequest);
+
+ static boolean isVod(Request request){
+ return request.url().pathSegments().contains("vod");
+ }
+
+ static String channelName(Request request) {
+ for (String pathSegment : request.url().pathSegments()) {
+ if (pathSegment.endsWith(".m3u8")) {
+ return pathSegment.replace(".m3u8", "");
+ }
+ }
+ return null;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/LuminousService.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/LuminousService.java
new file mode 100644
index 000000000..ef217daf0
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/LuminousService.java
@@ -0,0 +1,47 @@
+package app.revanced.extension.twitch.adblock;
+
+import app.revanced.extension.shared.Logger;
+import okhttp3.HttpUrl;
+import okhttp3.Request;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+public class LuminousService implements IAdblockService {
+ @Override
+ public String friendlyName() {
+ return str("revanced_proxy_luminous");
+ }
+
+ @Override
+ public Integer maxAttempts() {
+ return 2;
+ }
+
+ @Override
+ public Boolean isAvailable() {
+ return true;
+ }
+
+ @Override
+ public Request rewriteHlsRequest(Request originalRequest) {
+ var type = IAdblockService.isVod(originalRequest) ? "vod" : "playlist";
+ var url = HttpUrl.parse("https://eu.luminous.dev/" +
+ type +
+ "/" +
+ IAdblockService.channelName(originalRequest) +
+ ".m3u8" +
+ "%3Fallow_source%3Dtrue%26allow_audio_only%3Dtrue%26fast_bread%3Dtrue"
+ );
+
+ if (url == null) {
+ Logger.printException(() -> "Failed to parse rewritten URL");
+ return null;
+ }
+
+ // Overwrite old request
+ return new Request.Builder()
+ .get()
+ .url(url)
+ .build();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/PurpleAdblockService.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/PurpleAdblockService.java
new file mode 100644
index 000000000..ba1bd183a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/adblock/PurpleAdblockService.java
@@ -0,0 +1,96 @@
+package app.revanced.extension.twitch.adblock;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.twitch.api.RetrofitClient;
+import okhttp3.HttpUrl;
+import okhttp3.Request;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+public class PurpleAdblockService implements IAdblockService {
+ private final Map tunnels = new HashMap<>() {{
+ put("https://eu1.jupter.ga", false);
+ put("https://eu2.jupter.ga", false);
+ }};
+
+ @Override
+ public String friendlyName() {
+ return str("revanced_proxy_purpleadblock");
+ }
+
+ @Override
+ public Integer maxAttempts() {
+ return 3;
+ }
+
+ @Override
+ public Boolean isAvailable() {
+ for (String tunnel : tunnels.keySet()) {
+ var success = true;
+
+ try {
+ var response = RetrofitClient.getInstance().getPurpleAdblockApi().ping(tunnel).execute();
+ if (!response.isSuccessful()) {
+ Logger.printException(() ->
+ "PurpleAdBlock tunnel $tunnel returned an error: HTTP code " + response.code()
+ );
+ Logger.printDebug(response::message);
+
+ try (var errorBody = response.errorBody()) {
+ if (errorBody != null) {
+ Logger.printDebug(() -> {
+ try {
+ return errorBody.string();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+ }
+
+ success = false;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "PurpleAdBlock tunnel $tunnel is unavailable", ex);
+ success = false;
+ }
+
+ // Cache availability data
+ tunnels.put(tunnel, success);
+
+ if (success)
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public Request rewriteHlsRequest(Request originalRequest) {
+ for (Map.Entry entry : tunnels.entrySet()) {
+ if (!entry.getValue()) continue;
+
+ var server = entry.getKey();
+
+ // Compose new URL
+ var url = HttpUrl.parse(server + "/channel/" + IAdblockService.channelName(originalRequest));
+ if (url == null) {
+ Logger.printException(() -> "Failed to parse rewritten URL");
+ return null;
+ }
+
+ // Overwrite old request
+ return new Request.Builder()
+ .get()
+ .url(url)
+ .build();
+ }
+
+ Logger.printException(() -> "No tunnels are available");
+ return null;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/api/PurpleAdblockApi.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/api/PurpleAdblockApi.java
new file mode 100644
index 000000000..519a3fe8a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/api/PurpleAdblockApi.java
@@ -0,0 +1,12 @@
+package app.revanced.extension.twitch.api;
+
+import okhttp3.ResponseBody;
+import retrofit2.Call;
+import retrofit2.http.GET;
+import retrofit2.http.Url;
+
+/* only used for service pings */
+public interface PurpleAdblockApi {
+ @GET /* root */
+ Call ping(@Url String baseUrl);
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/api/RequestInterceptor.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/api/RequestInterceptor.java
new file mode 100644
index 000000000..2309bcf64
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/api/RequestInterceptor.java
@@ -0,0 +1,120 @@
+package app.revanced.extension.twitch.api;
+
+import androidx.annotation.NonNull;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.twitch.adblock.IAdblockService;
+import app.revanced.extension.twitch.adblock.LuminousService;
+import app.revanced.extension.twitch.adblock.PurpleAdblockService;
+import app.revanced.extension.twitch.settings.Settings;
+import okhttp3.Interceptor;
+import okhttp3.Response;
+
+import java.io.IOException;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+public class RequestInterceptor implements Interceptor {
+ private IAdblockService activeService = null;
+
+ private static final String PROXY_DISABLED = str("revanced_block_embedded_ads_entry_1");
+ private static final String LUMINOUS_SERVICE = str("revanced_block_embedded_ads_entry_2");
+ private static final String PURPLE_ADBLOCK_SERVICE = str("revanced_block_embedded_ads_entry_3");
+
+
+ @NonNull
+ @Override
+ public Response intercept(@NonNull Chain chain) throws IOException {
+ var originalRequest = chain.request();
+
+ if (Settings.BLOCK_EMBEDDED_ADS.get().equals(PROXY_DISABLED)) {
+ return chain.proceed(originalRequest);
+ }
+
+ Logger.printDebug(() -> "Intercepted request to URL:" + originalRequest.url());
+
+ // Skip if not HLS manifest request
+ if (!originalRequest.url().host().contains("usher.ttvnw.net")) {
+ return chain.proceed(originalRequest);
+ }
+
+ final String isVod;
+ if (IAdblockService.isVod(originalRequest)) isVod = "yes";
+ else isVod = "no";
+
+ Logger.printDebug(() -> "Found HLS manifest request. Is VOD? " +
+ isVod +
+ "; Channel: " +
+ IAdblockService.channelName(originalRequest)
+ );
+
+ // None of the services support VODs currently
+ if (IAdblockService.isVod(originalRequest)) return chain.proceed(originalRequest);
+
+ updateActiveService();
+
+ if (activeService != null) {
+ var available = activeService.isAvailable();
+ var rewritten = activeService.rewriteHlsRequest(originalRequest);
+
+
+ if (!available || rewritten == null) {
+ Utils.showToastShort(String.format(
+ str("revanced_embedded_ads_service_unavailable"), activeService.friendlyName()
+ ));
+ return chain.proceed(originalRequest);
+ }
+
+ Logger.printDebug(() -> "Rewritten HLS stream URL: " + rewritten.url());
+
+ var maxAttempts = activeService.maxAttempts();
+
+ for (var i = 1; i <= maxAttempts; i++) {
+ // Execute rewritten request and close body to allow multiple proceed() calls
+ var response = chain.proceed(rewritten);
+ response.close();
+
+ if (!response.isSuccessful()) {
+ int attempt = i;
+ Logger.printException(() -> "Request failed (attempt " +
+ attempt +
+ "/" + maxAttempts + "): HTTP error " +
+ response.code() +
+ " (" + response.message() + ")"
+ );
+
+ try {
+ Thread.sleep(50);
+ } catch (InterruptedException e) {
+ Logger.printException(() -> "Failed to sleep", e);
+ }
+ } else {
+ // Accept response from ad blocker
+ Logger.printDebug(() -> "Ad-blocker used");
+ return chain.proceed(rewritten);
+ }
+ }
+
+ // maxAttempts exceeded; giving up on using the ad blocker
+ Utils.showToastLong(String.format(
+ str("revanced_embedded_ads_service_failed"),
+ activeService.friendlyName())
+ );
+ }
+
+ // Adblock disabled
+ return chain.proceed(originalRequest);
+
+ }
+
+ private void updateActiveService() {
+ var current = Settings.BLOCK_EMBEDDED_ADS.get();
+
+ if (current.equals(LUMINOUS_SERVICE) && !(activeService instanceof LuminousService))
+ activeService = new LuminousService();
+ else if (current.equals(PURPLE_ADBLOCK_SERVICE) && !(activeService instanceof PurpleAdblockService))
+ activeService = new PurpleAdblockService();
+ else if (current.equals(PROXY_DISABLED))
+ activeService = null;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/api/RetrofitClient.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/api/RetrofitClient.java
new file mode 100644
index 000000000..24f4060b6
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/api/RetrofitClient.java
@@ -0,0 +1,25 @@
+package app.revanced.extension.twitch.api;
+
+import retrofit2.Retrofit;
+
+public class RetrofitClient {
+
+ private static RetrofitClient instance = null;
+ private final PurpleAdblockApi purpleAdblockApi;
+
+ private RetrofitClient() {
+ Retrofit retrofit = new Retrofit.Builder().baseUrl("http://localhost" /* dummy */).build();
+ purpleAdblockApi = retrofit.create(PurpleAdblockApi.class);
+ }
+
+ public static synchronized RetrofitClient getInstance() {
+ if (instance == null) {
+ instance = new RetrofitClient();
+ }
+ return instance;
+ }
+
+ public PurpleAdblockApi getPurpleAdblockApi() {
+ return purpleAdblockApi;
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/AudioAdsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/AudioAdsPatch.java
new file mode 100644
index 000000000..77b7cbd5a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/AudioAdsPatch.java
@@ -0,0 +1,10 @@
+package app.revanced.extension.twitch.patches;
+
+import app.revanced.extension.twitch.settings.Settings;
+
+@SuppressWarnings("unused")
+public class AudioAdsPatch {
+ public static boolean shouldBlockAudioAds() {
+ return Settings.BLOCK_AUDIO_ADS.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/AutoClaimChannelPointsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/AutoClaimChannelPointsPatch.java
new file mode 100644
index 000000000..55c32c7ea
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/AutoClaimChannelPointsPatch.java
@@ -0,0 +1,10 @@
+package app.revanced.extension.twitch.patches;
+
+import app.revanced.extension.twitch.settings.Settings;
+
+@SuppressWarnings("unused")
+public class AutoClaimChannelPointsPatch {
+ public static boolean shouldAutoClaim() {
+ return Settings.AUTO_CLAIM_CHANNEL_POINTS.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/DebugModePatch.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/DebugModePatch.java
new file mode 100644
index 000000000..dc4ab8094
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/DebugModePatch.java
@@ -0,0 +1,10 @@
+package app.revanced.extension.twitch.patches;
+
+import app.revanced.extension.twitch.settings.Settings;
+
+@SuppressWarnings("unused")
+public class DebugModePatch {
+ public static boolean isDebugModeEnabled() {
+ return Settings.TWITCH_DEBUG_MODE.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/EmbeddedAdsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/EmbeddedAdsPatch.java
new file mode 100644
index 000000000..bb172d1a8
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/EmbeddedAdsPatch.java
@@ -0,0 +1,10 @@
+package app.revanced.extension.twitch.patches;
+
+import app.revanced.extension.twitch.api.RequestInterceptor;
+
+@SuppressWarnings("unused")
+public class EmbeddedAdsPatch {
+ public static RequestInterceptor createRequestInterceptor() {
+ return new RequestInterceptor();
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/ShowDeletedMessagesPatch.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/ShowDeletedMessagesPatch.java
new file mode 100644
index 000000000..747a6b94d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/ShowDeletedMessagesPatch.java
@@ -0,0 +1,51 @@
+package app.revanced.extension.twitch.patches;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.SpannedString;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.StrikethroughSpan;
+import android.text.style.StyleSpan;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.twitch.settings.Settings;
+import tv.twitch.android.shared.chat.util.ClickableUsernameSpan;
+
+@SuppressWarnings("unused")
+public class ShowDeletedMessagesPatch {
+
+ /**
+ * Injection point.
+ */
+ public static boolean shouldUseSpoiler() {
+ return "spoiler".equals(Settings.SHOW_DELETED_MESSAGES.get());
+ }
+
+ public static boolean shouldCrossOut() {
+ return "cross-out".equals(Settings.SHOW_DELETED_MESSAGES.get());
+ }
+
+ @Nullable
+ public static Spanned reformatDeletedMessage(Spanned original) {
+ if (!shouldCrossOut())
+ return null;
+
+ SpannableStringBuilder ssb = new SpannableStringBuilder(original);
+ ssb.setSpan(new StrikethroughSpan(), 0, original.length(), 0);
+ ssb.append(" (").append(str("revanced_deleted_msg")).append(")");
+ ssb.setSpan(new StyleSpan(Typeface.ITALIC), original.length(), ssb.length(), 0);
+
+ // Gray-out username
+ ClickableUsernameSpan[] usernameSpans = original.getSpans(0, original.length(), ClickableUsernameSpan.class);
+ if (usernameSpans.length > 0) {
+ ssb.setSpan(new ForegroundColorSpan(Color.parseColor("#ADADB8")), 0, original.getSpanEnd(usernameSpans[0]), 0);
+ }
+
+ return new SpannedString(ssb);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/VideoAdsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/VideoAdsPatch.java
new file mode 100644
index 000000000..6c7b739af
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/patches/VideoAdsPatch.java
@@ -0,0 +1,10 @@
+package app.revanced.extension.twitch.patches;
+
+import app.revanced.extension.twitch.settings.Settings;
+
+@SuppressWarnings("unused")
+public class VideoAdsPatch {
+ public static boolean shouldBlockVideoAds() {
+ return Settings.BLOCK_VIDEO_ADS.get();
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/AppCompatActivityHook.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/AppCompatActivityHook.java
new file mode 100644
index 000000000..e617cf9b2
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/AppCompatActivityHook.java
@@ -0,0 +1,112 @@
+package app.revanced.extension.twitch.settings;
+
+import android.content.Intent;
+import android.os.Bundle;
+import androidx.appcompat.app.ActionBar;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.twitch.settings.preference.ReVancedPreferenceFragment;
+import tv.twitch.android.feature.settings.menu.SettingsMenuGroup;
+import tv.twitch.android.settings.SettingsActivity;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Hooks AppCompatActivity.
+ *
+ * This class is responsible for injecting our own fragment by replacing the AppCompatActivity.
+ * @noinspection unused
+ */
+public class AppCompatActivityHook {
+ private static final int REVANCED_SETTINGS_MENU_ITEM_ID = 0x7;
+ private static final String EXTRA_REVANCED_SETTINGS = "app.revanced.twitch.settings";
+
+ /**
+ * Launches SettingsActivity and show ReVanced settings
+ */
+ public static void startSettingsActivity() {
+ Logger.printDebug(() -> "Launching ReVanced settings");
+
+ final var context = Utils.getContext();
+
+ if (context != null) {
+ Intent intent = new Intent(context, SettingsActivity.class);
+ Bundle bundle = new Bundle();
+ bundle.putBoolean(EXTRA_REVANCED_SETTINGS, true);
+ intent.putExtras(bundle);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(intent);
+ }
+ }
+
+ /**
+ * Helper for easy access in smali
+ * @return Returns string resource id
+ */
+ public static int getReVancedSettingsString() {
+ return app.revanced.extension.twitch.Utils.getStringId("revanced_settings");
+ }
+
+ /**
+ * Intercepts settings menu group list creation in SettingsMenuPresenter$Event.MenuGroupsUpdated
+ * @return Returns a modified list of menu groups
+ */
+ public static List handleSettingMenuCreation(List settingGroups, Object revancedEntry) {
+ List groups = new ArrayList<>(settingGroups);
+
+ if (groups.isEmpty()) {
+ // Create new menu group if none exist yet
+ List items = new ArrayList<>();
+ items.add(revancedEntry);
+ groups.add(new SettingsMenuGroup(items));
+ } else {
+ // Add to last menu group
+ int groupIdx = groups.size() - 1;
+ List items = new ArrayList<>(groups.remove(groupIdx).getSettingsMenuItems());
+ items.add(revancedEntry);
+ groups.add(new SettingsMenuGroup(items));
+ }
+
+ Logger.printDebug(() -> settingGroups.size() + " menu groups in list");
+ return groups;
+ }
+
+ /**
+ * Intercepts settings menu group onclick events
+ * @return Returns true if handled, otherwise false
+ */
+ @SuppressWarnings("rawtypes")
+ public static boolean handleSettingMenuOnClick(Enum item) {
+ Logger.printDebug(() -> "item " + item.ordinal() + " clicked");
+ if (item.ordinal() != REVANCED_SETTINGS_MENU_ITEM_ID) {
+ return false;
+ }
+
+ startSettingsActivity();
+ return true;
+ }
+
+ /**
+ * Intercepts fragment loading in SettingsActivity.onCreate
+ * @return Returns true if the revanced settings have been requested by the user, otherwise false
+ */
+ public static boolean handleSettingsCreation(androidx.appcompat.app.AppCompatActivity base) {
+ if (!base.getIntent().getBooleanExtra(EXTRA_REVANCED_SETTINGS, false)) {
+ Logger.printDebug(() -> "Revanced settings not requested");
+ return false; // User wants to enter another settings fragment
+ }
+ Logger.printDebug(() -> "ReVanced settings requested");
+
+ ReVancedPreferenceFragment fragment = new ReVancedPreferenceFragment();
+ ActionBar supportActionBar = base.getSupportActionBar();
+ if (supportActionBar != null)
+ supportActionBar.setTitle(app.revanced.extension.twitch.Utils.getStringId("revanced_settings"));
+
+ base.getFragmentManager()
+ .beginTransaction()
+ .replace(Utils.getResourceIdentifier("fragment_container", "id"), fragment)
+ .commit();
+ return true;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/Settings.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/Settings.java
new file mode 100644
index 000000000..aa5fed4b2
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/Settings.java
@@ -0,0 +1,25 @@
+package app.revanced.extension.twitch.settings;
+
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.StringSetting;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+
+public class Settings extends BaseSettings {
+ /* Ads */
+ public static final BooleanSetting BLOCK_VIDEO_ADS = new BooleanSetting("revanced_block_video_ads", TRUE);
+ public static final BooleanSetting BLOCK_AUDIO_ADS = new BooleanSetting("revanced_block_audio_ads", TRUE);
+ public static final StringSetting BLOCK_EMBEDDED_ADS = new StringSetting("revanced_block_embedded_ads", "luminous");
+
+ /* Chat */
+ public static final StringSetting SHOW_DELETED_MESSAGES = new StringSetting("revanced_show_deleted_messages", "cross-out");
+ public static final BooleanSetting AUTO_CLAIM_CHANNEL_POINTS = new BooleanSetting("revanced_auto_claim_channel_points", TRUE);
+
+ /* Misc */
+ /**
+ * Not to be confused with {@link BaseSettings#DEBUG}.
+ */
+ public static final BooleanSetting TWITCH_DEBUG_MODE = new BooleanSetting("revanced_twitch_debug_mode", FALSE, true);
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/preference/CustomPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/preference/CustomPreferenceCategory.java
new file mode 100644
index 000000000..5cef4a0b7
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/preference/CustomPreferenceCategory.java
@@ -0,0 +1,23 @@
+package app.revanced.extension.twitch.settings.preference;
+
+import android.content.Context;
+import android.graphics.Color;
+import android.preference.PreferenceCategory;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.TextView;
+
+public class CustomPreferenceCategory extends PreferenceCategory {
+ public CustomPreferenceCategory(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onBindView(View rootView) {
+ super.onBindView(rootView);
+
+ if(rootView instanceof TextView) {
+ ((TextView) rootView).setTextColor(Color.parseColor("#8161b3"));
+ }
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/preference/ReVancedPreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/preference/ReVancedPreferenceFragment.java
new file mode 100644
index 000000000..d77d06e19
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitch/settings/preference/ReVancedPreferenceFragment.java
@@ -0,0 +1,21 @@
+package app.revanced.extension.twitch.settings.preference;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment;
+import app.revanced.extension.twitch.settings.Settings;
+
+/**
+ * Preference fragment for ReVanced settings
+ */
+public class ReVancedPreferenceFragment extends AbstractPreferenceFragment {
+
+ @Override
+ protected void initialize() {
+ super.initialize();
+
+ // Do anything that forces this apps Settings bundle to load.
+ if (Settings.BLOCK_VIDEO_ADS.get()) {
+ Logger.printDebug(() -> "Block video ads enabled"); // Any statement that references the app settings.
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/BaseJsonHook.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/BaseJsonHook.kt
new file mode 100644
index 000000000..306b58e0a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/BaseJsonHook.kt
@@ -0,0 +1,9 @@
+package app.revanced.extension.twitter.patches.hook.json
+
+import org.json.JSONObject
+
+abstract class BaseJsonHook : JsonHook {
+ abstract fun apply(json: JSONObject)
+
+ override fun transform(json: JSONObject) = json.apply { apply(json) }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/JsonHook.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/JsonHook.kt
new file mode 100644
index 000000000..2d6441be7
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/JsonHook.kt
@@ -0,0 +1,15 @@
+package app.revanced.extension.twitter.patches.hook.json
+
+import app.revanced.extension.twitter.patches.hook.patch.Hook
+import org.json.JSONObject
+
+interface JsonHook : Hook {
+ /**
+ * Transform a JSONObject.
+ *
+ * @param json The JSONObject.
+ */
+ fun transform(json: JSONObject): JSONObject
+
+ override fun hook(type: JSONObject) = transform(type)
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/JsonHookPatch.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/JsonHookPatch.kt
new file mode 100644
index 000000000..4d82c8b4e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/json/JsonHookPatch.kt
@@ -0,0 +1,30 @@
+package app.revanced.extension.twitter.patches.hook.json
+
+import app.revanced.extension.twitter.patches.hook.patch.dummy.DummyHook
+import app.revanced.extension.twitter.utils.json.JsonUtils.parseJson
+import app.revanced.extension.twitter.utils.stream.StreamUtils
+import org.json.JSONException
+import java.io.IOException
+import java.io.InputStream
+
+object JsonHookPatch {
+ // Additional hooks added by corresponding patch.
+ private val hooks = buildList {
+ add(DummyHook)
+ }
+
+ @JvmStatic
+ fun parseJsonHook(jsonInputStream: InputStream): InputStream {
+ var jsonObject = try {
+ parseJson(jsonInputStream)
+ } catch (ignored: IOException) {
+ return jsonInputStream // Unreachable.
+ } catch (ignored: JSONException) {
+ return jsonInputStream
+ }
+
+ for (hook in hooks) jsonObject = hook.hook(jsonObject)
+
+ return StreamUtils.fromString(jsonObject.toString())
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/Hook.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/Hook.kt
new file mode 100644
index 000000000..3211e40e8
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/Hook.kt
@@ -0,0 +1,9 @@
+package app.revanced.extension.twitter.patches.hook.patch
+
+interface Hook {
+ /**
+ * Hook the given type.
+ * @param type The type to hook
+ */
+ fun hook(type: T): T
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/ads/HideAdsHook.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/ads/HideAdsHook.kt
new file mode 100644
index 000000000..de2f7b2fa
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/ads/HideAdsHook.kt
@@ -0,0 +1,15 @@
+package app.revanced.extension.twitter.patches.hook.patch.ads
+
+import app.revanced.extension.twitter.patches.hook.json.BaseJsonHook
+import app.revanced.extension.twitter.patches.hook.twifucker.TwiFucker
+import org.json.JSONObject
+
+@Suppress("unused")
+object HideAdsHook : BaseJsonHook() {
+ /**
+ * Strips JSONObject from promoted ads.
+ *
+ * @param json The JSONObject.
+ */
+ override fun apply(json: JSONObject) = TwiFucker.hidePromotedAds(json)
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/dummy/DummyHook.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/dummy/DummyHook.kt
new file mode 100644
index 000000000..9dd620d91
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/dummy/DummyHook.kt
@@ -0,0 +1,14 @@
+package app.revanced.extension.twitter.patches.hook.patch.dummy
+
+import app.revanced.extension.twitter.patches.hook.json.BaseJsonHook
+import app.revanced.extension.twitter.patches.hook.json.JsonHookPatch
+import org.json.JSONObject
+
+/**
+ * Dummy hook to reserve a register in [JsonHookPatch.hooks] list.
+ */
+object DummyHook : BaseJsonHook() {
+ override fun apply(json: JSONObject) {
+ // Do nothing.
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/recommendation/RecommendedUsersHook.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/recommendation/RecommendedUsersHook.kt
new file mode 100644
index 000000000..161801dc2
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/patch/recommendation/RecommendedUsersHook.kt
@@ -0,0 +1,14 @@
+package app.revanced.extension.twitter.patches.hook.patch.recommendation
+
+import app.revanced.extension.twitter.patches.hook.json.BaseJsonHook
+import app.revanced.extension.twitter.patches.hook.twifucker.TwiFucker
+import org.json.JSONObject
+
+object RecommendedUsersHook : BaseJsonHook() {
+ /**
+ * Strips JSONObject from recommended users.
+ *
+ * @param json The JSONObject.
+ */
+ override fun apply(json: JSONObject) = TwiFucker.hideRecommendedUsers(json)
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/twifucker/TwiFucker.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/twifucker/TwiFucker.kt
new file mode 100644
index 000000000..af5b0312e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/twifucker/TwiFucker.kt
@@ -0,0 +1,218 @@
+package app.revanced.extension.twitter.patches.hook.twifucker
+
+import android.util.Log
+import app.revanced.extension.twitter.patches.hook.twifucker.TwiFuckerUtils.forEach
+import app.revanced.extension.twitter.patches.hook.twifucker.TwiFuckerUtils.forEachIndexed
+import org.json.JSONArray
+import org.json.JSONObject
+
+// https://raw.githubusercontent.com/Dr-TSNG/TwiFucker/880cdf1c1622e54ab45561ffcb4f53d94ed97bae/app/src/main/java/icu/nullptr/twifucker/hook/JsonHook.kt
+internal object TwiFucker {
+ // root
+ private fun JSONObject.jsonGetInstructions(): JSONArray? = optJSONObject("timeline")?.optJSONArray("instructions")
+
+ private fun JSONObject.jsonGetData(): JSONObject? = optJSONObject("data")
+
+ private fun JSONObject.jsonHasRecommendedUsers(): Boolean = has("recommended_users")
+
+ private fun JSONObject.jsonRemoveRecommendedUsers() {
+ remove("recommended_users")
+ }
+
+ private fun JSONObject.jsonCheckAndRemoveRecommendedUsers() {
+ if (jsonHasRecommendedUsers()) {
+ Log.d("ReVanced", "Handle recommended users: $this")
+ jsonRemoveRecommendedUsers()
+ }
+ }
+
+ private fun JSONObject.jsonHasThreads(): Boolean = has("threads")
+
+ private fun JSONObject.jsonRemoveThreads() {
+ remove("threads")
+ }
+
+ private fun JSONObject.jsonCheckAndRemoveThreads() {
+ if (jsonHasThreads()) {
+ Log.d("ReVanced", "Handle threads: $this")
+ jsonRemoveThreads()
+ }
+ }
+
+ // data
+ private fun JSONObject.dataGetInstructions(): JSONArray? {
+ val timeline =
+ optJSONObject("user_result")?.optJSONObject("result")
+ ?.optJSONObject("timeline_response")?.optJSONObject("timeline")
+ ?: optJSONObject("timeline_response")?.optJSONObject("timeline")
+ ?: optJSONObject("search")?.optJSONObject("timeline_response")?.optJSONObject("timeline")
+ ?: optJSONObject("timeline_response")
+ return timeline?.optJSONArray("instructions")
+ }
+
+ private fun JSONObject.dataCheckAndRemove() {
+ dataGetInstructions()?.forEach { instruction ->
+ instruction.instructionCheckAndRemove { it.entriesRemoveAnnoyance() }
+ }
+ }
+
+ private fun JSONObject.dataGetLegacy(): JSONObject? =
+ optJSONObject("tweet_result")?.optJSONObject("result")?.let {
+ if (it.has("tweet")) {
+ it.optJSONObject("tweet")
+ } else {
+ it
+ }
+ }?.optJSONObject("legacy")
+
+ // entry
+ private fun JSONObject.entryHasPromotedMetadata(): Boolean =
+ optJSONObject("content")?.optJSONObject("item")?.optJSONObject("content")
+ ?.optJSONObject("tweet")
+ ?.has("promotedMetadata") == true || optJSONObject("content")?.optJSONObject("content")
+ ?.has("tweetPromotedMetadata") == true || optJSONObject("item")?.optJSONObject("content")
+ ?.has("tweetPromotedMetadata") == true
+
+ private fun JSONObject.entryGetContentItems(): JSONArray? =
+ optJSONObject("content")?.optJSONArray("items")
+ ?: optJSONObject("content")?.optJSONObject("timelineModule")?.optJSONArray("items")
+
+ private fun JSONObject.entryIsTweetDetailRelatedTweets(): Boolean = optString("entryId").startsWith("tweetdetailrelatedtweets-")
+
+ private fun JSONObject.entryGetTrends(): JSONArray? = optJSONObject("content")?.optJSONObject("timelineModule")?.optJSONArray("items")
+
+ // trend
+ private fun JSONObject.trendHasPromotedMetadata(): Boolean =
+ optJSONObject("item")?.optJSONObject("content")?.optJSONObject("trend")
+ ?.has("promotedMetadata") == true
+
+ private fun JSONArray.trendRemoveAds() {
+ val trendRemoveIndex = mutableListOf()
+ forEachIndexed { trendIndex, trend ->
+ if (trend.trendHasPromotedMetadata()) {
+ Log.d("ReVanced", "Handle trends ads $trendIndex $trend")
+ trendRemoveIndex.add(trendIndex)
+ }
+ }
+ for (i in trendRemoveIndex.asReversed()) {
+ remove(i)
+ }
+ }
+
+ // instruction
+ private fun JSONObject.instructionTimelineAddEntries(): JSONArray? = optJSONArray("entries")
+
+ private fun JSONObject.instructionGetAddEntries(): JSONArray? = optJSONObject("addEntries")?.optJSONArray("entries")
+
+ private fun JSONObject.instructionCheckAndRemove(action: (JSONArray) -> Unit) {
+ instructionTimelineAddEntries()?.let(action)
+ instructionGetAddEntries()?.let(action)
+ }
+
+ // entries
+ private fun JSONArray.entriesRemoveTimelineAds() {
+ val removeIndex = mutableListOf()
+ forEachIndexed { entryIndex, entry ->
+ entry.entryGetTrends()?.trendRemoveAds()
+
+ if (entry.entryHasPromotedMetadata()) {
+ Log.d("ReVanced", "Handle timeline ads $entryIndex $entry")
+ removeIndex.add(entryIndex)
+ }
+
+ val innerRemoveIndex = mutableListOf()
+ val contentItems = entry.entryGetContentItems()
+ contentItems?.forEachIndexed inner@{ itemIndex, item ->
+ if (item.entryHasPromotedMetadata()) {
+ Log.d("ReVanced", "Handle timeline replies ads $entryIndex $entry")
+ if (contentItems.length() == 1) {
+ removeIndex.add(entryIndex)
+ } else {
+ innerRemoveIndex.add(itemIndex)
+ }
+ return@inner
+ }
+ }
+ for (i in innerRemoveIndex.asReversed()) {
+ contentItems?.remove(i)
+ }
+ }
+ for (i in removeIndex.reversed()) {
+ remove(i)
+ }
+ }
+
+ private fun JSONArray.entriesRemoveTweetDetailRelatedTweets() {
+ val removeIndex = mutableListOf()
+ forEachIndexed { entryIndex, entry ->
+
+ if (entry.entryIsTweetDetailRelatedTweets()) {
+ Log.d("ReVanced", "Handle tweet detail related tweets $entryIndex $entry")
+ removeIndex.add(entryIndex)
+ }
+ }
+ for (i in removeIndex.reversed()) {
+ remove(i)
+ }
+ }
+
+ private fun JSONArray.entriesRemoveAnnoyance() {
+ entriesRemoveTimelineAds()
+ entriesRemoveTweetDetailRelatedTweets()
+ }
+
+ private fun JSONObject.entryIsWhoToFollow(): Boolean =
+ optString("entryId").let {
+ it.startsWith("whoToFollow-") || it.startsWith("who-to-follow-") || it.startsWith("connect-module-")
+ }
+
+ private fun JSONObject.itemContainsPromotedUser(): Boolean =
+ optJSONObject("item")?.optJSONObject("content")
+ ?.has("userPromotedMetadata") == true || optJSONObject("item")?.optJSONObject("content")
+ ?.optJSONObject("user")
+ ?.has("userPromotedMetadata") == true || optJSONObject("item")?.optJSONObject("content")
+ ?.optJSONObject("user")?.has("promotedMetadata") == true
+
+ fun JSONArray.entriesRemoveWhoToFollow() {
+ val entryRemoveIndex = mutableListOf()
+ forEachIndexed { entryIndex, entry ->
+ if (!entry.entryIsWhoToFollow()) return@forEachIndexed
+
+ Log.d("ReVanced", "Handle whoToFollow $entryIndex $entry")
+ entryRemoveIndex.add(entryIndex)
+
+ val items = entry.entryGetContentItems()
+ val userRemoveIndex = mutableListOf()
+ items?.forEachIndexed { index, item ->
+ item.itemContainsPromotedUser().let {
+ if (it) {
+ Log.d("ReVanced", "Handle whoToFollow promoted user $index $item")
+ userRemoveIndex.add(index)
+ }
+ }
+ }
+ for (i in userRemoveIndex.reversed()) {
+ items?.remove(i)
+ }
+ }
+ for (i in entryRemoveIndex.reversed()) {
+ remove(i)
+ }
+ }
+
+ fun hideRecommendedUsers(json: JSONObject) {
+ json.filterInstructions { it.entriesRemoveWhoToFollow() }
+ json.jsonCheckAndRemoveRecommendedUsers()
+ }
+
+ fun hidePromotedAds(json: JSONObject) {
+ json.filterInstructions { it.entriesRemoveAnnoyance() }
+ json.jsonGetData()?.dataCheckAndRemove()
+ }
+
+ private fun JSONObject.filterInstructions(action: (JSONArray) -> Unit) {
+ jsonGetInstructions()?.forEach { instruction ->
+ instruction.instructionCheckAndRemove(action)
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/twifucker/TwiFuckerUtils.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/twifucker/TwiFuckerUtils.kt
new file mode 100644
index 000000000..4872d95aa
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/hook/twifucker/TwiFuckerUtils.kt
@@ -0,0 +1,22 @@
+package app.revanced.extension.twitter.patches.hook.twifucker
+
+import org.json.JSONArray
+import org.json.JSONObject
+
+internal object TwiFuckerUtils {
+ inline fun JSONArray.forEach(action: (JSONObject) -> Unit) {
+ (0 until this.length()).forEach { i ->
+ if (this[i] is JSONObject) {
+ action(this[i] as JSONObject)
+ }
+ }
+ }
+
+ inline fun JSONArray.forEachIndexed(action: (index: Int, JSONObject) -> Unit) {
+ (0 until this.length()).forEach { i ->
+ if (this[i] is JSONObject) {
+ action(i, this[i] as JSONObject)
+ }
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/links/ChangeLinkSharingDomainPatch.java b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/links/ChangeLinkSharingDomainPatch.java
new file mode 100644
index 000000000..808a8de03
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/links/ChangeLinkSharingDomainPatch.java
@@ -0,0 +1,16 @@
+package app.revanced.extension.twitter.patches.links;
+
+public final class ChangeLinkSharingDomainPatch {
+ private static final String DOMAIN_NAME = "https://fxtwitter.com";
+ private static final String LINK_FORMAT = "%s/%s/status/%s";
+
+ public static String formatResourceLink(Object... formatArgs) {
+ String username = (String) formatArgs[0];
+ String tweetId = (String) formatArgs[1];
+ return String.format(LINK_FORMAT, DOMAIN_NAME, username, tweetId);
+ }
+
+ public static String formatLink(long tweetId, String username) {
+ return String.format(LINK_FORMAT, DOMAIN_NAME, username, tweetId);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/links/OpenLinksWithAppChooserPatch.java b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/links/OpenLinksWithAppChooserPatch.java
new file mode 100644
index 000000000..2b4bdc124
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/patches/links/OpenLinksWithAppChooserPatch.java
@@ -0,0 +1,15 @@
+package app.revanced.extension.twitter.patches.links;
+
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+public final class OpenLinksWithAppChooserPatch {
+ public static void openWithChooser(final Context context, final Intent intent) {
+ Log.d("ReVanced", "Opening intent with chooser: " + intent);
+
+ intent.setAction("android.intent.action.VIEW");
+
+ context.startActivity(Intent.createChooser(intent, null));
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/utils/json/JsonUtils.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/utils/json/JsonUtils.kt
new file mode 100644
index 000000000..d046c6370
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/utils/json/JsonUtils.kt
@@ -0,0 +1,13 @@
+package app.revanced.extension.twitter.utils.json
+
+import app.revanced.extension.twitter.utils.stream.StreamUtils
+import org.json.JSONException
+import org.json.JSONObject
+import java.io.IOException
+import java.io.InputStream
+
+object JsonUtils {
+ @JvmStatic
+ @Throws(IOException::class, JSONException::class)
+ fun parseJson(jsonInputStream: InputStream) = JSONObject(StreamUtils.toString(jsonInputStream))
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/twitter/utils/stream/StreamUtils.kt b/extensions/shared/src/main/java/app/revanced/extension/twitter/utils/stream/StreamUtils.kt
new file mode 100644
index 000000000..ff33c4409
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/twitter/utils/stream/StreamUtils.kt
@@ -0,0 +1,24 @@
+package app.revanced.extension.twitter.utils.stream
+
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.IOException
+import java.io.InputStream
+
+object StreamUtils {
+ @Throws(IOException::class)
+ fun toString(inputStream: InputStream): String {
+ ByteArrayOutputStream().use { result ->
+ val buffer = ByteArray(1024)
+ var length: Int
+ while (inputStream.read(buffer).also { length = it } != -1) {
+ result.write(buffer, 0, length)
+ }
+ return result.toString()
+ }
+ }
+
+ fun fromString(string: String): InputStream {
+ return ByteArrayInputStream(string.toByteArray())
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/ByteTrieSearch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/ByteTrieSearch.java
new file mode 100644
index 000000000..162e0b040
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/ByteTrieSearch.java
@@ -0,0 +1,45 @@
+package app.revanced.extension.youtube;
+
+import androidx.annotation.NonNull;
+
+import java.nio.charset.StandardCharsets;
+
+public final class ByteTrieSearch extends TrieSearch {
+
+ private static final class ByteTrieNode extends TrieNode {
+ ByteTrieNode() {
+ super();
+ }
+ ByteTrieNode(char nodeCharacterValue) {
+ super(nodeCharacterValue);
+ }
+ @Override
+ TrieNode createNode(char nodeCharacterValue) {
+ return new ByteTrieNode(nodeCharacterValue);
+ }
+ @Override
+ char getCharValue(byte[] text, int index) {
+ return (char) text[index];
+ }
+ @Override
+ int getTextLength(byte[] text) {
+ return text.length;
+ }
+ }
+
+ /**
+ * Helper method for the common usage of converting Strings to raw UTF-8 bytes.
+ */
+ public static byte[][] convertStringsToBytes(String... strings) {
+ final int length = strings.length;
+ byte[][] replacement = new byte[length][];
+ for (int i = 0; i < length; i++) {
+ replacement[i] = strings[i].getBytes(StandardCharsets.UTF_8);
+ }
+ return replacement;
+ }
+
+ public ByteTrieSearch(@NonNull byte[]... patterns) {
+ super(new ByteTrieNode(), patterns);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/Event.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/Event.kt
new file mode 100644
index 000000000..72323949c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/Event.kt
@@ -0,0 +1,29 @@
+package app.revanced.extension.youtube
+
+/**
+ * generic event provider class
+ */
+class Event {
+ private val eventListeners = mutableSetOf<(T) -> Unit>()
+
+ operator fun plusAssign(observer: (T) -> Unit) {
+ addObserver(observer)
+ }
+
+ fun addObserver(observer: (T) -> Unit) {
+ eventListeners.add(observer)
+ }
+
+ operator fun minusAssign(observer: (T) -> Unit) {
+ removeObserver(observer)
+ }
+
+ fun removeObserver(observer: (T) -> Unit) {
+ eventListeners.remove(observer)
+ }
+
+ operator fun invoke(value: T) {
+ for (observer in eventListeners)
+ observer.invoke(value)
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/StringTrieSearch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/StringTrieSearch.java
new file mode 100644
index 000000000..fbff9beba
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/StringTrieSearch.java
@@ -0,0 +1,34 @@
+package app.revanced.extension.youtube;
+
+import androidx.annotation.NonNull;
+
+/**
+ * Text pattern searching using a prefix tree (trie).
+ */
+public final class StringTrieSearch extends TrieSearch {
+
+ private static final class StringTrieNode extends TrieNode {
+ StringTrieNode() {
+ super();
+ }
+ StringTrieNode(char nodeCharacterValue) {
+ super(nodeCharacterValue);
+ }
+ @Override
+ TrieNode createNode(char nodeValue) {
+ return new StringTrieNode(nodeValue);
+ }
+ @Override
+ char getCharValue(String text, int index) {
+ return text.charAt(index);
+ }
+ @Override
+ int getTextLength(String text) {
+ return text.length();
+ }
+ }
+
+ public StringTrieSearch(@NonNull String... patterns) {
+ super(new StringTrieNode(), patterns);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/ThemeHelper.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/ThemeHelper.java
new file mode 100644
index 000000000..6e0ea5974
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/ThemeHelper.java
@@ -0,0 +1,85 @@
+package app.revanced.extension.youtube;
+
+import android.app.Activity;
+import android.graphics.Color;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+
+public class ThemeHelper {
+ @Nullable
+ private static Integer darkThemeColor, lightThemeColor;
+ private static int themeValue;
+
+ /**
+ * Injection point.
+ */
+ @SuppressWarnings("unused")
+ public static void setTheme(Enum> value) {
+ final int newOrdinalValue = value.ordinal();
+ if (themeValue != newOrdinalValue) {
+ themeValue = newOrdinalValue;
+ Logger.printDebug(() -> "Theme value: " + newOrdinalValue);
+ }
+ }
+
+ public static boolean isDarkTheme() {
+ return themeValue == 1;
+ }
+
+ public static void setActivityTheme(Activity activity) {
+ final var theme = isDarkTheme()
+ ? "Theme.YouTube.Settings.Dark"
+ : "Theme.YouTube.Settings";
+ activity.setTheme(Utils.getResourceIdentifier(theme, "style"));
+ }
+
+ /**
+ * Injection point.
+ */
+ @SuppressWarnings("SameReturnValue")
+ private static String darkThemeResourceName() {
+ // Value is changed by Theme patch, if included.
+ return "@color/yt_black3";
+ }
+
+ /**
+ * @return The dark theme color as specified by the Theme patch (if included),
+ * or the dark mode background color unpatched YT uses.
+ */
+ public static int getDarkThemeColor() {
+ if (darkThemeColor == null) {
+ darkThemeColor = getColorInt(darkThemeResourceName());
+ }
+ return darkThemeColor;
+ }
+
+ /**
+ * Injection point.
+ */
+ @SuppressWarnings("SameReturnValue")
+ private static String lightThemeResourceName() {
+ // Value is changed by Theme patch, if included.
+ return "@color/yt_white1";
+ }
+
+ /**
+ * @return The light theme color as specified by the Theme patch (if included),
+ * or the non dark mode background color unpatched YT uses.
+ */
+ public static int getLightThemeColor() {
+ if (lightThemeColor == null) {
+ lightThemeColor = getColorInt(lightThemeResourceName());
+ }
+ return lightThemeColor;
+ }
+
+ private static int getColorInt(String colorString) {
+ if (colorString.startsWith("#")) {
+ return Color.parseColor(colorString);
+ }
+ return Utils.getResourceColor(colorString);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/TrieSearch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/TrieSearch.java
new file mode 100644
index 000000000..74fb4685d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/TrieSearch.java
@@ -0,0 +1,412 @@
+package app.revanced.extension.youtube;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Searches for a group of different patterns using a trie (prefix tree).
+ * Can significantly speed up searching for multiple patterns.
+ */
+public abstract class TrieSearch {
+
+ public interface TriePatternMatchedCallback {
+ /**
+ * Called when a pattern is matched.
+ *
+ * @param textSearched Text that was searched.
+ * @param matchedStartIndex Start index of the search text, where the pattern was matched.
+ * @param matchedLength Length of the match.
+ * @param callbackParameter Optional parameter passed into {@link TrieSearch#matches(Object, Object)}.
+ * @return True, if the search should stop here.
+ * If false, searching will continue to look for other matches.
+ */
+ boolean patternMatched(T textSearched, int matchedStartIndex, int matchedLength, Object callbackParameter);
+ }
+
+ /**
+ * Represents a compressed tree path for a single pattern that shares no sibling nodes.
+ *
+ * For example, if a tree contains the patterns: "foobar", "football", "feet",
+ * it would contain 3 compressed paths of: "bar", "tball", "eet".
+ *
+ * And the tree would contain children arrays only for the first level containing 'f',
+ * the second level containing 'o',
+ * and the third level containing 'o'.
+ *
+ * This is done to reduce memory usage, which can be substantial if many long patterns are used.
+ */
+ private static final class TrieCompressedPath {
+ final T pattern;
+ final int patternStartIndex;
+ final int patternLength;
+ final TriePatternMatchedCallback callback;
+
+ TrieCompressedPath(T pattern, int patternStartIndex, int patternLength, TriePatternMatchedCallback callback) {
+ this.pattern = pattern;
+ this.patternStartIndex = patternStartIndex;
+ this.patternLength = patternLength;
+ this.callback = callback;
+ }
+ boolean matches(TrieNode enclosingNode, // Used only for the get character method.
+ T searchText, int searchTextLength, int searchTextIndex, Object callbackParameter) {
+ if (searchTextLength - searchTextIndex < patternLength - patternStartIndex) {
+ return false; // Remaining search text is shorter than the remaining leaf pattern and they cannot match.
+ }
+ for (int i = searchTextIndex, j = patternStartIndex; j < patternLength; i++, j++) {
+ if (enclosingNode.getCharValue(searchText, i) != enclosingNode.getCharValue(pattern, j)) {
+ return false;
+ }
+ }
+ return callback == null || callback.patternMatched(searchText,
+ searchTextIndex - patternStartIndex, patternLength, callbackParameter);
+ }
+ }
+
+ static abstract class TrieNode {
+ /**
+ * Dummy value used for root node. Value can be anything as it's never referenced.
+ */
+ private static final char ROOT_NODE_CHARACTER_VALUE = 0; // ASCII null character.
+
+ /**
+ * How much to expand the children array when resizing.
+ */
+ private static final int CHILDREN_ARRAY_INCREASE_SIZE_INCREMENT = 2;
+
+ /**
+ * Character this node represents.
+ * This field is ignored for the root node (which does not represent any character).
+ */
+ private final char nodeValue;
+
+ /**
+ * A compressed graph path that represents the remaining pattern characters of a single child node.
+ *
+ * If present then child array is always null, although callbacks for other
+ * end of patterns can also exist on this same node.
+ */
+ @Nullable
+ private TrieCompressedPath leaf;
+
+ /**
+ * All child nodes. Only present if no compressed leaf exist.
+ *
+ * Array is dynamically increased in size as needed,
+ * and uses perfect hashing for the elements it contains.
+ *
+ * So if the array contains a given character,
+ * the character will always map to the node with index: (character % arraySize).
+ *
+ * Elements not contained can collide with elements the array does contain,
+ * so must compare the nodes character value.
+ *
+ * Alternatively this array could be a sorted and densely packed array,
+ * and lookup is done using binary search.
+ * That would save a small amount of memory because there's no null children entries,
+ * but would give a worst case search of O(nlog(m)) where n is the number of
+ * characters in the searched text and m is the maximum size of the sorted character arrays.
+ * Using a hash table array always gives O(n) search time.
+ * The memory usage here is very small (all Litho filters use ~10KB of memory),
+ * so the more performant hash implementation is chosen.
+ */
+ @Nullable
+ private TrieNode[] children;
+
+ /**
+ * Callbacks for all patterns that end at this node.
+ */
+ @Nullable
+ private List> endOfPatternCallback;
+
+ TrieNode() {
+ this.nodeValue = ROOT_NODE_CHARACTER_VALUE;
+ }
+ TrieNode(char nodeCharacterValue) {
+ this.nodeValue = nodeCharacterValue;
+ }
+
+ /**
+ * @param pattern Pattern to add.
+ * @param patternIndex Current recursive index of the pattern.
+ * @param patternLength Length of the pattern.
+ * @param callback Callback, where a value of NULL indicates to always accept a pattern match.
+ */
+ private void addPattern(@NonNull T pattern, int patternIndex, int patternLength,
+ @Nullable TriePatternMatchedCallback callback) {
+ if (patternIndex == patternLength) { // Reached the end of the pattern.
+ if (endOfPatternCallback == null) {
+ endOfPatternCallback = new ArrayList<>(1);
+ }
+ endOfPatternCallback.add(callback);
+ return;
+ }
+ if (leaf != null) {
+ // Reached end of the graph and a leaf exist.
+ // Recursively call back into this method and push the existing leaf down 1 level.
+ if (children != null) throw new IllegalStateException();
+ //noinspection unchecked
+ children = new TrieNode[1];
+ TrieCompressedPath temp = leaf;
+ leaf = null;
+ addPattern(temp.pattern, temp.patternStartIndex, temp.patternLength, temp.callback);
+ // Continue onward and add the parameter pattern.
+ } else if (children == null) {
+ leaf = new TrieCompressedPath<>(pattern, patternIndex, patternLength, callback);
+ return;
+ }
+ final char character = getCharValue(pattern, patternIndex);
+ final int arrayIndex = hashIndexForTableSize(children.length, character);
+ TrieNode child = children[arrayIndex];
+ if (child == null) {
+ child = createNode(character);
+ children[arrayIndex] = child;
+ } else if (child.nodeValue != character) {
+ // Hash collision. Resize the table until perfect hashing is found.
+ child = createNode(character);
+ expandChildArray(child);
+ }
+ child.addPattern(pattern, patternIndex + 1, patternLength, callback);
+ }
+
+ /**
+ * Resizes the children table until all nodes hash to exactly one array index.
+ */
+ private void expandChildArray(TrieNode child) {
+ int replacementArraySize = Objects.requireNonNull(children).length;
+ while (true) {
+ replacementArraySize += CHILDREN_ARRAY_INCREASE_SIZE_INCREMENT;
+ //noinspection unchecked
+ TrieNode[] replacement = new TrieNode[replacementArraySize];
+ addNodeToArray(replacement, child);
+ boolean collision = false;
+ for (TrieNode existingChild : children) {
+ if (existingChild != null) {
+ if (!addNodeToArray(replacement, existingChild)) {
+ collision = true;
+ break;
+ }
+ }
+ }
+ if (collision) {
+ continue;
+ }
+ children = replacement;
+ return;
+ }
+ }
+
+ private static boolean addNodeToArray(TrieNode[] array, TrieNode childToAdd) {
+ final int insertIndex = hashIndexForTableSize(array.length, childToAdd.nodeValue);
+ if (array[insertIndex] != null ) {
+ return false; // Collision.
+ }
+ array[insertIndex] = childToAdd;
+ return true;
+ }
+
+ private static int hashIndexForTableSize(int arraySize, char nodeValue) {
+ return nodeValue % arraySize;
+ }
+
+ /**
+ * This method is static and uses a loop to avoid all recursion.
+ * This is done for performance since the JVM does not optimize tail recursion.
+ *
+ * @param startNode Node to start the search from.
+ * @param searchText Text to search for patterns in.
+ * @param searchTextIndex Start index, inclusive.
+ * @param searchTextEndIndex End index, exclusive.
+ * @return If any pattern matches, and it's associated callback halted the search.
+ */
+ private static boolean matches(final TrieNode startNode, final T searchText,
+ int searchTextIndex, final int searchTextEndIndex,
+ final Object callbackParameter) {
+ TrieNode node = startNode;
+ int currentMatchLength = 0;
+
+ while (true) {
+ TrieCompressedPath leaf = node.leaf;
+ if (leaf != null && leaf.matches(startNode, searchText, searchTextEndIndex, searchTextIndex, callbackParameter)) {
+ return true; // Leaf exists and it matched the search text.
+ }
+ List> endOfPatternCallback = node.endOfPatternCallback;
+ if (endOfPatternCallback != null) {
+ final int matchStartIndex = searchTextIndex - currentMatchLength;
+ for (@Nullable TriePatternMatchedCallback callback : endOfPatternCallback) {
+ if (callback == null) {
+ return true; // No callback and all matches are valid.
+ }
+ if (callback.patternMatched(searchText, matchStartIndex, currentMatchLength, callbackParameter)) {
+ return true; // Callback confirmed the match.
+ }
+ }
+ }
+ TrieNode[] children = node.children;
+ if (children == null) {
+ return false; // Reached a graph end point and there's no further patterns to search.
+ }
+ if (searchTextIndex == searchTextEndIndex) {
+ return false; // Reached end of the search text and found no matches.
+ }
+
+ // Use the start node to reduce VM method lookup, since all nodes are the same class type.
+ final char character = startNode.getCharValue(searchText, searchTextIndex);
+ final int arrayIndex = hashIndexForTableSize(children.length, character);
+ TrieNode child = children[arrayIndex];
+ if (child == null || child.nodeValue != character) {
+ return false;
+ }
+
+ node = child;
+ searchTextIndex++;
+ currentMatchLength++;
+ }
+ }
+
+ /**
+ * Gives an approximate memory usage.
+ *
+ * @return Estimated number of memory pointers used, starting from this node and including all children.
+ */
+ private int estimatedNumberOfPointersUsed() {
+ int numberOfPointers = 4; // Number of fields in this class.
+ if (leaf != null) {
+ numberOfPointers += 4; // Number of fields in leaf node.
+ }
+ if (endOfPatternCallback != null) {
+ numberOfPointers += endOfPatternCallback.size();
+ }
+ if (children != null) {
+ numberOfPointers += children.length;
+ for (TrieNode child : children) {
+ if (child != null) {
+ numberOfPointers += child.estimatedNumberOfPointersUsed();
+ }
+ }
+ }
+ return numberOfPointers;
+ }
+
+ abstract TrieNode createNode(char nodeValue);
+ abstract char getCharValue(T text, int index);
+ abstract int getTextLength(T text);
+ }
+
+ /**
+ * Root node, and it's children represent the first pattern characters.
+ */
+ private final TrieNode root;
+
+ /**
+ * Patterns to match.
+ */
+ private final List patterns = new ArrayList<>();
+
+ @SafeVarargs
+ TrieSearch(@NonNull TrieNode root, @NonNull T... patterns) {
+ this.root = Objects.requireNonNull(root);
+ addPatterns(patterns);
+ }
+
+ @SafeVarargs
+ public final void addPatterns(@NonNull T... patterns) {
+ for (T pattern : patterns) {
+ addPattern(pattern);
+ }
+ }
+
+ /**
+ * Adds a pattern that will always return a positive match if found.
+ *
+ * @param pattern Pattern to add. Calling this with a zero length pattern does nothing.
+ */
+ public void addPattern(@NonNull T pattern) {
+ addPattern(pattern, root.getTextLength(pattern), null);
+ }
+
+ /**
+ * @param pattern Pattern to add. Calling this with a zero length pattern does nothing.
+ * @param callback Callback to determine if searching should halt when a match is found.
+ */
+ public void addPattern(@NonNull T pattern, @NonNull TriePatternMatchedCallback callback) {
+ addPattern(pattern, root.getTextLength(pattern), Objects.requireNonNull(callback));
+ }
+
+ void addPattern(@NonNull T pattern, int patternLength, @Nullable TriePatternMatchedCallback callback) {
+ if (patternLength == 0) return; // Nothing to match
+
+ patterns.add(pattern);
+ root.addPattern(pattern, 0, patternLength, callback);
+ }
+
+ public final boolean matches(@NonNull T textToSearch) {
+ return matches(textToSearch, 0);
+ }
+
+ public boolean matches(@NonNull T textToSearch, @NonNull Object callbackParameter) {
+ return matches(textToSearch, 0, root.getTextLength(textToSearch),
+ Objects.requireNonNull(callbackParameter));
+ }
+
+ public boolean matches(@NonNull T textToSearch, int startIndex) {
+ return matches(textToSearch, startIndex, root.getTextLength(textToSearch));
+ }
+
+ public final boolean matches(@NonNull T textToSearch, int startIndex, int endIndex) {
+ return matches(textToSearch, startIndex, endIndex, null);
+ }
+
+ /**
+ * Searches through text, looking for any substring that matches any pattern in this tree.
+ *
+ * @param textToSearch Text to search through.
+ * @param startIndex Index to start searching, inclusive value.
+ * @param endIndex Index to stop matching, exclusive value.
+ * @param callbackParameter Optional parameter passed to the callbacks.
+ * @return If any pattern matched, and it's callback halted searching.
+ */
+ public boolean matches(@NonNull T textToSearch, int startIndex, int endIndex, @Nullable Object callbackParameter) {
+ return matches(textToSearch, root.getTextLength(textToSearch), startIndex, endIndex, callbackParameter);
+ }
+
+ private boolean matches(@NonNull T textToSearch, int textToSearchLength, int startIndex, int endIndex,
+ @Nullable Object callbackParameter) {
+ if (endIndex > textToSearchLength) {
+ throw new IllegalArgumentException("endIndex: " + endIndex
+ + " is greater than texToSearchLength: " + textToSearchLength);
+ }
+ if (patterns.isEmpty()) {
+ return false; // No patterns were added.
+ }
+ for (int i = startIndex; i < endIndex; i++) {
+ if (TrieNode.matches(root, textToSearch, i, endIndex, callbackParameter)) return true;
+ }
+ return false;
+ }
+
+ /**
+ * @return Estimated memory size (in kilobytes) of this instance.
+ */
+ public int getEstimatedMemorySize() {
+ if (patterns.isEmpty()) {
+ return 0;
+ }
+ // Assume the device has less than 32GB of ram (and can use pointer compression),
+ // or the device is 32-bit.
+ final int numberOfBytesPerPointer = 4;
+ return (int) Math.ceil((numberOfBytesPerPointer * root.estimatedNumberOfPointersUsed()) / 1024.0);
+ }
+
+ public int numberOfPatterns() {
+ return patterns.size();
+ }
+
+ public List getPatterns() {
+ return Collections.unmodifiableList(patterns);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/AlternativeThumbnailsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/AlternativeThumbnailsPatch.java
new file mode 100644
index 000000000..92be08433
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/AlternativeThumbnailsPatch.java
@@ -0,0 +1,710 @@
+package app.revanced.extension.youtube.patches;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.youtube.settings.Settings.*;
+import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
+
+import android.net.Uri;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.chromium.net.UrlRequest;
+import org.chromium.net.UrlResponseInfo;
+import org.chromium.net.impl.CronetUrlRequest;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.NavigationBar;
+import app.revanced.extension.youtube.shared.PlayerType;
+
+/**
+ * Alternative YouTube thumbnails.
+ *
+ * Can show YouTube provided screen captures of beginning/middle/end of the video.
+ * (ie: sd1.jpg, sd2.jpg, sd3.jpg).
+ *
+ * Or can show crowd-sourced thumbnails provided by DeArrow (... ).
+ *
+ * Or can use DeArrow and fall back to screen captures if DeArrow is not available.
+ *
+ * Has an additional option to use 'fast' video still thumbnails,
+ * where it forces sd thumbnail quality and skips verifying if the alt thumbnail image exists.
+ * The UI loading time will be the same or better than using original thumbnails,
+ * but thumbnails will initially fail to load for all live streams, unreleased, and occasionally very old videos.
+ * If a failed thumbnail load is reloaded (ie: scroll off, then on screen), then the original thumbnail
+ * is reloaded instead. Fast thumbnails requires using SD or lower thumbnail resolution,
+ * because a noticeable number of videos do not have hq720 and too much fail to load.
+ */
+@SuppressWarnings("unused")
+public final class AlternativeThumbnailsPatch {
+
+ // These must be class declarations if declared here,
+ // otherwise the app will not load due to cyclic initialization errors.
+ public static final class DeArrowAvailability implements Setting.Availability {
+ public static boolean usingDeArrowAnywhere() {
+ return ALT_THUMBNAIL_HOME.get().useDeArrow
+ || ALT_THUMBNAIL_SUBSCRIPTIONS.get().useDeArrow
+ || ALT_THUMBNAIL_LIBRARY.get().useDeArrow
+ || ALT_THUMBNAIL_PLAYER.get().useDeArrow
+ || ALT_THUMBNAIL_SEARCH.get().useDeArrow;
+ }
+
+ @Override
+ public boolean isAvailable() {
+ return usingDeArrowAnywhere();
+ }
+ }
+
+ public static final class StillImagesAvailability implements Setting.Availability {
+ public static boolean usingStillImagesAnywhere() {
+ return ALT_THUMBNAIL_HOME.get().useStillImages
+ || ALT_THUMBNAIL_SUBSCRIPTIONS.get().useStillImages
+ || ALT_THUMBNAIL_LIBRARY.get().useStillImages
+ || ALT_THUMBNAIL_PLAYER.get().useStillImages
+ || ALT_THUMBNAIL_SEARCH.get().useStillImages;
+ }
+
+ @Override
+ public boolean isAvailable() {
+ return usingStillImagesAnywhere();
+ }
+ }
+
+ public enum ThumbnailOption {
+ ORIGINAL(false, false),
+ DEARROW(true, false),
+ DEARROW_STILL_IMAGES(true, true),
+ STILL_IMAGES(false, true);
+
+ final boolean useDeArrow;
+ final boolean useStillImages;
+
+ ThumbnailOption(boolean useDeArrow, boolean useStillImages) {
+ this.useDeArrow = useDeArrow;
+ this.useStillImages = useStillImages;
+ }
+ }
+
+ public enum ThumbnailStillTime {
+ BEGINNING(1),
+ MIDDLE(2),
+ END(3);
+
+ /**
+ * The url alt image number. Such as the 2 in 'hq720_2.jpg'
+ */
+ final int altImageNumber;
+
+ ThumbnailStillTime(int altImageNumber) {
+ this.altImageNumber = altImageNumber;
+ }
+ }
+
+ private static final Uri dearrowApiUri;
+
+ /**
+ * The scheme and host of {@link #dearrowApiUri}.
+ */
+ private static final String deArrowApiUrlPrefix;
+
+ /**
+ * How long to temporarily turn off DeArrow if it fails for any reason.
+ */
+ private static final long DEARROW_FAILURE_API_BACKOFF_MILLISECONDS = 5 * 60 * 1000; // 5 Minutes.
+
+ /**
+ * If non zero, then the system time of when DeArrow API calls can resume.
+ */
+ private static volatile long timeToResumeDeArrowAPICalls;
+
+ static {
+ dearrowApiUri = validateSettings();
+ final int port = dearrowApiUri.getPort();
+ String portString = port == -1 ? "" : (":" + port);
+ deArrowApiUrlPrefix = dearrowApiUri.getScheme() + "://" + dearrowApiUri.getHost() + portString + "/";
+ Logger.printDebug(() -> "Using DeArrow API address: " + deArrowApiUrlPrefix);
+ }
+
+ /**
+ * Fix any bad imported data.
+ */
+ private static Uri validateSettings() {
+ Uri apiUri = Uri.parse(Settings.ALT_THUMBNAIL_DEARROW_API_URL.get());
+ // Cannot use unsecured 'http', otherwise the connections fail to start and no callbacks hooks are made.
+ String scheme = apiUri.getScheme();
+ if (scheme == null || scheme.equals("http") || apiUri.getHost() == null) {
+ Utils.showToastLong("Invalid DeArrow API URL. Using default");
+ Settings.ALT_THUMBNAIL_DEARROW_API_URL.resetToDefault();
+ return validateSettings();
+ }
+ return apiUri;
+ }
+
+ private static ThumbnailOption optionSettingForCurrentNavigation() {
+ // Must check player type first, as search bar can be active behind the player.
+ if (PlayerType.getCurrent().isMaximizedOrFullscreen()) {
+ return ALT_THUMBNAIL_PLAYER.get();
+ }
+
+ // Must check second, as search can be from any tab.
+ if (NavigationBar.isSearchBarActive()) {
+ return ALT_THUMBNAIL_SEARCH.get();
+ }
+
+ // Avoid checking which navigation button is selected, if all other settings are the same.
+ ThumbnailOption homeOption = ALT_THUMBNAIL_HOME.get();
+ ThumbnailOption subscriptionsOption = ALT_THUMBNAIL_SUBSCRIPTIONS.get();
+ ThumbnailOption libraryOption = ALT_THUMBNAIL_LIBRARY.get();
+ if ((homeOption == subscriptionsOption) && (homeOption == libraryOption)) {
+ return homeOption; // All are the same option.
+ }
+
+ NavigationButton selectedNavButton = NavigationButton.getSelectedNavigationButton();
+ if (selectedNavButton == null) {
+ // Unknown tab, treat as the home tab;
+ return homeOption;
+ }
+ if (selectedNavButton == NavigationButton.HOME) {
+ return homeOption;
+ }
+ if (selectedNavButton == NavigationButton.SUBSCRIPTIONS || selectedNavButton == NavigationButton.NOTIFICATIONS) {
+ return subscriptionsOption;
+ }
+ // A library tab variant is active.
+ return libraryOption;
+ }
+
+ /**
+ * Build the alternative thumbnail url using YouTube provided still video captures.
+ *
+ * @param decodedUrl Decoded original thumbnail request url.
+ * @return The alternative thumbnail url, or if not available NULL.
+ */
+ @Nullable
+ private static String buildYouTubeVideoStillURL(@NonNull DecodedThumbnailUrl decodedUrl,
+ @NonNull ThumbnailQuality qualityToUse) {
+ String sanitizedReplacement = decodedUrl.createStillsUrl(qualityToUse, false);
+ if (VerifiedQualities.verifyAltThumbnailExist(decodedUrl.videoId, qualityToUse, sanitizedReplacement)) {
+ return sanitizedReplacement;
+ }
+
+ return null;
+ }
+
+ /**
+ * Build the alternative thumbnail url using DeArrow thumbnail cache.
+ *
+ * @param videoId ID of the video to get a thumbnail of. Can be any video (regular or Short).
+ * @param fallbackUrl URL to fall back to in case.
+ * @return The alternative thumbnail url, without tracking parameters.
+ */
+ @NonNull
+ private static String buildDeArrowThumbnailURL(String videoId, String fallbackUrl) {
+ // Build thumbnail request url.
+ // See https://github.com/ajayyy/DeArrowThumbnailCache/blob/29eb4359ebdf823626c79d944a901492d760bbbc/app.py#L29.
+ return dearrowApiUri
+ .buildUpon()
+ .appendQueryParameter("videoID", videoId)
+ .appendQueryParameter("redirectUrl", fallbackUrl)
+ .build()
+ .toString();
+ }
+
+ private static boolean urlIsDeArrow(@NonNull String imageUrl) {
+ return imageUrl.startsWith(deArrowApiUrlPrefix);
+ }
+
+ /**
+ * @return If this client has not recently experienced any DeArrow API errors.
+ */
+ private static boolean canUseDeArrowAPI() {
+ if (timeToResumeDeArrowAPICalls == 0) {
+ return true;
+ }
+ if (timeToResumeDeArrowAPICalls < System.currentTimeMillis()) {
+ Logger.printDebug(() -> "Resuming DeArrow API calls");
+ timeToResumeDeArrowAPICalls = 0;
+ return true;
+ }
+ return false;
+ }
+
+ private static void handleDeArrowError(@NonNull String url, int statusCode) {
+ Logger.printDebug(() -> "Encountered DeArrow error. Url: " + url);
+ final long now = System.currentTimeMillis();
+ if (timeToResumeDeArrowAPICalls < now) {
+ timeToResumeDeArrowAPICalls = now + DEARROW_FAILURE_API_BACKOFF_MILLISECONDS;
+ if (Settings.ALT_THUMBNAIL_DEARROW_CONNECTION_TOAST.get()) {
+ String toastMessage = (statusCode != 0)
+ ? str("revanced_alt_thumbnail_dearrow_error", statusCode)
+ : str("revanced_alt_thumbnail_dearrow_error_generic");
+ Utils.showToastLong(toastMessage);
+ }
+ }
+ }
+
+ /**
+ * Injection point. Called off the main thread and by multiple threads at the same time.
+ *
+ * @param originalUrl Image url for all url images loaded, including video thumbnails.
+ */
+ public static String overrideImageURL(String originalUrl) {
+ try {
+ ThumbnailOption option = optionSettingForCurrentNavigation();
+
+ if (option == ThumbnailOption.ORIGINAL) {
+ return originalUrl;
+ }
+
+ final var decodedUrl = DecodedThumbnailUrl.decodeImageUrl(originalUrl);
+ if (decodedUrl == null) {
+ return originalUrl; // Not a thumbnail.
+ }
+
+ Logger.printDebug(() -> "Original url: " + decodedUrl.sanitizedUrl);
+
+ ThumbnailQuality qualityToUse = ThumbnailQuality.getQualityToUse(decodedUrl.imageQuality);
+ if (qualityToUse == null) {
+ // Thumbnail is a Short or a Storyboard image used for seekbar thumbnails (must not replace these).
+ return originalUrl;
+ }
+
+ String sanitizedReplacementUrl;
+ final boolean includeTracking;
+ if (option.useDeArrow && canUseDeArrowAPI()) {
+ includeTracking = false; // Do not include view tracking parameters with API call.
+ String fallbackUrl = null;
+ if (option.useStillImages) {
+ fallbackUrl = buildYouTubeVideoStillURL(decodedUrl, qualityToUse);
+ }
+ if (fallbackUrl == null) {
+ fallbackUrl = decodedUrl.sanitizedUrl;
+ }
+
+ sanitizedReplacementUrl = buildDeArrowThumbnailURL(decodedUrl.videoId, fallbackUrl);
+ } else if (option.useStillImages) {
+ includeTracking = true; // Include view tracking parameters if present.
+ sanitizedReplacementUrl = buildYouTubeVideoStillURL(decodedUrl, qualityToUse);
+ if (sanitizedReplacementUrl == null) {
+ return originalUrl; // Still capture is not available. Return the untouched original url.
+ }
+ } else {
+ return originalUrl; // Recently experienced DeArrow failure and video stills are not enabled.
+ }
+
+ // Do not log any tracking parameters.
+ Logger.printDebug(() -> "Replacement url: " + sanitizedReplacementUrl);
+
+ return includeTracking
+ ? sanitizedReplacementUrl + decodedUrl.viewTrackingParameters
+ : sanitizedReplacementUrl;
+ } catch (Exception ex) {
+ Logger.printException(() -> "overrideImageURL failure", ex);
+ return originalUrl;
+ }
+ }
+
+ /**
+ * Injection point.
+ *
+ * Cronet considers all completed connections as a success, even if the response is 404 or 5xx.
+ */
+ public static void handleCronetSuccess(UrlRequest request, @NonNull UrlResponseInfo responseInfo) {
+ try {
+ final int statusCode = responseInfo.getHttpStatusCode();
+ if (statusCode == 200) {
+ return;
+ }
+
+ String url = responseInfo.getUrl();
+
+ if (urlIsDeArrow(url)) {
+ Logger.printDebug(() -> "handleCronetSuccess, statusCode: " + statusCode);
+ if (statusCode == 304) {
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304
+ return; // Normal response.
+ }
+ handleDeArrowError(url, statusCode);
+ return;
+ }
+
+ if (statusCode == 404) {
+ // Fast alt thumbnails is enabled and the thumbnail is not available.
+ // The video is:
+ // - live stream
+ // - upcoming unreleased video
+ // - very old
+ // - very low view count
+ // Take note of this, so if the image reloads the original thumbnail will be used.
+ DecodedThumbnailUrl decodedUrl = DecodedThumbnailUrl.decodeImageUrl(url);
+ if (decodedUrl == null) {
+ return; // Not a thumbnail.
+ }
+
+ Logger.printDebug(() -> "handleCronetSuccess, image not available: " + decodedUrl.sanitizedUrl);
+
+ ThumbnailQuality quality = ThumbnailQuality.altImageNameToQuality(decodedUrl.imageQuality);
+ if (quality == null) {
+ // Video is a short or a seekbar thumbnail, but somehow did not load. Should not happen.
+ Logger.printDebug(() -> "Failed to recognize image quality of url: " + decodedUrl.sanitizedUrl);
+ return;
+ }
+
+ VerifiedQualities.setAltThumbnailDoesNotExist(decodedUrl.videoId, quality);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "Callback success error", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ *
+ * To test failure cases, try changing the API URL to each of:
+ * - A non-existent domain.
+ * - A url path of something incorrect (ie: /v1/nonExistentEndPoint).
+ *
+ * Cronet uses a very timeout (several minutes), so if the API never responds this hook can take a while to be called.
+ * But this does not appear to be a problem, as the DeArrow API has not been observed to 'go silent'
+ * Instead if there's a problem it returns an error code status response, which is handled in this patch.
+ */
+ public static void handleCronetFailure(UrlRequest request,
+ @Nullable UrlResponseInfo responseInfo,
+ IOException exception) {
+ try {
+ String url = ((CronetUrlRequest) request).getHookedUrl();
+ if (urlIsDeArrow(url)) {
+ Logger.printDebug(() -> "handleCronetFailure, exception: " + exception);
+ final int statusCode = (responseInfo != null)
+ ? responseInfo.getHttpStatusCode()
+ : 0;
+ handleDeArrowError(url, statusCode);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "Callback failure error", ex);
+ }
+ }
+
+ private enum ThumbnailQuality {
+ // In order of lowest to highest resolution.
+ DEFAULT("default", ""), // effective alt name is 1.jpg, 2.jpg, 3.jpg
+ MQDEFAULT("mqdefault", "mq"),
+ HQDEFAULT("hqdefault", "hq"),
+ SDDEFAULT("sddefault", "sd"),
+ HQ720("hq720", "hq720_"),
+ MAXRESDEFAULT("maxresdefault", "maxres");
+
+ /**
+ * Lookup map of original name to enum.
+ */
+ private static final Map originalNameToEnum = new HashMap<>();
+
+ /**
+ * Lookup map of alt name to enum. ie: "hq720_1" to {@link #HQ720}.
+ */
+ private static final Map altNameToEnum = new HashMap<>();
+
+ static {
+ for (ThumbnailQuality quality : values()) {
+ originalNameToEnum.put(quality.originalName, quality);
+
+ for (ThumbnailStillTime time : ThumbnailStillTime.values()) {
+ // 'custom' thumbnails set by the content creator.
+ // These show up in place of regular thumbnails
+ // and seem to be limited to the same [1, 3] range as the still captures.
+ originalNameToEnum.put(quality.originalName + "_custom_" + time.altImageNumber, quality);
+
+ altNameToEnum.put(quality.altImageName + time.altImageNumber, quality);
+ }
+ }
+ }
+
+ /**
+ * Convert an alt image name to enum.
+ * ie: "hq720_2" returns {@link #HQ720}.
+ */
+ @Nullable
+ static ThumbnailQuality altImageNameToQuality(@NonNull String altImageName) {
+ return altNameToEnum.get(altImageName);
+ }
+
+ /**
+ * Original quality to effective alt quality to use.
+ * ie: If fast alt image is enabled, then "hq720" returns {@link #SDDEFAULT}.
+ */
+ @Nullable
+ static ThumbnailQuality getQualityToUse(@NonNull String originalSize) {
+ ThumbnailQuality quality = originalNameToEnum.get(originalSize);
+ if (quality == null) {
+ return null; // Not a thumbnail for a regular video.
+ }
+
+ final boolean useFastQuality = Settings.ALT_THUMBNAIL_STILLS_FAST.get();
+ switch (quality) {
+ case SDDEFAULT:
+ // SD alt images have somewhat worse quality with washed out color and poor contrast.
+ // But the 720 images look much better and don't suffer from these issues.
+ // For unknown reasons, the 720 thumbnails are used only for the home feed,
+ // while SD is used for the search and subscription feed
+ // (even though search and subscriptions use the exact same layout as the home feed).
+ // Of note, this image quality issue only appears with the alt thumbnail images,
+ // and the regular thumbnails have identical color/contrast quality for all sizes.
+ // Fix this by falling thru and upgrading SD to 720.
+ case HQ720:
+ if (useFastQuality) {
+ return SDDEFAULT; // SD is max resolution for fast alt images.
+ }
+ return HQ720;
+ case MAXRESDEFAULT:
+ if (useFastQuality) {
+ return SDDEFAULT;
+ }
+ return MAXRESDEFAULT;
+ default:
+ return quality;
+ }
+ }
+
+ final String originalName;
+ final String altImageName;
+
+ ThumbnailQuality(String originalName, String altImageName) {
+ this.originalName = originalName;
+ this.altImageName = altImageName;
+ }
+
+ String getAltImageNameToUse() {
+ return altImageName + Settings.ALT_THUMBNAIL_STILLS_TIME.get().altImageNumber;
+ }
+ }
+
+ /**
+ * Uses HTTP HEAD requests to verify and keep track of which thumbnail sizes
+ * are available and not available.
+ */
+ private static class VerifiedQualities {
+ /**
+ * After a quality is verified as not available, how long until the quality is re-verified again.
+ * Used only if fast mode is not enabled. Intended for live streams and unreleased videos
+ * that are now finished and available (and thus, the alt thumbnails are also now available).
+ */
+ private static final long NOT_AVAILABLE_TIMEOUT_MILLISECONDS = 10 * 60 * 1000; // 10 minutes.
+
+ /**
+ * Cache used to verify if an alternative thumbnails exists for a given video id.
+ */
+ @GuardedBy("itself")
+ private static final Map altVideoIdLookup = new LinkedHashMap<>(100) {
+ private static final int CACHE_LIMIT = 1000;
+
+ @Override
+ protected boolean removeEldestEntry(Entry eldest) {
+ return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit.
+ }
+ };
+
+ private static VerifiedQualities getVerifiedQualities(@NonNull String videoId, boolean returnNullIfDoesNotExist) {
+ synchronized (altVideoIdLookup) {
+ VerifiedQualities verified = altVideoIdLookup.get(videoId);
+ if (verified == null) {
+ if (returnNullIfDoesNotExist) {
+ return null;
+ }
+ verified = new VerifiedQualities();
+ altVideoIdLookup.put(videoId, verified);
+ }
+ return verified;
+ }
+ }
+
+ static boolean verifyAltThumbnailExist(@NonNull String videoId, @NonNull ThumbnailQuality quality,
+ @NonNull String imageUrl) {
+ VerifiedQualities verified = getVerifiedQualities(videoId, Settings.ALT_THUMBNAIL_STILLS_FAST.get());
+ if (verified == null) return true; // Fast alt thumbnails is enabled.
+ return verified.verifyYouTubeThumbnailExists(videoId, quality, imageUrl);
+ }
+
+ static void setAltThumbnailDoesNotExist(@NonNull String videoId, @NonNull ThumbnailQuality quality) {
+ VerifiedQualities verified = getVerifiedQualities(videoId, false);
+ //noinspection ConstantConditions
+ verified.setQualityVerified(videoId, quality, false);
+ }
+
+ /**
+ * Highest quality verified as existing.
+ */
+ @Nullable
+ private ThumbnailQuality highestQualityVerified;
+ /**
+ * Lowest quality verified as not existing.
+ */
+ @Nullable
+ private ThumbnailQuality lowestQualityNotAvailable;
+
+ /**
+ * System time, of when to invalidate {@link #lowestQualityNotAvailable}.
+ * Used only if fast mode is not enabled.
+ */
+ private long timeToReVerifyLowestQuality;
+
+ private synchronized void setQualityVerified(String videoId, ThumbnailQuality quality, boolean isVerified) {
+ if (isVerified) {
+ if (highestQualityVerified == null || highestQualityVerified.ordinal() < quality.ordinal()) {
+ highestQualityVerified = quality;
+ }
+ } else {
+ if (lowestQualityNotAvailable == null || lowestQualityNotAvailable.ordinal() > quality.ordinal()) {
+ lowestQualityNotAvailable = quality;
+ timeToReVerifyLowestQuality = System.currentTimeMillis() + NOT_AVAILABLE_TIMEOUT_MILLISECONDS;
+ }
+ Logger.printDebug(() -> quality + " not available for video: " + videoId);
+ }
+ }
+
+ /**
+ * Verify if a video alt thumbnail exists. Does so by making a minimal HEAD http request.
+ */
+ synchronized boolean verifyYouTubeThumbnailExists(@NonNull String videoId, @NonNull ThumbnailQuality quality,
+ @NonNull String imageUrl) {
+ if (highestQualityVerified != null && highestQualityVerified.ordinal() >= quality.ordinal()) {
+ return true; // Previously verified as existing.
+ }
+
+ final boolean fastQuality = Settings.ALT_THUMBNAIL_STILLS_FAST.get();
+ if (lowestQualityNotAvailable != null && lowestQualityNotAvailable.ordinal() <= quality.ordinal()) {
+ if (fastQuality || System.currentTimeMillis() < timeToReVerifyLowestQuality) {
+ return false; // Previously verified as not existing.
+ }
+ // Enough time has passed, and should re-verify again.
+ Logger.printDebug(() -> "Resetting lowest verified quality for: " + videoId);
+ lowestQualityNotAvailable = null;
+ }
+
+ if (fastQuality) {
+ return true; // Unknown if it exists or not. Use the URL anyways and update afterwards if loading fails.
+ }
+
+ boolean imageFileFound;
+ try {
+ // This hooked code is running on a low priority thread, and it's slightly faster
+ // to run the url connection through the extension thread pool which runs at the highest priority.
+ final long start = System.currentTimeMillis();
+ imageFileFound = Utils.submitOnBackgroundThread(() -> {
+ final int connectionTimeoutMillis = 10000; // 10 seconds.
+ HttpURLConnection connection = (HttpURLConnection) new URL(imageUrl).openConnection();
+ connection.setConnectTimeout(connectionTimeoutMillis);
+ connection.setReadTimeout(connectionTimeoutMillis);
+ connection.setRequestMethod("HEAD");
+ // Even with a HEAD request, the response is the same size as a full GET request.
+ // Using an empty range fixes this.
+ connection.setRequestProperty("Range", "bytes=0-0");
+ final int responseCode = connection.getResponseCode();
+ if (responseCode == HttpURLConnection.HTTP_PARTIAL) {
+ String contentType = connection.getContentType();
+ return (contentType != null && contentType.startsWith("image"));
+ }
+ if (responseCode != HttpURLConnection.HTTP_NOT_FOUND) {
+ Logger.printDebug(() -> "Unexpected response code: " + responseCode + " for url: " + imageUrl);
+ }
+ return false;
+ }).get();
+ Logger.printDebug(() -> "Verification took: " + (System.currentTimeMillis() - start) + "ms for image: " + imageUrl);
+ } catch (ExecutionException | InterruptedException ex) {
+ Logger.printInfo(() -> "Could not verify alt url: " + imageUrl, ex);
+ imageFileFound = false;
+ }
+
+ setQualityVerified(videoId, quality, imageFileFound);
+ return imageFileFound;
+ }
+ }
+
+ /**
+ * YouTube video thumbnail url, decoded into it's relevant parts.
+ */
+ private static class DecodedThumbnailUrl {
+ private static final String YOUTUBE_THUMBNAIL_DOMAIN = "https://i.ytimg.com/";
+
+ @Nullable
+ static DecodedThumbnailUrl decodeImageUrl(String url) {
+ final int urlPathStartIndex = url.indexOf('/', "https://".length()) + 1;
+ if (urlPathStartIndex <= 0) return null;
+
+ final int urlPathEndIndex = url.indexOf('/', urlPathStartIndex);
+ if (urlPathEndIndex < 0) return null;
+
+ final int videoIdStartIndex = url.indexOf('/', urlPathEndIndex) + 1;
+ if (videoIdStartIndex <= 0) return null;
+
+ final int videoIdEndIndex = url.indexOf('/', videoIdStartIndex);
+ if (videoIdEndIndex < 0) return null;
+
+ final int imageSizeStartIndex = videoIdEndIndex + 1;
+ final int imageSizeEndIndex = url.indexOf('.', imageSizeStartIndex);
+ if (imageSizeEndIndex < 0) return null;
+
+ int imageExtensionEndIndex = url.indexOf('?', imageSizeEndIndex);
+ if (imageExtensionEndIndex < 0) imageExtensionEndIndex = url.length();
+
+ return new DecodedThumbnailUrl(url, urlPathStartIndex, urlPathEndIndex, videoIdStartIndex, videoIdEndIndex,
+ imageSizeStartIndex, imageSizeEndIndex, imageExtensionEndIndex);
+ }
+
+ final String originalFullUrl;
+ /** Full usable url, but stripped of any tracking information. */
+ final String sanitizedUrl;
+ /** Url path, such as 'vi' or 'vi_webp' */
+ final String urlPath;
+ final String videoId;
+ /** Quality, such as hq720 or sddefault. */
+ final String imageQuality;
+ /** JPG or WEBP */
+ final String imageExtension;
+ /** User view tracking parameters, only present on some images. */
+ final String viewTrackingParameters;
+
+ DecodedThumbnailUrl(String fullUrl, int urlPathStartIndex, int urlPathEndIndex, int videoIdStartIndex, int videoIdEndIndex,
+ int imageSizeStartIndex, int imageSizeEndIndex, int imageExtensionEndIndex) {
+ originalFullUrl = fullUrl;
+ sanitizedUrl = fullUrl.substring(0, imageExtensionEndIndex);
+ urlPath = fullUrl.substring(urlPathStartIndex, urlPathEndIndex);
+ videoId = fullUrl.substring(videoIdStartIndex, videoIdEndIndex);
+ imageQuality = fullUrl.substring(imageSizeStartIndex, imageSizeEndIndex);
+ imageExtension = fullUrl.substring(imageSizeEndIndex + 1, imageExtensionEndIndex);
+ viewTrackingParameters = (imageExtensionEndIndex == fullUrl.length())
+ ? "" : fullUrl.substring(imageExtensionEndIndex);
+ }
+
+ /** @noinspection SameParameterValue */
+ String createStillsUrl(@NonNull ThumbnailQuality qualityToUse, boolean includeViewTracking) {
+ // Images could be upgraded to webp if they are not already, but this fails quite often,
+ // especially for new videos uploaded in the last hour.
+ // And even if alt webp images do exist, sometimes they can load much slower than the original jpg alt images.
+ // (as much as 4x slower network response has been observed, despite the alt webp image being a smaller file).
+ StringBuilder builder = new StringBuilder(originalFullUrl.length() + 2);
+ // Many different "i.ytimage.com" domains exist such as "i9.ytimg.com",
+ // but still captures are frequently not available on the other domains (especially newly uploaded videos).
+ // So always use the primary domain for a higher success rate.
+ builder.append(YOUTUBE_THUMBNAIL_DOMAIN).append(urlPath).append('/');
+ builder.append(videoId).append('/');
+ builder.append(qualityToUse.getAltImageNameToUse());
+ builder.append('.').append(imageExtension);
+ if (includeViewTracking) {
+ builder.append(viewTrackingParameters);
+ }
+ return builder.toString();
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/AutoRepeatPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/AutoRepeatPatch.java
new file mode 100644
index 000000000..21409e739
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/AutoRepeatPatch.java
@@ -0,0 +1,11 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class AutoRepeatPatch {
+ //Used by app.revanced.patches.youtube.layout.autorepeat.patch.AutoRepeatPatch
+ public static boolean shouldAutoRepeat() {
+ return Settings.AUTO_REPEAT.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BackgroundPlaybackPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BackgroundPlaybackPatch.java
new file mode 100644
index 000000000..d3fc82ae2
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BackgroundPlaybackPatch.java
@@ -0,0 +1,46 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.shared.PlayerType;
+
+@SuppressWarnings("unused")
+public class BackgroundPlaybackPatch {
+
+ /**
+ * Injection point.
+ */
+ public static boolean allowBackgroundPlayback(boolean original) {
+ if (original) return true;
+
+ // Steps to verify most edge cases:
+ // 1. Open a regular video
+ // 2. Minimize app (PIP should appear)
+ // 3. Reopen app
+ // 4. Open a Short (without closing the regular video)
+ // (try opening both Shorts in the video player suggestions AND Shorts from the home feed)
+ // 5. Minimize the app (PIP should not appear)
+ // 6. Reopen app
+ // 7. Close the Short
+ // 8. Resume playing the regular video
+ // 9. Minimize the app (PIP should appear)
+
+ if (!VideoInformation.lastVideoIdIsShort()) {
+ return true; // Definitely is not a Short.
+ }
+
+ // Might be a Short, or might be a prior regular video on screen again after a Short was closed.
+ // This incorrectly prevents PIP if player is in WATCH_WHILE_MINIMIZED after closing a Short,
+ // But there's no way around this unless an additional hook is added to definitively detect
+ // the Shorts player is on screen. This use case is unusual anyways so it's not a huge concern.
+ return !PlayerType.getCurrent().isNoneHiddenOrMinimized();
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean overrideBackgroundPlaybackAvailable() {
+ // This could be done entirely in the patch,
+ // but having a unique method to search for makes manually inspecting the patched apk much easier.
+ return true;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BypassImageRegionRestrictionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BypassImageRegionRestrictionsPatch.java
new file mode 100644
index 000000000..ccc853d4c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BypassImageRegionRestrictionsPatch.java
@@ -0,0 +1,46 @@
+package app.revanced.extension.youtube.patches;
+
+import static app.revanced.extension.youtube.settings.Settings.BYPASS_IMAGE_REGION_RESTRICTIONS;
+
+import java.util.regex.Pattern;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class BypassImageRegionRestrictionsPatch {
+
+ private static final boolean BYPASS_IMAGE_REGION_RESTRICTIONS_ENABLED = BYPASS_IMAGE_REGION_RESTRICTIONS.get();
+
+ private static final String REPLACEMENT_IMAGE_DOMAIN = "https://yt4.ggpht.com";
+
+ /**
+ * YouTube static images domain. Includes user and channel avatar images and community post images.
+ */
+ private static final Pattern YOUTUBE_STATIC_IMAGE_DOMAIN_PATTERN
+ = Pattern.compile("^https://(yt3|lh[3-6]|play-lh)\\.(ggpht|googleusercontent)\\.com");
+
+ /**
+ * Injection point. Called off the main thread and by multiple threads at the same time.
+ *
+ * @param originalUrl Image url for all image urls loaded.
+ */
+ public static String overrideImageURL(String originalUrl) {
+ try {
+ if (BYPASS_IMAGE_REGION_RESTRICTIONS_ENABLED) {
+ String replacement = YOUTUBE_STATIC_IMAGE_DOMAIN_PATTERN
+ .matcher(originalUrl).replaceFirst(REPLACEMENT_IMAGE_DOMAIN);
+
+ if (Settings.DEBUG.get() && !replacement.equals(originalUrl)) {
+ Logger.printDebug(() -> "Replaced: '" + originalUrl + "' with: '" + replacement + "'");
+ }
+
+ return replacement;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "overrideImageURL failure", ex);
+ }
+
+ return originalUrl;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BypassURLRedirectsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BypassURLRedirectsPatch.java
new file mode 100644
index 000000000..ebce7cd35
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/BypassURLRedirectsPatch.java
@@ -0,0 +1,31 @@
+package app.revanced.extension.youtube.patches;
+
+import android.net.Uri;
+
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.shared.Logger;
+
+@SuppressWarnings("unused")
+public class BypassURLRedirectsPatch {
+ private static final String YOUTUBE_REDIRECT_PATH = "/redirect";
+
+ /**
+ * Convert the YouTube redirect URI string to the redirect query URI.
+ *
+ * @param uri The YouTube redirect URI string.
+ * @return The redirect query URI.
+ */
+ public static Uri parseRedirectUri(String uri) {
+ final var parsed = Uri.parse(uri);
+
+ if (Settings.BYPASS_URL_REDIRECTS.get() && parsed.getPath().equals(YOUTUBE_REDIRECT_PATH)) {
+ var query = Uri.parse(Uri.decode(parsed.getQueryParameter("q")));
+
+ Logger.printDebug(() -> "Bypassing YouTube redirect URI: " + query);
+
+ return query;
+ }
+
+ return parsed;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ChangeStartPagePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ChangeStartPagePatch.java
new file mode 100644
index 000000000..350e5787d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ChangeStartPagePatch.java
@@ -0,0 +1,129 @@
+package app.revanced.extension.youtube.patches;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+
+import android.content.Intent;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class ChangeStartPagePatch {
+
+ public enum StartPage {
+ /**
+ * Unmodified type, and same as un-patched.
+ */
+ ORIGINAL("", null),
+
+ /**
+ * Browse id.
+ */
+ BROWSE("FEguide_builder", TRUE),
+ EXPLORE("FEexplore", TRUE),
+ HISTORY("FEhistory", TRUE),
+ LIBRARY("FElibrary", TRUE),
+ MOVIE("FEstorefront", TRUE),
+ SUBSCRIPTIONS("FEsubscriptions", TRUE),
+ TRENDING("FEtrending", TRUE),
+
+ /**
+ * Channel id, this can be used as a browseId.
+ */
+ GAMING("UCOpNcN46UbXVtpKMrmU4Abg", TRUE),
+ LIVE("UC4R8DWoMoI7CAwX8_LjQHig", TRUE),
+ MUSIC("UC-9-kyTW8ZkZNDHQJ6FgpwQ", TRUE),
+ SPORTS("UCEgdi0XIXXZ-qJOFPf4JSKw", TRUE),
+
+ /**
+ * Playlist id, this can be used as a browseId.
+ */
+ LIKED_VIDEO("VLLL", TRUE),
+ WATCH_LATER("VLWL", TRUE),
+
+ /**
+ * Intent action.
+ */
+ SEARCH("com.google.android.youtube.action.open.search", FALSE),
+ SHORTS("com.google.android.youtube.action.open.shorts", FALSE);
+
+ @Nullable
+ final Boolean isBrowseId;
+
+ @NonNull
+ final String id;
+
+ StartPage(@NonNull String id, @Nullable Boolean isBrowseId) {
+ this.id = id;
+ this.isBrowseId = isBrowseId;
+ }
+
+ private boolean isBrowseId() {
+ return TRUE.equals(isBrowseId);
+ }
+
+ @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+ private boolean isIntentAction() {
+ return FALSE.equals(isBrowseId);
+ }
+ }
+
+ /**
+ * Intent action when YouTube is cold started from the launcher.
+ *
+ * If you don't check this, the hooking will also apply in the following cases:
+ * Case 1. The user clicked Shorts button on the YouTube shortcut.
+ * Case 2. The user clicked Shorts button on the YouTube widget.
+ * In this case, instead of opening Shorts, the start page specified by the user is opened.
+ */
+ private static final String ACTION_MAIN = "android.intent.action.MAIN";
+
+ private static final StartPage START_PAGE = Settings.CHANGE_START_PAGE.get();
+
+ /**
+ * There is an issue where the back button on the toolbar doesn't work properly.
+ * As a workaround for this issue, instead of overriding the browserId multiple times, just override it once.
+ */
+ private static boolean appLaunched = false;
+
+ public static String overrideBrowseId(@NonNull String original) {
+ if (!START_PAGE.isBrowseId()) {
+ return original;
+ }
+
+ if (appLaunched) {
+ Logger.printDebug(() -> "Ignore override browseId as the app already launched");
+ return original;
+ }
+ appLaunched = true;
+
+ Logger.printDebug(() -> "Changing browseId to " + START_PAGE.id);
+ return START_PAGE.id;
+ }
+
+ public static void overrideIntentAction(@NonNull Intent intent) {
+ if (!START_PAGE.isIntentAction()) {
+ return;
+ }
+
+ if (!ACTION_MAIN.equals(intent.getAction())) {
+ Logger.printDebug(() -> "Ignore override intent action" +
+ " as the current activity is not the entry point of the application");
+ return;
+ }
+
+ if (appLaunched) {
+ Logger.printDebug(() -> "Ignore override intent action as the app already launched");
+ return;
+ }
+ appLaunched = true;
+
+ final String intentAction = START_PAGE.id;
+ Logger.printDebug(() -> "Changing intent action to " + intentAction);
+ intent.setAction(intentAction);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CheckWatchHistoryDomainNameResolutionPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CheckWatchHistoryDomainNameResolutionPatch.java
new file mode 100644
index 000000000..ccc2cc8c9
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CheckWatchHistoryDomainNameResolutionPatch.java
@@ -0,0 +1,84 @@
+package app.revanced.extension.youtube.patches;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.app.Activity;
+import android.text.Html;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class CheckWatchHistoryDomainNameResolutionPatch {
+
+ private static final String HISTORY_TRACKING_ENDPOINT = "s.youtube.com";
+
+ private static final String SINKHOLE_IPV4 = "0.0.0.0";
+ private static final String SINKHOLE_IPV6 = "::";
+
+ private static boolean domainResolvesToValidIP(String host) {
+ try {
+ InetAddress address = InetAddress.getByName(host);
+ String hostAddress = address.getHostAddress();
+
+ if (address.isLoopbackAddress()) {
+ Logger.printDebug(() -> host + " resolves to localhost");
+ } else if (SINKHOLE_IPV4.equals(hostAddress) || SINKHOLE_IPV6.equals(hostAddress)) {
+ Logger.printDebug(() -> host + " resolves to sinkhole ip");
+ } else {
+ return true; // Domain is not blocked.
+ }
+ } catch (UnknownHostException e) {
+ Logger.printDebug(() -> host + " failed to resolve");
+ }
+
+ return false;
+ }
+
+ /**
+ * Injection point.
+ *
+ * Checks if s.youtube.com is blacklisted and playback history will fail to work.
+ */
+ public static void checkDnsResolver(Activity context) {
+ if (!Utils.isNetworkConnected() || !Settings.CHECK_WATCH_HISTORY_DOMAIN_NAME.get()) return;
+
+ Utils.runOnBackgroundThread(() -> {
+ try {
+ // If the user has a flaky DNS server, or they just lost internet connectivity
+ // and the isNetworkConnected() check has not detected it yet (it can take a few
+ // seconds after losing connection), then the history tracking endpoint will
+ // show a resolving error but it's actually an internet connection problem.
+ //
+ // Prevent this false positive by verify youtube.com resolves.
+ // If youtube.com does not resolve, then it's not a watch history domain resolving error
+ // because the entire app will not work since no domains are resolving.
+ if (domainResolvesToValidIP(HISTORY_TRACKING_ENDPOINT)
+ || !domainResolvesToValidIP("youtube.com")) {
+ return;
+ }
+
+ Utils.runOnMainThread(() -> {
+ var alert = new android.app.AlertDialog.Builder(context)
+ .setTitle(str("revanced_check_watch_history_domain_name_dialog_title"))
+ .setMessage(Html.fromHtml(str("revanced_check_watch_history_domain_name_dialog_message")))
+ .setIconAttribute(android.R.attr.alertDialogIcon)
+ .setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ dialog.dismiss();
+ }).setNegativeButton(str("revanced_check_watch_history_domain_name_dialog_ignore"), (dialog, which) -> {
+ Settings.CHECK_WATCH_HISTORY_DOMAIN_NAME.save(false);
+ dialog.dismiss();
+ }).create();
+
+ Utils.showDialog(context, alert, false, null);
+ });
+ } catch (Exception ex) {
+ Logger.printException(() -> "checkDnsResolver failure", ex);
+ }
+ });
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CopyVideoUrlPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CopyVideoUrlPatch.java
new file mode 100644
index 000000000..db3338f52
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CopyVideoUrlPatch.java
@@ -0,0 +1,47 @@
+package app.revanced.extension.youtube.patches;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.os.Build;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+
+public class CopyVideoUrlPatch {
+
+ public static void copyUrl(boolean withTimestamp) {
+ try {
+ StringBuilder builder = new StringBuilder("https://youtu.be/");
+ builder.append(VideoInformation.getVideoId());
+ final long currentVideoTimeInSeconds = VideoInformation.getVideoTime() / 1000;
+ if (withTimestamp && currentVideoTimeInSeconds > 0) {
+ final long hour = currentVideoTimeInSeconds / (60 * 60);
+ final long minute = (currentVideoTimeInSeconds / 60) % 60;
+ final long second = currentVideoTimeInSeconds % 60;
+ builder.append("?t=");
+ if (hour > 0) {
+ builder.append(hour).append("h");
+ }
+ if (minute > 0) {
+ builder.append(minute).append("m");
+ }
+ if (second > 0) {
+ builder.append(second).append("s");
+ }
+ }
+
+ Utils.setClipboard(builder.toString());
+ // Do not show a toast if using Android 13+ as it shows it's own toast.
+ // But if the user copied with a timestamp then show a toast.
+ // Unfortunately this will show 2 toasts on Android 13+, but no way around this.
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2 || (withTimestamp && currentVideoTimeInSeconds > 0)) {
+ Utils.showToastShort(withTimestamp && currentVideoTimeInSeconds > 0
+ ? str("revanced_share_copy_url_timestamp_success")
+ : str("revanced_share_copy_url_success"));
+ }
+ } catch (Exception e) {
+ Logger.printException(() -> "Failed to generate video url", e);
+ }
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CustomPlayerOverlayOpacityPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CustomPlayerOverlayOpacityPatch.java
new file mode 100644
index 000000000..4f13deaac
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/CustomPlayerOverlayOpacityPatch.java
@@ -0,0 +1,33 @@
+package app.revanced.extension.youtube.patches;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.widget.ImageView;
+
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class CustomPlayerOverlayOpacityPatch {
+
+ private static final int PLAYER_OVERLAY_OPACITY_LEVEL;
+
+ static {
+ int opacity = Settings.PLAYER_OVERLAY_OPACITY.get();
+
+ if (opacity < 0 || opacity > 100) {
+ Utils.showToastLong(str("revanced_player_overlay_opacity_invalid_toast"));
+ Settings.PLAYER_OVERLAY_OPACITY.resetToDefault();
+ opacity = Settings.PLAYER_OVERLAY_OPACITY.defaultValue;
+ }
+
+ PLAYER_OVERLAY_OPACITY_LEVEL = (opacity * 255) / 100;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void changeOpacity(ImageView imageView) {
+ imageView.setImageAlpha(PLAYER_OVERLAY_OPACITY_LEVEL);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableAutoCaptionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableAutoCaptionsPatch.java
new file mode 100644
index 000000000..9d43159a5
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableAutoCaptionsPatch.java
@@ -0,0 +1,20 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.PlayerType;
+
+@SuppressWarnings("unused")
+public class DisableAutoCaptionsPatch {
+
+ /**
+ * Used by injected code. Do not delete.
+ */
+ public static boolean captionsButtonDisabled;
+
+ public static boolean autoCaptionsEnabled() {
+ return Settings.AUTO_CAPTIONS.get()
+ // Do not use auto captions for Shorts.
+ && !PlayerType.getCurrent().isNoneHiddenOrSlidingMinimized();
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableFullscreenAmbientModePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableFullscreenAmbientModePatch.java
new file mode 100644
index 000000000..962a0d7b7
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableFullscreenAmbientModePatch.java
@@ -0,0 +1,10 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+/** @noinspection unused*/
+public final class DisableFullscreenAmbientModePatch {
+ public static boolean enableFullScreenAmbientMode() {
+ return !Settings.DISABLE_FULLSCREEN_AMBIENT_MODE.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisablePlayerPopupPanelsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisablePlayerPopupPanelsPatch.java
new file mode 100644
index 000000000..dd2e0ca8f
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisablePlayerPopupPanelsPatch.java
@@ -0,0 +1,11 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class DisablePlayerPopupPanelsPatch {
+ //Used by app.revanced.patches.youtube.layout.playerpopuppanels.patch.PlayerPopupPanelsPatch
+ public static boolean disablePlayerPopupPanels() {
+ return Settings.PLAYER_POPUP_PANELS.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisablePreciseSeekingGesturePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisablePreciseSeekingGesturePatch.java
new file mode 100644
index 000000000..c6a80c6a0
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisablePreciseSeekingGesturePatch.java
@@ -0,0 +1,10 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class DisablePreciseSeekingGesturePatch {
+ public static boolean isGestureDisabled() {
+ return Settings.DISABLE_PRECISE_SEEKING_GESTURE.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableResumingStartupShortsPlayerPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableResumingStartupShortsPlayerPatch.java
new file mode 100644
index 000000000..938ac5458
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableResumingStartupShortsPlayerPatch.java
@@ -0,0 +1,14 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+/** @noinspection unused*/
+public class DisableResumingStartupShortsPlayerPatch {
+
+ /**
+ * Injection point.
+ */
+ public static boolean disableResumingStartupShortsPlayer() {
+ return Settings.DISABLE_RESUMING_SHORTS_PLAYER.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableRollingNumberAnimationsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableRollingNumberAnimationsPatch.java
new file mode 100644
index 000000000..f600f391a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableRollingNumberAnimationsPatch.java
@@ -0,0 +1,13 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class DisableRollingNumberAnimationsPatch {
+ /**
+ * Injection point.
+ */
+ public static boolean disableRollingNumberAnimations() {
+ return Settings.DISABLE_ROLLING_NUMBER_ANIMATIONS.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableSuggestedVideoEndScreenPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableSuggestedVideoEndScreenPatch.java
new file mode 100644
index 000000000..7cd91d9c2
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DisableSuggestedVideoEndScreenPatch.java
@@ -0,0 +1,24 @@
+package app.revanced.extension.youtube.patches;
+
+import android.annotation.SuppressLint;
+import android.widget.ImageView;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+/** @noinspection unused*/
+public final class DisableSuggestedVideoEndScreenPatch {
+ @SuppressLint("StaticFieldLeak")
+ private static ImageView lastView;
+
+ public static void closeEndScreen(final ImageView imageView) {
+ if (!Settings.DISABLE_SUGGESTED_VIDEO_END_SCREEN.get()) return;
+
+ // Prevent adding the listener multiple times.
+ if (lastView == imageView) return;
+ lastView = imageView;
+
+ imageView.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
+ if (imageView.isShown()) imageView.callOnClick();
+ });
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DownloadsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DownloadsPatch.java
new file mode 100644
index 000000000..6da31b6a4
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/DownloadsPatch.java
@@ -0,0 +1,103 @@
+package app.revanced.extension.youtube.patches;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+
+import androidx.annotation.NonNull;
+
+import java.lang.ref.WeakReference;
+import java.util.Objects;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.StringRef;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class DownloadsPatch {
+
+ private static WeakReference activityRef = new WeakReference<>(null);
+
+ /**
+ * Injection point.
+ */
+ public static void activityCreated(Activity mainActivity) {
+ activityRef = new WeakReference<>(mainActivity);
+ }
+
+ /**
+ * Injection point.
+ *
+ * Called from the in app download hook,
+ * for both the player action button (below the video)
+ * and the 'Download video' flyout option for feed videos.
+ *
+ * Appears to always be called from the main thread.
+ */
+ public static boolean inAppDownloadButtonOnClick(@NonNull String videoId) {
+ try {
+ if (!Settings.EXTERNAL_DOWNLOADER_ACTION_BUTTON.get()) {
+ return false;
+ }
+
+ // If possible, use the main activity as the context.
+ // Otherwise fall back on using the application context.
+ Context context = activityRef.get();
+ boolean isActivityContext = true;
+ if (context == null) {
+ // Utils context is the application context, and not an activity context.
+ context = Utils.getContext();
+ isActivityContext = false;
+ }
+
+ launchExternalDownloader(videoId, context, isActivityContext);
+ return true;
+ } catch (Exception ex) {
+ Logger.printException(() -> "inAppDownloadButtonOnClick failure", ex);
+ }
+ return false;
+ }
+
+ /**
+ * @param isActivityContext If the context parameter is for an Activity. If this is false, then
+ * the downloader is opened as a new task (which forces YT to minimize).
+ */
+ public static void launchExternalDownloader(@NonNull String videoId,
+ @NonNull Context context, boolean isActivityContext) {
+ try {
+ Objects.requireNonNull(videoId);
+ Logger.printDebug(() -> "Launching external downloader with context: " + context);
+
+ // Trim string to avoid any accidental whitespace.
+ var downloaderPackageName = Settings.EXTERNAL_DOWNLOADER_PACKAGE_NAME.get().trim();
+
+ boolean packageEnabled = false;
+ try {
+ packageEnabled = context.getPackageManager().getApplicationInfo(downloaderPackageName, 0).enabled;
+ } catch (PackageManager.NameNotFoundException error) {
+ Logger.printDebug(() -> "External downloader could not be found: " + error);
+ }
+
+ // If the package is not installed, show the toast
+ if (!packageEnabled) {
+ Utils.showToastLong(StringRef.str("revanced_external_downloader_not_installed_warning", downloaderPackageName));
+ return;
+ }
+
+ String content = "https://youtu.be/" + videoId;
+ Intent intent = new Intent("android.intent.action.SEND");
+ intent.setType("text/plain");
+ intent.setPackage(downloaderPackageName);
+ intent.putExtra("android.intent.extra.TEXT", content);
+ if (!isActivityContext) {
+ Logger.printDebug(() -> "Using new task intent");
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ }
+ context.startActivity(intent);
+ } catch (Exception ex) {
+ Logger.printException(() -> "launchExternalDownloader failure", ex);
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/FixBackToExitGesturePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/FixBackToExitGesturePatch.java
new file mode 100644
index 000000000..8fe4dbb18
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/FixBackToExitGesturePatch.java
@@ -0,0 +1,44 @@
+package app.revanced.extension.youtube.patches;
+
+import android.app.Activity;
+
+import app.revanced.extension.shared.Logger;
+
+@SuppressWarnings("unused")
+public class FixBackToExitGesturePatch {
+ /**
+ * State whether the scroll position reaches the top.
+ */
+ public static boolean isTopView = false;
+
+ /**
+ * Handle the event after clicking the back button.
+ *
+ * @param activity The activity, the app is launched with to finish.
+ */
+ public static void onBackPressed(Activity activity) {
+ if (!isTopView) return;
+
+ Logger.printDebug(() -> "Activity is closed");
+
+ activity.finish();
+ }
+
+ /**
+ * Handle the event when the homepage list of views is being scrolled.
+ */
+ public static void onScrollingViews() {
+ Logger.printDebug(() -> "Views are scrolling");
+
+ isTopView = false;
+ }
+
+ /**
+ * Handle the event when the homepage list of views reached the top.
+ */
+ public static void onTopView() {
+ Logger.printDebug(() -> "Scrolling reached the top");
+
+ isTopView = true;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/FullscreenPanelsRemoverPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/FullscreenPanelsRemoverPatch.java
new file mode 100644
index 000000000..f454b361c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/FullscreenPanelsRemoverPatch.java
@@ -0,0 +1,12 @@
+package app.revanced.extension.youtube.patches;
+
+import android.view.View;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class FullscreenPanelsRemoverPatch {
+ public static int getFullscreenPanelsVisibility() {
+ return Settings.HIDE_FULLSCREEN_PANELS.get() ? View.GONE : View.VISIBLE;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HDRAutoBrightnessPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HDRAutoBrightnessPatch.java
new file mode 100644
index 000000000..1aa9650cf
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HDRAutoBrightnessPatch.java
@@ -0,0 +1,42 @@
+package app.revanced.extension.youtube.patches;
+
+import android.view.WindowManager;
+
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.swipecontrols.SwipeControlsHostActivity;
+
+/**
+ * Patch class for 'hdr-auto-brightness' patch.
+ *
+ * Edit: This patch no longer does anything, as YT already uses BRIGHTNESS_OVERRIDE_NONE
+ * as the default brightness level. The hooked code was also removed from YT 19.09+ as well.
+ */
+@Deprecated
+@SuppressWarnings("unused")
+public class HDRAutoBrightnessPatch {
+ /**
+ * get brightness override for HDR brightness
+ *
+ * @param original brightness youtube would normally set
+ * @return brightness to set on HRD video
+ */
+ public static float getHDRBrightness(float original) {
+ // do nothing if disabled
+ if (!Settings.HDR_AUTO_BRIGHTNESS.get()) {
+ return original;
+ }
+
+ // override with brightness set by swipe-controls
+ // only when swipe-controls is active and has overridden the brightness
+ final SwipeControlsHostActivity swipeControlsHost = SwipeControlsHostActivity.getCurrentHost().get();
+ if (swipeControlsHost != null
+ && swipeControlsHost.getScreen() != null
+ && swipeControlsHost.getConfig().getEnableBrightnessControl()
+ && !swipeControlsHost.getScreen().isDefaultBrightness()) {
+ return swipeControlsHost.getScreen().getRawScreenBrightness();
+ }
+
+ // otherwise, set the brightness to auto
+ return WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideEmailAddressPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideEmailAddressPatch.java
new file mode 100644
index 000000000..656f36c38
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideEmailAddressPatch.java
@@ -0,0 +1,17 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+/**
+ * Patch is obsolete and will be deleted in a future release
+ */
+@SuppressWarnings("unused")
+@Deprecated()
+public class HideEmailAddressPatch {
+ //Used by app.revanced.patches.youtube.layout.personalinformation.patch.HideEmailAddressPatch
+ public static int hideEmailAddress(int originalValue) {
+ if (Settings.HIDE_EMAIL_ADDRESS.get())
+ return 8;
+ return originalValue;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideEndscreenCardsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideEndscreenCardsPatch.java
new file mode 100644
index 000000000..89261d119
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideEndscreenCardsPatch.java
@@ -0,0 +1,14 @@
+package app.revanced.extension.youtube.patches;
+
+import android.view.View;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class HideEndscreenCardsPatch {
+ //Used by app.revanced.patches.youtube.layout.hideendscreencards.bytecode.patch.HideEndscreenCardsPatch
+ public static void hideEndscreen(View view) {
+ if (!Settings.HIDE_ENDSCREEN_CARDS.get()) return;
+ view.setVisibility(View.GONE);
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideGetPremiumPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideGetPremiumPatch.java
new file mode 100644
index 000000000..35592c0ff
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideGetPremiumPatch.java
@@ -0,0 +1,13 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class HideGetPremiumPatch {
+ /**
+ * Injection point.
+ */
+ public static boolean hideGetPremiumView() {
+ return Settings.HIDE_GET_PREMIUM.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideInfoCardsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideInfoCardsPatch.java
new file mode 100644
index 000000000..e01c4a394
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideInfoCardsPatch.java
@@ -0,0 +1,17 @@
+package app.revanced.extension.youtube.patches;
+
+import android.view.View;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class HideInfoCardsPatch {
+ public static void hideInfoCardsIncognito(View view) {
+ if (!Settings.HIDE_INFO_CARDS.get()) return;
+ view.setVisibility(View.GONE);
+ }
+
+ public static boolean hideInfoCardsMethodCall() {
+ return Settings.HIDE_INFO_CARDS.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HidePlayerOverlayButtonsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HidePlayerOverlayButtonsPatch.java
new file mode 100644
index 000000000..c1a0065db
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HidePlayerOverlayButtonsPatch.java
@@ -0,0 +1,72 @@
+package app.revanced.extension.youtube.patches;
+
+import android.view.View;
+import android.widget.ImageView;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class HidePlayerOverlayButtonsPatch {
+
+ private static final boolean HIDE_AUTOPLAY_BUTTON_ENABLED = Settings.HIDE_AUTOPLAY_BUTTON.get();
+
+ /**
+ * Injection point.
+ */
+ public static boolean hideAutoPlayButton() {
+ return HIDE_AUTOPLAY_BUTTON_ENABLED;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static int getCastButtonOverrideV2(int original) {
+ return Settings.HIDE_CAST_BUTTON.get() ? View.GONE : original;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void hideCaptionsButton(ImageView imageView) {
+ imageView.setVisibility(Settings.HIDE_CAPTIONS_BUTTON.get() ? ImageView.GONE : ImageView.VISIBLE);
+ }
+
+ private static final boolean HIDE_PLAYER_PREVIOUS_NEXT_BUTTONS_ENABLED
+ = Settings.HIDE_PLAYER_PREVIOUS_NEXT_BUTTONS.get();
+
+ private static final int PLAYER_CONTROL_PREVIOUS_BUTTON_TOUCH_AREA_ID =
+ Utils.getResourceIdentifier("player_control_previous_button_touch_area", "id");
+
+ private static final int PLAYER_CONTROL_NEXT_BUTTON_TOUCH_AREA_ID =
+ Utils.getResourceIdentifier("player_control_next_button_touch_area", "id");
+
+ /**
+ * Injection point.
+ */
+ public static void hidePreviousNextButtons(View parentView) {
+ if (!HIDE_PLAYER_PREVIOUS_NEXT_BUTTONS_ENABLED) {
+ return;
+ }
+
+ // Must use a deferred call to main thread to hide the button.
+ // Otherwise the layout crashes if set to hidden now.
+ Utils.runOnMainThread(() -> {
+ hideView(parentView, PLAYER_CONTROL_PREVIOUS_BUTTON_TOUCH_AREA_ID);
+ hideView(parentView, PLAYER_CONTROL_NEXT_BUTTON_TOUCH_AREA_ID);
+ });
+ }
+
+ private static void hideView(View parentView, int resourceId) {
+ View nextPreviousButton = parentView.findViewById(resourceId);
+
+ if (nextPreviousButton == null) {
+ Logger.printException(() -> "Could not find player previous/next button");
+ return;
+ }
+
+ Logger.printDebug(() -> "Hiding previous/next button");
+ Utils.hideViewByRemovingFromParentUnderCondition(true, nextPreviousButton);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideSeekbarPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideSeekbarPatch.java
new file mode 100644
index 000000000..98065d7ec
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideSeekbarPatch.java
@@ -0,0 +1,10 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class HideSeekbarPatch {
+ public static boolean hideSeekbar() {
+ return Settings.HIDE_SEEKBAR.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideTimestampPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideTimestampPatch.java
new file mode 100644
index 000000000..a502bb690
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/HideTimestampPatch.java
@@ -0,0 +1,10 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class HideTimestampPatch {
+ public static boolean hideTimestamp() {
+ return Settings.HIDE_TIMESTAMP.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/MiniplayerPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/MiniplayerPatch.java
new file mode 100644
index 000000000..782ca2540
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/MiniplayerPatch.java
@@ -0,0 +1,328 @@
+package app.revanced.extension.youtube.patches;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.youtube.patches.MiniplayerPatch.MiniplayerType.*;
+import static app.revanced.extension.youtube.patches.VersionCheckPatch.*;
+
+import android.util.DisplayMetrics;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings({"unused", "SpellCheckingInspection"})
+public final class MiniplayerPatch {
+
+ /**
+ * Mini player type. Null fields indicates to use the original un-patched value.
+ */
+ public enum MiniplayerType {
+ /** Unmodified type, and same as un-patched. */
+ ORIGINAL(null, null),
+ PHONE(false, null),
+ TABLET(true, null),
+ MODERN_1(null, 1),
+ MODERN_2(null, 2),
+ MODERN_3(null, 3),
+ /**
+ * Half broken miniplayer, that might be work in progress or left over abandoned code.
+ * Can force this type by editing the import/export settings.
+ */
+ MODERN_4(null, 4);
+
+ /**
+ * Legacy tablet hook value.
+ */
+ @Nullable
+ final Boolean legacyTabletOverride;
+
+ /**
+ * Modern player type used by YT.
+ */
+ @Nullable
+ final Integer modernPlayerType;
+
+ MiniplayerType(@Nullable Boolean legacyTabletOverride, @Nullable Integer modernPlayerType) {
+ this.legacyTabletOverride = legacyTabletOverride;
+ this.modernPlayerType = modernPlayerType;
+ }
+
+ public boolean isModern() {
+ return modernPlayerType != null;
+ }
+ }
+
+ private static final int MINIPLAYER_SIZE;
+
+ static {
+ // YT appears to use the device screen dip width, plus an unknown fixed horizontal padding size.
+ DisplayMetrics displayMetrics = Utils.getContext().getResources().getDisplayMetrics();
+ final int deviceDipWidth = (int) (displayMetrics.widthPixels / displayMetrics.density);
+
+ // YT seems to use a minimum height to calculate the minimum miniplayer width based on the video.
+ // 170 seems to be the smallest that can be used and using less makes no difference.
+ final int WIDTH_DIP_MIN = 170; // Seems to be the smallest that works.
+ final int HORIZONTAL_PADDING_DIP = 15; // Estimated padding.
+ // Round down to the nearest 5 pixels, to keep any error toasts easier to read.
+ final int WIDTH_DIP_MAX = 5 * ((deviceDipWidth - HORIZONTAL_PADDING_DIP) / 5);
+ Logger.printDebug(() -> "Screen dip width: " + deviceDipWidth + " maxWidth: " + WIDTH_DIP_MAX);
+
+ int dipWidth = Settings.MINIPLAYER_WIDTH_DIP.get();
+
+ if (dipWidth < WIDTH_DIP_MIN || dipWidth > WIDTH_DIP_MAX) {
+ Utils.showToastLong(str("revanced_miniplayer_width_dip_invalid_toast",
+ WIDTH_DIP_MIN, WIDTH_DIP_MAX));
+
+ // Instead of resetting, clamp the size at the bounds.
+ dipWidth = Math.max(WIDTH_DIP_MIN, Math.min(dipWidth, WIDTH_DIP_MAX));
+ Settings.MINIPLAYER_WIDTH_DIP.save(dipWidth);
+ }
+
+ MINIPLAYER_SIZE = dipWidth;
+ }
+
+ /**
+ * Modern subtitle overlay for {@link MiniplayerType#MODERN_2}.
+ * Resource is not present in older targets, and this field will be zero.
+ */
+ private static final int MODERN_OVERLAY_SUBTITLE_TEXT
+ = Utils.getResourceIdentifier("modern_miniplayer_subtitle_text", "id");
+
+ private static final MiniplayerType CURRENT_TYPE = Settings.MINIPLAYER_TYPE.get();
+
+ /**
+ * Cannot turn off double tap with modern 2 or 3 with later targets,
+ * as forcing it off breakings tapping the miniplayer.
+ */
+ private static final boolean DOUBLE_TAP_ACTION_ENABLED =
+ // 19.29+ is very broken if double tap is not enabled.
+ IS_19_29_OR_GREATER ||
+ (CURRENT_TYPE.isModern() && Settings.MINIPLAYER_DOUBLE_TAP_ACTION.get());
+
+ private static final boolean DRAG_AND_DROP_ENABLED =
+ CURRENT_TYPE.isModern() && Settings.MINIPLAYER_DRAG_AND_DROP.get();
+
+ private static final boolean HIDE_EXPAND_CLOSE_ENABLED =
+ Settings.MINIPLAYER_HIDE_EXPAND_CLOSE.get()
+ && Settings.MINIPLAYER_HIDE_EXPAND_CLOSE.isAvailable();
+
+ private static final boolean HIDE_SUBTEXT_ENABLED =
+ (CURRENT_TYPE == MODERN_1 || CURRENT_TYPE == MODERN_3) && Settings.MINIPLAYER_HIDE_SUBTEXT.get();
+
+ private static final boolean HIDE_REWIND_FORWARD_ENABLED =
+ CURRENT_TYPE == MODERN_1 && Settings.MINIPLAYER_HIDE_REWIND_FORWARD.get();
+
+ private static final boolean MINIPLAYER_ROUNDED_CORNERS_ENABLED =
+ Settings.MINIPLAYER_ROUNDED_CORNERS.get();
+
+ /**
+ * Remove a broken and always present subtitle text that is only
+ * present with {@link MiniplayerType#MODERN_2}. Bug was fixed in 19.21.
+ */
+ private static final boolean HIDE_BROKEN_MODERN_2_SUBTITLE =
+ CURRENT_TYPE == MODERN_2 && !IS_19_21_OR_GREATER;
+
+ private static final int OPACITY_LEVEL;
+
+ public static final class MiniplayerHideExpandCloseAvailability implements Setting.Availability {
+ @Override
+ public boolean isAvailable() {
+ MiniplayerType type = Settings.MINIPLAYER_TYPE.get();
+ return (!IS_19_20_OR_GREATER && (type == MODERN_1 || type == MODERN_3))
+ || (!IS_19_26_OR_GREATER && type == MODERN_1
+ && !Settings.MINIPLAYER_DOUBLE_TAP_ACTION.get() && !Settings.MINIPLAYER_DRAG_AND_DROP.get())
+ || (IS_19_29_OR_GREATER && type == MODERN_3);
+ }
+ }
+
+ static {
+ int opacity = Settings.MINIPLAYER_OPACITY.get();
+
+ if (opacity < 0 || opacity > 100) {
+ Utils.showToastLong(str("revanced_miniplayer_opacity_invalid_toast"));
+ Settings.MINIPLAYER_OPACITY.resetToDefault();
+ opacity = Settings.MINIPLAYER_OPACITY.defaultValue;
+ }
+
+ OPACITY_LEVEL = (opacity * 255) / 100;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean getLegacyTabletMiniplayerOverride(boolean original) {
+ Boolean isTablet = CURRENT_TYPE.legacyTabletOverride;
+ return isTablet == null
+ ? original
+ : isTablet;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean getModernMiniplayerOverride(boolean original) {
+ return CURRENT_TYPE == ORIGINAL
+ ? original
+ : CURRENT_TYPE.isModern();
+ }
+
+ /**
+ * Injection point.
+ */
+ public static int getModernMiniplayerOverrideType(int original) {
+ Integer modernValue = CURRENT_TYPE.modernPlayerType;
+ return modernValue == null
+ ? original
+ : modernValue;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void adjustMiniplayerOpacity(ImageView view) {
+ if (CURRENT_TYPE == MODERN_1) {
+ view.setImageAlpha(OPACITY_LEVEL);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean getModernFeatureFlagsActiveOverride(boolean original) {
+ if (original) Logger.printDebug(() -> "getModernFeatureFlagsActiveOverride original: " + original);
+
+ if (CURRENT_TYPE == ORIGINAL) {
+ return original;
+ }
+
+ return CURRENT_TYPE.isModern();
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean enableMiniplayerDoubleTapAction(boolean original) {
+ if (original) Logger.printDebug(() -> "enableMiniplayerDoubleTapAction original: " + true);
+
+ if (CURRENT_TYPE == ORIGINAL) {
+ return original;
+ }
+
+ return DOUBLE_TAP_ACTION_ENABLED;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean enableMiniplayerDragAndDrop(boolean original) {
+ if (original) Logger.printDebug(() -> "enableMiniplayerDragAndDrop original: " + true);
+
+ if (CURRENT_TYPE == ORIGINAL) {
+ return original;
+ }
+
+ return DRAG_AND_DROP_ENABLED;
+ }
+
+
+ /**
+ * Injection point.
+ */
+ public static boolean setRoundedCorners(boolean original) {
+ if (original) Logger.printDebug(() -> "setRoundedCorners original: " + true);
+
+ if (CURRENT_TYPE.isModern()) {
+ return MINIPLAYER_ROUNDED_CORNERS_ENABLED;
+ }
+
+ return original;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static int setMiniplayerDefaultSize(int original) {
+ if (CURRENT_TYPE.isModern()) {
+ return MINIPLAYER_SIZE;
+ }
+
+ return original;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static float setMovementBoundFactor(float original) {
+ // Not clear if customizing this is useful or not.
+ // So for now just log this and use the original value.
+ if (original != 1.0) Logger.printDebug(() -> "setMovementBoundFactor original: " + original);
+
+ return original;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean setDropShadow(boolean original) {
+ if (original) Logger.printDebug(() -> "setViewElevation original: " + true);
+
+ return original;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void hideMiniplayerExpandClose(ImageView view) {
+ Utils.hideViewByRemovingFromParentUnderCondition(HIDE_EXPAND_CLOSE_ENABLED, view);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void hideMiniplayerRewindForward(ImageView view) {
+ Utils.hideViewByRemovingFromParentUnderCondition(HIDE_REWIND_FORWARD_ENABLED, view);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void hideMiniplayerSubTexts(View view) {
+ try {
+ // Different subviews are passed in, but only TextView is of interest here.
+ if (HIDE_SUBTEXT_ENABLED && view instanceof TextView) {
+ Logger.printDebug(() -> "Hiding subtext view");
+ Utils.hideViewByRemovingFromParentUnderCondition(true, view);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "hideMiniplayerSubTexts failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void playerOverlayGroupCreated(View group) {
+ try {
+ if (HIDE_BROKEN_MODERN_2_SUBTITLE && MODERN_OVERLAY_SUBTITLE_TEXT != 0) {
+ if (group instanceof ViewGroup) {
+ View subtitleText = Utils.getChildView((ViewGroup) group, true,
+ view -> view.getId() == MODERN_OVERLAY_SUBTITLE_TEXT);
+
+ if (subtitleText != null) {
+ subtitleText.setVisibility(View.GONE);
+ Logger.printDebug(() -> "Modern overlay subtitle view set to hidden");
+ }
+ }
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "playerOverlayGroupCreated failure", ex);
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/NavigationButtonsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/NavigationButtonsPatch.java
new file mode 100644
index 000000000..8c581fc1c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/NavigationButtonsPatch.java
@@ -0,0 +1,51 @@
+package app.revanced.extension.youtube.patches;
+
+import static app.revanced.extension.shared.Utils.hideViewUnderCondition;
+import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
+
+import android.view.View;
+
+import java.util.EnumMap;
+import java.util.Map;
+
+import android.widget.TextView;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class NavigationButtonsPatch {
+
+ private static final Map shouldHideMap = new EnumMap<>(NavigationButton.class) {
+ {
+ put(NavigationButton.HOME, Settings.HIDE_HOME_BUTTON.get());
+ put(NavigationButton.CREATE, Settings.HIDE_CREATE_BUTTON.get());
+ put(NavigationButton.SHORTS, Settings.HIDE_SHORTS_BUTTON.get());
+ put(NavigationButton.SUBSCRIPTIONS, Settings.HIDE_SUBSCRIPTIONS_BUTTON.get());
+ }
+ };
+
+ private static final boolean SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON
+ = Settings.SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON.get();
+
+ /**
+ * Injection point.
+ */
+ public static boolean switchCreateWithNotificationButton() {
+ return SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void navigationTabCreated(NavigationButton button, View tabView) {
+ if (Boolean.TRUE.equals(shouldHideMap.get(button))) {
+ tabView.setVisibility(View.GONE);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void hideNavigationButtonLabels(TextView navigationLabelsView) {
+ hideViewUnderCondition(Settings.HIDE_NAVIGATION_BUTTON_LABELS, navigationLabelsView);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/OpenLinksExternallyPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/OpenLinksExternallyPatch.java
new file mode 100644
index 000000000..1f5d52c5b
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/OpenLinksExternallyPatch.java
@@ -0,0 +1,18 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class OpenLinksExternallyPatch {
+ /**
+ * Return the intent to open links with. If empty, the link will be opened with the default browser.
+ *
+ * @param originalIntent The original intent to open links with.
+ * @return The intent to open links with. Empty means the link will be opened with the default browser.
+ */
+ public static String getIntent(String originalIntent) {
+ if (Settings.EXTERNAL_BROWSER.get()) return "";
+
+ return originalIntent;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerControlsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerControlsPatch.java
new file mode 100644
index 000000000..5dc3dde26
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerControlsPatch.java
@@ -0,0 +1,44 @@
+package app.revanced.extension.youtube.patches;
+
+import android.view.View;
+import android.view.ViewTreeObserver;
+import android.widget.ImageView;
+
+import app.revanced.extension.shared.Logger;
+
+@SuppressWarnings("unused")
+public class PlayerControlsPatch {
+ /**
+ * Injection point.
+ */
+ public static void setFullscreenCloseButton(ImageView imageButton) {
+ // Add a global listener, since the protected method
+ // View#onVisibilityChanged() does not have any call backs.
+ imageButton.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
+ int lastVisibility = View.VISIBLE;
+
+ @Override
+ public void onGlobalLayout() {
+ try {
+ final int visibility = imageButton.getVisibility();
+ if (lastVisibility != visibility) {
+ lastVisibility = visibility;
+
+ Logger.printDebug(() -> "fullscreen button visibility: "
+ + (visibility == View.VISIBLE ? "VISIBLE" :
+ visibility == View.GONE ? "GONE" : "INVISIBLE"));
+
+ fullscreenButtonVisibilityChanged(visibility == View.VISIBLE);
+ }
+ } catch (Exception ex) {
+ Logger.printDebug(() -> "OnGlobalLayoutListener failure", ex);
+ }
+ }
+ });
+ }
+
+ // noinspection EmptyMethod
+ public static void fullscreenButtonVisibilityChanged(boolean isVisible) {
+ // Code added during patching.
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerOverlaysHookPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerOverlaysHookPatch.java
new file mode 100644
index 000000000..cf44c9dd5
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerOverlaysHookPatch.java
@@ -0,0 +1,15 @@
+package app.revanced.extension.youtube.patches;
+
+import android.view.ViewGroup;
+
+import app.revanced.extension.youtube.shared.PlayerOverlays;
+
+@SuppressWarnings("unused")
+public class PlayerOverlaysHookPatch {
+ /**
+ * Injection point.
+ */
+ public static void playerOverlayInflated(ViewGroup group) {
+ PlayerOverlays.attach(group);
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerTypeHookPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerTypeHookPatch.java
new file mode 100644
index 000000000..3f1591950
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/PlayerTypeHookPatch.java
@@ -0,0 +1,27 @@
+package app.revanced.extension.youtube.patches;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.youtube.shared.PlayerType;
+import app.revanced.extension.youtube.shared.VideoState;
+
+@SuppressWarnings("unused")
+public class PlayerTypeHookPatch {
+ /**
+ * Injection point.
+ */
+ public static void setPlayerType(@Nullable Enum> youTubePlayerType) {
+ if (youTubePlayerType == null) return;
+
+ PlayerType.setFromString(youTubePlayerType.name());
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void setVideoState(@Nullable Enum> youTubeVideoState) {
+ if (youTubeVideoState == null) return;
+
+ VideoState.setFromString(youTubeVideoState.name());
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RemoveTrackingQueryParameterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RemoveTrackingQueryParameterPatch.java
new file mode 100644
index 000000000..3b05a239a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RemoveTrackingQueryParameterPatch.java
@@ -0,0 +1,17 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class RemoveTrackingQueryParameterPatch {
+ private static final String NEW_TRACKING_PARAMETER_REGEX = ".si=.+";
+ private static final String OLD_TRACKING_PARAMETER_REGEX = ".feature=.+";
+
+ public static String sanitize(String url) {
+ if (!Settings.REMOVE_TRACKING_QUERY_PARAMETER.get()) return url;
+
+ return url
+ .replaceAll(NEW_TRACKING_PARAMETER_REGEX, "")
+ .replaceAll(OLD_TRACKING_PARAMETER_REGEX, "");
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RemoveViewerDiscretionDialogPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RemoveViewerDiscretionDialogPatch.java
new file mode 100644
index 000000000..0260b2c69
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RemoveViewerDiscretionDialogPatch.java
@@ -0,0 +1,19 @@
+package app.revanced.extension.youtube.patches;
+
+import android.app.AlertDialog;
+import app.revanced.extension.youtube.settings.Settings;
+
+/** @noinspection unused*/
+public class RemoveViewerDiscretionDialogPatch {
+ public static void confirmDialog(AlertDialog dialog) {
+ if (!Settings.REMOVE_VIEWER_DISCRETION_DIALOG.get()) {
+ // Since the patch replaces the AlertDialog#show() method, we need to call the original method here.
+ dialog.show();
+ return;
+ }
+
+ final var button = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
+ button.setSoundEffectsEnabled(false);
+ button.performClick();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RestoreOldSeekbarThumbnailsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RestoreOldSeekbarThumbnailsPatch.java
new file mode 100644
index 000000000..8322ea70b
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/RestoreOldSeekbarThumbnailsPatch.java
@@ -0,0 +1,10 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class RestoreOldSeekbarThumbnailsPatch {
+ public static boolean useFullscreenSeekbarThumbnails() {
+ return !Settings.RESTORE_OLD_SEEKBAR_THUMBNAILS.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ReturnYouTubeDislikePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ReturnYouTubeDislikePatch.java
new file mode 100644
index 000000000..6a554fbd6
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ReturnYouTubeDislikePatch.java
@@ -0,0 +1,734 @@
+package app.revanced.extension.youtube.patches;
+
+import android.graphics.Rect;
+import android.graphics.drawable.ShapeDrawable;
+import android.os.Build;
+import android.text.*;
+import android.view.View;
+import android.widget.TextView;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.youtube.patches.components.ReturnYouTubeDislikeFilterPatch;
+import app.revanced.extension.youtube.patches.spoof.SpoofAppVersionPatch;
+import app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike;
+import app.revanced.extension.youtube.returnyoutubedislike.requests.ReturnYouTubeDislikeApi;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.PlayerType;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import static app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike.Vote;
+
+/**
+ * Handles all interaction of UI patch components.
+ *
+ * Known limitation:
+ * The implementation of Shorts litho requires blocking the loading the first Short until RYD has completed.
+ * This is because it modifies the dislikes text synchronously, and if the RYD fetch has
+ * not completed yet then the UI will be temporarily frozen.
+ *
+ * A (yet to be implemented) solution that fixes this problem. Any one of:
+ * - Modify patch to hook onto the Shorts Litho TextView, and update the dislikes text asynchronously.
+ * - Find a way to force Litho to rebuild it's component tree,
+ * and use that hook to force the shorts dislikes to update after the fetch is completed.
+ * - Hook into the dislikes button image view, and replace the dislikes thumb down image with a
+ * generated image of the number of dislikes, then update the image asynchronously. This Could
+ * also be used for the regular video player to give a better UI layout and completely remove
+ * the need for the Rolling Number patches.
+ */
+@SuppressWarnings("unused")
+public class ReturnYouTubeDislikePatch {
+
+ public static final boolean IS_SPOOFING_TO_NON_LITHO_SHORTS_PLAYER =
+ SpoofAppVersionPatch.isSpoofingToLessThan("18.34.00");
+
+ /**
+ * RYD data for the current video on screen.
+ */
+ @Nullable
+ private static volatile ReturnYouTubeDislike currentVideoData;
+
+ /**
+ * The last litho based Shorts loaded.
+ * May be the same value as {@link #currentVideoData}, but usually is the next short to swipe to.
+ */
+ @Nullable
+ private static volatile ReturnYouTubeDislike lastLithoShortsVideoData;
+
+ /**
+ * Because the litho Shorts spans are created after {@link ReturnYouTubeDislikeFilterPatch}
+ * detects the video ids, after the user votes the litho will update
+ * but {@link #lastLithoShortsVideoData} is not the correct data to use.
+ * If this is true, then instead use {@link #currentVideoData}.
+ */
+ private static volatile boolean lithoShortsShouldUseCurrentData;
+
+ /**
+ * Last video id prefetched. Field is to prevent prefetching the same video id multiple times in a row.
+ */
+ @Nullable
+ private static volatile String lastPrefetchedVideoId;
+
+ public static void onRYDStatusChange(boolean rydEnabled) {
+ ReturnYouTubeDislikeApi.resetRateLimits();
+ // Must remove all values to protect against using stale data
+ // if the user enables RYD while a video is on screen.
+ clearData();
+ }
+
+ private static void clearData() {
+ currentVideoData = null;
+ lastLithoShortsVideoData = null;
+ lithoShortsShouldUseCurrentData = false;
+ // Rolling number text should not be cleared,
+ // as it's used if incognito Short is opened/closed
+ // while a regular video is on screen.
+ }
+
+ //
+ // 17.x non litho regular video player.
+ //
+
+ /**
+ * Resource identifier of old UI dislike button.
+ */
+ private static final int OLD_UI_DISLIKE_BUTTON_RESOURCE_ID
+ = Utils.getResourceIdentifier("dislike_button", "id");
+
+ /**
+ * Dislikes text label used by old UI.
+ */
+ @NonNull
+ private static WeakReference oldUITextViewRef = new WeakReference<>(null);
+
+ /**
+ * Original old UI 'Dislikes' text before patch modifications.
+ * Required to reset the dislikes when changing videos and RYD is not available.
+ * Set only once during the first load.
+ */
+ private static Spanned oldUIOriginalSpan;
+
+ /**
+ * Replacement span that contains dislike value. Used by {@link #oldUiTextWatcher}.
+ */
+ @Nullable
+ private static Spanned oldUIReplacementSpan;
+
+ /**
+ * Old UI dislikes can be set multiple times by YouTube.
+ * To prevent reverting changes made here, this listener overrides any future changes YouTube makes.
+ */
+ private static final TextWatcher oldUiTextWatcher = new TextWatcher() {
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+ public void afterTextChanged(Editable s) {
+ if (oldUIReplacementSpan == null || oldUIReplacementSpan.toString().equals(s.toString())) {
+ return;
+ }
+ s.replace(0, s.length(), oldUIReplacementSpan); // Causes a recursive call back into this listener
+ }
+ };
+
+ private static void updateOldUIDislikesTextView() {
+ ReturnYouTubeDislike videoData = currentVideoData;
+ if (videoData == null) {
+ return;
+ }
+ TextView oldUITextView = oldUITextViewRef.get();
+ if (oldUITextView == null) {
+ return;
+ }
+ oldUIReplacementSpan = videoData.getDislikesSpanForRegularVideo(oldUIOriginalSpan, false, false);
+ if (!oldUIReplacementSpan.equals(oldUITextView.getText())) {
+ oldUITextView.setText(oldUIReplacementSpan);
+ }
+ }
+
+ /**
+ * Injection point. Called on main thread.
+ *
+ * Used when spoofing to 16.x and 17.x versions.
+ */
+ public static void setOldUILayoutDislikes(int buttonViewResourceId, @Nullable TextView textView) {
+ try {
+ if (!Settings.RYD_ENABLED.get()
+ || buttonViewResourceId != OLD_UI_DISLIKE_BUTTON_RESOURCE_ID
+ || textView == null) {
+ return;
+ }
+ Logger.printDebug(() -> "setOldUILayoutDislikes");
+
+ if (oldUIOriginalSpan == null) {
+ // Use value of the first instance, as it appears TextViews can be recycled
+ // and might contain dislikes previously added by the patch.
+ oldUIOriginalSpan = (Spanned) textView.getText();
+ }
+ oldUITextViewRef = new WeakReference<>(textView);
+ // No way to check if a listener is already attached, so remove and add again.
+ textView.removeTextChangedListener(oldUiTextWatcher);
+ textView.addTextChangedListener(oldUiTextWatcher);
+
+ updateOldUIDislikesTextView();
+
+ } catch (Exception ex) {
+ Logger.printException(() -> "setOldUILayoutDislikes failure", ex);
+ }
+ }
+
+
+ //
+ // Litho player for both regular videos and Shorts.
+ //
+
+ /**
+ * Injection point.
+ *
+ * For Litho segmented buttons and Litho Shorts player.
+ */
+ @NonNull
+ public static CharSequence onLithoTextLoaded(@NonNull Object conversionContext,
+ @NonNull CharSequence original) {
+ return onLithoTextLoaded(conversionContext, original, false);
+ }
+
+ /**
+ * Called when a litho text component is initially created,
+ * and also when a Span is later reused again (such as scrolling off/on screen).
+ *
+ * This method is sometimes called on the main thread, but it usually is called _off_ the main thread.
+ * This method can be called multiple times for the same UI element (including after dislikes was added).
+ *
+ * @param original Original char sequence was created or reused by Litho.
+ * @param isRollingNumber If the span is for a Rolling Number.
+ * @return The original char sequence (if nothing should change), or a replacement char sequence that contains dislikes.
+ */
+ @NonNull
+ private static CharSequence onLithoTextLoaded(@NonNull Object conversionContext,
+ @NonNull CharSequence original,
+ boolean isRollingNumber) {
+ try {
+ if (!Settings.RYD_ENABLED.get()) {
+ return original;
+ }
+
+ String conversionContextString = conversionContext.toString();
+
+ if (isRollingNumber && !conversionContextString.contains("video_action_bar.eml")) {
+ return original;
+ }
+
+ if (conversionContextString.contains("segmented_like_dislike_button.eml")) {
+ // Regular video.
+ ReturnYouTubeDislike videoData = currentVideoData;
+ if (videoData == null) {
+ return original; // User enabled RYD while a video was on screen.
+ }
+ if (!(original instanceof Spanned)) {
+ original = new SpannableString(original);
+ }
+ return videoData.getDislikesSpanForRegularVideo((Spanned) original,
+ true, isRollingNumber);
+ }
+
+ if (isRollingNumber) {
+ return original; // No need to check for Shorts in the context.
+ }
+
+ if (conversionContextString.contains("|shorts_dislike_button.eml")) {
+ return getShortsSpan(original, true);
+ }
+
+ if (conversionContextString.contains("|shorts_like_button.eml")
+ && !Utils.containsNumber(original)) {
+ Logger.printDebug(() -> "Replacing hidden likes count");
+ return getShortsSpan(original, false);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onLithoTextLoaded failure", ex);
+ }
+ return original;
+ }
+
+ private static CharSequence getShortsSpan(@NonNull CharSequence original, boolean isDislikesSpan) {
+ // Litho Shorts player.
+ if (!Settings.RYD_SHORTS.get() || (isDislikesSpan && Settings.HIDE_SHORTS_DISLIKE_BUTTON.get())
+ || (!isDislikesSpan && Settings.HIDE_SHORTS_LIKE_BUTTON.get())) {
+ return original;
+ }
+
+ ReturnYouTubeDislike videoData = lastLithoShortsVideoData;
+ if (videoData == null) {
+ // The Shorts litho video id filter did not detect the video id.
+ // This is normal in incognito mode, but otherwise is abnormal.
+ Logger.printDebug(() -> "Cannot modify Shorts litho span, data is null");
+ return original;
+ }
+
+ // Use the correct dislikes data after voting.
+ if (lithoShortsShouldUseCurrentData) {
+ if (isDislikesSpan) {
+ lithoShortsShouldUseCurrentData = false;
+ }
+ videoData = currentVideoData;
+ if (videoData == null) {
+ Logger.printException(() -> "currentVideoData is null"); // Should never happen
+ return original;
+ }
+ Logger.printDebug(() -> "Using current video data for litho span");
+ }
+
+ return isDislikesSpan
+ ? videoData.getDislikeSpanForShort((Spanned) original)
+ : videoData.getLikeSpanForShort((Spanned) original);
+ }
+
+ //
+ // Rolling Number
+ //
+
+ /**
+ * Current regular video rolling number text, if rolling number is in use.
+ * This is saved to a field as it's used in every draw() call.
+ */
+ @Nullable
+ private static volatile CharSequence rollingNumberSpan;
+
+ /**
+ * Injection point.
+ */
+ public static String onRollingNumberLoaded(@NonNull Object conversionContext,
+ @NonNull String original) {
+ try {
+ CharSequence replacement = onLithoTextLoaded(conversionContext, original, true);
+
+ String replacementString = replacement.toString();
+ if (!replacementString.equals(original)) {
+ rollingNumberSpan = replacement;
+ return replacementString;
+ } // Else, the text was not a likes count but instead the view count or something else.
+ } catch (Exception ex) {
+ Logger.printException(() -> "onRollingNumberLoaded failure", ex);
+ }
+ return original;
+ }
+
+ /**
+ * Injection point.
+ *
+ * Called for all usage of Rolling Number.
+ * Modifies the measured String text width to include the left separator and padding, if needed.
+ */
+ public static float onRollingNumberMeasured(String text, float measuredTextWidth) {
+ try {
+ if (Settings.RYD_ENABLED.get()) {
+ if (ReturnYouTubeDislike.isPreviouslyCreatedSegmentedSpan(text)) {
+ // +1 pixel is needed for some foreign languages that measure
+ // the text different from what is used for layout (Greek in particular).
+ // Probably a bug in Android, but who knows.
+ // Single line mode is also used as an additional fix for this issue.
+ if (Settings.RYD_COMPACT_LAYOUT.get()) {
+ return measuredTextWidth + 1;
+ }
+
+ return measuredTextWidth + 1
+ + ReturnYouTubeDislike.leftSeparatorBounds.right
+ + ReturnYouTubeDislike.leftSeparatorShapePaddingPixels;
+ }
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onRollingNumberMeasured failure", ex);
+ }
+
+ return measuredTextWidth;
+ }
+
+ /**
+ * Add Rolling Number text view modifications.
+ */
+ private static void addRollingNumberPatchChanges(TextView view) {
+ // YouTube Rolling Numbers do not use compound drawables or drawable padding.
+ if (view.getCompoundDrawablePadding() == 0) {
+ Logger.printDebug(() -> "Adding rolling number TextView changes");
+ view.setCompoundDrawablePadding(ReturnYouTubeDislike.leftSeparatorShapePaddingPixels);
+ ShapeDrawable separator = ReturnYouTubeDislike.getLeftSeparatorDrawable();
+ if (Utils.isRightToLeftTextLayout()) {
+ view.setCompoundDrawables(null, null, separator, null);
+ } else {
+ view.setCompoundDrawables(separator, null, null, null);
+ }
+
+ // Disliking can cause the span to grow in size, which is ok and is laid out correctly,
+ // but if the user then removes their dislike the layout will not adjust to the new shorter width.
+ // Use a center alignment to take up any extra space.
+ view.setTextAlignment(View.TEXT_ALIGNMENT_CENTER);
+
+ // Single line mode does not clip words if the span is larger than the view bounds.
+ // The styled span applied to the view should always have the same bounds,
+ // but use this feature just in case the measurements are somehow off by a few pixels.
+ view.setSingleLine(true);
+ }
+ }
+
+ /**
+ * Remove Rolling Number text view modifications made by this patch.
+ * Required as it appears text views can be reused for other rolling numbers (view count, upload time, etc).
+ */
+ private static void removeRollingNumberPatchChanges(TextView view) {
+ if (view.getCompoundDrawablePadding() != 0) {
+ Logger.printDebug(() -> "Removing rolling number TextView changes");
+ view.setCompoundDrawablePadding(0);
+ view.setCompoundDrawables(null, null, null, null);
+ view.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY); // Default alignment
+ view.setSingleLine(false);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static CharSequence updateRollingNumber(TextView view, CharSequence original) {
+ try {
+ if (!Settings.RYD_ENABLED.get()) {
+ removeRollingNumberPatchChanges(view);
+ return original;
+ }
+ // Called for all instances of RollingNumber, so must check if text is for a dislikes.
+ // Text will already have the correct content but it's missing the drawable separators.
+ if (!ReturnYouTubeDislike.isPreviouslyCreatedSegmentedSpan(original.toString())) {
+ // The text is the video view count, upload time, or some other text.
+ removeRollingNumberPatchChanges(view);
+ return original;
+ }
+
+ CharSequence replacement = rollingNumberSpan;
+ if (replacement == null) {
+ // User enabled RYD while a video was open,
+ // or user opened/closed a Short while a regular video was opened.
+ Logger.printDebug(() -> "Cannot update rolling number (field is null");
+ removeRollingNumberPatchChanges(view);
+ return original;
+ }
+
+ if (Settings.RYD_COMPACT_LAYOUT.get()) {
+ removeRollingNumberPatchChanges(view);
+ } else {
+ addRollingNumberPatchChanges(view);
+ }
+
+ // Remove any padding set by Rolling Number.
+ view.setPadding(0, 0, 0, 0);
+
+ // When displaying dislikes, the rolling animation is not visually correct
+ // and the dislikes always animate (even though the dislike count has not changed).
+ // The animation is caused by an image span attached to the span,
+ // and using only the modified segmented span prevents the animation from showing.
+ return replacement;
+ } catch (Exception ex) {
+ Logger.printException(() -> "updateRollingNumber failure", ex);
+ return original;
+ }
+ }
+
+ //
+ // Non litho Shorts player.
+ //
+
+ /**
+ * Replacement text to use for "Dislikes" while RYD is fetching.
+ */
+ private static final Spannable SHORTS_LOADING_SPAN = new SpannableString("-");
+
+ /**
+ * Dislikes TextViews used by Shorts.
+ *
+ * Multiple TextViews are loaded at once (for the prior and next videos to swipe to).
+ * Keep track of all of them, and later pick out the correct one based on their on screen position.
+ */
+ private static final List> shortsTextViewRefs = new ArrayList<>();
+
+ private static void clearRemovedShortsTextViews() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // YouTube requires Android N or greater
+ shortsTextViewRefs.removeIf(ref -> ref.get() == null);
+ }
+ }
+
+ /**
+ * Injection point. Called when a Shorts dislike is updated. Always on main thread.
+ * Handles update asynchronously, otherwise Shorts video will be frozen while the UI thread is blocked.
+ *
+ * @return if RYD is enabled and the TextView was updated.
+ */
+ public static boolean setShortsDislikes(@NonNull View likeDislikeView) {
+ try {
+ if (!Settings.RYD_ENABLED.get()) {
+ return false;
+ }
+ if (!Settings.RYD_SHORTS.get() || Settings.HIDE_SHORTS_DISLIKE_BUTTON.get()) {
+ // Must clear the data here, in case a new video was loaded while PlayerType
+ // suggested the video was not a short (can happen when spoofing to an old app version).
+ clearData();
+ return false;
+ }
+ Logger.printDebug(() -> "setShortsDislikes");
+
+ TextView textView = (TextView) likeDislikeView;
+ textView.setText(SHORTS_LOADING_SPAN); // Change 'Dislike' text to the loading text.
+ shortsTextViewRefs.add(new WeakReference<>(textView));
+
+ if (likeDislikeView.isSelected() && isShortTextViewOnScreen(textView)) {
+ Logger.printDebug(() -> "Shorts dislike is already selected");
+ ReturnYouTubeDislike videoData = currentVideoData;
+ if (videoData != null) videoData.setUserVote(Vote.DISLIKE);
+ }
+
+ // For the first short played, the Shorts dislike hook is called after the video id hook.
+ // But for most other times this hook is called before the video id (which is not ideal).
+ // Must update the TextViews here, and also after the videoId changes.
+ updateOnScreenShortsTextViews(false);
+
+ return true;
+ } catch (Exception ex) {
+ Logger.printException(() -> "setShortsDislikes failure", ex);
+ return false;
+ }
+ }
+
+ /**
+ * @param forceUpdate if false, then only update the 'loading text views.
+ * If true, update all on screen text views.
+ */
+ private static void updateOnScreenShortsTextViews(boolean forceUpdate) {
+ try {
+ clearRemovedShortsTextViews();
+ if (shortsTextViewRefs.isEmpty()) {
+ return;
+ }
+ ReturnYouTubeDislike videoData = currentVideoData;
+ if (videoData == null) {
+ return;
+ }
+
+ Logger.printDebug(() -> "updateShortsTextViews");
+
+ Runnable update = () -> {
+ Spanned shortsDislikesSpan = videoData.getDislikeSpanForShort(SHORTS_LOADING_SPAN);
+ Utils.runOnMainThreadNowOrLater(() -> {
+ String videoId = videoData.getVideoId();
+ if (!videoId.equals(VideoInformation.getVideoId())) {
+ // User swiped to new video before fetch completed
+ Logger.printDebug(() -> "Ignoring stale dislikes data for short: " + videoId);
+ return;
+ }
+
+ // Update text views that appear to be visible on screen.
+ // Only 1 will be the actual textview for the current Short,
+ // but discarded and not yet garbage collected views can remain.
+ // So must set the dislike span on all views that match.
+ for (WeakReference textViewRef : shortsTextViewRefs) {
+ TextView textView = textViewRef.get();
+ if (textView == null) {
+ continue;
+ }
+ if (isShortTextViewOnScreen(textView)
+ && (forceUpdate || textView.getText().toString().equals(SHORTS_LOADING_SPAN.toString()))) {
+ Logger.printDebug(() -> "Setting Shorts TextView to: " + shortsDislikesSpan);
+ textView.setText(shortsDislikesSpan);
+ }
+ }
+ });
+ };
+ if (videoData.fetchCompleted()) {
+ update.run(); // Network call is completed, no need to wait on background thread.
+ } else {
+ Utils.runOnBackgroundThread(update);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "updateOnScreenShortsTextViews failure", ex);
+ }
+ }
+
+ /**
+ * Check if a view is within the screen bounds.
+ */
+ private static boolean isShortTextViewOnScreen(@NonNull View view) {
+ final int[] location = new int[2];
+ view.getLocationInWindow(location);
+ if (location[0] <= 0 && location[1] <= 0) { // Lower bound
+ return false;
+ }
+ Rect windowRect = new Rect();
+ view.getWindowVisibleDisplayFrame(windowRect); // Upper bound
+ return location[0] < windowRect.width() && location[1] < windowRect.height();
+ }
+
+
+ //
+ // Video Id and voting hooks (all players).
+ //
+
+ private static volatile boolean lastPlayerResponseWasShort;
+
+ /**
+ * Injection point. Uses 'playback response' video id hook to preload RYD.
+ */
+ public static void preloadVideoId(@NonNull String videoId, boolean isShortAndOpeningOrPlaying) {
+ try {
+ if (!Settings.RYD_ENABLED.get()) {
+ return;
+ }
+ if (videoId.equals(lastPrefetchedVideoId)) {
+ return;
+ }
+
+ final boolean videoIdIsShort = VideoInformation.lastPlayerResponseIsShort();
+ // Shorts shelf in home and subscription feed causes player response hook to be called,
+ // and the 'is opening/playing' parameter will be false.
+ // This hook will be called again when the Short is actually opened.
+ if (videoIdIsShort && (!isShortAndOpeningOrPlaying || !Settings.RYD_SHORTS.get())) {
+ return;
+ }
+ final boolean waitForFetchToComplete = !IS_SPOOFING_TO_NON_LITHO_SHORTS_PLAYER
+ && videoIdIsShort && !lastPlayerResponseWasShort;
+
+ Logger.printDebug(() -> "Prefetching RYD for video: " + videoId);
+ ReturnYouTubeDislike fetch = ReturnYouTubeDislike.getFetchForVideoId(videoId);
+ if (waitForFetchToComplete && !fetch.fetchCompleted()) {
+ // This call is off the main thread, so wait until the RYD fetch completely finishes,
+ // otherwise if this returns before the fetch completes then the UI can
+ // become frozen when the main thread tries to modify the litho Shorts dislikes and
+ // it must wait for the fetch.
+ // Only need to do this for the first Short opened, as the next Short to swipe to
+ // are preloaded in the background.
+ //
+ // If an asynchronous litho Shorts solution is found, then this blocking call should be removed.
+ Logger.printDebug(() -> "Waiting for prefetch to complete: " + videoId);
+ fetch.getFetchData(20000); // Any arbitrarily large max wait time.
+ }
+
+ // Set the fields after the fetch completes, so any concurrent calls will also wait.
+ lastPlayerResponseWasShort = videoIdIsShort;
+ lastPrefetchedVideoId = videoId;
+ } catch (Exception ex) {
+ Logger.printException(() -> "preloadVideoId failure", ex);
+ }
+ }
+
+ /**
+ * Injection point. Uses 'current playing' video id hook. Always called on main thread.
+ */
+ public static void newVideoLoaded(@NonNull String videoId) {
+ try {
+ if (!Settings.RYD_ENABLED.get()) return;
+ Objects.requireNonNull(videoId);
+
+ PlayerType currentPlayerType = PlayerType.getCurrent();
+ final boolean isNoneHiddenOrSlidingMinimized = currentPlayerType.isNoneHiddenOrSlidingMinimized();
+ if (isNoneHiddenOrSlidingMinimized && !Settings.RYD_SHORTS.get()) {
+ // Must clear here, otherwise the wrong data can be used for a minimized regular video.
+ clearData();
+ return;
+ }
+
+ if (videoIdIsSame(currentVideoData, videoId)) {
+ return;
+ }
+ Logger.printDebug(() -> "New video id: " + videoId + " playerType: " + currentPlayerType);
+
+ ReturnYouTubeDislike data = ReturnYouTubeDislike.getFetchForVideoId(videoId);
+ // Pre-emptively set the data to short status.
+ // Required to prevent Shorts data from being used on a minimized video in incognito mode.
+ if (isNoneHiddenOrSlidingMinimized) {
+ data.setVideoIdIsShort(true);
+ }
+ currentVideoData = data;
+
+ // Current video id hook can be called out of order with the non litho Shorts text view hook.
+ // Must manually update again here.
+ if (isNoneHiddenOrSlidingMinimized) {
+ updateOnScreenShortsTextViews(true);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "newVideoLoaded failure", ex);
+ }
+ }
+
+ public static void setLastLithoShortsVideoId(@Nullable String videoId) {
+ if (videoIdIsSame(lastLithoShortsVideoData, videoId)) {
+ return;
+ }
+
+ if (videoId == null) {
+ // Litho filter did not detect the video id. App is in incognito mode,
+ // or the proto buffer structure was changed and the video id is no longer present.
+ // Must clear both currently playing and last litho data otherwise the
+ // next regular video may use the wrong data.
+ Logger.printDebug(() -> "Litho filter did not find any video ids");
+ clearData();
+ return;
+ }
+
+ Logger.printDebug(() -> "New litho Shorts video id: " + videoId);
+ ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(videoId);
+ videoData.setVideoIdIsShort(true);
+ lastLithoShortsVideoData = videoData;
+ lithoShortsShouldUseCurrentData = false;
+ }
+
+ private static boolean videoIdIsSame(@Nullable ReturnYouTubeDislike fetch, @Nullable String videoId) {
+ return (fetch == null && videoId == null)
+ || (fetch != null && fetch.getVideoId().equals(videoId));
+ }
+
+ /**
+ * Injection point.
+ *
+ * Called when the user likes or dislikes.
+ *
+ * @param vote int that matches {@link Vote#value}
+ */
+ public static void sendVote(int vote) {
+ try {
+ if (!Settings.RYD_ENABLED.get()) {
+ return;
+ }
+
+ final boolean isNoneHiddenOrMinimized = PlayerType.getCurrent().isNoneHiddenOrMinimized();
+ if (isNoneHiddenOrMinimized && !Settings.RYD_SHORTS.get()) {
+ return;
+ }
+
+ ReturnYouTubeDislike videoData = currentVideoData;
+ if (videoData == null) {
+ Logger.printDebug(() -> "Cannot send vote, as current video data is null");
+ return; // User enabled RYD while a regular video was minimized.
+ }
+
+ for (Vote v : Vote.values()) {
+ if (v.value == vote) {
+ videoData.sendVote(v);
+
+ if (isNoneHiddenOrMinimized) {
+ if (lastLithoShortsVideoData != null) {
+ lithoShortsShouldUseCurrentData = true;
+ }
+ updateOldUIDislikesTextView();
+ }
+
+ return;
+ }
+ }
+
+ Logger.printException(() -> "Unknown vote type: " + vote);
+ } catch (Exception ex) {
+ Logger.printException(() -> "sendVote failure", ex);
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/SeekbarTappingPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/SeekbarTappingPatch.java
new file mode 100644
index 000000000..dbbb363e1
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/SeekbarTappingPatch.java
@@ -0,0 +1,10 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class SeekbarTappingPatch {
+ public static boolean seekbarTappingEnabled() {
+ return Settings.SEEKBAR_TAPPING.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ShortsAutoplayPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ShortsAutoplayPatch.java
new file mode 100644
index 000000000..32576479d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ShortsAutoplayPatch.java
@@ -0,0 +1,119 @@
+package app.revanced.extension.youtube.patches;
+
+import android.app.Activity;
+import android.os.Build;
+
+import androidx.annotation.RequiresApi;
+
+import java.lang.ref.WeakReference;
+import java.util.Objects;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class ShortsAutoplayPatch {
+
+ private enum ShortsLoopBehavior {
+ UNKNOWN,
+ /**
+ * Repeat the same Short forever!
+ */
+ REPEAT,
+ /**
+ * Play once, then advanced to the next Short.
+ */
+ SINGLE_PLAY,
+ /**
+ * Pause playback after 1 play.
+ */
+ END_SCREEN;
+
+ static void setYTEnumValue(Enum> ytBehavior) {
+ for (ShortsLoopBehavior rvBehavior : values()) {
+ if (ytBehavior.name().endsWith(rvBehavior.name())) {
+ rvBehavior.ytEnumValue = ytBehavior;
+
+ Logger.printDebug(() -> rvBehavior + " set to YT enum: " + ytBehavior.name());
+ return;
+ }
+ }
+
+ Logger.printException(() -> "Unknown Shorts loop behavior: " + ytBehavior.name());
+ }
+
+ /**
+ * YouTube enum value of the obfuscated enum type.
+ */
+ private Enum> ytEnumValue;
+ }
+
+ private static WeakReference mainActivityRef = new WeakReference<>(null);
+
+
+ public static void setMainActivity(Activity activity) {
+ mainActivityRef = new WeakReference<>(activity);
+ }
+
+ /**
+ * @return If the app is currently in background PiP mode.
+ */
+ @RequiresApi(api = Build.VERSION_CODES.N)
+ private static boolean isAppInBackgroundPiPMode() {
+ Activity activity = mainActivityRef.get();
+ return activity != null && activity.isInPictureInPictureMode();
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void setYTShortsRepeatEnum(Enum> ytEnum) {
+ try {
+ for (Enum> ytBehavior : Objects.requireNonNull(ytEnum.getClass().getEnumConstants())) {
+ ShortsLoopBehavior.setYTEnumValue(ytBehavior);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "setYTShortsRepeatEnum failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ @RequiresApi(api = Build.VERSION_CODES.N)
+ public static Enum> changeShortsRepeatBehavior(Enum> original) {
+ try {
+ final boolean autoplay;
+
+ if (isAppInBackgroundPiPMode()) {
+ if (!VersionCheckPatch.IS_19_34_OR_GREATER) {
+ // 19.34+ is required to set background play behavior.
+ Logger.printDebug(() -> "PiP Shorts not supported, using original repeat behavior");
+
+ return original;
+ }
+
+ autoplay = Settings.SHORTS_AUTOPLAY_BACKGROUND.get();
+ } else {
+ autoplay = Settings.SHORTS_AUTOPLAY.get();
+ }
+
+ final ShortsLoopBehavior behavior = autoplay
+ ? ShortsLoopBehavior.SINGLE_PLAY
+ : ShortsLoopBehavior.REPEAT;
+
+ if (behavior.ytEnumValue != null) {
+ Logger.printDebug(() -> behavior.ytEnumValue == original
+ ? "Changing Shorts repeat behavior from: " + original.name() + " to: " + behavior.ytEnumValue
+ : "Behavior setting is same as original. Using original: " + original.name()
+ );
+
+ return behavior.ytEnumValue;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "changeShortsRepeatState failure", ex);
+ }
+
+ return original;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/SlideToSeekPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/SlideToSeekPatch.java
new file mode 100644
index 000000000..d17a1f866
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/SlideToSeekPatch.java
@@ -0,0 +1,14 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class SlideToSeekPatch {
+ private static final Boolean SLIDE_TO_SEEK_DISABLED = !Settings.SLIDE_TO_SEEK.get();
+
+ public static boolean isSlideToSeekDisabled(boolean isDisabled) {
+ if (!isDisabled) return isDisabled;
+
+ return SLIDE_TO_SEEK_DISABLED;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/TabletLayoutPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/TabletLayoutPatch.java
new file mode 100644
index 000000000..f2ae03598
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/TabletLayoutPatch.java
@@ -0,0 +1,16 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class TabletLayoutPatch {
+
+ private static final boolean TABLET_LAYOUT_ENABLED = Settings.TABLET_LAYOUT.get();
+
+ /**
+ * Injection point.
+ */
+ public static boolean getTabletLayoutEnabled() {
+ return TABLET_LAYOUT_ENABLED;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VersionCheckPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VersionCheckPatch.java
new file mode 100644
index 000000000..2d3817ce9
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VersionCheckPatch.java
@@ -0,0 +1,11 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.shared.Utils;
+
+public class VersionCheckPatch {
+ public static final boolean IS_19_20_OR_GREATER = Utils.getAppVersionName().compareTo("19.20.00") >= 0;
+ public static final boolean IS_19_21_OR_GREATER = Utils.getAppVersionName().compareTo("19.21.00") >= 0;
+ public static final boolean IS_19_26_OR_GREATER = Utils.getAppVersionName().compareTo("19.26.00") >= 0;
+ public static final boolean IS_19_29_OR_GREATER = Utils.getAppVersionName().compareTo("19.29.00") >= 0;
+ public static final boolean IS_19_34_OR_GREATER = Utils.getAppVersionName().compareTo("19.34.00") >= 0;
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VideoAdsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VideoAdsPatch.java
new file mode 100644
index 000000000..9950de5e0
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VideoAdsPatch.java
@@ -0,0 +1,14 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class VideoAdsPatch {
+
+ // Used by app.revanced.patches.youtube.ad.general.video.patch.VideoAdsPatch
+ // depends on Whitelist patch (still needs to be written)
+ public static boolean shouldShowAds() {
+ return !Settings.HIDE_VIDEO_ADS.get(); // TODO && Whitelist.shouldShowAds();
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VideoInformation.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VideoInformation.java
new file mode 100644
index 000000000..6b64ade12
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/VideoInformation.java
@@ -0,0 +1,359 @@
+package app.revanced.extension.youtube.patches;
+
+import androidx.annotation.NonNull;
+import app.revanced.extension.youtube.patches.playback.speed.RememberPlaybackSpeedPatch;
+import app.revanced.extension.youtube.shared.VideoState;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+
+import java.lang.ref.WeakReference;
+import java.util.Objects;
+
+/**
+ * Hooking class for the current playing video.
+ * @noinspection unused
+ */
+public final class VideoInformation {
+
+ public interface PlaybackController {
+ // Methods are added to YT classes during patching.
+ boolean seekTo(long videoTime);
+ void seekToRelative(long videoTimeOffset);
+ }
+
+ private static final float DEFAULT_YOUTUBE_PLAYBACK_SPEED = 1.0f;
+ /**
+ * Prefix present in all Short player parameters signature.
+ */
+ private static final String SHORTS_PLAYER_PARAMETERS = "8AEB";
+
+ private static WeakReference playerControllerRef = new WeakReference<>(null);
+ private static WeakReference mdxPlayerDirectorRef = new WeakReference<>(null);
+
+ @NonNull
+ private static String videoId = "";
+ private static long videoLength = 0;
+ private static long videoTime = -1;
+
+ @NonNull
+ private static volatile String playerResponseVideoId = "";
+ private static volatile boolean playerResponseVideoIdIsShort;
+ private static volatile boolean videoIdIsShort;
+
+ /**
+ * The current playback speed
+ */
+ private static float playbackSpeed = DEFAULT_YOUTUBE_PLAYBACK_SPEED;
+
+ /**
+ * Injection point.
+ *
+ * @param playerController player controller object.
+ */
+ public static void initialize(@NonNull PlaybackController playerController) {
+ try {
+ playerControllerRef = new WeakReference<>(Objects.requireNonNull(playerController));
+ videoTime = -1;
+ videoLength = 0;
+ playbackSpeed = DEFAULT_YOUTUBE_PLAYBACK_SPEED;
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to initialize", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param mdxPlayerDirector MDX player director object (casting mode).
+ */
+ public static void initializeMdx(@NonNull PlaybackController mdxPlayerDirector) {
+ try {
+ mdxPlayerDirectorRef = new WeakReference<>(Objects.requireNonNull(mdxPlayerDirector));
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to initialize MDX", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param newlyLoadedVideoId id of the current video
+ */
+ public static void setVideoId(@NonNull String newlyLoadedVideoId) {
+ if (!videoId.equals(newlyLoadedVideoId)) {
+ Logger.printDebug(() -> "New video id: " + newlyLoadedVideoId);
+ videoId = newlyLoadedVideoId;
+ }
+ }
+
+ /**
+ * @return If the player parameters are for a Short.
+ */
+ public static boolean playerParametersAreShort(@NonNull String parameters) {
+ return parameters.startsWith(SHORTS_PLAYER_PARAMETERS);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static String newPlayerResponseSignature(@NonNull String signature, String videoId, boolean isShortAndOpeningOrPlaying) {
+ final boolean isShort = playerParametersAreShort(signature);
+ playerResponseVideoIdIsShort = isShort;
+ if (!isShort || isShortAndOpeningOrPlaying) {
+ if (videoIdIsShort != isShort) {
+ videoIdIsShort = isShort;
+ Logger.printDebug(() -> "videoIdIsShort: " + isShort);
+ }
+ }
+ return signature; // Return the original value since we are observing and not modifying.
+ }
+
+ /**
+ * Injection point. Called off the main thread.
+ *
+ * @param videoId The id of the last video loaded.
+ */
+ public static void setPlayerResponseVideoId(@NonNull String videoId, boolean isShortAndOpeningOrPlaying) {
+ if (!playerResponseVideoId.equals(videoId)) {
+ Logger.printDebug(() -> "New player response video id: " + videoId);
+ playerResponseVideoId = videoId;
+ }
+ }
+
+ /**
+ * Injection point.
+ * Called when user selects a playback speed.
+ *
+ * @param userSelectedPlaybackSpeed The playback speed the user selected
+ */
+ public static void userSelectedPlaybackSpeed(float userSelectedPlaybackSpeed) {
+ Logger.printDebug(() -> "User selected playback speed: " + userSelectedPlaybackSpeed);
+ playbackSpeed = userSelectedPlaybackSpeed;
+ }
+
+ /**
+ * Overrides the current playback speed.
+ *
+ * Used exclusively by {@link RememberPlaybackSpeedPatch}
+ */
+ public static void overridePlaybackSpeed(float speedOverride) {
+ if (playbackSpeed != speedOverride) {
+ Logger.printDebug(() -> "Overriding playback speed to: " + speedOverride);
+ playbackSpeed = speedOverride;
+ }
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param length The length of the video in milliseconds.
+ */
+ public static void setVideoLength(final long length) {
+ if (videoLength != length) {
+ Logger.printDebug(() -> "Current video length: " + length);
+ videoLength = length;
+ }
+ }
+
+ /**
+ * Injection point.
+ * Called on the main thread every 1000ms.
+ *
+ * @param currentPlaybackTime The current playback time of the video in milliseconds.
+ */
+ public static void setVideoTime(final long currentPlaybackTime) {
+ videoTime = currentPlaybackTime;
+ }
+
+ /**
+ * Seek on the current video.
+ * Does not function for playback of Shorts.
+ *
+ * Caution: If called from a videoTimeHook() callback,
+ * this will cause a recursive call into the same videoTimeHook() callback.
+ *
+ * @param seekTime The seekTime to seek the video to.
+ * @return true if the seek was successful.
+ */
+ public static boolean seekTo(final long seekTime) {
+ Utils.verifyOnMainThread();
+ try {
+ final long videoTime = getVideoTime();
+ final long videoLength = getVideoLength();
+
+ // Prevent issues such as play/ pause button or autoplay not working.
+ final long adjustedSeekTime = Math.min(seekTime, videoLength - 250);
+ if (videoTime <= seekTime && videoTime >= adjustedSeekTime) {
+ // Both the current video time and the seekTo are in the last 250ms of the video.
+ // Ignore this seek call, otherwise if a video ends with multiple closely timed segments
+ // then seeking here can create an infinite loop of skip attempts.
+ Logger.printDebug(() -> "Ignoring seekTo call as video playback is almost finished. "
+ + " videoTime: " + videoTime + " videoLength: " + videoLength + " seekTo: " + seekTime);
+ return false;
+ }
+
+ Logger.printDebug(() -> "Seeking to: " + adjustedSeekTime);
+
+ // Try regular playback controller first, and it will not succeed if casting.
+ PlaybackController controller = playerControllerRef.get();
+ if (controller == null) {
+ Logger.printDebug(() -> "Cannot seekTo because player controller is null");
+ } else {
+ if (controller.seekTo(adjustedSeekTime)) return true;
+ Logger.printDebug(() -> "seekTo did not succeeded. Trying MXD.");
+ // Else the video is loading or changing videos, or video is casting to a different device.
+ }
+
+ // Try calling the seekTo method of the MDX player director (called when casting).
+ // The difference has to be a different second mark in order to avoid infinite skip loops
+ // as the Lounge API only supports seconds.
+ if (adjustedSeekTime / 1000 == videoTime / 1000) {
+ Logger.printDebug(() -> "Skipping seekTo for MDX because seek time is too small "
+ + "(" + (adjustedSeekTime - videoTime) + "ms)");
+ return false;
+ }
+
+ controller = mdxPlayerDirectorRef.get();
+ if (controller == null) {
+ Logger.printDebug(() -> "Cannot seekTo MXD because player controller is null");
+ return false;
+ }
+
+ return controller.seekTo(adjustedSeekTime);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to seek", ex);
+ return false;
+ }
+ }
+
+ /**
+ * Seeks a relative amount. Should always be used over {@link #seekTo(long)}
+ * when the desired seek time is an offset of the current time.
+ */
+ public static void seekToRelative(long seekTime) {
+ Utils.verifyOnMainThread();
+ try {
+ Logger.printDebug(() -> "Seeking relative to: " + seekTime);
+
+ // 19.39+ does not have a boolean return type for relative seek.
+ // But can call both methods and it works correctly for both situations.
+ PlaybackController controller = playerControllerRef.get();
+ if (controller == null) {
+ Logger.printDebug(() -> "Cannot seek relative as player controller is null");
+ } else {
+ controller.seekToRelative(seekTime);
+ }
+
+ // Adjust the fine adjustment function so it's at least 1 second before/after.
+ // Otherwise the fine adjustment will do nothing when casting.
+ final long adjustedSeekTime;
+ if (seekTime < 0) {
+ adjustedSeekTime = Math.min(seekTime, -1000);
+ } else {
+ adjustedSeekTime = Math.max(seekTime, 1000);
+ }
+
+ controller = mdxPlayerDirectorRef.get();
+ if (controller == null) {
+ Logger.printDebug(() -> "Cannot seek relative as MXD player controller is null");
+ } else {
+ controller.seekToRelative(adjustedSeekTime);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to seek relative", ex);
+ }
+ }
+
+ /**
+ * Id of the last video opened. Includes Shorts.
+ *
+ * @return The id of the video, or an empty string if no videos have been opened yet.
+ */
+ @NonNull
+ public static String getVideoId() {
+ return videoId;
+ }
+
+ /**
+ * Differs from {@link #videoId} as this is the video id for the
+ * last player response received, which may not be the last video opened.
+ *
+ * If Shorts are loading the background, this commonly will be
+ * different from the Short that is currently on screen.
+ *
+ * For most use cases, you should instead use {@link #getVideoId()}.
+ *
+ * @return The id of the last video loaded, or an empty string if no videos have been loaded yet.
+ */
+ @NonNull
+ public static String getPlayerResponseVideoId() {
+ return playerResponseVideoId;
+ }
+
+ /**
+ * @return If the last player response video id was a Short.
+ * Includes Shorts shelf items appearing in the feed that are not opened.
+ * @see #lastVideoIdIsShort()
+ */
+ public static boolean lastPlayerResponseIsShort() {
+ return playerResponseVideoIdIsShort;
+ }
+
+ /**
+ * @return If the last player response video id _that was opened_ was a Short.
+ */
+ public static boolean lastVideoIdIsShort() {
+ return videoIdIsShort;
+ }
+
+ /**
+ * @return The current playback speed.
+ */
+ public static float getPlaybackSpeed() {
+ return playbackSpeed;
+ }
+
+ /**
+ * Length of the current video playing. Includes Shorts.
+ *
+ * @return The length of the video in milliseconds.
+ * If the video is not yet loaded, or if the video is playing in the background with no video visible,
+ * then this returns zero.
+ */
+ public static long getVideoLength() {
+ return videoLength;
+ }
+
+ /**
+ * Playback time of the current video playing. Includes Shorts.
+ *
+ * Value will lag behind the actual playback time by a variable amount based on the playback speed.
+ *
+ * If playback speed is 2.0x, this value may be up to 2000ms behind the actual playback time.
+ * If playback speed is 1.0x, this value may be up to 1000ms behind the actual playback time.
+ * If playback speed is 0.5x, this value may be up to 500ms behind the actual playback time.
+ * Etc.
+ *
+ * @return The time of the video in milliseconds. -1 if not set yet.
+ */
+ public static long getVideoTime() {
+ return videoTime;
+ }
+
+ /**
+ * @return If the playback is at the end of the video.
+ *
+ * If video is playing in the background with no video visible,
+ * this always returns false (even if the video is actually at the end).
+ *
+ * This is equivalent to checking for {@link VideoState#ENDED},
+ * but can give a more up-to-date result for code calling from some hooks.
+ *
+ * @see VideoState
+ */
+ @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+ public static boolean isAtEndOfVideo() {
+ return videoTime >= videoLength && videoLength > 0;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/WideSearchbarPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/WideSearchbarPatch.java
new file mode 100644
index 000000000..57ec7442f
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/WideSearchbarPatch.java
@@ -0,0 +1,11 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class WideSearchbarPatch {
+
+ public static boolean enableWideSearchbar(boolean original) {
+ return Settings.WIDE_SEARCHBAR.get() || original;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ZoomHapticsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ZoomHapticsPatch.java
new file mode 100644
index 000000000..0367eb868
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ZoomHapticsPatch.java
@@ -0,0 +1,10 @@
+package app.revanced.extension.youtube.patches;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class ZoomHapticsPatch {
+ public static boolean shouldVibrate() {
+ return !Settings.DISABLE_ZOOM_HAPTICS.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/announcements/AnnouncementsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/announcements/AnnouncementsPatch.java
new file mode 100644
index 000000000..0bea72373
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/announcements/AnnouncementsPatch.java
@@ -0,0 +1,157 @@
+package app.revanced.extension.youtube.patches.announcements;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.os.Build;
+import android.text.Html;
+import android.text.method.LinkMovementMethod;
+import android.widget.TextView;
+import androidx.annotation.RequiresApi;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.patches.announcements.requests.AnnouncementsRoutes;
+import app.revanced.extension.youtube.requests.Requester;
+import app.revanced.extension.youtube.settings.Settings;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.Locale;
+
+import static android.text.Html.FROM_HTML_MODE_COMPACT;
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.youtube.patches.announcements.requests.AnnouncementsRoutes.GET_LATEST_ANNOUNCEMENT;
+
+@SuppressWarnings("unused")
+public final class AnnouncementsPatch {
+ private AnnouncementsPatch() {
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ public static void showAnnouncement(final Activity context) {
+ if (!Settings.ANNOUNCEMENTS.get()) return;
+
+ // Check if there is internet connection
+ if (!Utils.isNetworkConnected()) return;
+
+ Utils.runOnBackgroundThread(() -> {
+ try {
+ HttpURLConnection connection = AnnouncementsRoutes.getAnnouncementsConnectionFromRoute(
+ GET_LATEST_ANNOUNCEMENT, Locale.getDefault().toLanguageTag());
+
+ Logger.printDebug(() -> "Get latest announcement route connection url: " + connection.getURL());
+
+ try {
+ // Do not show the announcement if the request failed.
+ if (connection.getResponseCode() != 200) {
+ if (Settings.ANNOUNCEMENT_LAST_ID.isSetToDefault())
+ return;
+
+ Settings.ANNOUNCEMENT_LAST_ID.resetToDefault();
+ Utils.showToastLong(str("revanced_announcements_connection_failed"));
+
+ return;
+ }
+ } catch (IOException ex) {
+ final var message = "Failed connecting to announcements provider";
+
+ Logger.printException(() -> message, ex);
+ return;
+ }
+
+ var jsonString = Requester.parseStringAndDisconnect(connection);
+
+
+ // Parse the announcement. Fall-back to raw string if it fails.
+ int id = Settings.ANNOUNCEMENT_LAST_ID.defaultValue;
+ String title;
+ String message;
+ Level level = Level.INFO;
+ try {
+ final var announcement = new JSONObject(jsonString);
+
+ id = announcement.getInt("id");
+ title = announcement.getString("title");
+ message = announcement.getJSONObject("content").getString("message");
+ if (!announcement.isNull("level")) level = Level.fromInt(announcement.getInt("level"));
+
+ } catch (Throwable ex) {
+ Logger.printException(() -> "Failed to parse announcement. Fall-backing to raw string", ex);
+
+ title = "Announcement";
+ message = jsonString;
+ }
+
+ // TODO: Remove this migration code after a few months.
+ if (!Settings.DEPRECATED_ANNOUNCEMENT_LAST_HASH.isSetToDefault()){
+ final byte[] hashBytes = MessageDigest
+ .getInstance("SHA-256")
+ .digest(jsonString.getBytes(StandardCharsets.UTF_8));
+
+ final var hash = java.util.Base64.getEncoder().encodeToString(hashBytes);
+
+ // Migrate to saving the id instead of the hash.
+ if (hash.equals(Settings.DEPRECATED_ANNOUNCEMENT_LAST_HASH.get())) {
+ Settings.ANNOUNCEMENT_LAST_ID.save(id);
+ }
+
+ Settings.DEPRECATED_ANNOUNCEMENT_LAST_HASH.resetToDefault();
+ }
+
+ // Do not show the announcement, if the last announcement id is the same as the current one.
+ if (Settings.ANNOUNCEMENT_LAST_ID.get() == id) return;
+
+ int finalId = id;
+ final var finalTitle = title;
+ final var finalMessage = Html.fromHtml(message, FROM_HTML_MODE_COMPACT);
+ final Level finalLevel = level;
+
+ Utils.runOnMainThread(() -> {
+ // Show the announcement.
+ var alert = new AlertDialog.Builder(context)
+ .setTitle(finalTitle)
+ .setMessage(finalMessage)
+ .setIcon(finalLevel.icon)
+ .setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ Settings.ANNOUNCEMENT_LAST_ID.save(finalId);
+ dialog.dismiss();
+ }).setNegativeButton(str("revanced_announcements_dialog_dismiss"), (dialog, which) -> {
+ dialog.dismiss();
+ })
+ .setCancelable(false)
+ .create();
+
+ Utils.showDialog(context, alert, false, (AlertDialog dialog) -> {
+ // Make links clickable.
+ ((TextView) dialog.findViewById(android.R.id.message))
+ .setMovementMethod(LinkMovementMethod.getInstance());
+ });
+ });
+ } catch (Exception e) {
+ final var message = "Failed to get announcement";
+
+ Logger.printException(() -> message, e);
+ }
+ });
+ }
+
+ // TODO: Use better icons.
+ private enum Level {
+ INFO(android.R.drawable.ic_dialog_info),
+ WARNING(android.R.drawable.ic_dialog_alert),
+ SEVERE(android.R.drawable.ic_dialog_alert);
+
+ public final int icon;
+
+ Level(int icon) {
+ this.icon = icon;
+ }
+
+ public static Level fromInt(int value) {
+ return values()[Math.min(value, values().length - 1)];
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/announcements/requests/AnnouncementsRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/announcements/requests/AnnouncementsRoutes.java
new file mode 100644
index 000000000..94d340e6e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/announcements/requests/AnnouncementsRoutes.java
@@ -0,0 +1,25 @@
+package app.revanced.extension.youtube.patches.announcements.requests;
+
+import app.revanced.extension.youtube.requests.Requester;
+import app.revanced.extension.youtube.requests.Route;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+
+import static app.revanced.extension.youtube.requests.Route.Method.GET;
+
+public class AnnouncementsRoutes {
+ private static final String ANNOUNCEMENTS_PROVIDER = "https://api.revanced.app/v2";
+
+ /**
+ * 'language' parameter is IETF format (for USA it would be 'en-us').
+ */
+ public static final Route GET_LATEST_ANNOUNCEMENT = new Route(GET, "/announcements/youtube/latest?language={language}");
+
+ private AnnouncementsRoutes() {
+ }
+
+ public static HttpURLConnection getAnnouncementsConnectionFromRoute(Route route, String... params) throws IOException {
+ return Requester.getConnectionFromRoute(ANNOUNCEMENTS_PROVIDER, route, params);
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/AdsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/AdsFilter.java
new file mode 100644
index 000000000..0bf9e9c3f
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/AdsFilter.java
@@ -0,0 +1,240 @@
+package app.revanced.extension.youtube.patches.components;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.app.Instrumentation;
+import android.view.KeyEvent;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.StringTrieSearch;
+
+@SuppressWarnings("unused")
+public final class AdsFilter extends Filter {
+ // region Fullscreen ad
+ private static volatile long lastTimeClosedFullscreenAd;
+ private static final Instrumentation instrumentation = new Instrumentation();
+ private final StringFilterGroup fullscreenAd;
+
+ // endregion
+
+ private final StringTrieSearch exceptions = new StringTrieSearch();
+
+ private final StringFilterGroup playerShoppingShelf;
+ private final ByteArrayFilterGroup playerShoppingShelfBuffer;
+
+ private final StringFilterGroup channelProfile;
+ private final ByteArrayFilterGroup visitStoreButton;
+
+ private final StringFilterGroup shoppingLinks;
+
+ public AdsFilter() {
+ exceptions.addPatterns(
+ "home_video_with_context", // Don't filter anything in the home page video component.
+ "related_video_with_context", // Don't filter anything in the related video component.
+ "comment_thread", // Don't filter anything in the comments.
+ "|comment.", // Don't filter anything in the comments replies.
+ "library_recent_shelf"
+ );
+
+ // Identifiers.
+
+
+ final var carouselAd = new StringFilterGroup(
+ Settings.HIDE_GENERAL_ADS,
+ "carousel_ad"
+ );
+ addIdentifierCallbacks(carouselAd);
+
+ // Paths.
+
+ fullscreenAd = new StringFilterGroup(
+ Settings.HIDE_FULLSCREEN_ADS,
+ "_interstitial"
+ );
+
+ final var buttonedAd = new StringFilterGroup(
+ Settings.HIDE_BUTTONED_ADS,
+ "_ad_with",
+ "_buttoned_layout",
+ // text_image_button_group_layout, landscape_image_button_group_layout, full_width_square_image_button_group_layout
+ "image_button_group_layout",
+ "full_width_square_image_layout",
+ "video_display_button_group_layout",
+ "landscape_image_wide_button_layout",
+ "video_display_carousel_button_group_layout"
+ );
+
+ final var generalAds = new StringFilterGroup(
+ Settings.HIDE_GENERAL_ADS,
+ "ads_video_with_context",
+ "banner_text_icon",
+ "square_image_layout",
+ "watch_metadata_app_promo",
+ "video_display_full_layout",
+ "hero_promo_image",
+ "statement_banner",
+ "carousel_footered_layout",
+ "text_image_button_layout",
+ "primetime_promo",
+ "product_details",
+ "composite_concurrent_carousel_layout",
+ "carousel_headered_layout",
+ "full_width_portrait_image_layout",
+ "brand_video_shelf"
+ );
+
+ final var movieAds = new StringFilterGroup(
+ Settings.HIDE_MOVIES_SECTION,
+ "browsy_bar",
+ "compact_movie",
+ "horizontal_movie_shelf",
+ "movie_and_show_upsell_card",
+ "compact_tvfilm_item",
+ "offer_module_root"
+ );
+
+ final var viewProducts = new StringFilterGroup(
+ Settings.HIDE_PRODUCTS_BANNER,
+ "product_item",
+ "products_in_video"
+ );
+
+ shoppingLinks = new StringFilterGroup(
+ Settings.HIDE_SHOPPING_LINKS,
+ "expandable_list"
+ );
+
+ channelProfile = new StringFilterGroup(
+ null,
+ "channel_profile.eml"
+ );
+
+ playerShoppingShelf = new StringFilterGroup(
+ null,
+ "horizontal_shelf.eml"
+ );
+
+ playerShoppingShelfBuffer = new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_STORE_SHELF,
+ "shopping_item_card_list.eml"
+ );
+
+ visitStoreButton = new ByteArrayFilterGroup(
+ Settings.HIDE_VISIT_STORE_BUTTON,
+ "header_store_button"
+ );
+
+ final var webLinkPanel = new StringFilterGroup(
+ Settings.HIDE_WEB_SEARCH_RESULTS,
+ "web_link_panel"
+ );
+
+ final var merchandise = new StringFilterGroup(
+ Settings.HIDE_MERCHANDISE_BANNERS,
+ "product_carousel"
+ );
+
+ final var selfSponsor = new StringFilterGroup(
+ Settings.HIDE_SELF_SPONSOR,
+ "cta_shelf_card"
+ );
+
+ addPathCallbacks(
+ generalAds,
+ buttonedAd,
+ merchandise,
+ viewProducts,
+ selfSponsor,
+ fullscreenAd,
+ channelProfile,
+ webLinkPanel,
+ shoppingLinks,
+ playerShoppingShelf,
+ movieAds
+ );
+ }
+
+ @Override
+ boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (matchedGroup == playerShoppingShelf) {
+ if (contentIndex == 0 && playerShoppingShelfBuffer.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ }
+
+ if (exceptions.matches(path))
+ return false;
+
+ if (matchedGroup == fullscreenAd) {
+ if (path.contains("|ImageType|")) closeFullscreenAd();
+
+ return false; // Do not actually filter the fullscreen ad otherwise it will leave a dimmed screen.
+ }
+
+ if (matchedGroup == channelProfile) {
+ if (visitStoreButton.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ }
+
+ // Check for the index because of likelihood of false positives.
+ if (matchedGroup == shoppingLinks && contentIndex != 0)
+ return false;
+
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+
+ /**
+ * Hide the view, which shows ads in the homepage.
+ *
+ * @param view The view, which shows ads.
+ */
+ public static void hideAdAttributionView(View view) {
+ Utils.hideViewBy0dpUnderCondition(Settings.HIDE_GENERAL_ADS, view);
+ }
+
+ /**
+ * Close the fullscreen ad.
+ *
+ * The strategy is to send a back button event to the app to close the fullscreen ad using the back button event.
+ */
+ private static void closeFullscreenAd() {
+ final var currentTime = System.currentTimeMillis();
+
+ // Prevent spamming the back button.
+ if (currentTime - lastTimeClosedFullscreenAd < 10000) return;
+ lastTimeClosedFullscreenAd = currentTime;
+
+ Logger.printDebug(() -> "Closing fullscreen ad");
+
+ Utils.runOnMainThreadDelayed(() -> {
+ // Must run off main thread (Odd, but whatever).
+ Utils.runOnBackgroundThread(() -> {
+ try {
+ instrumentation.sendKeyDownUpSync(KeyEvent.KEYCODE_BACK);
+ } catch (Exception ex) {
+ // Injecting user events on Android 10+ requires the manifest to include
+ // INJECT_EVENTS, and it's usage is heavily restricted
+ // and requires the user to manually approve the permission in the device settings.
+ //
+ // And no matter what, permissions cannot be added for root installations
+ // as manifest changes are ignored for mount installations.
+ //
+ // Instead, catch the SecurityException and turn off hide full screen ads
+ // since this functionality does not work for these devices.
+ Logger.printInfo(() -> "Could not inject back button event", ex);
+ Settings.HIDE_FULLSCREEN_ADS.save(false);
+ Utils.showToastLong(str("revanced_hide_fullscreen_ads_feature_not_available_toast"));
+ }
+ });
+ }, 1000);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ButtonsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ButtonsFilter.java
new file mode 100644
index 000000000..35337bee0
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ButtonsFilter.java
@@ -0,0 +1,103 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+final class ButtonsFilter extends Filter {
+ private static final String VIDEO_ACTION_BAR_PATH = "video_action_bar.eml";
+
+ private final StringFilterGroup actionBarGroup;
+ private final StringFilterGroup bufferFilterPathGroup;
+ private final ByteArrayFilterGroupList bufferButtonsGroupList = new ByteArrayFilterGroupList();
+
+ public ButtonsFilter() {
+ actionBarGroup = new StringFilterGroup(
+ null,
+ VIDEO_ACTION_BAR_PATH
+ );
+ addIdentifierCallbacks(actionBarGroup);
+
+
+ bufferFilterPathGroup = new StringFilterGroup(
+ null,
+ "|ContainerType|button.eml|"
+ );
+ addPathCallbacks(
+ new StringFilterGroup(
+ Settings.HIDE_LIKE_DISLIKE_BUTTON,
+ "|segmented_like_dislike_button"
+ ),
+ new StringFilterGroup(
+ Settings.HIDE_DOWNLOAD_BUTTON,
+ "|download_button.eml|"
+ ),
+ new StringFilterGroup(
+ Settings.HIDE_PLAYLIST_BUTTON,
+ "|save_to_playlist_button"
+ ),
+ new StringFilterGroup(
+ Settings.HIDE_CLIP_BUTTON,
+ "|clip_button.eml|"
+ ),
+ bufferFilterPathGroup
+ );
+
+ bufferButtonsGroupList.addAll(
+ new ByteArrayFilterGroup(
+ Settings.HIDE_REPORT_BUTTON,
+ "yt_outline_flag"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHARE_BUTTON,
+ "yt_outline_share"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_REMIX_BUTTON,
+ "yt_outline_youtube_shorts_plus"
+ ),
+ // Check for clip button both here and using a path filter,
+ // as there's a chance the path is a generic action button and won't contain 'clip_button'
+ new ByteArrayFilterGroup(
+ Settings.HIDE_CLIP_BUTTON,
+ "yt_outline_scissors"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_THANKS_BUTTON,
+ "yt_outline_dollar_sign_heart"
+ )
+ );
+ }
+
+ private boolean isEveryFilterGroupEnabled() {
+ for (var group : pathCallbacks)
+ if (!group.isEnabled()) return false;
+
+ for (var group : bufferButtonsGroupList)
+ if (!group.isEnabled()) return false;
+
+ return true;
+ }
+
+ @Override
+ boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ // If the current matched group is the action bar group,
+ // in case every filter group is enabled, hide the action bar.
+ if (matchedGroup == actionBarGroup) {
+ if (!isEveryFilterGroupEnabled()) {
+ return false;
+ }
+ } else if (matchedGroup == bufferFilterPathGroup) {
+ // Make sure the current path is the right one
+ // to avoid false positives.
+ if (!path.startsWith(VIDEO_ACTION_BAR_PATH)) return false;
+
+ // In case the group list has no match, return false.
+ if (!bufferButtonsGroupList.check(protobufBufferArray).isFiltered()) return false;
+ }
+
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CommentsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CommentsFilter.java
new file mode 100644
index 000000000..0e7ebc440
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CommentsFilter.java
@@ -0,0 +1,83 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+final class CommentsFilter extends Filter {
+
+ private static final String TIMESTAMP_OR_EMOJI_BUTTONS_ENDS_WITH_PATH
+ = "|CellType|ContainerType|ContainerType|ContainerType|ContainerType|ContainerType|";
+
+ private final StringFilterGroup commentComposer;
+ private final ByteArrayFilterGroup emojiPickerBufferGroup;
+
+ public CommentsFilter() {
+ var commentsByMembers = new StringFilterGroup(
+ Settings.HIDE_COMMENTS_BY_MEMBERS_HEADER,
+ "sponsorships_comments_header.eml",
+ "sponsorships_comments_footer.eml"
+ );
+
+ var comments = new StringFilterGroup(
+ Settings.HIDE_COMMENTS_SECTION,
+ "video_metadata_carousel",
+ "_comments"
+ );
+
+ var createAShort = new StringFilterGroup(
+ Settings.HIDE_COMMENTS_CREATE_A_SHORT_BUTTON,
+ "composer_short_creation_button.eml"
+ );
+
+ var previewComment = new StringFilterGroup(
+ Settings.HIDE_COMMENTS_PREVIEW_COMMENT,
+ "|carousel_item",
+ "comments_entry_point_teaser",
+ "comments_entry_point_simplebox"
+ );
+
+ var thanksButton = new StringFilterGroup(
+ Settings.HIDE_COMMENTS_THANKS_BUTTON,
+ "super_thanks_button.eml"
+ );
+
+ commentComposer = new StringFilterGroup(
+ Settings.HIDE_COMMENTS_TIMESTAMP_AND_EMOJI_BUTTONS,
+ "comment_composer.eml"
+ );
+
+ emojiPickerBufferGroup = new ByteArrayFilterGroup(
+ null,
+ "id.comment.quick_emoji.button"
+ );
+
+ addPathCallbacks(
+ commentsByMembers,
+ comments,
+ createAShort,
+ previewComment,
+ thanksButton,
+ commentComposer
+ );
+ }
+
+ @Override
+ boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (matchedGroup == commentComposer) {
+ // To completely hide the emoji buttons (and leave no empty space), the timestamp button is
+ // also hidden because the buffer is exactly the same and there's no way selectively hide.
+ if (contentIndex == 0
+ && path.endsWith(TIMESTAMP_OR_EMOJI_BUTTONS_ENDS_WITH_PATH)
+ && emojiPickerBufferGroup.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+
+ return false;
+ }
+
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CustomFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CustomFilter.java
new file mode 100644
index 000000000..37062d6e2
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CustomFilter.java
@@ -0,0 +1,161 @@
+package app.revanced.extension.youtube.patches.components;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.ByteTrieSearch;
+import app.revanced.extension.youtube.settings.Settings;
+
+/**
+ * Allows custom filtering using a path and optionally a proto buffer string.
+ */
+@SuppressWarnings("unused")
+final class CustomFilter extends Filter {
+
+ private static void showInvalidSyntaxToast(@NonNull String expression) {
+ Utils.showToastLong(str("revanced_custom_filter_toast_invalid_syntax", expression));
+ }
+
+ private static class CustomFilterGroup extends StringFilterGroup {
+ /**
+ * Optional character for the path that indicates the custom filter path must match the start.
+ * Must be the first character of the expression.
+ */
+ public static final String SYNTAX_STARTS_WITH = "^";
+
+ /**
+ * Optional character that separates the path from a proto buffer string pattern.
+ */
+ public static final String SYNTAX_BUFFER_SYMBOL = "$";
+
+ /**
+ * @return the parsed objects
+ */
+ @NonNull
+ @SuppressWarnings("ConstantConditions")
+ static Collection parseCustomFilterGroups() {
+ String rawCustomFilterText = Settings.CUSTOM_FILTER_STRINGS.get();
+ if (rawCustomFilterText.isBlank()) {
+ return Collections.emptyList();
+ }
+
+ // Map key is the path including optional special characters (^ and/or $)
+ Map result = new HashMap<>();
+ Pattern pattern = Pattern.compile(
+ "(" // map key group
+ + "(\\Q" + SYNTAX_STARTS_WITH + "\\E?)" // optional starts with
+ + "([^\\Q" + SYNTAX_BUFFER_SYMBOL + "\\E]*)" // path
+ + "(\\Q" + SYNTAX_BUFFER_SYMBOL + "\\E?)" // optional buffer symbol
+ + ")" // end map key group
+ + "(.*)"); // optional buffer string
+
+ for (String expression : rawCustomFilterText.split("\n")) {
+ if (expression.isBlank()) continue;
+
+ Matcher matcher = pattern.matcher(expression);
+ if (!matcher.find()) {
+ showInvalidSyntaxToast(expression);
+ continue;
+ }
+
+ final String mapKey = matcher.group(1);
+ final boolean pathStartsWith = !matcher.group(2).isEmpty();
+ final String path = matcher.group(3);
+ final boolean hasBufferSymbol = !matcher.group(4).isEmpty();
+ final String bufferString = matcher.group(5);
+
+ if (path.isBlank() || (hasBufferSymbol && bufferString.isBlank())) {
+ showInvalidSyntaxToast(expression);
+ continue;
+ }
+
+ // Use one group object for all expressions with the same path.
+ // This ensures the buffer is searched exactly once
+ // when multiple paths are used with different buffer strings.
+ CustomFilterGroup group = result.get(mapKey);
+ if (group == null) {
+ group = new CustomFilterGroup(pathStartsWith, path);
+ result.put(mapKey, group);
+ }
+ if (hasBufferSymbol) {
+ group.addBufferString(bufferString);
+ }
+ }
+
+ return result.values();
+ }
+
+ final boolean startsWith;
+ ByteTrieSearch bufferSearch;
+
+ CustomFilterGroup(boolean startsWith, @NonNull String path) {
+ super(Settings.CUSTOM_FILTER, path);
+ this.startsWith = startsWith;
+ }
+
+ void addBufferString(@NonNull String bufferString) {
+ if (bufferSearch == null) {
+ bufferSearch = new ByteTrieSearch();
+ }
+ bufferSearch.addPattern(bufferString.getBytes());
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("CustomFilterGroup{");
+ builder.append("path=");
+ if (startsWith) builder.append(SYNTAX_STARTS_WITH);
+ builder.append(filters[0]);
+
+ if (bufferSearch != null) {
+ String delimitingCharacter = "❙";
+ builder.append(", bufferStrings=");
+ builder.append(delimitingCharacter);
+ for (byte[] bufferString : bufferSearch.getPatterns()) {
+ builder.append(new String(bufferString));
+ builder.append(delimitingCharacter);
+ }
+ }
+ builder.append("}");
+ return builder.toString();
+ }
+ }
+
+ public CustomFilter() {
+ Collection groups = CustomFilterGroup.parseCustomFilterGroups();
+
+ if (!groups.isEmpty()) {
+ CustomFilterGroup[] groupsArray = groups.toArray(new CustomFilterGroup[0]);
+ Logger.printDebug(()-> "Using Custom filters: " + Arrays.toString(groupsArray));
+ addPathCallbacks(groupsArray);
+ }
+ }
+
+ @Override
+ boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ // All callbacks are custom filter groups.
+ CustomFilterGroup custom = (CustomFilterGroup) matchedGroup;
+ if (custom.startsWith && contentIndex != 0) {
+ return false;
+ }
+ if (custom.bufferSearch != null && !custom.bufferSearch.matches(protobufBufferArray)) {
+ return false;
+ }
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/DescriptionComponentsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/DescriptionComponentsFilter.java
new file mode 100644
index 000000000..2ddd8489c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/DescriptionComponentsFilter.java
@@ -0,0 +1,88 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.youtube.StringTrieSearch;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+final class DescriptionComponentsFilter extends Filter {
+
+ private final StringTrieSearch exceptions = new StringTrieSearch();
+
+ private final ByteArrayFilterGroupList macroMarkersCarouselGroupList = new ByteArrayFilterGroupList();
+
+ private final StringFilterGroup macroMarkersCarousel;
+
+ public DescriptionComponentsFilter() {
+ exceptions.addPatterns(
+ "compact_channel",
+ "description",
+ "grid_video",
+ "inline_expander",
+ "metadata"
+ );
+
+ final StringFilterGroup attributesSection = new StringFilterGroup(
+ Settings.HIDE_ATTRIBUTES_SECTION,
+ "gaming_section",
+ "music_section",
+ "video_attributes_section"
+ );
+
+ final StringFilterGroup infoCardsSection = new StringFilterGroup(
+ Settings.HIDE_INFO_CARDS_SECTION,
+ "infocards_section"
+ );
+
+ final StringFilterGroup podcastSection = new StringFilterGroup(
+ Settings.HIDE_PODCAST_SECTION,
+ "playlist_section"
+ );
+
+ final StringFilterGroup transcriptSection = new StringFilterGroup(
+ Settings.HIDE_TRANSCRIPT_SECTION,
+ "transcript_section"
+ );
+
+ macroMarkersCarousel = new StringFilterGroup(
+ null,
+ "macro_markers_carousel.eml"
+ );
+
+ macroMarkersCarouselGroupList.addAll(
+ new ByteArrayFilterGroup(
+ Settings.HIDE_CHAPTERS_SECTION,
+ "chapters_horizontal_shelf"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_KEY_CONCEPTS_SECTION,
+ "learning_concept_macro_markers_carousel_shelf"
+ )
+ );
+
+ addPathCallbacks(
+ attributesSection,
+ infoCardsSection,
+ podcastSection,
+ transcriptSection,
+ macroMarkersCarousel
+ );
+ }
+
+ @Override
+ boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (exceptions.matches(path)) return false;
+
+ if (matchedGroup == macroMarkersCarousel) {
+ if (contentIndex == 0 && macroMarkersCarouselGroupList.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(path, identifier, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+
+ return false;
+ }
+
+ return super.isFiltered(path, identifier, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/Filter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/Filter.java
new file mode 100644
index 000000000..42b86d589
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/Filter.java
@@ -0,0 +1,90 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.settings.BaseSettings;
+
+/**
+ * Filters litho based components.
+ *
+ * Callbacks to filter content are added using {@link #addIdentifierCallbacks(StringFilterGroup...)}
+ * and {@link #addPathCallbacks(StringFilterGroup...)}.
+ *
+ * To filter {@link FilterContentType#PROTOBUFFER}, first add a callback to
+ * either an identifier or a path.
+ * Then inside {@link #isFiltered(String, String, byte[], StringFilterGroup, FilterContentType, int)}
+ * search for the buffer content using either a {@link ByteArrayFilterGroup} (if searching for 1 pattern)
+ * or a {@link ByteArrayFilterGroupList} (if searching for more than 1 pattern).
+ *
+ * All callbacks must be registered before the constructor completes.
+ */
+abstract class Filter {
+
+ public enum FilterContentType {
+ IDENTIFIER,
+ PATH,
+ PROTOBUFFER
+ }
+
+ /**
+ * Identifier callbacks. Do not add to this instance,
+ * and instead use {@link #addIdentifierCallbacks(StringFilterGroup...)}.
+ */
+ protected final List identifierCallbacks = new ArrayList<>();
+ /**
+ * Path callbacks. Do not add to this instance,
+ * and instead use {@link #addPathCallbacks(StringFilterGroup...)}.
+ */
+ protected final List pathCallbacks = new ArrayList<>();
+
+ /**
+ * Adds callbacks to {@link #isFiltered(String, String, byte[], StringFilterGroup, FilterContentType, int)}
+ * if any of the groups are found.
+ */
+ protected final void addIdentifierCallbacks(StringFilterGroup... groups) {
+ identifierCallbacks.addAll(Arrays.asList(groups));
+ }
+
+ /**
+ * Adds callbacks to {@link #isFiltered(String, String, byte[], StringFilterGroup, FilterContentType, int)}
+ * if any of the groups are found.
+ */
+ protected final void addPathCallbacks(StringFilterGroup... groups) {
+ pathCallbacks.addAll(Arrays.asList(groups));
+ }
+
+ /**
+ * Called after an enabled filter has been matched.
+ * Default implementation is to always filter the matched component and log the action.
+ * Subclasses can perform additional or different checks if needed.
+ *
+ * If the content is to be filtered, subclasses should always
+ * call this method (and never return a plain 'true').
+ * That way the logs will always show when a component was filtered and which filter hide it.
+ *
+ * Method is called off the main thread.
+ *
+ * @param matchedGroup The actual filter that matched.
+ * @param contentType The type of content matched.
+ * @param contentIndex Matched index of the identifier or path.
+ * @return True if the litho component should be filtered out.
+ */
+ boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (BaseSettings.DEBUG.get()) {
+ String filterSimpleName = getClass().getSimpleName();
+ if (contentType == FilterContentType.IDENTIFIER) {
+ Logger.printDebug(() -> filterSimpleName + " Filtered identifier: " + identifier);
+ } else {
+ Logger.printDebug(() -> filterSimpleName + " Filtered path: " + path);
+ }
+ }
+ return true;
+ }
+}
+
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FilterGroup.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FilterGroup.java
new file mode 100644
index 000000000..4e20bc82a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FilterGroup.java
@@ -0,0 +1,214 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.youtube.ByteTrieSearch;
+
+abstract class FilterGroup {
+ final static class FilterGroupResult {
+ private BooleanSetting setting;
+ private int matchedIndex;
+ private int matchedLength;
+ // In the future it might be useful to include which pattern matched,
+ // but for now that is not needed.
+
+ FilterGroupResult() {
+ this(null, -1, 0);
+ }
+
+ FilterGroupResult(BooleanSetting setting, int matchedIndex, int matchedLength) {
+ setValues(setting, matchedIndex, matchedLength);
+ }
+
+ public void setValues(BooleanSetting setting, int matchedIndex, int matchedLength) {
+ this.setting = setting;
+ this.matchedIndex = matchedIndex;
+ this.matchedLength = matchedLength;
+ }
+
+ /**
+ * A null value if the group has no setting,
+ * or if no match is returned from {@link FilterGroupList#check(Object)}.
+ */
+ public BooleanSetting getSetting() {
+ return setting;
+ }
+
+ public boolean isFiltered() {
+ return matchedIndex >= 0;
+ }
+
+ /**
+ * Matched index of first pattern that matched, or -1 if nothing matched.
+ */
+ public int getMatchedIndex() {
+ return matchedIndex;
+ }
+
+ /**
+ * Length of the matched filter pattern.
+ */
+ public int getMatchedLength() {
+ return matchedLength;
+ }
+ }
+
+ protected final BooleanSetting setting;
+ protected final T[] filters;
+
+ /**
+ * Initialize a new filter group.
+ *
+ * @param setting The associated setting.
+ * @param filters The filters.
+ */
+ @SafeVarargs
+ public FilterGroup(final BooleanSetting setting, final T... filters) {
+ this.setting = setting;
+ this.filters = filters;
+ if (filters.length == 0) {
+ throw new IllegalArgumentException("Must use one or more filter patterns (zero specified)");
+ }
+ }
+
+ public boolean isEnabled() {
+ return setting == null || setting.get();
+ }
+
+ /**
+ * @return If {@link FilterGroupList} should include this group when searching.
+ * By default, all filters are included except non enabled settings that require reboot.
+ */
+ @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+ public boolean includeInSearch() {
+ return isEnabled() || !setting.rebootApp;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + ": " + (setting == null ? "(null setting)" : setting);
+ }
+
+ public abstract FilterGroupResult check(final T stack);
+}
+
+class StringFilterGroup extends FilterGroup {
+
+ public StringFilterGroup(final BooleanSetting setting, final String... filters) {
+ super(setting, filters);
+ }
+
+ @Override
+ public FilterGroupResult check(final String string) {
+ int matchedIndex = -1;
+ int matchedLength = 0;
+ if (isEnabled()) {
+ for (String pattern : filters) {
+ if (!string.isEmpty()) {
+ final int indexOf = string.indexOf(pattern);
+ if (indexOf >= 0) {
+ matchedIndex = indexOf;
+ matchedLength = pattern.length();
+ break;
+ }
+ }
+ }
+ }
+ return new FilterGroupResult(setting, matchedIndex, matchedLength);
+ }
+}
+
+/**
+ * If you have more than 1 filter patterns, then all instances of
+ * this class should filtered using {@link ByteArrayFilterGroupList#check(byte[])},
+ * which uses a prefix tree to give better performance.
+ */
+class ByteArrayFilterGroup extends FilterGroup {
+
+ private volatile int[][] failurePatterns;
+
+ // Modified implementation from https://stackoverflow.com/a/1507813
+ private static int indexOf(final byte[] data, final byte[] pattern, final int[] failure) {
+ // Finds the first occurrence of the pattern in the byte array using
+ // KMP matching algorithm.
+ int patternLength = pattern.length;
+ for (int i = 0, j = 0, dataLength = data.length; i < dataLength; i++) {
+ while (j > 0 && pattern[j] != data[i]) {
+ j = failure[j - 1];
+ }
+ if (pattern[j] == data[i]) {
+ j++;
+ }
+ if (j == patternLength) {
+ return i - patternLength + 1;
+ }
+ }
+ return -1;
+ }
+
+ private static int[] createFailurePattern(byte[] pattern) {
+ // Computes the failure function using a boot-strapping process,
+ // where the pattern is matched against itself.
+ final int patternLength = pattern.length;
+ final int[] failure = new int[patternLength];
+
+ for (int i = 1, j = 0; i < patternLength; i++) {
+ while (j > 0 && pattern[j] != pattern[i]) {
+ j = failure[j - 1];
+ }
+ if (pattern[j] == pattern[i]) {
+ j++;
+ }
+ failure[i] = j;
+ }
+ return failure;
+ }
+
+ public ByteArrayFilterGroup(BooleanSetting setting, byte[]... filters) {
+ super(setting, filters);
+ }
+
+ /**
+ * Converts the Strings into byte arrays. Used to search for text in binary data.
+ */
+ public ByteArrayFilterGroup(BooleanSetting setting, String... filters) {
+ super(setting, ByteTrieSearch.convertStringsToBytes(filters));
+ }
+
+ private synchronized void buildFailurePatterns() {
+ if (failurePatterns != null) return; // Thread race and another thread already initialized the search.
+ Logger.printDebug(() -> "Building failure array for: " + this);
+ int[][] failurePatterns = new int[filters.length][];
+ int i = 0;
+ for (byte[] pattern : filters) {
+ failurePatterns[i++] = createFailurePattern(pattern);
+ }
+ this.failurePatterns = failurePatterns; // Must set after initialization finishes.
+ }
+
+ @Override
+ public FilterGroupResult check(final byte[] bytes) {
+ int matchedLength = 0;
+ int matchedIndex = -1;
+ if (isEnabled()) {
+ int[][] failures = failurePatterns;
+ if (failures == null) {
+ buildFailurePatterns(); // Lazy load.
+ failures = failurePatterns;
+ }
+ for (int i = 0, length = filters.length; i < length; i++) {
+ byte[] filter = filters[i];
+ matchedIndex = indexOf(bytes, filter, failures[i]);
+ if (matchedIndex >= 0) {
+ matchedLength = filter.length;
+ break;
+ }
+ }
+ }
+ return new FilterGroupResult(setting, matchedIndex, matchedLength);
+ }
+}
+
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FilterGroupList.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FilterGroupList.java
new file mode 100644
index 000000000..ac0e23ca8
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FilterGroupList.java
@@ -0,0 +1,85 @@
+package app.revanced.extension.youtube.patches.components;
+
+import android.os.Build;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+
+import java.util.*;
+import java.util.function.Consumer;
+
+import app.revanced.extension.youtube.ByteTrieSearch;
+import app.revanced.extension.youtube.StringTrieSearch;
+import app.revanced.extension.youtube.TrieSearch;
+
+abstract class FilterGroupList> implements Iterable {
+
+ private final List filterGroups = new ArrayList<>();
+ private final TrieSearch search = createSearchGraph();
+
+ @SafeVarargs
+ protected final void addAll(final T... groups) {
+ filterGroups.addAll(Arrays.asList(groups));
+
+ for (T group : groups) {
+ if (!group.includeInSearch()) {
+ continue;
+ }
+ for (V pattern : group.filters) {
+ search.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> {
+ if (group.isEnabled()) {
+ FilterGroup.FilterGroupResult result = (FilterGroup.FilterGroupResult) callbackParameter;
+ result.setValues(group.setting, matchedStartIndex, matchedLength);
+ return true;
+ }
+ return false;
+ });
+ }
+ }
+ }
+
+ @NonNull
+ @Override
+ public Iterator iterator() {
+ return filterGroups.iterator();
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.N)
+ @Override
+ public void forEach(@NonNull Consumer super T> action) {
+ filterGroups.forEach(action);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.N)
+ @NonNull
+ @Override
+ public Spliterator spliterator() {
+ return filterGroups.spliterator();
+ }
+
+ protected FilterGroup.FilterGroupResult check(V stack) {
+ FilterGroup.FilterGroupResult result = new FilterGroup.FilterGroupResult();
+ search.matches(stack, result);
+ return result;
+
+ }
+
+ protected abstract TrieSearch createSearchGraph();
+}
+
+final class StringFilterGroupList extends FilterGroupList {
+ protected StringTrieSearch createSearchGraph() {
+ return new StringTrieSearch();
+ }
+}
+
+/**
+ * If searching for a single byte pattern, then it is slightly better to use
+ * {@link ByteArrayFilterGroup#check(byte[])} as it uses KMP which is faster
+ * than a prefix tree to search for only 1 pattern.
+ */
+final class ByteArrayFilterGroupList extends FilterGroupList {
+ protected ByteTrieSearch createSearchGraph() {
+ return new ByteTrieSearch();
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/HideInfoCardsFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/HideInfoCardsFilterPatch.java
new file mode 100644
index 000000000..ce92b592e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/HideInfoCardsFilterPatch.java
@@ -0,0 +1,16 @@
+package app.revanced.extension.youtube.patches.components;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class HideInfoCardsFilterPatch extends Filter {
+
+ public HideInfoCardsFilterPatch() {
+ addIdentifierCallbacks(
+ new StringFilterGroup(
+ Settings.HIDE_INFO_CARDS,
+ "info_card_teaser_overlay.eml"
+ )
+ );
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/KeywordContentFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/KeywordContentFilter.java
new file mode 100644
index 000000000..b451fd282
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/KeywordContentFilter.java
@@ -0,0 +1,597 @@
+package app.revanced.extension.youtube.patches.components;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
+import static java.lang.Character.UnicodeBlock.*;
+
+import android.os.Build;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.ByteTrieSearch;
+import app.revanced.extension.youtube.StringTrieSearch;
+import app.revanced.extension.youtube.TrieSearch;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.NavigationBar;
+import app.revanced.extension.youtube.shared.PlayerType;
+
+/**
+ *
+ * Allows hiding home feed and search results based on video title keywords and/or channel names.
+ *
+ * Limitations:
+ * - Searching for a keyword phrase will give no search results.
+ * This is because the buffer for each video contains the text the user searched for, and everything
+ * will be filtered away (even if that video title/channel does not contain any keywords).
+ * - Filtering a channel name can still show Shorts from that channel in the search results.
+ * The most common Shorts layouts do not include the channel name, so they will not be filtered.
+ * - Some layout component residue will remain, such as the video chapter previews for some search results.
+ * These components do not include the video title or channel name, and they
+ * appear outside the filtered components so they are not caught.
+ * - Keywords are case sensitive, but some casing variation is manually added.
+ * (ie: "mr beast" automatically filters "Mr Beast" and "MR BEAST").
+ * - Keywords present in the layout or video data cannot be used as filters, otherwise all videos
+ * will always be hidden. This patch checks for some words of these words.
+ * - When using whole word syntax, some keywords may need additional pluralized variations.
+ */
+@SuppressWarnings("unused")
+@RequiresApi(api = Build.VERSION_CODES.N)
+final class KeywordContentFilter extends Filter {
+
+ /**
+ * Strings found in the buffer for every videos. Full strings should be specified.
+ *
+ * This list does not include every common buffer string, and this can be added/changed as needed.
+ * Words must be entered with the exact casing as found in the buffer.
+ */
+ private static final String[] STRINGS_IN_EVERY_BUFFER = {
+ // Video playback data.
+ "googlevideo.com/initplayback?source=youtube", // Video url.
+ "ANDROID", // Video url parameter.
+ "https://i.ytimg.com/vi/", // Thumbnail url.
+ "mqdefault.jpg",
+ "hqdefault.jpg",
+ "sddefault.jpg",
+ "hq720.jpg",
+ "webp",
+ "_custom_", // Custom thumbnail set by video creator.
+ // Video decoders.
+ "OMX.ffmpeg.vp9.decoder",
+ "OMX.Intel.sw_vd.vp9",
+ "OMX.MTK.VIDEO.DECODER.SW.VP9",
+ "OMX.google.vp9.decoder",
+ "OMX.google.av1.decoder",
+ "OMX.sprd.av1.decoder",
+ "c2.android.av1.decoder",
+ "c2.android.av1-dav1d.decoder",
+ "c2.android.vp9.decoder",
+ "c2.mtk.sw.vp9.decoder",
+ // Analytics.
+ "searchR",
+ "browse-feed",
+ "FEwhat_to_watch",
+ "FEsubscriptions",
+ "search_vwc_description_transition_key",
+ "g-high-recZ",
+ // Text and litho components found in the buffer that belong to path filters.
+ "expandable_metadata.eml",
+ "thumbnail.eml",
+ "avatar.eml",
+ "overflow_button.eml",
+ "shorts-lockup-image",
+ "shorts-lockup.overlay-metadata.secondary-text",
+ "YouTubeSans-SemiBold",
+ "sans-serif"
+ };
+
+ /**
+ * Substrings that are always first in the identifier.
+ */
+ private final StringFilterGroup startsWithFilter = new StringFilterGroup(
+ null, // Multiple settings are used and must be individually checked if active.
+ "home_video_with_context.eml",
+ "search_video_with_context.eml",
+ "video_with_context.eml", // Subscription tab videos.
+ "related_video_with_context.eml",
+ // A/B test for subscribed video, and sometimes when tablet layout is enabled.
+ "video_lockup_with_attachment.eml",
+ "compact_video.eml",
+ "inline_shorts",
+ "shorts_video_cell",
+ "shorts_pivot_item.eml"
+ );
+
+ /**
+ * Substrings that are never at the start of the path.
+ */
+ @SuppressWarnings("FieldCanBeLocal")
+ private final StringFilterGroup containsFilter = new StringFilterGroup(
+ null,
+ "modern_type_shelf_header_content.eml",
+ "shorts_lockup_cell.eml", // Part of 'shorts_shelf_carousel.eml'
+ "video_card.eml" // Shorts that appear in a horizontal shelf.
+ );
+
+ /**
+ * Path components to not filter. Cannot filter the buffer when these are present,
+ * otherwise text in UI controls can be filtered as a keyword (such as using "Playlist" as a keyword).
+ *
+ * This is also a small performance improvement since
+ * the buffer of the parent component was already searched and passed.
+ */
+ private final StringTrieSearch exceptions = new StringTrieSearch(
+ "metadata.eml",
+ "thumbnail.eml",
+ "avatar.eml",
+ "overflow_button.eml"
+ );
+
+ /**
+ * Minimum keyword/phrase length to prevent excessively broad content filtering.
+ * Only applies when not using whole word syntax.
+ */
+ private static final int MINIMUM_KEYWORD_LENGTH = 3;
+
+ /**
+ * Threshold for {@link #filteredVideosPercentage}
+ * that indicates all or nearly all videos have been filtered.
+ * This should be close to 100% to reduce false positives.
+ */
+ private static final float ALL_VIDEOS_FILTERED_THRESHOLD = 0.95f;
+
+ private static final float ALL_VIDEOS_FILTERED_SAMPLE_SIZE = 50;
+
+ private static final long ALL_VIDEOS_FILTERED_BACKOFF_MILLISECONDS = 60 * 1000; // 60 seconds
+
+ private static final int UTF8_MAX_BYTE_COUNT = 4;
+
+ /**
+ * Rolling average of how many videos were filtered by a keyword.
+ * Used to detect if a keyword passes the initial check against {@link #STRINGS_IN_EVERY_BUFFER}
+ * but a keyword is still hiding all videos.
+ *
+ * This check can still fail if some extra UI elements pass the keywords,
+ * such as the video chapter preview or any other elements.
+ *
+ * To test this, add a filter that appears in all videos (such as 'ovd='),
+ * and open the subscription feed. In practice this does not always identify problems
+ * in the home feed and search, because the home feed has a finite amount of content and
+ * search results have a lot of extra video junk that is not hidden and interferes with the detection.
+ */
+ private volatile float filteredVideosPercentage;
+
+ /**
+ * If filtering is temporarily turned off, the time to resume filtering.
+ * Field is zero if no backoff is in effect.
+ */
+ private volatile long timeToResumeFiltering;
+
+ /**
+ * The last value of {@link Settings#HIDE_KEYWORD_CONTENT_PHRASES}
+ * parsed and loaded into {@link #bufferSearch}.
+ * Allows changing the keywords without restarting the app.
+ */
+ private volatile String lastKeywordPhrasesParsed;
+
+ private volatile ByteTrieSearch bufferSearch;
+
+ /**
+ * Change first letter of the first word to use title case.
+ */
+ private static String titleCaseFirstWordOnly(String sentence) {
+ if (sentence.isEmpty()) {
+ return sentence;
+ }
+ final int firstCodePoint = sentence.codePointAt(0);
+ // In some non English languages title case is different than uppercase.
+ return new StringBuilder()
+ .appendCodePoint(Character.toTitleCase(firstCodePoint))
+ .append(sentence, Character.charCount(firstCodePoint), sentence.length())
+ .toString();
+ }
+
+ /**
+ * Uppercase the first letter of each word.
+ */
+ private static String capitalizeAllFirstLetters(String sentence) {
+ if (sentence.isEmpty()) {
+ return sentence;
+ }
+
+ final int delimiter = ' ';
+ // Use code points and not characters to handle unicode surrogates.
+ int[] codePoints = sentence.codePoints().toArray();
+ boolean capitalizeNext = true;
+ for (int i = 0, length = codePoints.length; i < length; i++) {
+ final int codePoint = codePoints[i];
+ if (codePoint == delimiter) {
+ capitalizeNext = true;
+ } else if (capitalizeNext) {
+ codePoints[i] = Character.toUpperCase(codePoint);
+ capitalizeNext = false;
+ }
+ }
+
+ return new String(codePoints, 0, codePoints.length);
+ }
+
+ /**
+ * @return If the string contains any characters from languages that do not use spaces between words.
+ */
+ private static boolean isLanguageWithNoSpaces(String text) {
+ for (int i = 0, length = text.length(); i < length;) {
+ final int codePoint = text.codePointAt(i);
+
+ Character.UnicodeBlock block = Character.UnicodeBlock.of(codePoint);
+ if (block == CJK_UNIFIED_IDEOGRAPHS // Chinese and Kanji
+ || block == HIRAGANA // Japanese Hiragana
+ || block == KATAKANA // Japanese Katakana
+ || block == THAI
+ || block == LAO
+ || block == MYANMAR
+ || block == KHMER
+ || block == TIBETAN) {
+ return true;
+ }
+
+ i += Character.charCount(codePoint);
+ }
+
+ return false;
+ }
+
+ /**
+ * @return If the phrase will hide all videos. Not an exhaustive check.
+ */
+ private static boolean phrasesWillHideAllVideos(@NonNull String[] phrases, boolean matchWholeWords) {
+ for (String phrase : phrases) {
+ for (String commonString : STRINGS_IN_EVERY_BUFFER) {
+ if (matchWholeWords) {
+ byte[] commonStringBytes = commonString.getBytes(StandardCharsets.UTF_8);
+ int matchIndex = 0;
+ while (true) {
+ matchIndex = commonString.indexOf(phrase, matchIndex);
+ if (matchIndex < 0) break;
+
+ if (keywordMatchIsWholeWord(commonStringBytes, matchIndex, phrase.length())) {
+ return true;
+ }
+
+ matchIndex++;
+ }
+ } else if (Utils.containsAny(commonString, phrases)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @return If the start and end indexes are not surrounded by other letters.
+ * If the indexes are surrounded by numbers/symbols/punctuation it is considered a whole word.
+ */
+ private static boolean keywordMatchIsWholeWord(byte[] text, int keywordStartIndex, int keywordLength) {
+ final Integer codePointBefore = getUtf8CodePointBefore(text, keywordStartIndex);
+ if (codePointBefore != null && Character.isLetter(codePointBefore)) {
+ return false;
+ }
+
+ final Integer codePointAfter = getUtf8CodePointAt(text, keywordStartIndex + keywordLength);
+ //noinspection RedundantIfStatement
+ if (codePointAfter != null && Character.isLetter(codePointAfter)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @return The UTF8 character point immediately before the index,
+ * or null if the bytes before the index is not a valid UTF8 character.
+ */
+ @Nullable
+ private static Integer getUtf8CodePointBefore(byte[] data, int index) {
+ int characterByteCount = 0;
+ while (--index >= 0 && ++characterByteCount <= UTF8_MAX_BYTE_COUNT) {
+ if (isValidUtf8(data, index, characterByteCount)) {
+ return decodeUtf8ToCodePoint(data, index, characterByteCount);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @return The UTF8 character point at the index,
+ * or null if the index holds no valid UTF8 character.
+ */
+ @Nullable
+ private static Integer getUtf8CodePointAt(byte[] data, int index) {
+ int characterByteCount = 0;
+ final int dataLength = data.length;
+ while (index + characterByteCount < dataLength && ++characterByteCount <= UTF8_MAX_BYTE_COUNT) {
+ if (isValidUtf8(data, index, characterByteCount)) {
+ return decodeUtf8ToCodePoint(data, index, characterByteCount);
+ }
+ }
+
+ return null;
+ }
+
+ public static boolean isValidUtf8(byte[] data, int startIndex, int numberOfBytes) {
+ switch (numberOfBytes) {
+ case 1: // 0xxxxxxx (ASCII)
+ return (data[startIndex] & 0x80) == 0;
+ case 2: // 110xxxxx, 10xxxxxx
+ return (data[startIndex] & 0xE0) == 0xC0
+ && (data[startIndex + 1] & 0xC0) == 0x80;
+ case 3: // 1110xxxx, 10xxxxxx, 10xxxxxx
+ return (data[startIndex] & 0xF0) == 0xE0
+ && (data[startIndex + 1] & 0xC0) == 0x80
+ && (data[startIndex + 2] & 0xC0) == 0x80;
+ case 4: // 11110xxx, 10xxxxxx, 10xxxxxx, 10xxxxxx
+ return (data[startIndex] & 0xF8) == 0xF0
+ && (data[startIndex + 1] & 0xC0) == 0x80
+ && (data[startIndex + 2] & 0xC0) == 0x80
+ && (data[startIndex + 3] & 0xC0) == 0x80;
+ }
+
+ throw new IllegalArgumentException("numberOfBytes: " + numberOfBytes);
+ }
+
+ public static int decodeUtf8ToCodePoint(byte[] data, int startIndex, int numberOfBytes) {
+ switch (numberOfBytes) {
+ case 1:
+ return data[startIndex];
+ case 2:
+ return ((data[startIndex] & 0x1F) << 6) |
+ (data[startIndex + 1] & 0x3F);
+ case 3:
+ return ((data[startIndex] & 0x0F) << 12) |
+ ((data[startIndex + 1] & 0x3F) << 6) |
+ (data[startIndex + 2] & 0x3F);
+ case 4:
+ return ((data[startIndex] & 0x07) << 18) |
+ ((data[startIndex + 1] & 0x3F) << 12) |
+ ((data[startIndex + 2] & 0x3F) << 6) |
+ (data[startIndex + 3] & 0x3F);
+ }
+ throw new IllegalArgumentException("numberOfBytes: " + numberOfBytes);
+ }
+
+ private static boolean phraseUsesWholeWordSyntax(String phrase) {
+ return phrase.startsWith("\"") && phrase.endsWith("\"");
+ }
+
+ private static String stripWholeWordSyntax(String phrase) {
+ return phrase.substring(1, phrase.length() - 1);
+ }
+
+ private synchronized void parseKeywords() { // Must be synchronized since Litho is multi-threaded.
+ String rawKeywords = Settings.HIDE_KEYWORD_CONTENT_PHRASES.get();
+
+ //noinspection StringEquality
+ if (rawKeywords == lastKeywordPhrasesParsed) {
+ Logger.printDebug(() -> "Using previously initialized search");
+ return; // Another thread won the race, and search is already initialized.
+ }
+
+ ByteTrieSearch search = new ByteTrieSearch();
+ String[] split = rawKeywords.split("\n");
+ if (split.length != 0) {
+ // Linked Set so log statement are more organized and easier to read.
+ // Map is: Phrase -> isWholeWord
+ Map keywords = new LinkedHashMap<>(10 * split.length);
+
+ for (String phrase : split) {
+ // Remove any trailing spaces the user may have accidentally included.
+ phrase = phrase.stripTrailing();
+ if (phrase.isBlank()) continue;
+
+ final boolean wholeWordMatching;
+ if (phraseUsesWholeWordSyntax(phrase)) {
+ if (phrase.length() == 2) {
+ continue; // Empty "" phrase
+ }
+ phrase = stripWholeWordSyntax(phrase);
+ wholeWordMatching = true;
+ } else if (phrase.length() < MINIMUM_KEYWORD_LENGTH && !isLanguageWithNoSpaces(phrase)) {
+ // Allow phrases of 1 and 2 characters if using a
+ // language that does not use spaces between words.
+
+ // Do not reset the setting. Keep the invalid keywords so the user can fix the mistake.
+ Utils.showToastLong(str("revanced_hide_keyword_toast_invalid_length", phrase, MINIMUM_KEYWORD_LENGTH));
+ continue;
+ } else {
+ wholeWordMatching = false;
+ }
+
+ // Common casing that might appear.
+ //
+ // This could be simplified by adding case insensitive search to the prefix search,
+ // which is very simple to add to StringTreSearch for Unicode and ByteTrieSearch for ASCII.
+ //
+ // But to support Unicode with ByteTrieSearch would require major changes because
+ // UTF-8 characters can be different byte lengths, which does
+ // not allow comparing two different byte arrays using simple plain array indexes.
+ //
+ // Instead use all common case variations of the words.
+ String[] phraseVariations = {
+ phrase,
+ phrase.toLowerCase(),
+ titleCaseFirstWordOnly(phrase),
+ capitalizeAllFirstLetters(phrase),
+ phrase.toUpperCase()
+ };
+
+ if (phrasesWillHideAllVideos(phraseVariations, wholeWordMatching)) {
+ String toastMessage;
+ // If whole word matching is off, but would pass with on, then show a different toast.
+ if (!wholeWordMatching && !phrasesWillHideAllVideos(phraseVariations, true)) {
+ toastMessage = "revanced_hide_keyword_toast_invalid_common_whole_word_required";
+ } else {
+ toastMessage = "revanced_hide_keyword_toast_invalid_common";
+ }
+
+ Utils.showToastLong(str(toastMessage, phrase));
+ continue;
+ }
+
+ for (String variation : phraseVariations) {
+ // Check if the same phrase is declared both with and without quotes.
+ Boolean existing = keywords.get(variation);
+ if (existing == null) {
+ keywords.put(variation, wholeWordMatching);
+ } else if (existing != wholeWordMatching) {
+ Utils.showToastLong(str("revanced_hide_keyword_toast_invalid_conflicting", phrase));
+ break;
+ }
+ }
+ }
+
+ for (Map.Entry entry : keywords.entrySet()) {
+ String keyword = entry.getKey();
+ //noinspection ExtractMethodRecommender
+ final boolean isWholeWord = entry.getValue();
+
+ TrieSearch.TriePatternMatchedCallback callback =
+ (textSearched, startIndex, matchLength, callbackParameter) -> {
+ if (isWholeWord && !keywordMatchIsWholeWord(textSearched, startIndex, matchLength)) {
+ return false;
+ }
+
+ Logger.printDebug(() -> (isWholeWord ? "Matched whole keyword: '"
+ : "Matched keyword: '") + keyword + "'");
+ // noinspection unchecked
+ ((MutableReference) callbackParameter).value = keyword;
+ return true;
+ };
+ byte[] stringBytes = keyword.getBytes(StandardCharsets.UTF_8);
+ search.addPattern(stringBytes, callback);
+ }
+
+ Logger.printDebug(() -> "Search using: (" + search.getEstimatedMemorySize() + " KB) keywords: " + keywords.keySet());
+ }
+
+ bufferSearch = search;
+ timeToResumeFiltering = 0;
+ filteredVideosPercentage = 0;
+ lastKeywordPhrasesParsed = rawKeywords; // Must set last.
+ }
+
+ public KeywordContentFilter() {
+ // Keywords are parsed on first call to isFiltered()
+ addPathCallbacks(startsWithFilter, containsFilter);
+ }
+
+ private boolean hideKeywordSettingIsActive() {
+ if (timeToResumeFiltering != 0) {
+ if (System.currentTimeMillis() < timeToResumeFiltering) {
+ return false;
+ }
+
+ timeToResumeFiltering = 0;
+ filteredVideosPercentage = 0;
+ Logger.printDebug(() -> "Resuming keyword filtering");
+ }
+
+ // Must check player type first, as search bar can be active behind the player.
+ if (PlayerType.getCurrent().isMaximizedOrFullscreen()) {
+ // For now, consider the under video results the same as the home feed.
+ return Settings.HIDE_KEYWORD_CONTENT_HOME.get();
+ }
+
+ // Must check second, as search can be from any tab.
+ if (NavigationBar.isSearchBarActive()) {
+ return Settings.HIDE_KEYWORD_CONTENT_SEARCH.get();
+ }
+
+ // Avoid checking navigation button status if all other settings are off.
+ final boolean hideHome = Settings.HIDE_KEYWORD_CONTENT_HOME.get();
+ final boolean hideSubscriptions = Settings.HIDE_KEYWORD_CONTENT_SUBSCRIPTIONS.get();
+ if (!hideHome && !hideSubscriptions) {
+ return false;
+ }
+
+ NavigationButton selectedNavButton = NavigationButton.getSelectedNavigationButton();
+ if (selectedNavButton == null) {
+ return hideHome; // Unknown tab, treat the same as home.
+ }
+ if (selectedNavButton == NavigationButton.HOME) {
+ return hideHome;
+ }
+ if (selectedNavButton == NavigationButton.SUBSCRIPTIONS) {
+ return hideSubscriptions;
+ }
+ // User is in the Library or Notifications tab.
+ return false;
+ }
+
+ private void updateStats(boolean videoWasHidden, @Nullable String keyword) {
+ float updatedAverage = filteredVideosPercentage
+ * ((ALL_VIDEOS_FILTERED_SAMPLE_SIZE - 1) / ALL_VIDEOS_FILTERED_SAMPLE_SIZE);
+ if (videoWasHidden) {
+ updatedAverage += 1 / ALL_VIDEOS_FILTERED_SAMPLE_SIZE;
+ }
+
+ if (updatedAverage <= ALL_VIDEOS_FILTERED_THRESHOLD) {
+ filteredVideosPercentage = updatedAverage;
+ return;
+ }
+
+ // A keyword is hiding everything.
+ // Inform the user, and temporarily turn off filtering.
+ timeToResumeFiltering = System.currentTimeMillis() + ALL_VIDEOS_FILTERED_BACKOFF_MILLISECONDS;
+
+ Logger.printDebug(() -> "Temporarily turning off filtering due to excessively broad filter: " + keyword);
+ Utils.showToastLong(str("revanced_hide_keyword_toast_invalid_broad", keyword));
+ }
+
+ @Override
+ boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (contentIndex != 0 && matchedGroup == startsWithFilter) {
+ return false;
+ }
+
+ // Field is intentionally compared using reference equality.
+ //noinspection StringEquality
+ if (Settings.HIDE_KEYWORD_CONTENT_PHRASES.get() != lastKeywordPhrasesParsed) {
+ // User changed the keywords or whole word setting.
+ parseKeywords();
+ }
+
+ if (!hideKeywordSettingIsActive()) return false;
+
+ if (exceptions.matches(path)) {
+ return false; // Do not update statistics.
+ }
+
+ MutableReference matchRef = new MutableReference<>();
+ if (bufferSearch.matches(protobufBufferArray, matchRef)) {
+ updateStats(true, matchRef.value);
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+
+ updateStats(false, null);
+ return false;
+ }
+}
+
+/**
+ * Simple non-atomic wrapper since {@link AtomicReference#setPlain(Object)} is not available with Android 8.0.
+ */
+final class MutableReference {
+ T value;
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/LayoutComponentsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/LayoutComponentsFilter.java
new file mode 100644
index 000000000..891124987
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/LayoutComponentsFilter.java
@@ -0,0 +1,474 @@
+package app.revanced.extension.youtube.patches.components;
+
+import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
+
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.StringTrieSearch;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.NavigationBar;
+import app.revanced.extension.youtube.shared.PlayerType;
+
+@SuppressWarnings("unused")
+public final class LayoutComponentsFilter extends Filter {
+ private static final String COMPACT_CHANNEL_BAR_PATH_PREFIX = "compact_channel_bar.eml";
+ private static final String VIDEO_ACTION_BAR_PATH_PREFIX = "video_action_bar.eml";
+ private static final String ANIMATED_VECTOR_TYPE_PATH = "AnimatedVectorType";
+
+ private static final StringTrieSearch mixPlaylistsExceptions = new StringTrieSearch(
+ "V.ED", // Playlist browse id.
+ "java.lang.ref.WeakReference"
+ );
+ private static final ByteArrayFilterGroup mixPlaylistsExceptions2 = new ByteArrayFilterGroup(
+ null,
+ "cell_description_body"
+ );
+ private static final ByteArrayFilterGroup mixPlaylists = new ByteArrayFilterGroup(
+ Settings.HIDE_MIX_PLAYLISTS,
+ "&list="
+ );
+
+ private final StringTrieSearch exceptions = new StringTrieSearch();
+ private final StringFilterGroup searchResultShelfHeader;
+ private final StringFilterGroup inFeedSurvey;
+ private final StringFilterGroup notifyMe;
+ private final StringFilterGroup expandableMetadata;
+ private final ByteArrayFilterGroup searchResultRecommendations;
+ private final StringFilterGroup searchResultVideo;
+ private final StringFilterGroup compactChannelBarInner;
+ private final StringFilterGroup compactChannelBarInnerButton;
+ private final ByteArrayFilterGroup joinMembershipButton;
+ private final StringFilterGroup likeSubscribeGlow;
+ private final StringFilterGroup horizontalShelves;
+
+ @RequiresApi(api = Build.VERSION_CODES.N)
+ public LayoutComponentsFilter() {
+ exceptions.addPatterns(
+ "home_video_with_context",
+ "related_video_with_context",
+ "search_video_with_context",
+ "comment_thread", // Whitelist comments
+ "|comment.", // Whitelist comment replies
+ "library_recent_shelf"
+ );
+
+ // Identifiers.
+
+ final var graySeparator = new StringFilterGroup(
+ Settings.HIDE_GRAY_SEPARATOR,
+ "cell_divider" // layout residue (gray line above the buttoned ad),
+ );
+
+ final var chipsShelf = new StringFilterGroup(
+ Settings.HIDE_CHIPS_SHELF,
+ "chips_shelf"
+ );
+
+ addIdentifierCallbacks(
+ graySeparator,
+ chipsShelf
+ );
+
+ // Paths.
+
+ final var communityPosts = new StringFilterGroup(
+ Settings.HIDE_COMMUNITY_POSTS,
+ "post_base_wrapper",
+ "text_post_root.eml",
+ "images_post_root.eml",
+ "images_post_slim.eml",
+ "images_post_root_slim.eml",
+ "text_post_root_slim.eml",
+ "post_base_wrapper_slim.eml"
+ );
+
+ final var communityGuidelines = new StringFilterGroup(
+ Settings.HIDE_COMMUNITY_GUIDELINES,
+ "community_guidelines"
+ );
+
+ final var subscribersCommunityGuidelines = new StringFilterGroup(
+ Settings.HIDE_SUBSCRIBERS_COMMUNITY_GUIDELINES,
+ "sponsorships_comments_upsell"
+ );
+
+ final var channelMemberShelf = new StringFilterGroup(
+ Settings.HIDE_CHANNEL_MEMBER_SHELF,
+ "member_recognition_shelf"
+ );
+
+ final var compactBanner = new StringFilterGroup(
+ Settings.HIDE_COMPACT_BANNER,
+ "compact_banner"
+ );
+
+ inFeedSurvey = new StringFilterGroup(
+ Settings.HIDE_FEED_SURVEY,
+ "in_feed_survey",
+ "slimline_survey"
+ );
+
+ final var medicalPanel = new StringFilterGroup(
+ Settings.HIDE_MEDICAL_PANELS,
+ "medical_panel"
+ );
+
+ final var paidPromotion = new StringFilterGroup(
+ Settings.HIDE_PAID_PROMOTION_LABEL,
+ "paid_content_overlay"
+ );
+
+ final var infoPanel = new StringFilterGroup(
+ Settings.HIDE_HIDE_INFO_PANELS,
+ "publisher_transparency_panel",
+ "single_item_information_panel"
+ );
+
+ final var latestPosts = new StringFilterGroup(
+ Settings.HIDE_HIDE_LATEST_POSTS,
+ "post_shelf"
+ );
+
+ final var channelGuidelines = new StringFilterGroup(
+ Settings.HIDE_HIDE_CHANNEL_GUIDELINES,
+ "channel_guidelines_entry_banner"
+ );
+
+ final var emergencyBox = new StringFilterGroup(
+ Settings.HIDE_EMERGENCY_BOX,
+ "emergency_onebox"
+ );
+
+ // The player audio track button does the exact same function as the audio track flyout menu option.
+ // Previously this was a setting to show/hide the player button.
+ // But it was decided it's simpler to always hide this button because:
+ // - the button is rare
+ // - always hiding makes the ReVanced settings simpler and easier to understand
+ // - nobody is going to notice the redundant button is always hidden
+ final var audioTrackButton = new StringFilterGroup(
+ null,
+ "multi_feed_icon_button"
+ );
+
+ final var artistCard = new StringFilterGroup(
+ Settings.HIDE_ARTIST_CARDS,
+ "official_card"
+ );
+
+ expandableMetadata = new StringFilterGroup(
+ Settings.HIDE_EXPANDABLE_CHIP,
+ "inline_expander"
+ );
+
+ final var channelBar = new StringFilterGroup(
+ Settings.HIDE_CHANNEL_BAR,
+ "channel_bar"
+ );
+
+ final var relatedVideos = new StringFilterGroup(
+ Settings.HIDE_RELATED_VIDEOS,
+ "fullscreen_related_videos"
+ );
+
+ final var playables = new StringFilterGroup(
+ Settings.HIDE_PLAYABLES,
+ "horizontal_gaming_shelf.eml",
+ "mini_game_card.eml"
+ );
+
+ final var quickActions = new StringFilterGroup(
+ Settings.HIDE_QUICK_ACTIONS,
+ "quick_actions"
+ );
+
+ final var imageShelf = new StringFilterGroup(
+ Settings.HIDE_IMAGE_SHELF,
+ "image_shelf"
+ );
+
+
+ final var timedReactions = new StringFilterGroup(
+ Settings.HIDE_TIMED_REACTIONS,
+ "emoji_control_panel",
+ "timed_reaction"
+ );
+
+ searchResultShelfHeader = new StringFilterGroup(
+ Settings.HIDE_SEARCH_RESULT_SHELF_HEADER,
+ "shelf_header.eml"
+ );
+
+ notifyMe = new StringFilterGroup(
+ Settings.HIDE_NOTIFY_ME_BUTTON,
+ "set_reminder_button"
+ );
+
+ compactChannelBarInner = new StringFilterGroup(
+ Settings.HIDE_JOIN_MEMBERSHIP_BUTTON,
+ "compact_channel_bar_inner"
+ );
+
+ compactChannelBarInnerButton = new StringFilterGroup(
+ null,
+ "|button.eml|"
+ );
+
+ joinMembershipButton = new ByteArrayFilterGroup(
+ null,
+ "sponsorships"
+ );
+
+ likeSubscribeGlow = new StringFilterGroup(
+ Settings.DISABLE_LIKE_SUBSCRIBE_GLOW,
+ "animated_button_border.eml"
+ );
+
+ final var channelWatermark = new StringFilterGroup(
+ Settings.HIDE_VIDEO_CHANNEL_WATERMARK,
+ "featured_channel_watermark_overlay"
+ );
+
+ final var forYouShelf = new StringFilterGroup(
+ Settings.HIDE_FOR_YOU_SHELF,
+ "mixed_content_shelf"
+ );
+
+ searchResultVideo = new StringFilterGroup(
+ Settings.HIDE_SEARCH_RESULT_RECOMMENDATIONS,
+ "search_video_with_context.eml"
+ );
+
+ searchResultRecommendations = new ByteArrayFilterGroup(
+ Settings.HIDE_SEARCH_RESULT_RECOMMENDATIONS,
+ "endorsement_header_footer"
+ );
+
+ horizontalShelves = new StringFilterGroup(
+ Settings.HIDE_HORIZONTAL_SHELVES,
+ "horizontal_video_shelf.eml",
+ "horizontal_shelf.eml",
+ "horizontal_shelf_inline.eml",
+ "horizontal_tile_shelf.eml"
+ );
+
+ addPathCallbacks(
+ expandableMetadata,
+ inFeedSurvey,
+ notifyMe,
+ likeSubscribeGlow,
+ channelBar,
+ communityPosts,
+ paidPromotion,
+ searchResultVideo,
+ latestPosts,
+ channelWatermark,
+ communityGuidelines,
+ playables,
+ quickActions,
+ relatedVideos,
+ compactBanner,
+ compactChannelBarInner,
+ medicalPanel,
+ infoPanel,
+ emergencyBox,
+ subscribersCommunityGuidelines,
+ channelGuidelines,
+ audioTrackButton,
+ artistCard,
+ timedReactions,
+ imageShelf,
+ channelMemberShelf,
+ forYouShelf,
+ horizontalShelves
+ );
+ }
+
+ @Override
+ boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (matchedGroup == searchResultVideo) {
+ if (searchResultRecommendations.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ }
+
+ if (matchedGroup == likeSubscribeGlow) {
+ if ((path.startsWith(VIDEO_ACTION_BAR_PATH_PREFIX) || path.startsWith(COMPACT_CHANNEL_BAR_PATH_PREFIX))
+ && path.contains(ANIMATED_VECTOR_TYPE_PATH)) {
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+
+ return false;
+ }
+
+ // The groups are excluded from the filter due to the exceptions list below.
+ // Filter them separately here.
+ if (matchedGroup == notifyMe || matchedGroup == inFeedSurvey || matchedGroup == expandableMetadata)
+ {
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+
+ if (exceptions.matches(path)) return false; // Exceptions are not filtered.
+
+ if (matchedGroup == compactChannelBarInner) {
+ if (compactChannelBarInnerButton.check(path).isFiltered()) {
+ // The filter may be broad, but in the context of a compactChannelBarInnerButton,
+ // it's safe to assume that the button is the only thing that should be hidden.
+ if (joinMembershipButton.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ }
+
+ return false;
+ }
+
+ // TODO: This also hides the feed Shorts shelf header
+ if (matchedGroup == searchResultShelfHeader && contentIndex != 0) return false;
+
+ if (matchedGroup == horizontalShelves) {
+ if (contentIndex == 0 && hideShelves()) {
+ return super.isFiltered(path, identifier, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+
+ return false;
+ }
+
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+
+ /**
+ * Injection point.
+ * Called from a different place then the other filters.
+ */
+ public static boolean filterMixPlaylists(final Object conversionContext, @Nullable final byte[] bytes) {
+ try {
+ if (bytes == null) {
+ Logger.printDebug(() -> "bytes is null");
+ return false;
+ }
+
+ // Prevent playlist items being hidden, if a mix playlist is present in it.
+ if (mixPlaylistsExceptions.matches(conversionContext.toString())) {
+ return false;
+ }
+
+ // Prevent hiding the description of some videos accidentally.
+ if (mixPlaylistsExceptions2.check(bytes).isFiltered()) {
+ return false;
+ }
+
+ if (mixPlaylists.check(bytes).isFiltered()) {
+ Logger.printDebug(() -> "Filtered mix playlist");
+ return true;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "filterMixPlaylists failure", ex);
+ }
+
+ return false;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean showWatermark() {
+ return !Settings.HIDE_VIDEO_CHANNEL_WATERMARK.get();
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void hideAlbumCard(View view) {
+ Utils.hideViewBy0dpUnderCondition(Settings.HIDE_ALBUM_CARDS, view);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void hideCrowdfundingBox(View view) {
+ Utils.hideViewBy0dpUnderCondition(Settings.HIDE_CROWDFUNDING_BOX, view);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean hideFloatingMicrophoneButton(final boolean original) {
+ return original || Settings.HIDE_FLOATING_MICROPHONE_BUTTON.get();
+ }
+
+ /**
+ * Injection point.
+ */
+ public static int hideInFeed(final int height) {
+ return Settings.HIDE_FILTER_BAR_FEED_IN_FEED.get()
+ ? 0
+ : height;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static int hideInSearch(int height) {
+ return Settings.HIDE_FILTER_BAR_FEED_IN_SEARCH.get()
+ ? 0
+ : height;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void hideInRelatedVideos(View chipView) {
+ Utils.hideViewBy0dpUnderCondition(Settings.HIDE_FILTER_BAR_FEED_IN_RELATED_VIDEOS, chipView);
+ }
+
+ private static final boolean HIDE_DOODLES_ENABLED = Settings.HIDE_DOODLES.get();
+
+ /**
+ * Injection point.
+ */
+ @Nullable
+ public static Drawable hideYoodles(Drawable animatedYoodle) {
+ if (HIDE_DOODLES_ENABLED) {
+ return null;
+ }
+
+ return animatedYoodle;
+ }
+
+ private static final boolean HIDE_SHOW_MORE_BUTTON_ENABLED = Settings.HIDE_SHOW_MORE_BUTTON.get();
+
+ /**
+ * Injection point.
+ */
+ public static void hideShowMoreButton(View view) {
+ if (HIDE_SHOW_MORE_BUTTON_ENABLED
+ && NavigationBar.isSearchBarActive()
+ // Search bar can be active but behind the player.
+ && !PlayerType.getCurrent().isMaximizedOrFullscreen()) {
+ Utils.hideViewByLayoutParams(view);
+ }
+ }
+
+ private static boolean hideShelves() {
+ // If the player is opened while library is selected,
+ // then filter any recommendations below the player.
+ if (PlayerType.getCurrent().isMaximizedOrFullscreen()
+ // Or if the search is active while library is selected, then also filter.
+ || NavigationBar.isSearchBarActive()) {
+ return true;
+ }
+
+ // Check navigation button last.
+ // Only filter if the library tab is not selected.
+ // This check is important as the shelf layout is used for the library tab playlists.
+ return NavigationButton.getSelectedNavigationButton() != NavigationButton.LIBRARY;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/LithoFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/LithoFilterPatch.java
new file mode 100644
index 000000000..0e7ac8ab4
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/LithoFilterPatch.java
@@ -0,0 +1,189 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.nio.ByteBuffer;
+import java.util.List;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.youtube.StringTrieSearch;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class LithoFilterPatch {
+ /**
+ * Simple wrapper to pass the litho parameters through the prefix search.
+ */
+ private static final class LithoFilterParameters {
+ @Nullable
+ final String identifier;
+ final String path;
+ final byte[] protoBuffer;
+
+ LithoFilterParameters(@Nullable String lithoIdentifier, String lithoPath, byte[] protoBuffer) {
+ this.identifier = lithoIdentifier;
+ this.path = lithoPath;
+ this.protoBuffer = protoBuffer;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ // Estimate the percentage of the buffer that are Strings.
+ StringBuilder builder = new StringBuilder(Math.max(100, protoBuffer.length / 2));
+ builder.append( "ID: ");
+ builder.append(identifier);
+ builder.append(" Path: ");
+ builder.append(path);
+ if (Settings.DEBUG_PROTOBUFFER.get()) {
+ builder.append(" BufferStrings: ");
+ findAsciiStrings(builder, protoBuffer);
+ }
+
+ return builder.toString();
+ }
+
+ /**
+ * Search through a byte array for all ASCII strings.
+ */
+ private static void findAsciiStrings(StringBuilder builder, byte[] buffer) {
+ // Valid ASCII values (ignore control characters).
+ final int minimumAscii = 32; // 32 = space character
+ final int maximumAscii = 126; // 127 = delete character
+ final int minimumAsciiStringLength = 4; // Minimum length of an ASCII string to include.
+ String delimitingCharacter = "❙"; // Non ascii character, to allow easier log filtering.
+
+ final int length = buffer.length;
+ int start = 0;
+ int end = 0;
+ while (end < length) {
+ int value = buffer[end];
+ if (value < minimumAscii || value > maximumAscii || end == length - 1) {
+ if (end - start >= minimumAsciiStringLength) {
+ for (int i = start; i < end; i++) {
+ builder.append((char) buffer[i]);
+ }
+ builder.append(delimitingCharacter);
+ }
+ start = end + 1;
+ }
+ end++;
+ }
+ }
+ }
+
+ private static final Filter[] filters = new Filter[] {
+ new DummyFilter() // Replaced by patch.
+ };
+
+ private static final StringTrieSearch pathSearchTree = new StringTrieSearch();
+ private static final StringTrieSearch identifierSearchTree = new StringTrieSearch();
+
+ private static final byte[] EMPTY_BYTE_ARRAY = new byte[0];
+
+ /**
+ * Because litho filtering is multi-threaded and the buffer is passed in from a different injection point,
+ * the buffer is saved to a ThreadLocal so each calling thread does not interfere with other threads.
+ */
+ private static final ThreadLocal bufferThreadLocal = new ThreadLocal<>();
+
+ static {
+ for (Filter filter : filters) {
+ filterUsingCallbacks(identifierSearchTree, filter,
+ filter.identifierCallbacks, Filter.FilterContentType.IDENTIFIER);
+ filterUsingCallbacks(pathSearchTree, filter,
+ filter.pathCallbacks, Filter.FilterContentType.PATH);
+ }
+
+ Logger.printDebug(() -> "Using: "
+ + identifierSearchTree.numberOfPatterns() + " identifier filters"
+ + " (" + identifierSearchTree.getEstimatedMemorySize() + " KB), "
+ + pathSearchTree.numberOfPatterns() + " path filters"
+ + " (" + pathSearchTree.getEstimatedMemorySize() + " KB)");
+ }
+
+ private static void filterUsingCallbacks(StringTrieSearch pathSearchTree,
+ Filter filter, List groups,
+ Filter.FilterContentType type) {
+ for (StringFilterGroup group : groups) {
+ if (!group.includeInSearch()) {
+ continue;
+ }
+ for (String pattern : group.filters) {
+ pathSearchTree.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> {
+ if (!group.isEnabled()) return false;
+ LithoFilterParameters parameters = (LithoFilterParameters) callbackParameter;
+ return filter.isFiltered(parameters.identifier, parameters.path, parameters.protoBuffer,
+ group, type, matchedStartIndex);
+ }
+ );
+ }
+ }
+ }
+
+ /**
+ * Injection point. Called off the main thread.
+ */
+ @SuppressWarnings("unused")
+ public static void setProtoBuffer(@Nullable ByteBuffer protobufBuffer) {
+ // Set the buffer to a thread local. The buffer will remain in memory, even after the call to #filter completes.
+ // This is intentional, as it appears the buffer can be set once and then filtered multiple times.
+ // The buffer will be cleared from memory after a new buffer is set by the same thread,
+ // or when the calling thread eventually dies.
+ if (protobufBuffer == null) {
+ // It appears the buffer can be cleared out just before the call to #filter()
+ // Ignore this null value and retain the last buffer that was set.
+ Logger.printDebug(() -> "Ignoring null protobuffer");
+ } else {
+ bufferThreadLocal.set(protobufBuffer);
+ }
+ }
+
+ /**
+ * Injection point. Called off the main thread, and commonly called by multiple threads at the same time.
+ */
+ @SuppressWarnings("unused")
+ public static boolean filter(@Nullable String lithoIdentifier, @NonNull StringBuilder pathBuilder) {
+ try {
+ if (pathBuilder.length() == 0) {
+ return false;
+ }
+
+ ByteBuffer protobufBuffer = bufferThreadLocal.get();
+ final byte[] bufferArray;
+ // Potentially the buffer may have been null or never set up until now.
+ // Use an empty buffer so the litho id/path filters still work correctly.
+ if (protobufBuffer == null) {
+ Logger.printDebug(() -> "Proto buffer is null, using an empty buffer array");
+ bufferArray = EMPTY_BYTE_ARRAY;
+ } else if (!protobufBuffer.hasArray()) {
+ Logger.printDebug(() -> "Proto buffer does not have an array, using an empty buffer array");
+ bufferArray = EMPTY_BYTE_ARRAY;
+ } else {
+ bufferArray = protobufBuffer.array();
+ }
+
+ LithoFilterParameters parameter = new LithoFilterParameters(lithoIdentifier,
+ pathBuilder.toString(), bufferArray);
+ Logger.printDebug(() -> "Searching " + parameter);
+
+ if (parameter.identifier != null && identifierSearchTree.matches(parameter.identifier, parameter)) {
+ return true;
+ }
+
+ if (pathSearchTree.matches(parameter.path, parameter)) {
+ return true;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "Litho filter failure", ex);
+ }
+
+ return false;
+ }
+}
+
+/**
+ * Placeholder for actual filters.
+ */
+final class DummyFilter extends Filter { }
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlaybackSpeedMenuFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlaybackSpeedMenuFilterPatch.java
new file mode 100644
index 000000000..e49ff0853
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlaybackSpeedMenuFilterPatch.java
@@ -0,0 +1,28 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.youtube.patches.playback.speed.CustomPlaybackSpeedPatch;
+
+/**
+ * Abuse LithoFilter for {@link CustomPlaybackSpeedPatch}.
+ */
+public final class PlaybackSpeedMenuFilterPatch extends Filter {
+ // Must be volatile or synchronized, as litho filtering runs off main thread and this field is then access from the main thread.
+ public static volatile boolean isPlaybackSpeedMenuVisible;
+
+ public PlaybackSpeedMenuFilterPatch() {
+ addPathCallbacks(new StringFilterGroup(
+ null,
+ "playback_speed_sheet_content.eml-js"
+ ));
+ }
+
+ @Override
+ boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ isPlaybackSpeedMenuVisible = true;
+
+ return false;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerFlyoutMenuItemsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerFlyoutMenuItemsFilter.java
new file mode 100644
index 000000000..3469bbb85
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerFlyoutMenuItemsFilter.java
@@ -0,0 +1,104 @@
+package app.revanced.extension.youtube.patches.components;
+
+import android.os.Build;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.PlayerType;
+
+@SuppressWarnings("unused")
+public class PlayerFlyoutMenuItemsFilter extends Filter {
+
+ private final ByteArrayFilterGroupList flyoutFilterGroupList = new ByteArrayFilterGroupList();
+
+ private final ByteArrayFilterGroup exception;
+ private final StringFilterGroup videoQualityMenuFooter;
+
+ @RequiresApi(api = Build.VERSION_CODES.N)
+ public PlayerFlyoutMenuItemsFilter() {
+ exception = new ByteArrayFilterGroup(
+ // Whitelist Quality menu item when "Hide Additional settings menu" is enabled
+ Settings.HIDE_ADDITIONAL_SETTINGS_MENU,
+ "quality_sheet"
+ );
+
+ videoQualityMenuFooter = new StringFilterGroup(
+ Settings.HIDE_VIDEO_QUALITY_MENU_FOOTER,
+ "quality_sheet_footer"
+ );
+
+ addPathCallbacks(
+ videoQualityMenuFooter,
+ new StringFilterGroup(null, "overflow_menu_item.eml|")
+ );
+
+ flyoutFilterGroupList.addAll(
+ new ByteArrayFilterGroup(
+ Settings.HIDE_CAPTIONS_MENU,
+ "closed_caption"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_ADDITIONAL_SETTINGS_MENU,
+ "yt_outline_gear"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_LOOP_VIDEO_MENU,
+ "yt_outline_arrow_repeat_1_"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_AMBIENT_MODE_MENU,
+ "yt_outline_screen_light"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_HELP_MENU,
+ "yt_outline_question_circle"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_MORE_INFO_MENU,
+ "yt_outline_info_circle"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_LOCK_SCREEN_MENU,
+ "yt_outline_lock"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SPEED_MENU,
+ "yt_outline_play_arrow_half_circle"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_AUDIO_TRACK_MENU,
+ "yt_outline_person_radar"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_WATCH_IN_VR_MENU,
+ "yt_outline_vr"
+ )
+ );
+ }
+
+ @Override
+ boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (matchedGroup == videoQualityMenuFooter) {
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+
+ if (contentIndex != 0) {
+ return false; // Overflow menu is always the start of the path.
+ }
+
+ // Shorts also use this player flyout panel
+ if (PlayerType.getCurrent().isNoneOrHidden() || exception.check(protobufBufferArray).isFiltered()) {
+ return false;
+ }
+
+ if (flyoutFilterGroupList.check(protobufBufferArray).isFiltered()) {
+ // Super class handles logging.
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+
+ return false;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java
new file mode 100644
index 000000000..bac1d4ce7
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java
@@ -0,0 +1,144 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+
+import app.revanced.extension.youtube.patches.ReturnYouTubeDislikePatch;
+import app.revanced.extension.youtube.patches.VideoInformation;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.youtube.TrieSearch;
+
+/**
+ * Searches for video id's in the proto buffer of Shorts dislike.
+ *
+ * Because multiple litho dislike spans are created in the background
+ * (and also anytime litho refreshes the components, which is somewhat arbitrary),
+ * that makes the value of {@link VideoInformation#getVideoId()} and {@link VideoInformation#getPlayerResponseVideoId()}
+ * unreliable to determine which video id a Shorts litho span belongs to.
+ *
+ * But the correct video id does appear in the protobuffer just before a Shorts litho span is created.
+ *
+ * Once a way to asynchronously update litho text is found, this strategy will no longer be needed.
+ */
+public final class ReturnYouTubeDislikeFilterPatch extends Filter {
+
+ /**
+ * Last unique video id's loaded. Value is ignored and Map is treated as a Set.
+ * Cannot use {@link LinkedHashSet} because it's missing #removeEldestEntry().
+ */
+ @GuardedBy("itself")
+ private static final Map lastVideoIds = new LinkedHashMap<>() {
+ /**
+ * Number of video id's to keep track of for searching thru the buffer.
+ * A minimum value of 3 should be sufficient, but check a few more just in case.
+ */
+ private static final int NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK = 5;
+
+ @Override
+ protected boolean removeEldestEntry(Entry eldest) {
+ return size() > NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK;
+ }
+ };
+
+ /**
+ * Injection point.
+ */
+ @SuppressWarnings("unused")
+ public static void newPlayerResponseVideoId(String videoId, boolean isShortAndOpeningOrPlaying) {
+ try {
+ if (!isShortAndOpeningOrPlaying || !Settings.RYD_ENABLED.get() || !Settings.RYD_SHORTS.get()) {
+ return;
+ }
+ synchronized (lastVideoIds) {
+ if (lastVideoIds.put(videoId, Boolean.TRUE) == null) {
+ Logger.printDebug(() -> "New Short video id: " + videoId);
+ }
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "newPlayerResponseVideoId failure", ex);
+ }
+ }
+
+ private final ByteArrayFilterGroupList videoIdFilterGroup = new ByteArrayFilterGroupList();
+
+ public ReturnYouTubeDislikeFilterPatch() {
+ // When a new Short is opened, the like buttons always seem to load before the dislike.
+ // But if swiping back to a previous video and liking/disliking, then only that single button reloads.
+ // So must check for both buttons.
+ addPathCallbacks(
+ new StringFilterGroup(null, "|shorts_like_button.eml"),
+ new StringFilterGroup(null, "|shorts_dislike_button.eml")
+ );
+
+ // After the likes icon name is some binary data and then the video id for that specific short.
+ videoIdFilterGroup.addAll(
+ // on_shadowed = Video was previously like/disliked before opening.
+ // off_shadowed = Video was not previously liked/disliked before opening.
+ new ByteArrayFilterGroup(null, "ic_right_like_on_shadowed"),
+ new ByteArrayFilterGroup(null, "ic_right_like_off_shadowed"),
+
+ new ByteArrayFilterGroup(null, "ic_right_dislike_on_shadowed"),
+ new ByteArrayFilterGroup(null, "ic_right_dislike_off_shadowed")
+ );
+ }
+
+ @Override
+ boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (!Settings.RYD_ENABLED.get() || !Settings.RYD_SHORTS.get()) {
+ return false;
+ }
+
+ FilterGroup.FilterGroupResult result = videoIdFilterGroup.check(protobufBufferArray);
+ if (result.isFiltered()) {
+ String matchedVideoId = findVideoId(protobufBufferArray);
+ // Matched video will be null if in incognito mode.
+ // Must pass a null id to correctly clear out the current video data.
+ // Otherwise if a Short is opened in non-incognito, then incognito is enabled and another Short is opened,
+ // the new incognito Short will show the old prior data.
+ ReturnYouTubeDislikePatch.setLastLithoShortsVideoId(matchedVideoId);
+ }
+
+ return false;
+ }
+
+ @Nullable
+ private String findVideoId(byte[] protobufBufferArray) {
+ synchronized (lastVideoIds) {
+ for (String videoId : lastVideoIds.keySet()) {
+ if (byteArrayContainsString(protobufBufferArray, videoId)) {
+ return videoId;
+ }
+ }
+
+ return null;
+ }
+ }
+
+ /**
+ * This could use {@link TrieSearch}, but since the patterns are constantly changing
+ * the overhead of updating the Trie might negate the search performance gain.
+ */
+ private static boolean byteArrayContainsString(@NonNull byte[] array, @NonNull String text) {
+ for (int i = 0, lastArrayStartIndex = array.length - text.length(); i <= lastArrayStartIndex; i++) {
+ boolean found = true;
+ for (int j = 0, textLength = text.length(); j < textLength; j++) {
+ if (array[i + j] != (byte) text.charAt(j)) {
+ found = false;
+ break;
+ }
+ }
+ if (found) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsFilter.java
new file mode 100644
index 000000000..5e994a003
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsFilter.java
@@ -0,0 +1,444 @@
+package app.revanced.extension.youtube.patches.components;
+
+import static app.revanced.extension.shared.Utils.hideViewUnderCondition;
+import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
+
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+import com.google.android.libraries.youtube.rendering.ui.pivotbar.PivotBar;
+
+import java.lang.ref.WeakReference;
+import java.util.Arrays;
+import java.util.List;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.NavigationBar;
+import app.revanced.extension.youtube.shared.PlayerType;
+
+@SuppressWarnings("unused")
+public final class ShortsFilter extends Filter {
+ public static final Boolean HIDE_SHORTS_NAVIGATION_BAR = Settings.HIDE_SHORTS_NAVIGATION_BAR.get();
+ private final static String REEL_CHANNEL_BAR_PATH = "reel_channel_bar.eml";
+
+ /**
+ * For paid promotion label and subscribe button that appears in the channel bar.
+ */
+ private final static String REEL_METAPANEL_PATH = "reel_metapanel.eml";
+
+ /**
+ * Tags that appears when opening the Shorts player.
+ */
+ private static final List REEL_WATCH_FRAGMENT_INIT_PLAYBACK = Arrays.asList("r_fs", "r_ts");
+
+ /**
+ * Vertical padding between the bottom of the screen and the seekbar, when the Shorts navigation bar is hidden.
+ */
+ public static final int HIDDEN_NAVIGATION_BAR_VERTICAL_HEIGHT = 100;
+
+ private static WeakReference pivotBarRef = new WeakReference<>(null);
+
+ private final StringFilterGroup shortsCompactFeedVideoPath;
+ private final ByteArrayFilterGroup shortsCompactFeedVideoBuffer;
+
+ private final StringFilterGroup subscribeButton;
+ private final StringFilterGroup joinButton;
+ private final StringFilterGroup paidPromotionButton;
+ private final StringFilterGroup shelfHeader;
+
+ private final StringFilterGroup suggestedAction;
+ private final ByteArrayFilterGroupList suggestedActionsGroupList = new ByteArrayFilterGroupList();
+
+ private final StringFilterGroup actionBar;
+ private final ByteArrayFilterGroupList videoActionButtonGroupList = new ByteArrayFilterGroupList();
+
+ public ShortsFilter() {
+ //
+ // Identifier components.
+ //
+
+ var shortsIdentifiers = new StringFilterGroup(
+ null, // Setting is based on navigation state.
+ "shorts_shelf",
+ "inline_shorts",
+ "shorts_grid",
+ "shorts_video_cell",
+ "shorts_pivot_item"
+ );
+
+ // Feed Shorts shelf header.
+ // Use a different filter group for this pattern, as it requires an additional check after matching.
+ shelfHeader = new StringFilterGroup(
+ null,
+ "shelf_header.eml"
+ );
+
+ addIdentifierCallbacks(shortsIdentifiers, shelfHeader);
+
+ //
+ // Path components.
+ //
+
+ shortsCompactFeedVideoPath = new StringFilterGroup(null,
+ // Shorts that appear in the feed/search when the device is using tablet layout.
+ "compact_video.eml",
+ // 'video_lockup_with_attachment.eml' is shown instead of 'compact_video.eml' for some users
+ "video_lockup_with_attachment.eml",
+ // Search results that appear in a horizontal shelf.
+ "video_card.eml");
+
+ // Filter out items that use the 'frame0' thumbnail.
+ // This is a valid thumbnail for both regular videos and Shorts,
+ // but it appears these thumbnails are used only for Shorts.
+ shortsCompactFeedVideoBuffer = new ByteArrayFilterGroup(null, "/frame0.jpg");
+
+ // Shorts player components.
+ StringFilterGroup pausedOverlayButtons = new StringFilterGroup(
+ Settings.HIDE_SHORTS_PAUSED_OVERLAY_BUTTONS,
+ "shorts_paused_state"
+ );
+
+ StringFilterGroup channelBar = new StringFilterGroup(
+ Settings.HIDE_SHORTS_CHANNEL_BAR,
+ REEL_CHANNEL_BAR_PATH
+ );
+
+ StringFilterGroup fullVideoLinkLabel = new StringFilterGroup(
+ Settings.HIDE_SHORTS_FULL_VIDEO_LINK_LABEL,
+ "reel_multi_format_link"
+ );
+
+ StringFilterGroup videoTitle = new StringFilterGroup(
+ Settings.HIDE_SHORTS_VIDEO_TITLE,
+ "shorts_video_title_item"
+ );
+
+ StringFilterGroup reelSoundMetadata = new StringFilterGroup(
+ Settings.HIDE_SHORTS_SOUND_METADATA_LABEL,
+ "reel_sound_metadata"
+ );
+
+ StringFilterGroup soundButton = new StringFilterGroup(
+ Settings.HIDE_SHORTS_SOUND_BUTTON,
+ "reel_pivot_button"
+ );
+
+ StringFilterGroup infoPanel = new StringFilterGroup(
+ Settings.HIDE_SHORTS_INFO_PANEL,
+ "shorts_info_panel_overview"
+ );
+
+ StringFilterGroup stickers = new StringFilterGroup(
+ Settings.HIDE_SHORTS_STICKERS,
+ "stickers_layer.eml"
+ );
+
+ StringFilterGroup likeFountain = new StringFilterGroup(
+ Settings.HIDE_SHORTS_LIKE_FOUNTAIN,
+ "like_fountain.eml"
+ );
+
+ joinButton = new StringFilterGroup(
+ Settings.HIDE_SHORTS_JOIN_BUTTON,
+ "sponsor_button"
+ );
+
+ subscribeButton = new StringFilterGroup(
+ Settings.HIDE_SHORTS_SUBSCRIBE_BUTTON,
+ "subscribe_button"
+ );
+
+ paidPromotionButton = new StringFilterGroup(
+ Settings.HIDE_PAID_PROMOTION_LABEL,
+ "reel_player_disclosure.eml"
+ );
+
+ actionBar = new StringFilterGroup(
+ null,
+ "shorts_action_bar"
+ );
+
+ suggestedAction = new StringFilterGroup(
+ null,
+ "suggested_action.eml"
+ );
+
+ addPathCallbacks(
+ shortsCompactFeedVideoPath, suggestedAction, actionBar, joinButton, subscribeButton,
+ paidPromotionButton, pausedOverlayButtons, channelBar, fullVideoLinkLabel, videoTitle,
+ reelSoundMetadata, soundButton, infoPanel, stickers, likeFountain
+ );
+
+ //
+ // Action buttons
+ //
+ videoActionButtonGroupList.addAll(
+ // This also appears as the path item 'shorts_like_button.eml'
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_LIKE_BUTTON,
+ "reel_like_button",
+ "reel_like_toggled_button"
+ ),
+ // This also appears as the path item 'shorts_dislike_button.eml'
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_DISLIKE_BUTTON,
+ "reel_dislike_button",
+ "reel_dislike_toggled_button"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_COMMENTS_BUTTON,
+ "reel_comment_button"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_SHARE_BUTTON,
+ "reel_share_button"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_REMIX_BUTTON,
+ "reel_remix_button"
+ )
+ );
+
+ //
+ // Suggested actions.
+ //
+ suggestedActionsGroupList.addAll(
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_SHOP_BUTTON,
+ "yt_outline_bag_"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_TAGGED_PRODUCTS,
+ // Product buttons show pictures of the products, and does not have any unique icons to identify.
+ // Instead use a unique identifier found in the buffer.
+ "PAproduct_listZ"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_LOCATION_LABEL,
+ "yt_outline_location_point_"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_SAVE_SOUND_BUTTON,
+ "yt_outline_bookmark_",
+ // 'Save sound' button. It seems this has been removed and only 'Save music' is used.
+ // Still hide this in case it's still present.
+ "yt_outline_list_add_",
+ // 'Use this sound' button. It seems this has been removed and only 'Save music' is used.
+ // Still hide this in case it's still present.
+ "yt_outline_camera_"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_SEARCH_SUGGESTIONS,
+ "yt_outline_search_"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_SUPER_THANKS_BUTTON,
+ "yt_outline_dollar_sign_heart_"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_USE_TEMPLATE_BUTTON,
+ "yt_outline_template_add_"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_UPCOMING_BUTTON,
+ "yt_outline_bell_"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_GREEN_SCREEN_BUTTON,
+ "greenscreen_temp"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_HASHTAG_BUTTON,
+ "yt_outline_hashtag_"
+ )
+ );
+ }
+
+ private boolean isEverySuggestedActionFilterEnabled() {
+ for (ByteArrayFilterGroup group : suggestedActionsGroupList) {
+ if (!group.isEnabled()) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ @Override
+ boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (contentType == FilterContentType.PATH) {
+ if (matchedGroup == subscribeButton || matchedGroup == joinButton || matchedGroup == paidPromotionButton) {
+ // Selectively filter to avoid false positive filtering of other subscribe/join buttons.
+ if (path.startsWith(REEL_CHANNEL_BAR_PATH) || path.startsWith(REEL_METAPANEL_PATH)) {
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ }
+
+ if (matchedGroup == shortsCompactFeedVideoPath) {
+ if (shouldHideShortsFeedItems() && shortsCompactFeedVideoBuffer.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ }
+
+ // Video action buttons (like, dislike, comment, share, remix) have the same path.
+ if (matchedGroup == actionBar) {
+ if (videoActionButtonGroupList.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ }
+
+ if (matchedGroup == suggestedAction) {
+ // Skip searching the buffer if all suggested actions are set to hidden.
+ // This has a secondary effect of hiding all new un-identified actions
+ // under the assumption that the user wants all actions hidden.
+ if (isEverySuggestedActionFilterEnabled()) {
+ return super.isFiltered(path, identifier, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+
+ if (suggestedActionsGroupList.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ }
+
+ } else {
+ // Feed/search identifier components.
+ if (matchedGroup == shelfHeader) {
+ // Because the header is used in watch history and possibly other places, check for the index,
+ // which is 0 when the shelf header is used for Shorts.
+ if (contentIndex != 0) return false;
+ }
+
+ if (!shouldHideShortsFeedItems()) return false;
+ }
+
+ // Super class handles logging.
+ return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+
+ private static boolean shouldHideShortsFeedItems() {
+ final boolean hideHome = Settings.HIDE_SHORTS_HOME.get();
+ final boolean hideSubscriptions = Settings.HIDE_SHORTS_SUBSCRIPTIONS.get();
+ final boolean hideSearch = Settings.HIDE_SHORTS_SEARCH.get();
+
+ if (hideHome && hideSubscriptions && hideSearch) {
+ // Shorts suggestions can load in the background if a video is opened and
+ // then immediately minimized before any suggestions are loaded.
+ // In this state the player type will show minimized, which makes it not possible to
+ // distinguish between Shorts suggestions loading in the player and between
+ // scrolling thru search/home/subscription tabs while a player is minimized.
+ //
+ // To avoid this situation for users that never want to show Shorts (all hide Shorts options are enabled)
+ // then hide all Shorts everywhere including the Library history and Library playlists.
+ return true;
+ }
+
+ // Must check player type first, as search bar can be active behind the player.
+ if (PlayerType.getCurrent().isMaximizedOrFullscreen()) {
+ // For now, consider the under video results the same as the home feed.
+ return hideHome;
+ }
+
+ // Must check second, as search can be from any tab.
+ if (NavigationBar.isSearchBarActive()) {
+ return hideSearch;
+ }
+
+ // Avoid checking navigation button status if all other Shorts should show.
+ if (!hideHome && !hideSubscriptions) {
+ return false;
+ }
+
+ NavigationButton selectedNavButton = NavigationButton.getSelectedNavigationButton();
+ if (selectedNavButton == null) {
+ return hideHome; // Unknown tab, treat the same as home.
+ }
+ if (selectedNavButton == NavigationButton.HOME) {
+ return hideHome;
+ }
+ if (selectedNavButton == NavigationButton.SUBSCRIPTIONS) {
+ return hideSubscriptions;
+ }
+ // User must be in the library tab. Don't hide the history or any playlists here.
+ return false;
+ }
+
+ public static void hideShortsShelf(final View shortsShelfView) {
+ if (shouldHideShortsFeedItems()) {
+ Utils.hideViewByLayoutParams(shortsShelfView);
+ }
+ }
+
+ public static int getSoundButtonSize(int original) {
+ if (Settings.HIDE_SHORTS_SOUND_BUTTON.get()) {
+ return 0;
+ }
+
+ return original;
+ }
+
+ // region Hide the buttons in older versions of YouTube. New versions use Litho.
+
+ public static void hideLikeButton(final View likeButtonView) {
+ // Cannot set the visibility to gone for like/dislike,
+ // as some other unknown YT code also sets the visibility after this hook.
+ //
+ // Setting the view to 0dp works, but that leaves a blank space where
+ // the button was (only relevant for dislikes button).
+ //
+ // Instead remove the view from the parent.
+ Utils.hideViewByRemovingFromParentUnderCondition(Settings.HIDE_SHORTS_LIKE_BUTTON, likeButtonView);
+ }
+
+ public static void hideDislikeButton(final View dislikeButtonView) {
+ Utils.hideViewByRemovingFromParentUnderCondition(Settings.HIDE_SHORTS_DISLIKE_BUTTON, dislikeButtonView);
+ }
+
+ public static void hideShortsCommentsButton(final View commentsButtonView) {
+ hideViewUnderCondition(Settings.HIDE_SHORTS_COMMENTS_BUTTON, commentsButtonView);
+ }
+
+ public static void hideShortsRemixButton(final View remixButtonView) {
+ hideViewUnderCondition(Settings.HIDE_SHORTS_REMIX_BUTTON, remixButtonView);
+ }
+
+ public static void hideShortsShareButton(final View shareButtonView) {
+ hideViewUnderCondition(Settings.HIDE_SHORTS_SHARE_BUTTON, shareButtonView);
+ }
+
+ // endregion
+
+ public static void setNavigationBar(PivotBar view) {
+ Logger.printDebug(() -> "Setting navigation bar");
+ pivotBarRef = new WeakReference<>(view);
+ }
+
+ public static void hideNavigationBar(String tag) {
+ if (HIDE_SHORTS_NAVIGATION_BAR) {
+ if (REEL_WATCH_FRAGMENT_INIT_PLAYBACK.contains(tag)) {
+ var pivotBar = pivotBarRef.get();
+ if (pivotBar == null) return;
+
+ Logger.printDebug(() -> "Hiding navbar by setting to GONE");
+ pivotBar.setVisibility(View.GONE);
+ } else {
+ Logger.printDebug(() -> "Ignoring tag: " + tag);
+ }
+ }
+ }
+
+ public static int getNavigationBarHeight(int original) {
+ if (HIDE_SHORTS_NAVIGATION_BAR) {
+ return HIDDEN_NAVIGATION_BAR_VERTICAL_HEIGHT;
+ }
+
+ return original;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/VideoQualityMenuFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/VideoQualityMenuFilterPatch.java
new file mode 100644
index 000000000..7ee3cab77
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/VideoQualityMenuFilterPatch.java
@@ -0,0 +1,30 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.youtube.patches.playback.quality.RestoreOldVideoQualityMenuPatch;
+import app.revanced.extension.youtube.settings.Settings;
+
+/**
+ * Abuse LithoFilter for {@link RestoreOldVideoQualityMenuPatch}.
+ */
+public final class VideoQualityMenuFilterPatch extends Filter {
+ // Must be volatile or synchronized, as litho filtering runs off main thread
+ // and this field is then access from the main thread.
+ public static volatile boolean isVideoQualityMenuVisible;
+
+ public VideoQualityMenuFilterPatch() {
+ addPathCallbacks(new StringFilterGroup(
+ Settings.RESTORE_OLD_VIDEO_QUALITY_MENU,
+ "quick_quality_sheet_content.eml-js"
+ ));
+ }
+
+ @Override
+ boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ isVideoQualityMenuVisible = true;
+
+ return false;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/quality/RememberVideoQualityPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/quality/RememberVideoQualityPatch.java
new file mode 100644
index 000000000..785603895
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/quality/RememberVideoQualityPatch.java
@@ -0,0 +1,167 @@
+package app.revanced.extension.youtube.patches.playback.quality;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.Utils.NetworkType;
+
+import androidx.annotation.Nullable;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.IntegerSetting;
+import app.revanced.extension.youtube.patches.VideoInformation;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class RememberVideoQualityPatch {
+ private static final int AUTOMATIC_VIDEO_QUALITY_VALUE = -2;
+ private static final IntegerSetting wifiQualitySetting = Settings.VIDEO_QUALITY_DEFAULT_WIFI;
+ private static final IntegerSetting mobileQualitySetting = Settings.VIDEO_QUALITY_DEFAULT_MOBILE;
+
+ private static boolean qualityNeedsUpdating;
+
+ /**
+ * If the user selected a new quality from the flyout menu,
+ * and {@link Settings#REMEMBER_VIDEO_QUALITY_LAST_SELECTED} is enabled.
+ */
+ private static boolean userChangedDefaultQuality;
+
+ /**
+ * Index of the video quality chosen by the user from the flyout menu.
+ */
+ private static int userSelectedQualityIndex;
+
+ /**
+ * The available qualities of the current video in human readable form: [1080, 720, 480]
+ */
+ @Nullable
+ private static List videoQualities;
+
+ private static void changeDefaultQuality(int defaultQuality) {
+ String networkTypeMessage;
+ if (Utils.getNetworkType() == NetworkType.MOBILE) {
+ mobileQualitySetting.save(defaultQuality);
+ networkTypeMessage = str("revanced_remember_video_quality_mobile");
+ } else {
+ wifiQualitySetting.save(defaultQuality);
+ networkTypeMessage = str("revanced_remember_video_quality_wifi");
+ }
+ Utils.showToastShort(
+ str("revanced_remember_video_quality_toast", networkTypeMessage, (defaultQuality + "p")));
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param qualities Video qualities available, ordered from largest to smallest, with index 0 being the 'automatic' value of -2
+ * @param originalQualityIndex quality index to use, as chosen by YouTube
+ */
+ public static int setVideoQuality(Object[] qualities, final int originalQualityIndex, Object qInterface, String qIndexMethod) {
+ try {
+ if (!(qualityNeedsUpdating || userChangedDefaultQuality) || qInterface == null) {
+ return originalQualityIndex;
+ }
+ qualityNeedsUpdating = false;
+
+ final int preferredQuality;
+ if (Utils.getNetworkType() == NetworkType.MOBILE) {
+ preferredQuality = mobileQualitySetting.get();
+ } else {
+ preferredQuality = wifiQualitySetting.get();
+ }
+ if (!userChangedDefaultQuality && preferredQuality == AUTOMATIC_VIDEO_QUALITY_VALUE) {
+ return originalQualityIndex; // nothing to do
+ }
+
+ if (videoQualities == null || videoQualities.size() != qualities.length) {
+ videoQualities = new ArrayList<>(qualities.length);
+ for (Object streamQuality : qualities) {
+ for (Field field : streamQuality.getClass().getFields()) {
+ if (field.getType().isAssignableFrom(Integer.TYPE)
+ && field.getName().length() <= 2) {
+ videoQualities.add(field.getInt(streamQuality));
+ }
+ }
+ }
+ Logger.printDebug(() -> "videoQualities: " + videoQualities);
+ }
+
+ if (userChangedDefaultQuality) {
+ userChangedDefaultQuality = false;
+ final int quality = videoQualities.get(userSelectedQualityIndex);
+ Logger.printDebug(() -> "User changed default quality to: " + quality);
+ changeDefaultQuality(quality);
+ return userSelectedQualityIndex;
+ }
+
+ // find the highest quality that is equal to or less than the preferred
+ int qualityToUse = videoQualities.get(0); // first element is automatic mode
+ int qualityIndexToUse = 0;
+ int i = 0;
+ for (Integer quality : videoQualities) {
+ if (quality <= preferredQuality && qualityToUse < quality) {
+ qualityToUse = quality;
+ qualityIndexToUse = i;
+ }
+ i++;
+ }
+
+ // If the desired quality index is equal to the original index,
+ // then the video is already set to the desired default quality.
+ //
+ // The method could return here, but the UI video quality flyout will still
+ // show 'Auto' (ie: Auto (480p))
+ // It appears that "Auto" picks the resolution on video load,
+ // and it does not appear to change the resolution during playback.
+ //
+ // To prevent confusion, set the video index anyways (even if it matches the existing index)
+ // As that will force the UI picker to not display "Auto" which may confuse the user.
+ if (qualityIndexToUse == originalQualityIndex) {
+ Logger.printDebug(() -> "Video is already preferred quality: " + preferredQuality);
+ } else {
+ final int qualityToUseLog = qualityToUse;
+ Logger.printDebug(() -> "Quality changed from: "
+ + videoQualities.get(originalQualityIndex) + " to: " + qualityToUseLog);
+ }
+
+ Method m = qInterface.getClass().getMethod(qIndexMethod, Integer.TYPE);
+ m.invoke(qInterface, qualityToUse);
+ return qualityIndexToUse;
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to set quality", ex);
+ return originalQualityIndex;
+ }
+ }
+
+ /**
+ * Injection point. Old quality menu.
+ */
+ public static void userChangedQuality(int selectedQualityIndex) {
+ if (!Settings.REMEMBER_VIDEO_QUALITY_LAST_SELECTED.get()) return;
+
+ userSelectedQualityIndex = selectedQualityIndex;
+ userChangedDefaultQuality = true;
+ }
+
+ /**
+ * Injection point. New quality menu.
+ */
+ public static void userChangedQualityInNewFlyout(int selectedQuality) {
+ if (!Settings.REMEMBER_VIDEO_QUALITY_LAST_SELECTED.get()) return;
+
+ changeDefaultQuality(selectedQuality); // Quality is human readable resolution (ie: 1080).
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void newVideoStarted(VideoInformation.PlaybackController ignoredPlayerController) {
+ Logger.printDebug(() -> "newVideoStarted");
+ qualityNeedsUpdating = true;
+ videoQualities = null;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/quality/RestoreOldVideoQualityMenuPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/quality/RestoreOldVideoQualityMenuPatch.java
new file mode 100644
index 000000000..ac74bc810
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/quality/RestoreOldVideoQualityMenuPatch.java
@@ -0,0 +1,110 @@
+package app.revanced.extension.youtube.patches.playback.quality;
+
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.widget.ListView;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.patches.components.VideoQualityMenuFilterPatch;
+import app.revanced.extension.youtube.settings.Settings;
+
+/**
+ * This patch contains the logic to show the old video quality menu.
+ * Two methods are required, because the quality menu is a RecyclerView in the new YouTube version
+ * and a ListView in the old one.
+ */
+@SuppressWarnings("unused")
+public final class RestoreOldVideoQualityMenuPatch {
+
+ /**
+ * Injection point.
+ */
+ public static void onFlyoutMenuCreate(RecyclerView recyclerView) {
+ if (!Settings.RESTORE_OLD_VIDEO_QUALITY_MENU.get()) return;
+
+ recyclerView.getViewTreeObserver().addOnDrawListener(() -> {
+ try {
+ // Check if the current view is the quality menu.
+ if (!VideoQualityMenuFilterPatch.isVideoQualityMenuVisible || recyclerView.getChildCount() == 0) {
+ return;
+ }
+ VideoQualityMenuFilterPatch.isVideoQualityMenuVisible = false;
+
+ ViewParent quickQualityViewParent = Utils.getParentView(recyclerView, 3);
+ if (!(quickQualityViewParent instanceof ViewGroup)) {
+ return;
+ }
+
+ View firstChild = recyclerView.getChildAt(0);
+ if (!(firstChild instanceof ViewGroup)) {
+ return;
+ }
+
+ ViewGroup advancedQualityParentView = (ViewGroup) firstChild;
+ if (advancedQualityParentView.getChildCount() < 4) {
+ return;
+ }
+
+ View advancedQualityView = advancedQualityParentView.getChildAt(3);
+ if (advancedQualityView == null) {
+ return;
+ }
+
+ ((ViewGroup) quickQualityViewParent).setVisibility(View.GONE);
+
+ // Click the "Advanced" quality menu to show the "old" quality menu.
+ advancedQualityView.setSoundEffectsEnabled(false);
+ advancedQualityView.performClick();
+ } catch (Exception ex) {
+ Logger.printException(() -> "onFlyoutMenuCreate failure", ex);
+ }
+ });
+ }
+
+
+ /**
+ * Injection point.
+ *
+ * Used to force the creation of the advanced menu item for the Shorts quality flyout.
+ */
+ public static boolean forceAdvancedVideoQualityMenuCreation(boolean original) {
+ return Settings.RESTORE_OLD_VIDEO_QUALITY_MENU.get() || original;
+ }
+
+ /**
+ * Injection point.
+ *
+ * Used if spoofing to an old app version, and also used for the Shorts video quality flyout.
+ */
+ public static void showOldVideoQualityMenu(final ListView listView) {
+ if (!Settings.RESTORE_OLD_VIDEO_QUALITY_MENU.get()) return;
+
+ listView.setOnHierarchyChangeListener(new ViewGroup.OnHierarchyChangeListener() {
+ @Override
+ public void onChildViewAdded(View parent, View child) {
+ try {
+ parent.setVisibility(View.GONE);
+
+ final var indexOfAdvancedQualityMenuItem = 4;
+ if (listView.indexOfChild(child) != indexOfAdvancedQualityMenuItem) return;
+
+ Logger.printDebug(() -> "Found advanced menu item in old type of quality menu");
+
+ listView.setSoundEffectsEnabled(false);
+ final var qualityItemMenuPosition = 4;
+ listView.performItemClick(null, qualityItemMenuPosition, 0);
+
+ } catch (Exception ex) {
+ Logger.printException(() -> "showOldVideoQualityMenu failure", ex);
+ }
+ }
+
+ @Override
+ public void onChildViewRemoved(View parent, View child) {
+ }
+ });
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/speed/CustomPlaybackSpeedPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/speed/CustomPlaybackSpeedPatch.java
new file mode 100644
index 000000000..cad6050fb
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/speed/CustomPlaybackSpeedPatch.java
@@ -0,0 +1,175 @@
+package app.revanced.extension.youtube.patches.playback.speed;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.preference.ListPreference;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.youtube.patches.components.PlaybackSpeedMenuFilterPatch;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+
+import java.util.Arrays;
+
+@SuppressWarnings("unused")
+public class CustomPlaybackSpeedPatch {
+ /**
+ * Maximum playback speed, exclusive value. Custom speeds must be less than this value.
+ *
+ * Going over 8x does not increase the actual playback speed any higher,
+ * and the UI selector starts flickering and acting weird.
+ * Over 10x and the speeds show up out of order in the UI selector.
+ */
+ public static final float MAXIMUM_PLAYBACK_SPEED = 8;
+
+ /**
+ * Custom playback speeds.
+ */
+ public static float[] customPlaybackSpeeds;
+
+ /**
+ * The last time the old playback menu was forcefully called.
+ */
+ private static long lastTimeOldPlaybackMenuInvoked;
+
+ /**
+ * PreferenceList entries and values, of all available playback speeds.
+ */
+ private static String[] preferenceListEntries, preferenceListEntryValues;
+
+ static {
+ loadCustomSpeeds();
+ }
+
+ private static void resetCustomSpeeds(@NonNull String toastMessage) {
+ Utils.showToastLong(toastMessage);
+ Settings.CUSTOM_PLAYBACK_SPEEDS.resetToDefault();
+ }
+
+ private static void loadCustomSpeeds() {
+ try {
+ String[] speedStrings = Settings.CUSTOM_PLAYBACK_SPEEDS.get().split("\\s+");
+ Arrays.sort(speedStrings);
+ if (speedStrings.length == 0) {
+ throw new IllegalArgumentException();
+ }
+ customPlaybackSpeeds = new float[speedStrings.length];
+ for (int i = 0, length = speedStrings.length; i < length; i++) {
+ final float speed = Float.parseFloat(speedStrings[i]);
+ if (speed <= 0 || arrayContains(customPlaybackSpeeds, speed)) {
+ throw new IllegalArgumentException();
+ }
+ if (speed >= MAXIMUM_PLAYBACK_SPEED) {
+ resetCustomSpeeds(str("revanced_custom_playback_speeds_invalid", MAXIMUM_PLAYBACK_SPEED));
+ loadCustomSpeeds();
+ return;
+ }
+ customPlaybackSpeeds[i] = speed;
+ }
+ } catch (Exception ex) {
+ Logger.printInfo(() -> "parse error", ex);
+ resetCustomSpeeds(str("revanced_custom_playback_speeds_parse_exception"));
+ loadCustomSpeeds();
+ }
+ }
+
+ private static boolean arrayContains(float[] array, float value) {
+ for (float arrayValue : array) {
+ if (arrayValue == value) return true;
+ }
+ return false;
+ }
+
+ /**
+ * Initialize a settings preference list with the available playback speeds.
+ */
+ public static void initializeListPreference(ListPreference preference) {
+ if (preferenceListEntries == null) {
+ preferenceListEntries = new String[customPlaybackSpeeds.length];
+ preferenceListEntryValues = new String[customPlaybackSpeeds.length];
+ int i = 0;
+ for (float speed : customPlaybackSpeeds) {
+ String speedString = String.valueOf(speed);
+ preferenceListEntries[i] = speedString + "x";
+ preferenceListEntryValues[i] = speedString;
+ i++;
+ }
+ }
+ preference.setEntries(preferenceListEntries);
+ preference.setEntryValues(preferenceListEntryValues);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void onFlyoutMenuCreate(RecyclerView recyclerView) {
+ recyclerView.getViewTreeObserver().addOnDrawListener(() -> {
+ try {
+ // For some reason, the custom playback speed flyout panel is activated when the user opens the share panel. (A/B tests)
+ // Check the child count of playback speed flyout panel to prevent this issue.
+ // Child count of playback speed flyout panel is always 8.
+ if (!PlaybackSpeedMenuFilterPatch.isPlaybackSpeedMenuVisible || recyclerView.getChildCount() == 0) {
+ return;
+ }
+
+ View firstChild = recyclerView.getChildAt(0);
+ if (!(firstChild instanceof ViewGroup)) {
+ return;
+ }
+ ViewGroup PlaybackSpeedParentView = (ViewGroup) firstChild;
+ if (PlaybackSpeedParentView.getChildCount() != 8) {
+ return;
+ }
+
+ PlaybackSpeedMenuFilterPatch.isPlaybackSpeedMenuVisible = false;
+
+ ViewParent parentView3rd = Utils.getParentView(recyclerView, 3);
+ if (!(parentView3rd instanceof ViewGroup)) {
+ return;
+ }
+ ViewParent parentView4th = parentView3rd.getParent();
+ if (!(parentView4th instanceof ViewGroup)) {
+ return;
+ }
+
+ // Dismiss View [R.id.touch_outside] is the 1st ChildView of the 4th ParentView.
+ // This only shows in phone layout.
+ final var touchInsidedView = ((ViewGroup) parentView4th).getChildAt(0);
+ touchInsidedView.setSoundEffectsEnabled(false);
+ touchInsidedView.performClick();
+
+ // In tablet layout there is no Dismiss View, instead we just hide all two parent views.
+ ((ViewGroup) parentView3rd).setVisibility(View.GONE);
+ ((ViewGroup) parentView4th).setVisibility(View.GONE);
+
+ // This works without issues for both tablet and phone layouts,
+ // So no code is needed to check whether the current device is a tablet or phone.
+
+ // Close the new Playback speed menu and show the old one.
+ showOldPlaybackSpeedMenu();
+ } catch (Exception ex) {
+ Logger.printException(() -> "onFlyoutMenuCreate failure", ex);
+ }
+ });
+ }
+
+ public static void showOldPlaybackSpeedMenu() {
+ // This method is sometimes used multiple times.
+ // To prevent this, ignore method reuse within 1 second.
+ final long now = System.currentTimeMillis();
+ if (now - lastTimeOldPlaybackMenuInvoked < 1000) {
+ Logger.printDebug(() -> "Ignoring call to showOldPlaybackSpeedMenu");
+ return;
+ }
+ lastTimeOldPlaybackMenuInvoked = now;
+ Logger.printDebug(() -> "Old video quality menu shown");
+
+ // Rest of the implementation added by patch.
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/speed/RememberPlaybackSpeedPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/speed/RememberPlaybackSpeedPatch.java
new file mode 100644
index 000000000..2d6d0f781
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/playback/speed/RememberPlaybackSpeedPatch.java
@@ -0,0 +1,56 @@
+package app.revanced.extension.youtube.patches.playback.speed;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.patches.VideoInformation;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class RememberPlaybackSpeedPatch {
+
+ private static final long TOAST_DELAY_MILLISECONDS = 750;
+
+ private static long lastTimeSpeedChanged;
+
+ /**
+ * Injection point.
+ */
+ public static void newVideoStarted(VideoInformation.PlaybackController ignoredPlayerController) {
+ Logger.printDebug(() -> "newVideoStarted");
+ VideoInformation.overridePlaybackSpeed(Settings.PLAYBACK_SPEED_DEFAULT.get());
+ }
+
+ /**
+ * Injection point.
+ * Called when user selects a playback speed.
+ *
+ * @param playbackSpeed The playback speed the user selected
+ */
+ public static void userSelectedPlaybackSpeed(float playbackSpeed) {
+ if (Settings.REMEMBER_PLAYBACK_SPEED_LAST_SELECTED.get()) {
+ Settings.PLAYBACK_SPEED_DEFAULT.save(playbackSpeed);
+
+ // Prevent toast spamming if using the 0.05x adjustments.
+ // Show exactly one toast after the user stops interacting with the speed menu.
+ final long now = System.currentTimeMillis();
+ lastTimeSpeedChanged = now;
+
+ Utils.runOnMainThreadDelayed(() -> {
+ if (lastTimeSpeedChanged == now) {
+ Utils.showToastLong(str("revanced_remember_playback_speed_toast", (playbackSpeed + "x")));
+ } // else, the user made additional speed adjustments and this call is outdated.
+ }, TOAST_DELAY_MILLISECONDS);
+ }
+ }
+
+ /**
+ * Injection point.
+ * Overrides the video speed. Called after video loads, and immediately after user selects a different playback speed
+ */
+ public static float getPlaybackSpeedOverride() {
+ return VideoInformation.getPlaybackSpeed();
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/ClientType.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/ClientType.java
new file mode 100644
index 000000000..de6a2a12c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/ClientType.java
@@ -0,0 +1,79 @@
+package app.revanced.extension.youtube.patches.spoof;
+
+import static app.revanced.extension.youtube.patches.spoof.DeviceHardwareSupport.allowAV1;
+import static app.revanced.extension.youtube.patches.spoof.DeviceHardwareSupport.allowVP9;
+
+import android.os.Build;
+
+import androidx.annotation.Nullable;
+
+public enum ClientType {
+ // https://dumps.tadiphone.dev/dumps/oculus/eureka
+ IOS(5,
+ // iPhone 15 supports AV1 hardware decoding.
+ // Only use if this Android device also has hardware decoding.
+ allowAV1()
+ ? "iPhone16,2" // 15 Pro Max
+ : "iPhone11,4", // XS Max
+ // iOS 14+ forces VP9.
+ allowVP9()
+ ? "17.5.1.21F90"
+ : "13.7.17H35",
+ allowVP9()
+ ? "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 17_5_1 like Mac OS X)"
+ : "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 13_7 like Mac OS X)",
+ null,
+ // Version number should be a valid iOS release.
+ // https://www.ipa4fun.com/history/185230
+ "19.10.7"
+ ),
+ ANDROID_VR(28,
+ "Quest 3",
+ "12",
+ "com.google.android.apps.youtube.vr.oculus/1.56.21 (Linux; U; Android 12; GB) gzip",
+ "32", // Android 12.1
+ "1.56.21"
+ );
+
+ /**
+ * YouTube
+ * client type
+ */
+ public final int id;
+
+ /**
+ * Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model)
+ */
+ public final String model;
+
+ /**
+ * Device OS version.
+ */
+ public final String osVersion;
+
+ /**
+ * Player user-agent.
+ */
+ public final String userAgent;
+
+ /**
+ * Android SDK version, equivalent to {@link Build.VERSION#SDK} (System property: ro.build.version.sdk)
+ * Field is null if not applicable.
+ */
+ @Nullable
+ public final String androidSdkVersion;
+
+ /**
+ * App version.
+ */
+ public final String appVersion;
+
+ ClientType(int id, String model, String osVersion, String userAgent, @Nullable String androidSdkVersion, String appVersion) {
+ this.id = id;
+ this.model = model;
+ this.osVersion = osVersion;
+ this.userAgent = userAgent;
+ this.androidSdkVersion = androidSdkVersion;
+ this.appVersion = appVersion;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/DeviceHardwareSupport.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/DeviceHardwareSupport.java
new file mode 100644
index 000000000..3adc6befb
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/DeviceHardwareSupport.java
@@ -0,0 +1,53 @@
+package app.revanced.extension.youtube.patches.spoof;
+
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import android.os.Build;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+
+public class DeviceHardwareSupport {
+ public static final boolean DEVICE_HAS_HARDWARE_DECODING_VP9;
+ public static final boolean DEVICE_HAS_HARDWARE_DECODING_AV1;
+
+ static {
+ boolean vp9found = false;
+ boolean av1found = false;
+ MediaCodecList codecList = new MediaCodecList(MediaCodecList.ALL_CODECS);
+ final boolean deviceIsAndroidTenOrLater = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
+
+ for (MediaCodecInfo codecInfo : codecList.getCodecInfos()) {
+ final boolean isHardwareAccelerated = deviceIsAndroidTenOrLater
+ ? codecInfo.isHardwareAccelerated()
+ : !codecInfo.getName().startsWith("OMX.google"); // Software decoder.
+ if (isHardwareAccelerated && !codecInfo.isEncoder()) {
+ for (String type : codecInfo.getSupportedTypes()) {
+ if (type.equalsIgnoreCase("video/x-vnd.on2.vp9")) {
+ vp9found = true;
+ } else if (type.equalsIgnoreCase("video/av01")) {
+ av1found = true;
+ }
+ }
+ }
+ }
+
+ DEVICE_HAS_HARDWARE_DECODING_VP9 = vp9found;
+ DEVICE_HAS_HARDWARE_DECODING_AV1 = av1found;
+
+ Logger.printDebug(() -> DEVICE_HAS_HARDWARE_DECODING_AV1
+ ? "Device supports AV1 hardware decoding\n"
+ : "Device does not support AV1 hardware decoding\n"
+ + (DEVICE_HAS_HARDWARE_DECODING_VP9
+ ? "Device supports VP9 hardware decoding"
+ : "Device does not support VP9 hardware decoding"));
+ }
+
+ public static boolean allowVP9() {
+ return DEVICE_HAS_HARDWARE_DECODING_VP9 && !Settings.SPOOF_VIDEO_STREAMS_IOS_FORCE_AVC.get();
+ }
+
+ public static boolean allowAV1() {
+ return allowVP9() && DEVICE_HAS_HARDWARE_DECODING_AV1;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofAppVersionPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofAppVersionPatch.java
new file mode 100644
index 000000000..25ad35d64
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofAppVersionPatch.java
@@ -0,0 +1,23 @@
+package app.revanced.extension.youtube.patches.spoof;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class SpoofAppVersionPatch {
+
+ private static final boolean SPOOF_APP_VERSION_ENABLED = Settings.SPOOF_APP_VERSION.get();
+ private static final String SPOOF_APP_VERSION_TARGET = Settings.SPOOF_APP_VERSION_TARGET.get();
+
+ /**
+ * Injection point
+ */
+ public static String getYouTubeVersionOverride(String version) {
+ if (SPOOF_APP_VERSION_ENABLED) return SPOOF_APP_VERSION_TARGET;
+ return version;
+ }
+
+ public static boolean isSpoofingToLessThan(String version) {
+ return SPOOF_APP_VERSION_ENABLED && SPOOF_APP_VERSION_TARGET.compareTo(version) < 0;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofDeviceDimensionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofDeviceDimensionsPatch.java
new file mode 100644
index 000000000..6df52a4a3
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofDeviceDimensionsPatch.java
@@ -0,0 +1,17 @@
+package app.revanced.extension.youtube.patches.spoof;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class SpoofDeviceDimensionsPatch {
+ private static final boolean SPOOF = Settings.SPOOF_DEVICE_DIMENSIONS.get();
+
+
+ public static int getMinHeightOrWidth(int minHeightOrWidth) {
+ return SPOOF ? 64 : minHeightOrWidth;
+ }
+
+ public static int getMaxHeightOrWidth(int maxHeightOrWidth) {
+ return SPOOF ? 4096 : maxHeightOrWidth;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofVideoStreamsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofVideoStreamsPatch.java
new file mode 100644
index 000000000..b50e3f4ff
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofVideoStreamsPatch.java
@@ -0,0 +1,168 @@
+package app.revanced.extension.youtube.patches.spoof;
+
+import android.net.Uri;
+
+import androidx.annotation.Nullable;
+
+import java.nio.ByteBuffer;
+import java.util.Map;
+import java.util.Objects;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.youtube.patches.spoof.requests.StreamingDataRequest;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class SpoofVideoStreamsPatch {
+ public static final class ForceiOSAVCAvailability implements Setting.Availability {
+ @Override
+ public boolean isAvailable() {
+ return Settings.SPOOF_VIDEO_STREAMS.get() && Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.IOS;
+ }
+ }
+
+ private static final boolean SPOOF_STREAMING_DATA = Settings.SPOOF_VIDEO_STREAMS.get();
+
+ /**
+ * Any unreachable ip address. Used to intentionally fail requests.
+ */
+ private static final String UNREACHABLE_HOST_URI_STRING = "https://127.0.0.0";
+ private static final Uri UNREACHABLE_HOST_URI = Uri.parse(UNREACHABLE_HOST_URI_STRING);
+
+ /**
+ * Injection point.
+ * Blocks /get_watch requests by returning an unreachable URI.
+ *
+ * @param playerRequestUri The URI of the player request.
+ * @return An unreachable URI if the request is a /get_watch request, otherwise the original URI.
+ */
+ public static Uri blockGetWatchRequest(Uri playerRequestUri) {
+ if (SPOOF_STREAMING_DATA) {
+ try {
+ String path = playerRequestUri.getPath();
+
+ if (path != null && path.contains("get_watch")) {
+ Logger.printDebug(() -> "Blocking 'get_watch' by returning unreachable uri");
+
+ return UNREACHABLE_HOST_URI;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "blockGetWatchRequest failure", ex);
+ }
+ }
+
+ return playerRequestUri;
+ }
+
+ /**
+ * Injection point.
+ *
+ * Blocks /initplayback requests.
+ */
+ public static String blockInitPlaybackRequest(String originalUrlString) {
+ if (SPOOF_STREAMING_DATA) {
+ try {
+ var originalUri = Uri.parse(originalUrlString);
+ String path = originalUri.getPath();
+
+ if (path != null && path.contains("initplayback")) {
+ Logger.printDebug(() -> "Blocking 'initplayback' by returning unreachable url");
+
+ return UNREACHABLE_HOST_URI_STRING;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "blockInitPlaybackRequest failure", ex);
+ }
+ }
+
+ return originalUrlString;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean isSpoofingEnabled() {
+ return SPOOF_STREAMING_DATA;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void fetchStreams(String url, Map requestHeaders) {
+ if (SPOOF_STREAMING_DATA) {
+ try {
+ Uri uri = Uri.parse(url);
+ String path = uri.getPath();
+ // 'heartbeat' has no video id and appears to be only after playback has started.
+ if (path != null && path.contains("player") && !path.contains("heartbeat")) {
+ String videoId = Objects.requireNonNull(uri.getQueryParameter("id"));
+ StreamingDataRequest.fetchRequest(videoId, requestHeaders);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "buildRequest failure", ex);
+ }
+ }
+ }
+
+ /**
+ * Injection point.
+ * Fix playback by replace the streaming data.
+ * Called after {@link #fetchStreams(String, Map)}.
+ */
+ @Nullable
+ public static ByteBuffer getStreamingData(String videoId) {
+ if (SPOOF_STREAMING_DATA) {
+ try {
+ StreamingDataRequest request = StreamingDataRequest.getRequestForVideoId(videoId);
+ if (request != null) {
+ // This hook is always called off the main thread,
+ // but this can later be called for the same video id from the main thread.
+ // This is not a concern, since the fetch will always be finished
+ // and never block the main thread.
+ // But if debugging, then still verify this is the situation.
+ if (BaseSettings.DEBUG.get() && !request.fetchCompleted() && Utils.isCurrentlyOnMainThread()) {
+ Logger.printException(() -> "Error: Blocking main thread");
+ }
+
+ var stream = request.getStream();
+ if (stream != null) {
+ Logger.printDebug(() -> "Overriding video stream: " + videoId);
+ return stream;
+ }
+ }
+
+ Logger.printDebug(() -> "Not overriding streaming data (video stream is null): " + videoId);
+ } catch (Exception ex) {
+ Logger.printException(() -> "getStreamingData failure", ex);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Injection point.
+ * Called after {@link #getStreamingData(String)}.
+ */
+ @Nullable
+ public static byte[] removeVideoPlaybackPostBody(Uri uri, int method, byte[] postData) {
+ if (SPOOF_STREAMING_DATA) {
+ try {
+ final int methodPost = 2;
+ if (method == methodPost) {
+ String path = uri.getPath();
+ if (path != null && path.contains("videoplayback")) {
+ return null;
+ }
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "removeVideoPlaybackPostBody failure", ex);
+ }
+ }
+
+ return postData;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/requests/PlayerRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/requests/PlayerRoutes.java
new file mode 100644
index 000000000..364dc173a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/requests/PlayerRoutes.java
@@ -0,0 +1,74 @@
+package app.revanced.extension.youtube.patches.spoof.requests;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.youtube.patches.spoof.ClientType;
+import app.revanced.extension.youtube.requests.Requester;
+import app.revanced.extension.youtube.requests.Route;
+
+final class PlayerRoutes {
+ private static final String YT_API_URL = "https://youtubei.googleapis.com/youtubei/v1/";
+
+ static final Route.CompiledRoute GET_STREAMING_DATA = new Route(
+ Route.Method.POST,
+ "player" +
+ "?fields=streamingData" +
+ "&alt=proto"
+ ).compile();
+
+ /**
+ * TCP connection and HTTP read timeout
+ */
+ private static final int CONNECTION_TIMEOUT_MILLISECONDS = 10 * 1000; // 10 Seconds.
+
+ private PlayerRoutes() {
+ }
+
+ static String createInnertubeBody(ClientType clientType) {
+ JSONObject innerTubeBody = new JSONObject();
+
+ try {
+ JSONObject context = new JSONObject();
+
+ JSONObject client = new JSONObject();
+ client.put("clientName", clientType.name());
+ client.put("clientVersion", clientType.appVersion);
+ client.put("deviceModel", clientType.model);
+ client.put("osVersion", clientType.osVersion);
+ if (clientType.androidSdkVersion != null) {
+ client.put("androidSdkVersion", clientType.androidSdkVersion);
+ }
+
+ context.put("client", client);
+
+ innerTubeBody.put("context", context);
+ innerTubeBody.put("contentCheckOk", true);
+ innerTubeBody.put("racyCheckOk", true);
+ innerTubeBody.put("videoId", "%s");
+ } catch (JSONException e) {
+ Logger.printException(() -> "Failed to create innerTubeBody", e);
+ }
+
+ return innerTubeBody.toString();
+ }
+
+ /** @noinspection SameParameterValue*/
+ static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route, ClientType clientType) throws IOException {
+ var connection = Requester.getConnectionFromCompiledRoute(YT_API_URL, route);
+
+ connection.setRequestProperty("Content-Type", "application/json");
+ connection.setRequestProperty("User-Agent", clientType.userAgent);
+
+ connection.setUseCaches(false);
+ connection.setDoOutput(true);
+
+ connection.setConnectTimeout(CONNECTION_TIMEOUT_MILLISECONDS);
+ connection.setReadTimeout(CONNECTION_TIMEOUT_MILLISECONDS);
+ return connection;
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/requests/StreamingDataRequest.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/requests/StreamingDataRequest.java
new file mode 100644
index 000000000..e66f4d885
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spoof/requests/StreamingDataRequest.java
@@ -0,0 +1,223 @@
+package app.revanced.extension.youtube.patches.spoof.requests;
+
+import static app.revanced.extension.youtube.patches.spoof.requests.PlayerRoutes.GET_STREAMING_DATA;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.SocketTimeoutException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.youtube.patches.spoof.ClientType;
+import app.revanced.extension.youtube.settings.Settings;
+
+/**
+ * Video streaming data. Fetching is tied to the behavior YT uses,
+ * where this class fetches the streams only when YT fetches.
+ *
+ * Effectively the cache expiration of these fetches is the same as the stock app,
+ * since the stock app would not use expired streams and therefor
+ * the extension replace stream hook is called only if YT
+ * did use its own client streams.
+ */
+public class StreamingDataRequest {
+
+ private static final ClientType[] CLIENT_ORDER_TO_USE;
+
+ static {
+ ClientType[] allClientTypes = ClientType.values();
+ ClientType preferredClient = Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get();
+
+ CLIENT_ORDER_TO_USE = new ClientType[allClientTypes.length];
+ CLIENT_ORDER_TO_USE[0] = preferredClient;
+
+ int i = 1;
+ for (ClientType c : allClientTypes) {
+ if (c != preferredClient) {
+ CLIENT_ORDER_TO_USE[i++] = c;
+ }
+ }
+ }
+
+ private static final String[] REQUEST_HEADER_KEYS = {
+ "Authorization", // Available only to logged in users.
+ "X-GOOG-API-FORMAT-VERSION",
+ "X-Goog-Visitor-Id"
+ };
+
+ /**
+ * TCP connection and HTTP read timeout.
+ */
+ private static final int HTTP_TIMEOUT_MILLISECONDS = 10 * 1000;
+
+ /**
+ * Any arbitrarily large value, but must be at least twice {@link #HTTP_TIMEOUT_MILLISECONDS}
+ */
+ private static final int MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000;
+
+ private static final Map cache = Collections.synchronizedMap(
+ new LinkedHashMap<>(100) {
+ /**
+ * Cache limit must be greater than the maximum number of videos open at once,
+ * which theoretically is more than 4 (3 Shorts + one regular minimized video).
+ * But instead use a much larger value, to handle if a video viewed a while ago
+ * is somehow still referenced. Each stream is a small array of Strings
+ * so memory usage is not a concern.
+ */
+ private static final int CACHE_LIMIT = 50;
+
+ @Override
+ protected boolean removeEldestEntry(Entry eldest) {
+ return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit.
+ }
+ });
+
+ public static void fetchRequest(String videoId, Map fetchHeaders) {
+ // Always fetch, even if there is a existing request for the same video.
+ cache.put(videoId, new StreamingDataRequest(videoId, fetchHeaders));
+ }
+
+ @Nullable
+ public static StreamingDataRequest getRequestForVideoId(String videoId) {
+ return cache.get(videoId);
+ }
+
+ private static void handleConnectionError(String toastMessage, @Nullable Exception ex, boolean showToast) {
+ if (showToast) Utils.showToastShort(toastMessage);
+ Logger.printInfo(() -> toastMessage, ex);
+ }
+
+ @Nullable
+ private static HttpURLConnection send(ClientType clientType, String videoId,
+ Map playerHeaders,
+ boolean showErrorToasts) {
+ Objects.requireNonNull(clientType);
+ Objects.requireNonNull(videoId);
+ Objects.requireNonNull(playerHeaders);
+
+ final long startTime = System.currentTimeMillis();
+ String clientTypeName = clientType.name();
+ Logger.printDebug(() -> "Fetching video streams for: " + videoId + " using client: " + clientType.name());
+
+ try {
+ HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_STREAMING_DATA, clientType);
+ connection.setConnectTimeout(HTTP_TIMEOUT_MILLISECONDS);
+ connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS);
+
+ for (String key : REQUEST_HEADER_KEYS) {
+ String value = playerHeaders.get(key);
+ if (value != null) {
+ connection.setRequestProperty(key, value);
+ }
+ }
+
+ String innerTubeBody = String.format(PlayerRoutes.createInnertubeBody(clientType), videoId);
+ byte[] requestBody = innerTubeBody.getBytes(StandardCharsets.UTF_8);
+ connection.setFixedLengthStreamingMode(requestBody.length);
+ connection.getOutputStream().write(requestBody);
+
+ final int responseCode = connection.getResponseCode();
+ if (responseCode == 200) return connection;
+
+ handleConnectionError(clientTypeName + " not available with response code: "
+ + responseCode + " message: " + connection.getResponseMessage(),
+ null, showErrorToasts);
+ } catch (SocketTimeoutException ex) {
+ handleConnectionError("Connection timeout", ex, showErrorToasts);
+ } catch (IOException ex) {
+ handleConnectionError("Network error", ex, showErrorToasts);
+ } catch (Exception ex) {
+ Logger.printException(() -> "send failed", ex);
+ } finally {
+ Logger.printDebug(() -> "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms");
+ }
+
+ return null;
+ }
+
+ private static ByteBuffer fetch(String videoId, Map playerHeaders) {
+ final boolean debugEnabled = BaseSettings.DEBUG.get();
+
+ // Retry with different client if empty response body is received.
+ int i = 0;
+ for (ClientType clientType : CLIENT_ORDER_TO_USE) {
+ // Show an error if the last client type fails, or if the debug is enabled then show for all attempts.
+ final boolean showErrorToast = (++i == CLIENT_ORDER_TO_USE.length) || debugEnabled;
+
+ HttpURLConnection connection = send(clientType, videoId, playerHeaders, showErrorToast);
+ if (connection != null) {
+ try {
+ // gzip encoding doesn't response with content length (-1),
+ // but empty response body does.
+ if (connection.getContentLength() != 0) {
+ try (InputStream inputStream = new BufferedInputStream(connection.getInputStream())) {
+ try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+ byte[] buffer = new byte[2048];
+ int bytesRead;
+ while ((bytesRead = inputStream.read(buffer)) >= 0) {
+ baos.write(buffer, 0, bytesRead);
+ }
+
+ return ByteBuffer.wrap(baos.toByteArray());
+ }
+ }
+ }
+ } catch (IOException ex) {
+ Logger.printException(() -> "Fetch failed while processing response data", ex);
+ }
+ }
+ }
+
+ handleConnectionError("Could not fetch any client streams", null, debugEnabled);
+ return null;
+ }
+
+ private final String videoId;
+ private final Future future;
+
+ private StreamingDataRequest(String videoId, Map playerHeaders) {
+ Objects.requireNonNull(playerHeaders);
+ this.videoId = videoId;
+ this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId, playerHeaders));
+ }
+
+ public boolean fetchCompleted() {
+ return future.isDone();
+ }
+
+ @Nullable
+ public ByteBuffer getStream() {
+ try {
+ return future.get(MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS);
+ } catch (TimeoutException ex) {
+ Logger.printInfo(() -> "getStream timed out", ex);
+ } catch (InterruptedException ex) {
+ Logger.printException(() -> "getStream interrupted", ex);
+ Thread.currentThread().interrupt(); // Restore interrupt status flag.
+ } catch (ExecutionException ex) {
+ Logger.printException(() -> "getStream failure", ex);
+ }
+
+ return null;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "StreamingDataRequest{" + "videoId='" + videoId + '\'' + '}';
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/theme/ProgressBarDrawable.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/theme/ProgressBarDrawable.java
new file mode 100644
index 000000000..bf0284f79
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/theme/ProgressBarDrawable.java
@@ -0,0 +1,48 @@
+package app.revanced.extension.youtube.patches.theme;
+
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.PixelFormat;
+import android.graphics.drawable.Drawable;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.youtube.patches.HideSeekbarPatch;
+import app.revanced.extension.youtube.settings.Settings;
+
+/**
+ * Used by {@link SeekbarColorPatch} change the color of the seekbar.
+ * and {@link HideSeekbarPatch} to hide the seekbar of the feed and watch history.
+ */
+@SuppressWarnings("unused")
+public class ProgressBarDrawable extends Drawable {
+
+ private final Paint paint = new Paint();
+
+ @Override
+ public void draw(@NonNull Canvas canvas) {
+ if (Settings.HIDE_SEEKBAR_THUMBNAIL.get()) {
+ return;
+ }
+ paint.setColor(SeekbarColorPatch.getSeekbarColor());
+ canvas.drawRect(getBounds(), paint);
+ }
+
+ @Override
+ public void setAlpha(int alpha) {
+ paint.setAlpha(alpha);
+ }
+
+ @Override
+ public void setColorFilter(@Nullable ColorFilter colorFilter) {
+ paint.setColorFilter(colorFilter);
+ }
+
+ @Override
+ public int getOpacity() {
+ return PixelFormat.TRANSLUCENT;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/theme/SeekbarColorPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/theme/SeekbarColorPatch.java
new file mode 100644
index 000000000..aea5c227c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/theme/SeekbarColorPatch.java
@@ -0,0 +1,192 @@
+package app.revanced.extension.youtube.patches.theme;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.graphics.Color;
+
+import java.util.Arrays;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class SeekbarColorPatch {
+
+ private static final boolean SEEKBAR_CUSTOM_COLOR_ENABLED = Settings.SEEKBAR_CUSTOM_COLOR.get();
+
+ /**
+ * Default color of the seekbar.
+ */
+ private static final int ORIGINAL_SEEKBAR_COLOR = 0xFFFF0000;
+
+ /**
+ * Default colors of the gradient seekbar.
+ */
+ private static final int[] ORIGINAL_SEEKBAR_GRADIENT_COLORS = { 0xFFFF0033, 0xFFFF2791 };
+
+ /**
+ * Default positions of the gradient seekbar.
+ */
+ private static final float[] ORIGINAL_SEEKBAR_GRADIENT_POSITIONS = { 0.8f, 1.0f };
+
+ /**
+ * Default YouTube seekbar color brightness.
+ */
+ private static final float ORIGINAL_SEEKBAR_COLOR_BRIGHTNESS;
+
+ /**
+ * If {@link Settings#SEEKBAR_CUSTOM_COLOR} is enabled,
+ * this is the color value of {@link Settings#SEEKBAR_CUSTOM_COLOR_VALUE}.
+ * Otherwise this is {@link #ORIGINAL_SEEKBAR_COLOR}.
+ */
+ private static int seekbarColor = ORIGINAL_SEEKBAR_COLOR;
+
+ /**
+ * Custom seekbar hue, saturation, and brightness values.
+ */
+ private static final float[] customSeekbarColorHSV = new float[3];
+
+ static {
+ float[] hsv = new float[3];
+ Color.colorToHSV(ORIGINAL_SEEKBAR_COLOR, hsv);
+ ORIGINAL_SEEKBAR_COLOR_BRIGHTNESS = hsv[2];
+
+ if (SEEKBAR_CUSTOM_COLOR_ENABLED) {
+ loadCustomSeekbarColor();
+ }
+ }
+
+ private static void loadCustomSeekbarColor() {
+ try {
+ seekbarColor = Color.parseColor(Settings.SEEKBAR_CUSTOM_COLOR_VALUE.get());
+ Color.colorToHSV(seekbarColor, customSeekbarColorHSV);
+ } catch (Exception ex) {
+ Utils.showToastShort(str("revanced_seekbar_custom_color_invalid"));
+ Settings.SEEKBAR_CUSTOM_COLOR_VALUE.resetToDefault();
+ loadCustomSeekbarColor();
+ }
+ }
+
+ public static int getSeekbarColor() {
+ return seekbarColor;
+ }
+
+ public static boolean playerSeekbarGradientEnabled(boolean original) {
+ if (SEEKBAR_CUSTOM_COLOR_ENABLED) return false;
+
+ return original;
+ }
+
+ /**
+ * Injection point.
+ *
+ * Overrides all Litho components that use the YouTube seekbar color.
+ * Used only for the video thumbnails seekbar.
+ *
+ * If {@link Settings#HIDE_SEEKBAR_THUMBNAIL} is enabled, this returns a fully transparent color.
+ */
+ public static int getLithoColor(int colorValue) {
+ if (colorValue == ORIGINAL_SEEKBAR_COLOR) {
+ if (Settings.HIDE_SEEKBAR_THUMBNAIL.get()) {
+ return 0x00000000;
+ }
+
+ return getSeekbarColorValue(ORIGINAL_SEEKBAR_COLOR);
+ }
+ return colorValue;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void setLinearGradient(int[] colors, float[] positions) {
+ final boolean hideSeekbar = Settings.HIDE_SEEKBAR_THUMBNAIL.get();
+
+ if (SEEKBAR_CUSTOM_COLOR_ENABLED || hideSeekbar) {
+ // Most litho usage of linear gradients is hooked here,
+ // so must only change if the values are those for the seekbar.
+ if (Arrays.equals(ORIGINAL_SEEKBAR_GRADIENT_COLORS, colors)
+ && Arrays.equals(ORIGINAL_SEEKBAR_GRADIENT_POSITIONS, positions)) {
+ Arrays.fill(colors, hideSeekbar
+ ? 0x00000000
+ : seekbarColor);
+ return;
+ }
+
+ Logger.printDebug(() -> "Ignoring gradient colors: " + Arrays.toString(colors)
+ + " positions: " + Arrays.toString(positions));
+ }
+ }
+
+ /**
+ * Injection point.
+ *
+ * Overrides color when video player seekbar is clicked.
+ */
+ public static int getVideoPlayerSeekbarClickedColor(int colorValue) {
+ if (!SEEKBAR_CUSTOM_COLOR_ENABLED) {
+ return colorValue;
+ }
+
+ return colorValue == ORIGINAL_SEEKBAR_COLOR
+ ? getSeekbarColorValue(ORIGINAL_SEEKBAR_COLOR)
+ : colorValue;
+ }
+
+ /**
+ * Injection point.
+ *
+ * Overrides color used for the video player seekbar.
+ */
+ public static int getVideoPlayerSeekbarColor(int originalColor) {
+ if (!SEEKBAR_CUSTOM_COLOR_ENABLED) {
+ return originalColor;
+ }
+
+ return getSeekbarColorValue(originalColor);
+ }
+
+ /**
+ * Color parameter is changed to the custom seekbar color, while retaining
+ * the brightness and alpha changes of the parameter value compared to the original seekbar color.
+ */
+ private static int getSeekbarColorValue(int originalColor) {
+ try {
+ if (!SEEKBAR_CUSTOM_COLOR_ENABLED || originalColor == seekbarColor) {
+ return originalColor; // nothing to do
+ }
+
+ final int alphaDifference = Color.alpha(originalColor) - Color.alpha(ORIGINAL_SEEKBAR_COLOR);
+
+ // The seekbar uses the same color but different brightness for different situations.
+ float[] hsv = new float[3];
+ Color.colorToHSV(originalColor, hsv);
+ final float brightnessDifference = hsv[2] - ORIGINAL_SEEKBAR_COLOR_BRIGHTNESS;
+
+ // Apply the brightness difference to the custom seekbar color.
+ hsv[0] = customSeekbarColorHSV[0];
+ hsv[1] = customSeekbarColorHSV[1];
+ hsv[2] = clamp(customSeekbarColorHSV[2] + brightnessDifference, 0, 1);
+
+ final int replacementAlpha = clamp(Color.alpha(seekbarColor) + alphaDifference, 0, 255);
+ final int replacementColor = Color.HSVToColor(replacementAlpha, hsv);
+ Logger.printDebug(() -> String.format("Original color: #%08X replacement color: #%08X",
+ originalColor, replacementColor));
+ return replacementColor;
+ } catch (Exception ex) {
+ Logger.printException(() -> "getSeekbarColorValue failure", ex);
+ return originalColor;
+ }
+ }
+
+ /** @noinspection SameParameterValue */
+ private static int clamp(int value, int lower, int upper) {
+ return Math.max(lower, Math.min(value, upper));
+ }
+
+ /** @noinspection SameParameterValue */
+ private static float clamp(float value, float lower, float upper) {
+ return Math.max(lower, Math.min(value, upper));
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/theme/ThemePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/theme/ThemePatch.java
new file mode 100644
index 000000000..77372e400
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/theme/ThemePatch.java
@@ -0,0 +1,63 @@
+package app.revanced.extension.youtube.patches.theme;
+
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.ThemeHelper;
+
+@SuppressWarnings("unused")
+public class ThemePatch {
+ // color constants used in relation with litho components
+ private static final int[] WHITE_VALUES = {
+ -1, // comments chip background
+ -394759, // music related results panel background
+ -83886081, // video chapters list background
+ };
+
+ private static final int[] DARK_VALUES = {
+ -14145496, // explore drawer background
+ -14606047, // comments chip background
+ -15198184, // music related results panel background
+ -15790321, // comments chip background (new layout)
+ -98492127 // video chapters list background
+ };
+
+ // background colors
+ private static int whiteColor = 0;
+ private static int blackColor = 0;
+
+ // Used by app.revanced.patches.youtube.layout.theme.patch.LithoThemePatch
+ /**
+ * Change the color of Litho components.
+ * If the color of the component matches one of the values, return the background color .
+ *
+ * @param originalValue The original color value.
+ * @return The new or original color value
+ */
+ public static int getValue(int originalValue) {
+ if (ThemeHelper.isDarkTheme()) {
+ if (anyEquals(originalValue, DARK_VALUES)) return getBlackColor();
+ } else {
+ if (anyEquals(originalValue, WHITE_VALUES)) return getWhiteColor();
+ }
+ return originalValue;
+ }
+
+ public static boolean gradientLoadingScreenEnabled() {
+ return Settings.GRADIENT_LOADING_SCREEN.get();
+ }
+
+ private static int getBlackColor() {
+ if (blackColor == 0) blackColor = Utils.getResourceColor("yt_black1");
+ return blackColor;
+ }
+
+ private static int getWhiteColor() {
+ if (whiteColor == 0) whiteColor = Utils.getResourceColor("yt_white1");
+ return whiteColor;
+ }
+
+ private static boolean anyEquals(int value, int... of) {
+ for (int v : of) if (value == v) return true;
+ return false;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/requests/Requester.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/requests/Requester.java
new file mode 100644
index 000000000..69d43a4be
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/requests/Requester.java
@@ -0,0 +1,145 @@
+package app.revanced.extension.youtube.requests;
+
+import app.revanced.extension.shared.Utils;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+
+public class Requester {
+ private Requester() {
+ }
+
+ public static HttpURLConnection getConnectionFromRoute(String apiUrl, Route route, String... params) throws IOException {
+ return getConnectionFromCompiledRoute(apiUrl, route.compile(params));
+ }
+
+ public static HttpURLConnection getConnectionFromCompiledRoute(String apiUrl, Route.CompiledRoute route) throws IOException {
+ String url = apiUrl + route.getCompiledRoute();
+ HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
+ // Request data is in the URL parameters and no body is sent.
+ // The calling code must set a length if using a request body.
+ connection.setFixedLengthStreamingMode(0);
+ connection.setRequestMethod(route.getMethod().name());
+ String agentString = System.getProperty("http.agent")
+ + "; ReVanced/" + Utils.getAppVersionName()
+ + " (" + Utils.getPatchesReleaseVersion() + ")";
+ connection.setRequestProperty("User-Agent", agentString);
+
+ return connection;
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection}, and closes the underlying InputStream.
+ */
+ private static String parseInputStreamAndClose(InputStream inputStream) throws IOException {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
+ StringBuilder jsonBuilder = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ jsonBuilder.append(line);
+ jsonBuilder.append('\n');
+ }
+ return jsonBuilder.toString();
+ }
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} response as a String.
+ * This does not close the url connection. If further requests to this host are unlikely
+ * in the near future, then instead use {@link #parseStringAndDisconnect(HttpURLConnection)}.
+ */
+ public static String parseString(HttpURLConnection connection) throws IOException {
+ return parseInputStreamAndClose(connection.getInputStream());
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} response as a String, and disconnect.
+ *
+ * Should only be used if other requests to the server in the near future are unlikely
+ *
+ * @see #parseString(HttpURLConnection)
+ */
+ public static String parseStringAndDisconnect(HttpURLConnection connection) throws IOException {
+ String result = parseString(connection);
+ connection.disconnect();
+ return result;
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} error stream as a String.
+ * If the server sent no error response data, this returns an empty string.
+ */
+ public static String parseErrorString(HttpURLConnection connection) throws IOException {
+ InputStream errorStream = connection.getErrorStream();
+ if (errorStream == null) {
+ return "";
+ }
+ return parseInputStreamAndClose(errorStream);
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} error stream as a String, and disconnect.
+ * If the server sent no error response data, this returns an empty string.
+ *
+ * Should only be used if other requests to the server are unlikely in the near future.
+ *
+ * @see #parseErrorString(HttpURLConnection)
+ */
+ public static String parseErrorStringAndDisconnect(HttpURLConnection connection) throws IOException {
+ String result = parseErrorString(connection);
+ connection.disconnect();
+ return result;
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} response into a JSONObject.
+ * This does not close the url connection. If further requests to this host are unlikely
+ * in the near future, then instead use {@link #parseJSONObjectAndDisconnect(HttpURLConnection)}.
+ */
+ public static JSONObject parseJSONObject(HttpURLConnection connection) throws JSONException, IOException {
+ return new JSONObject(parseString(connection));
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection}, close the underlying InputStream, and disconnect.
+ *
+ * Should only be used if other requests to the server in the near future are unlikely
+ *
+ * @see #parseJSONObject(HttpURLConnection)
+ */
+ public static JSONObject parseJSONObjectAndDisconnect(HttpURLConnection connection) throws JSONException, IOException {
+ JSONObject object = parseJSONObject(connection);
+ connection.disconnect();
+ return object;
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection}, and closes the underlying InputStream.
+ * This does not close the url connection. If further requests to this host are unlikely
+ * in the near future, then instead use {@link #parseJSONArrayAndDisconnect(HttpURLConnection)}.
+ */
+ public static JSONArray parseJSONArray(HttpURLConnection connection) throws JSONException, IOException {
+ return new JSONArray(parseString(connection));
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection}, close the underlying InputStream, and disconnect.
+ *
+ * Should only be used if other requests to the server in the near future are unlikely
+ *
+ * @see #parseJSONArray(HttpURLConnection)
+ */
+ public static JSONArray parseJSONArrayAndDisconnect(HttpURLConnection connection) throws JSONException, IOException {
+ JSONArray array = parseJSONArray(connection);
+ connection.disconnect();
+ return array;
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/requests/Route.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/requests/Route.java
new file mode 100644
index 000000000..c25d108b9
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/requests/Route.java
@@ -0,0 +1,66 @@
+package app.revanced.extension.youtube.requests;
+
+public class Route {
+ private final String route;
+ private final Method method;
+ private final int paramCount;
+
+ public Route(Method method, String route) {
+ this.method = method;
+ this.route = route;
+ this.paramCount = countMatches(route, '{');
+
+ if (paramCount != countMatches(route, '}'))
+ throw new IllegalArgumentException("Not enough parameters");
+ }
+
+ public Method getMethod() {
+ return method;
+ }
+
+ public CompiledRoute compile(String... params) {
+ if (params.length != paramCount)
+ throw new IllegalArgumentException("Error compiling route [" + route + "], incorrect amount of parameters provided. " +
+ "Expected: " + paramCount + ", provided: " + params.length);
+
+ StringBuilder compiledRoute = new StringBuilder(route);
+ for (int i = 0; i < paramCount; i++) {
+ int paramStart = compiledRoute.indexOf("{");
+ int paramEnd = compiledRoute.indexOf("}");
+ compiledRoute.replace(paramStart, paramEnd + 1, params[i]);
+ }
+ return new CompiledRoute(this, compiledRoute.toString());
+ }
+
+ public static class CompiledRoute {
+ private final Route baseRoute;
+ private final String compiledRoute;
+
+ private CompiledRoute(Route baseRoute, String compiledRoute) {
+ this.baseRoute = baseRoute;
+ this.compiledRoute = compiledRoute;
+ }
+
+ public String getCompiledRoute() {
+ return compiledRoute;
+ }
+
+ public Method getMethod() {
+ return baseRoute.method;
+ }
+ }
+
+ private int countMatches(CharSequence seq, char c) {
+ int count = 0;
+ for (int i = 0; i < seq.length(); i++) {
+ if (seq.charAt(i) == c)
+ count++;
+ }
+ return count;
+ }
+
+ public enum Method {
+ GET,
+ POST
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/ReturnYouTubeDislike.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/ReturnYouTubeDislike.java
new file mode 100644
index 000000000..a67a96fa8
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/ReturnYouTubeDislike.java
@@ -0,0 +1,730 @@
+package app.revanced.extension.youtube.returnyoutubedislike;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.ShapeDrawable;
+import android.graphics.drawable.shapes.OvalShape;
+import android.graphics.drawable.shapes.RectShape;
+import android.icu.text.CompactDecimalFormat;
+import android.icu.text.DecimalFormat;
+import android.icu.text.DecimalFormatSymbols;
+import android.icu.text.NumberFormat;
+import android.os.Build;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.ImageSpan;
+import android.text.style.ReplacementSpan;
+import android.util.DisplayMetrics;
+import android.util.TypedValue;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.*;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.ThemeHelper;
+import app.revanced.extension.youtube.patches.spoof.SpoofAppVersionPatch;
+import app.revanced.extension.youtube.returnyoutubedislike.requests.RYDVoteData;
+import app.revanced.extension.youtube.returnyoutubedislike.requests.ReturnYouTubeDislikeApi;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.PlayerType;
+
+/**
+ * Handles fetching and creation/replacing of RYD dislike text spans.
+ *
+ * Because Litho creates spans using multiple threads, this entire class supports multithreading as well.
+ */
+public class ReturnYouTubeDislike {
+
+ public enum Vote {
+ LIKE(1),
+ DISLIKE(-1),
+ LIKE_REMOVE(0);
+
+ public final int value;
+
+ Vote(int value) {
+ this.value = value;
+ }
+ }
+
+ /**
+ * Maximum amount of time to block the UI from updates while waiting for network call to complete.
+ *
+ * Must be less than 5 seconds, as per:
+ * https://developer.android.com/topic/performance/vitals/anr
+ */
+ private static final long MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH = 4000;
+
+ /**
+ * How long to retain successful RYD fetches.
+ */
+ private static final long CACHE_TIMEOUT_SUCCESS_MILLISECONDS = 7 * 60 * 1000; // 7 Minutes
+
+ /**
+ * How long to retain unsuccessful RYD fetches,
+ * and also the minimum time before retrying again.
+ */
+ private static final long CACHE_TIMEOUT_FAILURE_MILLISECONDS = 3 * 60 * 1000; // 3 Minutes
+
+ /**
+ * Unique placeholder character, used to detect if a segmented span already has dislikes added to it.
+ * Must be something YouTube is unlikely to use, as it's searched for in all usage of Rolling Number.
+ */
+ private static final char MIDDLE_SEPARATOR_CHARACTER = '◎'; // 'bullseye'
+
+ private static final boolean IS_SPOOFING_TO_OLD_SEPARATOR_COLOR
+ = SpoofAppVersionPatch.isSpoofingToLessThan("18.10.00");
+
+ /**
+ * Cached lookup of all video ids.
+ */
+ @GuardedBy("itself")
+ private static final Map fetchCache = new HashMap<>();
+
+ /**
+ * Used to send votes, one by one, in the same order the user created them.
+ */
+ private static final ExecutorService voteSerialExecutor = Executors.newSingleThreadExecutor();
+
+ /**
+ * For formatting dislikes as number.
+ */
+ @GuardedBy("ReturnYouTubeDislike.class") // not thread safe
+ private static CompactDecimalFormat dislikeCountFormatter;
+
+ /**
+ * For formatting dislikes as percentage.
+ */
+ @GuardedBy("ReturnYouTubeDislike.class")
+ private static NumberFormat dislikePercentageFormatter;
+
+ // Used for segmented dislike spans in Litho regular player.
+ public static final Rect leftSeparatorBounds;
+ private static final Rect middleSeparatorBounds;
+
+ /**
+ * Left separator horizontal padding for Rolling Number layout.
+ */
+ public static final int leftSeparatorShapePaddingPixels;
+ private static final ShapeDrawable leftSeparatorShape;
+
+ static {
+ DisplayMetrics dp = Objects.requireNonNull(Utils.getContext()).getResources().getDisplayMetrics();
+
+ leftSeparatorBounds = new Rect(0, 0,
+ (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1.2f, dp),
+ (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 18, dp));
+ final int middleSeparatorSize =
+ (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3.7f, dp);
+ middleSeparatorBounds = new Rect(0, 0, middleSeparatorSize, middleSeparatorSize);
+
+ leftSeparatorShapePaddingPixels = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10.0f, dp);
+
+ leftSeparatorShape = new ShapeDrawable(new RectShape());
+ leftSeparatorShape.setBounds(leftSeparatorBounds);
+ }
+
+ private final String videoId;
+
+ /**
+ * Stores the results of the vote api fetch, and used as a barrier to wait until fetch completes.
+ * Absolutely cannot be holding any lock during calls to {@link Future#get()}.
+ */
+ private final Future future;
+
+ /**
+ * Time this instance and the fetch future was created.
+ */
+ private final long timeFetched;
+
+ /**
+ * If this instance was previously used for a Short.
+ */
+ @GuardedBy("this")
+ private boolean isShort;
+
+ /**
+ * Optional current vote status of the UI. Used to apply a user vote that was done on a previous video viewing.
+ */
+ @Nullable
+ @GuardedBy("this")
+ private Vote userVote;
+
+ /**
+ * Original dislike span, before modifications.
+ */
+ @Nullable
+ @GuardedBy("this")
+ private Spanned originalDislikeSpan;
+
+ /**
+ * Replacement like/dislike span that includes formatted dislikes.
+ * Used to prevent recreating the same span multiple times.
+ */
+ @Nullable
+ @GuardedBy("this")
+ private SpannableString replacementLikeDislikeSpan;
+
+ /**
+ * Color of the left and middle separator, based on the color of the right separator.
+ * It's unknown where YT gets the color from, and the values here are approximated by hand.
+ * Ideally, this would be the actual color YT uses at runtime.
+ *
+ * Older versions before the 'Me' library tab use a slightly different color.
+ * If spoofing was previously used and is now turned off,
+ * or an old version was recently upgraded then the old colors are sometimes still used.
+ */
+ private static int getSeparatorColor() {
+ if (IS_SPOOFING_TO_OLD_SEPARATOR_COLOR) {
+ return ThemeHelper.isDarkTheme()
+ ? 0x29AAAAAA // transparent dark gray
+ : 0xFFD9D9D9; // light gray
+ }
+ return ThemeHelper.isDarkTheme()
+ ? 0x33FFFFFF
+ : 0xFFD9D9D9;
+ }
+
+ public static ShapeDrawable getLeftSeparatorDrawable() {
+ leftSeparatorShape.getPaint().setColor(getSeparatorColor());
+ return leftSeparatorShape;
+ }
+
+ /**
+ * @param isSegmentedButton If UI is using the segmented single UI component for both like and dislike.
+ */
+ @NonNull
+ private static SpannableString createDislikeSpan(@NonNull Spanned oldSpannable,
+ boolean isSegmentedButton,
+ boolean isRollingNumber,
+ @NonNull RYDVoteData voteData) {
+ if (!isSegmentedButton) {
+ // Simple replacement of 'dislike' with a number/percentage.
+ return newSpannableWithDislikes(oldSpannable, voteData);
+ }
+
+ // Note: Some locales use right to left layout (Arabic, Hebrew, etc).
+ // If making changes to this code, change device settings to a RTL language and verify layout is correct.
+ CharSequence oldLikes = oldSpannable;
+
+ // YouTube creators can hide the like count on a video,
+ // and the like count appears as a device language specific string that says 'Like'.
+ // Check if the string contains any numbers.
+ if (!Utils.containsNumber(oldLikes)) {
+ // Likes are hidden by video creator
+ //
+ // RYD does not directly provide like data, but can use an estimated likes
+ // using the same scale factor RYD applied to the raw dislikes.
+ //
+ // example video: https://www.youtube.com/watch?v=UnrU5vxCHxw
+ // RYD data: https://returnyoutubedislikeapi.com/votes?videoId=UnrU5vxCHxw
+ //
+ Logger.printDebug(() -> "Using estimated likes");
+ oldLikes = formatDislikeCount(voteData.getLikeCount());
+ }
+
+ SpannableStringBuilder builder = new SpannableStringBuilder();
+ final boolean compactLayout = Settings.RYD_COMPACT_LAYOUT.get();
+
+ if (!compactLayout) {
+ String leftSeparatorString = getTextDirectionString();
+ final Spannable leftSeparatorSpan;
+ if (isRollingNumber) {
+ leftSeparatorSpan = new SpannableString(leftSeparatorString);
+ } else {
+ leftSeparatorString += " ";
+ leftSeparatorSpan = new SpannableString(leftSeparatorString);
+ // Styling spans cannot overwrite RTL or LTR character.
+ leftSeparatorSpan.setSpan(
+ new VerticallyCenteredImageSpan(getLeftSeparatorDrawable(), false),
+ 1, 2, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
+ leftSeparatorSpan.setSpan(
+ new FixedWidthEmptySpan(leftSeparatorShapePaddingPixels),
+ 2, 3, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
+ }
+ builder.append(leftSeparatorSpan);
+ }
+
+ // likes
+ builder.append(newSpanUsingStylingOfAnotherSpan(oldSpannable, oldLikes));
+
+ // middle separator
+ String middleSeparatorString = compactLayout
+ ? " " + MIDDLE_SEPARATOR_CHARACTER + " "
+ : " \u2009" + MIDDLE_SEPARATOR_CHARACTER + "\u2009 "; // u2009 = 'narrow space' character
+ final int shapeInsertionIndex = middleSeparatorString.length() / 2;
+ Spannable middleSeparatorSpan = new SpannableString(middleSeparatorString);
+ ShapeDrawable shapeDrawable = new ShapeDrawable(new OvalShape());
+ shapeDrawable.getPaint().setColor(getSeparatorColor());
+ shapeDrawable.setBounds(middleSeparatorBounds);
+ // Use original text width if using Rolling Number,
+ // to ensure the replacement styled span has the same width as the measured String,
+ // otherwise layout can be broken (especially on devices with small system font sizes).
+ middleSeparatorSpan.setSpan(
+ new VerticallyCenteredImageSpan(shapeDrawable, isRollingNumber),
+ shapeInsertionIndex, shapeInsertionIndex + 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
+ builder.append(middleSeparatorSpan);
+
+ // dislikes
+ builder.append(newSpannableWithDislikes(oldSpannable, voteData));
+
+ return new SpannableString(builder);
+ }
+
+ private static @NonNull String getTextDirectionString() {
+ return Utils.isRightToLeftTextLayout()
+ ? "\u200F" // u200F = right to left character
+ : "\u200E"; // u200E = left to right character
+ }
+
+ /**
+ * @return If the text is likely for a previously created likes/dislikes segmented span.
+ */
+ public static boolean isPreviouslyCreatedSegmentedSpan(@NonNull String text) {
+ return text.indexOf(MIDDLE_SEPARATOR_CHARACTER) >= 0;
+ }
+
+ private static boolean spansHaveEqualTextAndColor(@NonNull Spanned one, @NonNull Spanned two) {
+ // Cannot use equals on the span, because many of the inner styling spans do not implement equals.
+ // Instead, compare the underlying text and the text color to handle when dark mode is changed.
+ // Cannot compare the status of device dark mode, as Litho components are updated just before dark mode status changes.
+ if (!one.toString().equals(two.toString())) {
+ return false;
+ }
+ ForegroundColorSpan[] oneColors = one.getSpans(0, one.length(), ForegroundColorSpan.class);
+ ForegroundColorSpan[] twoColors = two.getSpans(0, two.length(), ForegroundColorSpan.class);
+ final int oneLength = oneColors.length;
+ if (oneLength != twoColors.length) {
+ return false;
+ }
+ for (int i = 0; i < oneLength; i++) {
+ if (oneColors[i].getForegroundColor() != twoColors[i].getForegroundColor()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static SpannableString newSpannableWithLikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) {
+ return newSpanUsingStylingOfAnotherSpan(sourceStyling, formatDislikeCount(voteData.getLikeCount()));
+ }
+
+ private static SpannableString newSpannableWithDislikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) {
+ return newSpanUsingStylingOfAnotherSpan(sourceStyling,
+ Settings.RYD_DISLIKE_PERCENTAGE.get()
+ ? formatDislikePercentage(voteData.getDislikePercentage())
+ : formatDislikeCount(voteData.getDislikeCount()));
+ }
+
+ private static SpannableString newSpanUsingStylingOfAnotherSpan(@NonNull Spanned sourceStyle, @NonNull CharSequence newSpanText) {
+ if (sourceStyle == newSpanText && sourceStyle instanceof SpannableString) {
+ return (SpannableString) sourceStyle; // Nothing to do.
+ }
+
+ SpannableString destination = new SpannableString(newSpanText);
+ Object[] spans = sourceStyle.getSpans(0, sourceStyle.length(), Object.class);
+ for (Object span : spans) {
+ destination.setSpan(span, 0, destination.length(), sourceStyle.getSpanFlags(span));
+ }
+
+ return destination;
+ }
+
+ private static String formatDislikeCount(long dislikeCount) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize
+ if (dislikeCountFormatter == null) {
+ Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale;
+ dislikeCountFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT);
+
+ // YouTube disregards locale specific number characters
+ // and instead shows english number characters everywhere.
+ // To use the same behavior, override the digit characters to use English
+ // so languages such as Arabic will show "1.234" instead of the native "۱,۲۳٤"
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale);
+ symbols.setDigitStrings(DecimalFormatSymbols.getInstance(Locale.ENGLISH).getDigitStrings());
+ dislikeCountFormatter.setDecimalFormatSymbols(symbols);
+ }
+ }
+ return dislikeCountFormatter.format(dislikeCount);
+ }
+ }
+
+ // Will never be reached, as the oldest supported YouTube app requires Android N or greater.
+ return String.valueOf(dislikeCount);
+ }
+
+ private static String formatDislikePercentage(float dislikePercentage) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize
+ if (dislikePercentageFormatter == null) {
+ Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale;
+ dislikePercentageFormatter = NumberFormat.getPercentInstance(locale);
+
+ // Want to set the digit strings, and the simplest way is to cast to the implementation NumberFormat returns.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
+ && dislikePercentageFormatter instanceof DecimalFormat) {
+ DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale);
+ symbols.setDigitStrings(DecimalFormatSymbols.getInstance(Locale.ENGLISH).getDigitStrings());
+ ((DecimalFormat) dislikePercentageFormatter).setDecimalFormatSymbols(symbols);
+ }
+ }
+ if (dislikePercentage >= 0.01) { // at least 1%
+ dislikePercentageFormatter.setMaximumFractionDigits(0); // show only whole percentage points
+ } else {
+ dislikePercentageFormatter.setMaximumFractionDigits(1); // show up to 1 digit precision
+ }
+ return dislikePercentageFormatter.format(dislikePercentage);
+ }
+ }
+
+ // Will never be reached, as the oldest supported YouTube app requires Android N or greater.
+ return String.valueOf((int) (dislikePercentage * 100));
+ }
+
+ @NonNull
+ public static ReturnYouTubeDislike getFetchForVideoId(@Nullable String videoId) {
+ Objects.requireNonNull(videoId);
+ synchronized (fetchCache) {
+ // Remove any expired entries.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ final long now = System.currentTimeMillis();
+ fetchCache.values().removeIf(value -> {
+ final boolean expired = value.isExpired(now);
+ if (expired)
+ Logger.printDebug(() -> "Removing expired fetch: " + value.videoId);
+ return expired;
+ });
+ }
+
+ ReturnYouTubeDislike fetch = fetchCache.get(videoId);
+ if (fetch == null) {
+ fetch = new ReturnYouTubeDislike(videoId);
+ fetchCache.put(videoId, fetch);
+ }
+ return fetch;
+ }
+ }
+
+ /**
+ * Should be called if the user changes dislikes appearance settings.
+ */
+ public static void clearAllUICaches() {
+ synchronized (fetchCache) {
+ for (ReturnYouTubeDislike fetch : fetchCache.values()) {
+ fetch.clearUICache();
+ }
+ }
+ }
+
+ private ReturnYouTubeDislike(@NonNull String videoId) {
+ this.videoId = Objects.requireNonNull(videoId);
+ this.timeFetched = System.currentTimeMillis();
+ this.future = Utils.submitOnBackgroundThread(() -> ReturnYouTubeDislikeApi.fetchVotes(videoId));
+ }
+
+ private boolean isExpired(long now) {
+ final long timeSinceCreation = now - timeFetched;
+ if (timeSinceCreation < CACHE_TIMEOUT_FAILURE_MILLISECONDS) {
+ return false; // Not expired, even if the API call failed.
+ }
+ if (timeSinceCreation > CACHE_TIMEOUT_SUCCESS_MILLISECONDS) {
+ return true; // Always expired.
+ }
+ // Only expired if the fetch failed (API null response).
+ return (!fetchCompleted() || getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH) == null);
+ }
+
+ @Nullable
+ public RYDVoteData getFetchData(long maxTimeToWait) {
+ try {
+ return future.get(maxTimeToWait, TimeUnit.MILLISECONDS);
+ } catch (TimeoutException ex) {
+ Logger.printDebug(() -> "Waited but future was not complete after: " + maxTimeToWait + "ms");
+ } catch (ExecutionException | InterruptedException ex) {
+ Logger.printException(() -> "Future failure ", ex); // will never happen
+ }
+ return null;
+ }
+
+ /**
+ * @return if the RYD fetch call has completed.
+ */
+ public boolean fetchCompleted() {
+ return future.isDone();
+ }
+
+ private synchronized void clearUICache() {
+ if (replacementLikeDislikeSpan != null) {
+ Logger.printDebug(() -> "Clearing replacement span for: " + videoId);
+ }
+ replacementLikeDislikeSpan = null;
+ }
+
+ @NonNull
+ public String getVideoId() {
+ return videoId;
+ }
+
+ /**
+ * Pre-emptively set this as a Short.
+ */
+ public synchronized void setVideoIdIsShort(boolean isShort) {
+ this.isShort = isShort;
+ }
+
+ /**
+ * @return the replacement span containing dislikes, or the original span if RYD is not available.
+ */
+ @NonNull
+ public synchronized Spanned getDislikesSpanForRegularVideo(@NonNull Spanned original,
+ boolean isSegmentedButton,
+ boolean isRollingNumber) {
+ return waitForFetchAndUpdateReplacementSpan(original, isSegmentedButton,
+ isRollingNumber, false, false);
+ }
+
+ /**
+ * Called when a Shorts like Spannable is created.
+ */
+ @NonNull
+ public synchronized Spanned getLikeSpanForShort(@NonNull Spanned original) {
+ return waitForFetchAndUpdateReplacementSpan(original, false,
+ false, true, true);
+ }
+
+ /**
+ * Called when a Shorts dislike Spannable is created.
+ */
+ @NonNull
+ public synchronized Spanned getDislikeSpanForShort(@NonNull Spanned original) {
+ return waitForFetchAndUpdateReplacementSpan(original, false,
+ false, true, false);
+ }
+
+ @NonNull
+ private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original,
+ boolean isSegmentedButton,
+ boolean isRollingNumber,
+ boolean spanIsForShort,
+ boolean spanIsForLikes) {
+ try {
+ RYDVoteData votingData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH);
+ if (votingData == null) {
+ Logger.printDebug(() -> "Cannot add dislike to UI (RYD data not available)");
+ return original;
+ }
+
+ synchronized (this) {
+ if (spanIsForShort) {
+ // Cannot set this to false if span is not for a Short.
+ // When spoofing to an old version and a Short is opened while a regular video
+ // is on screen, this instance can be loaded for the minimized regular video.
+ // But this Shorts data won't be displayed for that call
+ // and when it is un-minimized it will reload again and the load will be ignored.
+ isShort = true;
+ } else if (isShort) {
+ // user:
+ // 1, opened a video
+ // 2. opened a short (without closing the regular video)
+ // 3. closed the short
+ // 4. regular video is now present, but the videoId and RYD data is still for the short
+ Logger.printDebug(() -> "Ignoring regular video dislike span,"
+ + " as data loaded was previously used for a Short: " + videoId);
+ return original;
+ }
+
+ if (spanIsForLikes) {
+ // Scrolling Shorts does not cause the Spans to be reloaded,
+ // so there is no need to cache the likes for this situations.
+ Logger.printDebug(() -> "Creating likes span for: " + votingData.videoId);
+ return newSpannableWithLikes(original, votingData);
+ }
+
+ if (originalDislikeSpan != null && replacementLikeDislikeSpan != null
+ && spansHaveEqualTextAndColor(original, originalDislikeSpan)) {
+ Logger.printDebug(() -> "Replacing span with previously created dislike span of data: " + videoId);
+ return replacementLikeDislikeSpan;
+ }
+
+ // No replacement span exist, create it now.
+
+ if (userVote != null) {
+ votingData.updateUsingVote(userVote);
+ }
+ originalDislikeSpan = original;
+ replacementLikeDislikeSpan = createDislikeSpan(original, isSegmentedButton, isRollingNumber, votingData);
+ Logger.printDebug(() -> "Replaced: '" + originalDislikeSpan + "' with: '"
+ + replacementLikeDislikeSpan + "'" + " using video: " + videoId);
+
+ return replacementLikeDislikeSpan;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", ex);
+ }
+
+ return original;
+ }
+
+ public void sendVote(@NonNull Vote vote) {
+ Utils.verifyOnMainThread();
+ Objects.requireNonNull(vote);
+
+ try {
+ PlayerType currentType = PlayerType.getCurrent();
+ if (isShort != currentType.isNoneHiddenOrMinimized()) {
+ Logger.printDebug(() -> "Cannot vote for video: " + videoId
+ + " as current player type does not match: " + currentType);
+
+ // Shorts was loaded with regular video present, then Shorts was closed.
+ // and then user voted on the now visible original video.
+ // Cannot send a vote, because this instance is for the wrong video.
+ Utils.showToastLong(str("revanced_ryd_failure_ryd_enabled_while_playing_video_then_user_voted"));
+ return;
+ }
+
+ setUserVote(vote);
+
+ voteSerialExecutor.execute(() -> {
+ try { // Must wrap in try/catch to properly log exceptions.
+ ReturnYouTubeDislikeApi.sendVote(videoId, vote);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to send vote", ex);
+ }
+ });
+ } catch (Exception ex) {
+ Logger.printException(() -> "Error trying to send vote", ex);
+ }
+ }
+
+ /**
+ * Sets the current user vote value, and does not send the vote to the RYD API.
+ *
+ * Only used to set value if thumbs up/down is already selected on video load.
+ */
+ public void setUserVote(@NonNull Vote vote) {
+ Objects.requireNonNull(vote);
+ try {
+ Logger.printDebug(() -> "setUserVote: " + vote);
+
+ synchronized (this) {
+ userVote = vote;
+ clearUICache();
+ }
+
+ if (future.isDone()) {
+ // Update the fetched vote data.
+ RYDVoteData voteData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH);
+ if (voteData == null) {
+ // RYD fetch failed.
+ Logger.printDebug(() -> "Cannot update UI (vote data not available)");
+ return;
+ }
+ voteData.updateUsingVote(vote);
+ } // Else, vote will be applied after fetch completes.
+
+ } catch (Exception ex) {
+ Logger.printException(() -> "setUserVote failure", ex);
+ }
+ }
+}
+
+/**
+ * Styles a Spannable with an empty fixed width.
+ */
+class FixedWidthEmptySpan extends ReplacementSpan {
+ final int fixedWidth;
+ /**
+ * @param fixedWith Fixed width in screen pixels.
+ */
+ FixedWidthEmptySpan(int fixedWith) {
+ this.fixedWidth = fixedWith;
+ if (fixedWith < 0) throw new IllegalArgumentException();
+ }
+ @Override
+ public int getSize(@NonNull Paint paint, @NonNull CharSequence text,
+ int start, int end, @Nullable Paint.FontMetricsInt fontMetrics) {
+ return fixedWidth;
+ }
+ @Override
+ public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end,
+ float x, int top, int y, int bottom, @NonNull Paint paint) {
+ // Nothing to draw.
+ }
+}
+
+/**
+ * Vertically centers a Spanned Drawable.
+ */
+class VerticallyCenteredImageSpan extends ImageSpan {
+ final boolean useOriginalWidth;
+
+ /**
+ * @param useOriginalWidth Use the original layout width of the text this span is applied to,
+ * and not the bounds of the Drawable. Drawable is always displayed using it's own bounds,
+ * and this setting only affects the layout width of the entire span.
+ */
+ public VerticallyCenteredImageSpan(Drawable drawable, boolean useOriginalWidth) {
+ super(drawable);
+ this.useOriginalWidth = useOriginalWidth;
+ }
+
+ @Override
+ public int getSize(@NonNull Paint paint, @NonNull CharSequence text,
+ int start, int end, @Nullable Paint.FontMetricsInt fontMetrics) {
+ Drawable drawable = getDrawable();
+ Rect bounds = drawable.getBounds();
+ if (fontMetrics != null) {
+ Paint.FontMetricsInt paintMetrics = paint.getFontMetricsInt();
+ final int fontHeight = paintMetrics.descent - paintMetrics.ascent;
+ final int drawHeight = bounds.bottom - bounds.top;
+ final int halfDrawHeight = drawHeight / 2;
+ final int yCenter = paintMetrics.ascent + fontHeight / 2;
+
+ fontMetrics.ascent = yCenter - halfDrawHeight;
+ fontMetrics.top = fontMetrics.ascent;
+ fontMetrics.bottom = yCenter + halfDrawHeight;
+ fontMetrics.descent = fontMetrics.bottom;
+ }
+ if (useOriginalWidth) {
+ return (int) paint.measureText(text, start, end);
+ }
+ return bounds.right;
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end,
+ float x, int top, int y, int bottom, @NonNull Paint paint) {
+ Drawable drawable = getDrawable();
+ canvas.save();
+ Paint.FontMetricsInt paintMetrics = paint.getFontMetricsInt();
+ final int fontHeight = paintMetrics.descent - paintMetrics.ascent;
+ final int yCenter = y + paintMetrics.descent - fontHeight / 2;
+ final Rect drawBounds = drawable.getBounds();
+ float translateX = x;
+ if (useOriginalWidth) {
+ // Horizontally center the drawable in the same space as the original text.
+ translateX += (paint.measureText(text, start, end) - (drawBounds.right - drawBounds.left)) / 2;
+ }
+ final int translateY = yCenter - (drawBounds.bottom - drawBounds.top) / 2;
+ canvas.translate(translateX, translateY);
+ drawable.draw(canvas);
+ canvas.restore();
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/requests/RYDVoteData.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/requests/RYDVoteData.java
new file mode 100644
index 000000000..b57eadcfd
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/requests/RYDVoteData.java
@@ -0,0 +1,179 @@
+package app.revanced.extension.youtube.returnyoutubedislike.requests;
+
+import static app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike.Vote;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import app.revanced.extension.shared.Logger;
+
+/**
+ * ReturnYouTubeDislike API estimated like/dislike/view counts.
+ *
+ * ReturnYouTubeDislike does not guarantee when the counts are updated.
+ * So these values may lag behind what YouTube shows.
+ */
+public final class RYDVoteData {
+ @NonNull
+ public final String videoId;
+
+ /**
+ * Estimated number of views
+ */
+ public final long viewCount;
+
+ private final long fetchedLikeCount;
+ private volatile long likeCount; // Read/write from different threads.
+ /**
+ * Like count can be hidden by video creator, but RYD still tracks the number
+ * of like/dislikes it received thru it's browser extension and and API.
+ * The raw like/dislikes can be used to calculate a percentage.
+ *
+ * Raw values can be null, especially for older videos with little to no views.
+ */
+ @Nullable
+ private final Long fetchedRawLikeCount;
+ private volatile float likePercentage;
+
+ private final long fetchedDislikeCount;
+ private volatile long dislikeCount; // Read/write from different threads.
+ @Nullable
+ private final Long fetchedRawDislikeCount;
+ private volatile float dislikePercentage;
+
+ @Nullable
+ private static Long getLongIfExist(JSONObject json, String key) throws JSONException {
+ return json.isNull(key)
+ ? null
+ : json.getLong(key);
+ }
+
+ /**
+ * @throws JSONException if JSON parse error occurs, or if the values make no sense (ie: negative values)
+ */
+ public RYDVoteData(@NonNull JSONObject json) throws JSONException {
+ videoId = json.getString("id");
+ viewCount = json.getLong("viewCount");
+
+ fetchedLikeCount = json.getLong("likes");
+ fetchedRawLikeCount = getLongIfExist(json, "rawLikes");
+
+ fetchedDislikeCount = json.getLong("dislikes");
+ fetchedRawDislikeCount = getLongIfExist(json, "rawDislikes");
+
+ if (viewCount < 0 || fetchedLikeCount < 0 || fetchedDislikeCount < 0) {
+ throw new JSONException("Unexpected JSON values: " + json);
+ }
+ likeCount = fetchedLikeCount;
+ dislikeCount = fetchedDislikeCount;
+
+ updateUsingVote(Vote.LIKE_REMOVE); // Calculate percentages.
+ }
+
+ /**
+ * Public like count of the video, as reported by YT when RYD last updated it's data.
+ *
+ * If the likes were hidden by the video creator, then this returns an
+ * estimated likes using the same extrapolation as the dislikes.
+ */
+ public long getLikeCount() {
+ return likeCount;
+ }
+
+ /**
+ * Estimated total dislike count, extrapolated from the public like count using RYD data.
+ */
+ public long getDislikeCount() {
+ return dislikeCount;
+ }
+
+ /**
+ * Estimated percentage of likes for all votes. Value has range of [0, 1]
+ *
+ * A video with 400 positive votes, and 100 negative votes, has a likePercentage of 0.8
+ */
+ public float getLikePercentage() {
+ return likePercentage;
+ }
+
+ /**
+ * Estimated percentage of dislikes for all votes. Value has range of [0, 1]
+ *
+ * A video with 400 positive votes, and 100 negative votes, has a dislikePercentage of 0.2
+ */
+ public float getDislikePercentage() {
+ return dislikePercentage;
+ }
+
+ public void updateUsingVote(Vote vote) {
+ final int likesToAdd, dislikesToAdd;
+
+ switch (vote) {
+ case LIKE:
+ likesToAdd = 1;
+ dislikesToAdd = 0;
+ break;
+ case DISLIKE:
+ likesToAdd = 0;
+ dislikesToAdd = 1;
+ break;
+ case LIKE_REMOVE:
+ likesToAdd = 0;
+ dislikesToAdd = 0;
+ break;
+ default:
+ throw new IllegalStateException();
+ }
+
+ // If a video has no public likes but RYD has raw like data,
+ // then use the raw data instead.
+ final boolean videoHasNoPublicLikes = fetchedLikeCount == 0;
+ final boolean hasRawData = fetchedRawLikeCount != null && fetchedRawDislikeCount != null;
+
+ if (videoHasNoPublicLikes && hasRawData && fetchedRawDislikeCount > 0) {
+ // YT creator has hidden the likes count, and this is an older video that
+ // RYD does not provide estimated like counts.
+ //
+ // But we can calculate the public likes the same way RYD does for newer videos with hidden likes,
+ // by using the same raw to estimated scale factor applied to dislikes.
+ // This calculation exactly matches the public likes RYD provides for newer hidden videos.
+ final float estimatedRawScaleFactor = (float) fetchedDislikeCount / fetchedRawDislikeCount;
+ likeCount = (long) (estimatedRawScaleFactor * fetchedRawLikeCount) + likesToAdd;
+ Logger.printDebug(() -> "Using locally calculated estimated likes since RYD did not return an estimate");
+ } else {
+ likeCount = fetchedLikeCount + likesToAdd;
+ }
+ // RYD now always returns an estimated dislike count, even if the likes are hidden.
+ dislikeCount = fetchedDislikeCount + dislikesToAdd;
+
+ // Update percentages.
+
+ final float totalCount = likeCount + dislikeCount;
+ if (totalCount == 0) {
+ likePercentage = 0;
+ dislikePercentage = 0;
+ } else {
+ likePercentage = likeCount / totalCount;
+ dislikePercentage = dislikeCount / totalCount;
+ }
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "RYDVoteData{"
+ + "videoId=" + videoId
+ + ", viewCount=" + viewCount
+ + ", likeCount=" + likeCount
+ + ", dislikeCount=" + dislikeCount
+ + ", likePercentage=" + likePercentage
+ + ", dislikePercentage=" + dislikePercentage
+ + '}';
+ }
+
+ // equals and hashcode is not implemented (currently not needed)
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java
new file mode 100644
index 000000000..07c8f3c55
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java
@@ -0,0 +1,610 @@
+package app.revanced.extension.youtube.returnyoutubedislike.requests;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.youtube.returnyoutubedislike.requests.ReturnYouTubeDislikeRoutes.getRYDConnectionFromRoute;
+
+import android.util.Base64;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.ProtocolException;
+import java.net.SocketTimeoutException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Objects;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.requests.Requester;
+import app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike;
+import app.revanced.extension.youtube.settings.Settings;
+
+public class ReturnYouTubeDislikeApi {
+ /**
+ * {@link #fetchVotes(String)} TCP connection timeout
+ */
+ private static final int API_GET_VOTES_TCP_TIMEOUT_MILLISECONDS = 2 * 1000; // 2 Seconds.
+
+ /**
+ * {@link #fetchVotes(String)} HTTP read timeout.
+ * To locally debug and force timeouts, change this to a very small number (ie: 100)
+ */
+ private static final int API_GET_VOTES_HTTP_TIMEOUT_MILLISECONDS = 4 * 1000; // 4 Seconds.
+
+ /**
+ * Default connection and response timeout for voting and registration.
+ *
+ * Voting and user registration runs in the background and has has no urgency
+ * so this can be a larger value.
+ */
+ private static final int API_REGISTER_VOTE_TIMEOUT_MILLISECONDS = 60 * 1000; // 60 Seconds.
+
+ /**
+ * Response code of a successful API call
+ */
+ private static final int HTTP_STATUS_CODE_SUCCESS = 200;
+
+ /**
+ * Indicates a client rate limit has been reached and the client must back off.
+ */
+ private static final int HTTP_STATUS_CODE_RATE_LIMIT = 429;
+
+ /**
+ * How long to wait until API calls are resumed, if the API requested a back off.
+ * No clear guideline of how long to wait until resuming.
+ */
+ private static final int BACKOFF_RATE_LIMIT_MILLISECONDS = 10 * 60 * 1000; // 10 Minutes.
+
+ /**
+ * How long to wait until API calls are resumed, if any connection error occurs.
+ */
+ private static final int BACKOFF_CONNECTION_ERROR_MILLISECONDS = 2 * 60 * 1000; // 2 Minutes.
+
+ /**
+ * If non zero, then the system time of when API calls can resume.
+ */
+ private static volatile long timeToResumeAPICalls;
+
+ /**
+ * If the last API getVotes call failed for any reason (including server requested rate limit).
+ * Used to prevent showing repeat connection toasts when the API is down.
+ */
+ private static volatile boolean lastApiCallFailed;
+
+ /**
+ * Number of times {@link #HTTP_STATUS_CODE_RATE_LIMIT} was requested by RYD api.
+ * Does not include network calls attempted while rate limit is in effect,
+ * and does not include rate limit imposed if a fetch fails.
+ */
+ private static volatile int numberOfRateLimitRequestsEncountered;
+
+ /**
+ * Number of network calls made in {@link #fetchVotes(String)}
+ */
+ private static volatile int fetchCallCount;
+
+ /**
+ * Number of times {@link #fetchVotes(String)} failed due to timeout or any other error.
+ * This does not include when rate limit requests are encountered.
+ */
+ private static volatile int fetchCallNumberOfFailures;
+
+ /**
+ * Total time spent waiting for {@link #fetchVotes(String)} network call to complete.
+ * Value does does not persist on app shut down.
+ */
+ private static volatile long fetchCallResponseTimeTotal;
+
+ /**
+ * Round trip network time for the most recent call to {@link #fetchVotes(String)}
+ */
+ private static volatile long fetchCallResponseTimeLast;
+ private static volatile long fetchCallResponseTimeMin;
+ private static volatile long fetchCallResponseTimeMax;
+
+ public static final int FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT = -1;
+
+ /**
+ * If rate limit was hit, this returns {@link #FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT}
+ */
+ public static long getFetchCallResponseTimeLast() {
+ return fetchCallResponseTimeLast;
+ }
+ public static long getFetchCallResponseTimeMin() {
+ return fetchCallResponseTimeMin;
+ }
+ public static long getFetchCallResponseTimeMax() {
+ return fetchCallResponseTimeMax;
+ }
+ public static long getFetchCallResponseTimeAverage() {
+ return fetchCallCount == 0 ? 0 : (fetchCallResponseTimeTotal / fetchCallCount);
+ }
+ public static int getFetchCallCount() {
+ return fetchCallCount;
+ }
+ public static int getFetchCallNumberOfFailures() {
+ return fetchCallNumberOfFailures;
+ }
+ public static int getNumberOfRateLimitRequestsEncountered() {
+ return numberOfRateLimitRequestsEncountered;
+ }
+
+ private ReturnYouTubeDislikeApi() {
+ } // utility class
+
+ /**
+ * Simulates a slow response by doing meaningless calculations.
+ * Used to debug the app UI and verify UI timeout logic works
+ */
+ private static void randomlyWaitIfLocallyDebugging() {
+ final boolean DEBUG_RANDOMLY_DELAY_NETWORK_CALLS = false; // set true to debug UI
+ if (DEBUG_RANDOMLY_DELAY_NETWORK_CALLS) {
+ final long amountOfTimeToWaste = (long) (Math.random()
+ * (API_GET_VOTES_TCP_TIMEOUT_MILLISECONDS + API_GET_VOTES_HTTP_TIMEOUT_MILLISECONDS));
+ Utils.doNothingForDuration(amountOfTimeToWaste);
+ }
+ }
+
+ /**
+ * Clears any backoff rate limits in effect.
+ * Should be called if RYD is turned on/off.
+ */
+ public static void resetRateLimits() {
+ if (lastApiCallFailed || timeToResumeAPICalls != 0) {
+ Logger.printDebug(() -> "Reset rate limit");
+ }
+ lastApiCallFailed = false;
+ timeToResumeAPICalls = 0;
+ }
+
+ /**
+ * @return True, if api rate limit is in effect.
+ */
+ private static boolean checkIfRateLimitInEffect(String apiEndPointName) {
+ if (timeToResumeAPICalls == 0) {
+ return false;
+ }
+ final long now = System.currentTimeMillis();
+ if (now > timeToResumeAPICalls) {
+ timeToResumeAPICalls = 0;
+ return false;
+ }
+ Logger.printDebug(() -> "Ignoring api call " + apiEndPointName + " as rate limit is in effect");
+ return true;
+ }
+
+ /**
+ * @return True, if a client rate limit was requested
+ */
+ private static boolean checkIfRateLimitWasHit(int httpResponseCode) {
+ final boolean DEBUG_RATE_LIMIT = false; // set to true, to verify rate limit works
+ if (DEBUG_RATE_LIMIT) {
+ final double RANDOM_RATE_LIMIT_PERCENTAGE = 0.2; // 20% chance of a triggering a rate limit
+ if (Math.random() < RANDOM_RATE_LIMIT_PERCENTAGE) {
+ Logger.printDebug(() -> "Artificially triggering rate limit for debug purposes");
+ httpResponseCode = HTTP_STATUS_CODE_RATE_LIMIT;
+ }
+ }
+ return httpResponseCode == HTTP_STATUS_CODE_RATE_LIMIT;
+ }
+
+ @SuppressWarnings("NonAtomicOperationOnVolatileField") // Don't care, fields are only estimates.
+ private static void updateRateLimitAndStats(long timeNetworkCallStarted, boolean connectionError, boolean rateLimitHit) {
+ if (connectionError && rateLimitHit) {
+ throw new IllegalArgumentException();
+ }
+ final long responseTimeOfFetchCall = System.currentTimeMillis() - timeNetworkCallStarted;
+ fetchCallResponseTimeTotal += responseTimeOfFetchCall;
+ fetchCallResponseTimeMin = (fetchCallResponseTimeMin == 0) ? responseTimeOfFetchCall : Math.min(responseTimeOfFetchCall, fetchCallResponseTimeMin);
+ fetchCallResponseTimeMax = Math.max(responseTimeOfFetchCall, fetchCallResponseTimeMax);
+ fetchCallCount++;
+ if (connectionError) {
+ timeToResumeAPICalls = System.currentTimeMillis() + BACKOFF_CONNECTION_ERROR_MILLISECONDS;
+ fetchCallResponseTimeLast = responseTimeOfFetchCall;
+ fetchCallNumberOfFailures++;
+ lastApiCallFailed = true;
+ } else if (rateLimitHit) {
+ Logger.printDebug(() -> "API rate limit was hit. Stopping API calls for the next "
+ + BACKOFF_RATE_LIMIT_MILLISECONDS + " seconds");
+ timeToResumeAPICalls = System.currentTimeMillis() + BACKOFF_RATE_LIMIT_MILLISECONDS;
+ numberOfRateLimitRequestsEncountered++;
+ fetchCallResponseTimeLast = FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT;
+ if (!lastApiCallFailed && Settings.RYD_TOAST_ON_CONNECTION_ERROR.get()) {
+ Utils.showToastLong(str("revanced_ryd_failure_client_rate_limit_requested"));
+ }
+ lastApiCallFailed = true;
+ } else {
+ fetchCallResponseTimeLast = responseTimeOfFetchCall;
+ lastApiCallFailed = false;
+ }
+ }
+
+ private static void handleConnectionError(@NonNull String toastMessage,
+ @Nullable Exception ex,
+ boolean showLongToast) {
+ if (!lastApiCallFailed && Settings.RYD_TOAST_ON_CONNECTION_ERROR.get()) {
+ if (showLongToast) {
+ Utils.showToastLong(toastMessage);
+ } else {
+ Utils.showToastShort(toastMessage);
+ }
+ }
+ lastApiCallFailed = true;
+
+ Logger.printInfo(() -> toastMessage, ex);
+ }
+
+ /**
+ * @return NULL if fetch failed, or if a rate limit is in effect.
+ */
+ @Nullable
+ public static RYDVoteData fetchVotes(String videoId) {
+ Utils.verifyOffMainThread();
+ Objects.requireNonNull(videoId);
+
+ if (checkIfRateLimitInEffect("fetchVotes")) {
+ return null;
+ }
+ Logger.printDebug(() -> "Fetching votes for: " + videoId);
+ final long timeNetworkCallStarted = System.currentTimeMillis();
+
+ try {
+ HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.GET_DISLIKES, videoId);
+ // request headers, as per https://returnyoutubedislike.com/docs/fetching
+ // the documentation says to use 'Accept:text/html', but the RYD browser plugin uses 'Accept:application/json'
+ connection.setRequestProperty("Accept", "application/json");
+ connection.setRequestProperty("Connection", "keep-alive"); // keep-alive is on by default with http 1.1, but specify anyways
+ connection.setRequestProperty("Pragma", "no-cache");
+ connection.setRequestProperty("Cache-Control", "no-cache");
+ connection.setUseCaches(false);
+ connection.setConnectTimeout(API_GET_VOTES_TCP_TIMEOUT_MILLISECONDS); // timeout for TCP connection to server
+ connection.setReadTimeout(API_GET_VOTES_HTTP_TIMEOUT_MILLISECONDS); // timeout for server response
+
+ randomlyWaitIfLocallyDebugging();
+
+ final int responseCode = connection.getResponseCode();
+ if (checkIfRateLimitWasHit(responseCode)) {
+ connection.disconnect(); // rate limit hit, should disconnect
+ updateRateLimitAndStats(timeNetworkCallStarted, false, true);
+ return null;
+ }
+
+ if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
+ // Do not disconnect, the same server connection will likely be used again soon.
+ JSONObject json = Requester.parseJSONObject(connection);
+ try {
+ RYDVoteData votingData = new RYDVoteData(json);
+ updateRateLimitAndStats(timeNetworkCallStarted, false, false);
+ Logger.printDebug(() -> "Voting data fetched: " + votingData);
+ return votingData;
+ } catch (JSONException ex) {
+ Logger.printException(() -> "Failed to parse video: " + videoId + " json: " + json, ex);
+ // fall thru to update statistics
+ }
+ } else {
+ // Unexpected response code. Most likely RYD is temporarily broken.
+ handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode),
+ null, true);
+ }
+ connection.disconnect(); // Something went wrong, might as well disconnect.
+ } catch (SocketTimeoutException ex) {
+ handleConnectionError((str("revanced_ryd_failure_connection_timeout")), ex, false);
+ } catch (IOException ex) {
+ handleConnectionError((str("revanced_ryd_failure_generic", ex.getMessage())), ex, true);
+ } catch (Exception ex) {
+ // should never happen
+ Logger.printException(() -> "Failed to fetch votes", ex, str("revanced_ryd_failure_generic", ex.getMessage()));
+ }
+
+ updateRateLimitAndStats(timeNetworkCallStarted, true, false);
+ return null;
+ }
+
+ /**
+ * @return The newly created and registered user id. Returns NULL if registration failed.
+ */
+ @Nullable
+ public static String registerAsNewUser() {
+ Utils.verifyOffMainThread();
+ try {
+ if (checkIfRateLimitInEffect("registerAsNewUser")) {
+ return null;
+ }
+ String userId = randomString(36);
+ Logger.printDebug(() -> "Trying to register new user");
+
+ HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.GET_REGISTRATION, userId);
+ connection.setRequestProperty("Accept", "application/json");
+ connection.setConnectTimeout(API_REGISTER_VOTE_TIMEOUT_MILLISECONDS);
+ connection.setReadTimeout(API_REGISTER_VOTE_TIMEOUT_MILLISECONDS);
+
+ final int responseCode = connection.getResponseCode();
+ if (checkIfRateLimitWasHit(responseCode)) {
+ connection.disconnect(); // disconnect, as no more connections will be made for a little while
+ return null;
+ }
+ if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
+ JSONObject json = Requester.parseJSONObject(connection);
+ String challenge = json.getString("challenge");
+ int difficulty = json.getInt("difficulty");
+
+ String solution = solvePuzzle(challenge, difficulty);
+ return confirmRegistration(userId, solution);
+ }
+ handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode),
+ null, true);
+ connection.disconnect();
+ } catch (SocketTimeoutException ex) {
+ handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex, false);
+ } catch (IOException ex) {
+ handleConnectionError(str("revanced_ryd_failure_generic", "registration failed"), ex, true);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to register user", ex); // should never happen
+ }
+ return null;
+ }
+
+ @Nullable
+ private static String confirmRegistration(String userId, String solution) {
+ Utils.verifyOffMainThread();
+ Objects.requireNonNull(userId);
+ Objects.requireNonNull(solution);
+ try {
+ if (checkIfRateLimitInEffect("confirmRegistration")) {
+ return null;
+ }
+ Logger.printDebug(() -> "Trying to confirm registration with solution: " + solution);
+
+ HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.CONFIRM_REGISTRATION, userId);
+ applyCommonPostRequestSettings(connection);
+
+ String jsonInputString = "{\"solution\": \"" + solution + "\"}";
+ byte[] body = jsonInputString.getBytes(StandardCharsets.UTF_8);
+ connection.setFixedLengthStreamingMode(body.length);
+ try (OutputStream os = connection.getOutputStream()) {
+ os.write(body);
+ }
+
+ final int responseCode = connection.getResponseCode();
+ if (checkIfRateLimitWasHit(responseCode)) {
+ connection.disconnect(); // disconnect, as no more connections will be made for a little while
+ return null;
+ }
+ if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
+ Logger.printDebug(() -> "Registration confirmation successful");
+ return userId;
+ }
+
+ // Something went wrong, might as well disconnect.
+ String response = Requester.parseStringAndDisconnect(connection);
+ Logger.printInfo(() -> "Failed to confirm registration for user: " + userId
+ + " solution: " + solution + " responseCode: " + responseCode + " response: '" + response + "''");
+ handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode),
+ null, true);
+ } catch (SocketTimeoutException ex) {
+ handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex, false);
+ } catch (IOException ex) {
+ handleConnectionError(str("revanced_ryd_failure_generic", "confirm registration failed"),
+ ex, true);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to confirm registration for user: " + userId
+ + "solution: " + solution, ex);
+ }
+ return null;
+ }
+
+ /**
+ * Must call off main thread, as this will make a network call if user is not yet registered.
+ *
+ * @return ReturnYouTubeDislike user ID. If user registration has never happened
+ * and the network call fails, this returns NULL.
+ */
+ @Nullable
+ private static String getUserId() {
+ Utils.verifyOffMainThread();
+
+ String userId = Settings.RYD_USER_ID.get();
+ if (!userId.isEmpty()) {
+ return userId;
+ }
+
+ userId = registerAsNewUser();
+ if (userId != null) {
+ Settings.RYD_USER_ID.save(userId);
+ }
+ return userId;
+ }
+
+ public static boolean sendVote(String videoId, ReturnYouTubeDislike.Vote vote) {
+ Utils.verifyOffMainThread();
+ Objects.requireNonNull(videoId);
+ Objects.requireNonNull(vote);
+
+ try {
+ String userId = getUserId();
+ if (userId == null) return false;
+
+ if (checkIfRateLimitInEffect("sendVote")) {
+ return false;
+ }
+ Logger.printDebug(() -> "Trying to vote for video: " + videoId + " with vote: " + vote);
+
+ HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.SEND_VOTE);
+ applyCommonPostRequestSettings(connection);
+
+ String voteJsonString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"value\": \"" + vote.value + "\"}";
+ byte[] body = voteJsonString.getBytes(StandardCharsets.UTF_8);
+ connection.setFixedLengthStreamingMode(body.length);
+ try (OutputStream os = connection.getOutputStream()) {
+ os.write(body);
+ }
+
+ final int responseCode = connection.getResponseCode();
+ if (checkIfRateLimitWasHit(responseCode)) {
+ connection.disconnect(); // disconnect, as no more connections will be made for a little while
+ return false;
+ }
+ if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
+ JSONObject json = Requester.parseJSONObject(connection);
+ String challenge = json.getString("challenge");
+ int difficulty = json.getInt("difficulty");
+
+ String solution = solvePuzzle(challenge, difficulty);
+ return confirmVote(videoId, userId, solution);
+ }
+
+ Logger.printInfo(() -> "Failed to send vote for video: " + videoId + " vote: " + vote
+ + " response code was: " + responseCode);
+ handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode),
+ null, true);
+ connection.disconnect(); // something went wrong, might as well disconnect
+ } catch (SocketTimeoutException ex) {
+ handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex, false);
+ } catch (IOException ex) {
+ handleConnectionError(str("revanced_ryd_failure_generic", "send vote failed"), ex, true);
+ } catch (Exception ex) {
+ // should never happen
+ Logger.printException(() -> "Failed to send vote for video: " + videoId + " vote: " + vote, ex);
+ }
+ return false;
+ }
+
+ private static boolean confirmVote(String videoId, String userId, String solution) {
+ Utils.verifyOffMainThread();
+ Objects.requireNonNull(videoId);
+ Objects.requireNonNull(userId);
+ Objects.requireNonNull(solution);
+
+ try {
+ if (checkIfRateLimitInEffect("confirmVote")) {
+ return false;
+ }
+ Logger.printDebug(() -> "Trying to confirm vote for video: " + videoId + " solution: " + solution);
+ HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.CONFIRM_VOTE);
+ applyCommonPostRequestSettings(connection);
+
+ String jsonInputString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"solution\": \"" + solution + "\"}";
+ byte[] body = jsonInputString.getBytes(StandardCharsets.UTF_8);
+ connection.setFixedLengthStreamingMode(body.length);
+ try (OutputStream os = connection.getOutputStream()) {
+ os.write(body);
+ }
+
+ final int responseCode = connection.getResponseCode();
+ if (checkIfRateLimitWasHit(responseCode)) {
+ connection.disconnect(); // disconnect, as no more connections will be made for a little while
+ return false;
+ }
+ if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
+ Logger.printDebug(() -> "Vote confirm successful for video: " + videoId);
+ return true;
+ }
+
+ // Something went wrong, might as well disconnect.
+ String response = Requester.parseStringAndDisconnect(connection);
+ Logger.printInfo(() -> "Failed to confirm vote for video: " + videoId
+ + " solution: " + solution + " responseCode: " + responseCode + " response: '" + response + "'");
+ handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode),
+ null, true);
+ } catch (SocketTimeoutException ex) {
+ handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex, false);
+ } catch (IOException ex) {
+ handleConnectionError(str("revanced_ryd_failure_generic", "confirm vote failed"),
+ ex, true);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to confirm vote for video: " + videoId
+ + " solution: " + solution, ex); // should never happen
+ }
+ return false;
+ }
+
+ private static void applyCommonPostRequestSettings(HttpURLConnection connection) throws ProtocolException {
+ connection.setRequestMethod("POST");
+ connection.setRequestProperty("Content-Type", "application/json");
+ connection.setRequestProperty("Accept", "application/json");
+ connection.setRequestProperty("Pragma", "no-cache");
+ connection.setRequestProperty("Cache-Control", "no-cache");
+ connection.setUseCaches(false);
+ connection.setDoOutput(true);
+ connection.setConnectTimeout(API_REGISTER_VOTE_TIMEOUT_MILLISECONDS); // timeout for TCP connection to server
+ connection.setReadTimeout(API_REGISTER_VOTE_TIMEOUT_MILLISECONDS); // timeout for server response
+ }
+
+
+ private static String solvePuzzle(String challenge, int difficulty) {
+ final long timeSolveStarted = System.currentTimeMillis();
+ byte[] decodedChallenge = Base64.decode(challenge, Base64.NO_WRAP);
+
+ byte[] buffer = new byte[20];
+ System.arraycopy(decodedChallenge, 0, buffer, 4, 16);
+
+ MessageDigest md;
+ try {
+ md = MessageDigest.getInstance("SHA-512");
+ } catch (NoSuchAlgorithmException ex) {
+ throw new IllegalStateException(ex); // should never happen
+ }
+
+ final int maxCount = (int) (Math.pow(2, difficulty + 1) * 5);
+ for (int i = 0; i < maxCount; i++) {
+ buffer[0] = (byte) i;
+ buffer[1] = (byte) (i >> 8);
+ buffer[2] = (byte) (i >> 16);
+ buffer[3] = (byte) (i >> 24);
+ byte[] messageDigest = md.digest(buffer);
+
+ if (countLeadingZeroes(messageDigest) >= difficulty) {
+ String solution = Base64.encodeToString(new byte[]{buffer[0], buffer[1], buffer[2], buffer[3]}, Base64.NO_WRAP);
+ Logger.printDebug(() -> "Found puzzle solution: " + solution + " of difficulty: " + difficulty
+ + " in: " + (System.currentTimeMillis() - timeSolveStarted) + " ms");
+ return solution;
+ }
+ }
+
+ // should never be reached
+ throw new IllegalStateException("Failed to solve puzzle challenge: " + challenge + " difficulty: " + difficulty);
+ }
+
+ // https://stackoverflow.com/a/157202
+ private static String randomString(int len) {
+ String AB = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ SecureRandom rnd = new SecureRandom();
+
+ StringBuilder sb = new StringBuilder(len);
+ for (int i = 0; i < len; i++)
+ sb.append(AB.charAt(rnd.nextInt(AB.length())));
+ return sb.toString();
+ }
+
+ private static int countLeadingZeroes(byte[] uInt8View) {
+ int zeroes = 0;
+ for (byte b : uInt8View) {
+ int value = b & 0xFF;
+ if (value == 0) {
+ zeroes += 8;
+ } else {
+ int count = 1;
+ if (value >>> 4 == 0) {
+ count += 4;
+ value <<= 4;
+ }
+ if (value >>> 6 == 0) {
+ count += 2;
+ value <<= 2;
+ }
+ zeroes += count - (value >>> 7);
+ break;
+ }
+ }
+ return zeroes;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeRoutes.java
new file mode 100644
index 000000000..2c2ae7255
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeRoutes.java
@@ -0,0 +1,28 @@
+package app.revanced.extension.youtube.returnyoutubedislike.requests;
+
+import static app.revanced.extension.youtube.requests.Route.Method.GET;
+import static app.revanced.extension.youtube.requests.Route.Method.POST;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+
+import app.revanced.extension.youtube.requests.Requester;
+import app.revanced.extension.youtube.requests.Route;
+
+class ReturnYouTubeDislikeRoutes {
+ static final String RYD_API_URL = "https://returnyoutubedislikeapi.com/";
+
+ static final Route SEND_VOTE = new Route(POST, "interact/vote");
+ static final Route CONFIRM_VOTE = new Route(POST, "interact/confirmVote");
+ static final Route GET_DISLIKES = new Route(GET, "votes?videoId={video_id}");
+ static final Route GET_REGISTRATION = new Route(GET, "puzzle/registration?userId={user_id}");
+ static final Route CONFIRM_REGISTRATION = new Route(POST, "puzzle/registration?userId={user_id}");
+
+ private ReturnYouTubeDislikeRoutes() {
+ }
+
+ static HttpURLConnection getRYDConnectionFromRoute(Route route, String... params) throws IOException {
+ return Requester.getConnectionFromRoute(RYD_API_URL, route, params);
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/LicenseActivityHook.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/LicenseActivityHook.java
new file mode 100644
index 000000000..acf565712
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/LicenseActivityHook.java
@@ -0,0 +1,99 @@
+package app.revanced.extension.youtube.settings;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.preference.PreferenceFragment;
+import android.view.ViewGroup;
+import android.widget.ImageButton;
+import android.widget.TextView;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.youtube.ThemeHelper;
+import app.revanced.extension.youtube.settings.preference.ReVancedPreferenceFragment;
+import app.revanced.extension.youtube.settings.preference.ReturnYouTubeDislikePreferenceFragment;
+import app.revanced.extension.youtube.settings.preference.SponsorBlockPreferenceFragment;
+
+import java.util.Objects;
+
+import static app.revanced.extension.shared.Utils.getChildView;
+import static app.revanced.extension.shared.Utils.getResourceIdentifier;
+
+/**
+ * Hooks LicenseActivity.
+ *
+ * This class is responsible for injecting our own fragment by replacing the LicenseActivity.
+ */
+@SuppressWarnings("unused")
+public class LicenseActivityHook {
+
+ /**
+ * Injection point.
+ *
+ * Hooks LicenseActivity#onCreate in order to inject our own fragment.
+ */
+ public static void initialize(Activity licenseActivity) {
+ try {
+ ThemeHelper.setActivityTheme(licenseActivity);
+ licenseActivity.setContentView(
+ getResourceIdentifier("revanced_settings_with_toolbar", "layout"));
+ setBackButton(licenseActivity);
+
+ PreferenceFragment fragment;
+ String toolbarTitleResourceName;
+ String dataString = licenseActivity.getIntent().getDataString();
+ switch (dataString) {
+ case "revanced_sb_settings_intent":
+ toolbarTitleResourceName = "revanced_sb_settings_title";
+ fragment = new SponsorBlockPreferenceFragment();
+ break;
+ case "revanced_ryd_settings_intent":
+ toolbarTitleResourceName = "revanced_ryd_settings_title";
+ fragment = new ReturnYouTubeDislikePreferenceFragment();
+ break;
+ case "revanced_settings_intent":
+ toolbarTitleResourceName = "revanced_settings_title";
+ fragment = new ReVancedPreferenceFragment();
+ break;
+ default:
+ Logger.printException(() -> "Unknown setting: " + dataString);
+ return;
+ }
+
+ setToolbarTitle(licenseActivity, toolbarTitleResourceName);
+ licenseActivity.getFragmentManager()
+ .beginTransaction()
+ .replace(getResourceIdentifier("revanced_settings_fragments", "id"), fragment)
+ .commit();
+ } catch (Exception ex) {
+ Logger.printException(() -> "onCreate failure", ex);
+ }
+ }
+
+ private static void setToolbarTitle(Activity activity, String toolbarTitleResourceName) {
+ ViewGroup toolbar = activity.findViewById(getToolbarResourceId());
+ TextView toolbarTextView = Objects.requireNonNull(getChildView(toolbar, false,
+ view -> view instanceof TextView));
+ toolbarTextView.setText(getResourceIdentifier(toolbarTitleResourceName, "string"));
+ }
+
+ @SuppressLint("UseCompatLoadingForDrawables")
+ private static void setBackButton(Activity activity) {
+ ViewGroup toolbar = activity.findViewById(getToolbarResourceId());
+ ImageButton imageButton = Objects.requireNonNull(getChildView(toolbar, false,
+ view -> view instanceof ImageButton));
+ final int backButtonResource = getResourceIdentifier(ThemeHelper.isDarkTheme()
+ ? "yt_outline_arrow_left_white_24"
+ : "yt_outline_arrow_left_black_24",
+ "drawable");
+ imageButton.setImageDrawable(activity.getResources().getDrawable(backButtonResource));
+ imageButton.setOnClickListener(view -> activity.onBackPressed());
+ }
+
+ private static int getToolbarResourceId() {
+ final int toolbarResourceId = getResourceIdentifier("revanced_toolbar", "id");
+ if (toolbarResourceId == 0) {
+ throw new IllegalStateException("Could not find back button resource");
+ }
+ return toolbarResourceId;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java
new file mode 100644
index 000000000..479080623
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java
@@ -0,0 +1,448 @@
+package app.revanced.extension.youtube.settings;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+import static app.revanced.extension.shared.settings.Setting.*;
+import static app.revanced.extension.youtube.patches.ChangeStartPagePatch.StartPage;
+import static app.revanced.extension.youtube.patches.MiniplayerPatch.MiniplayerHideExpandCloseAvailability;
+import static app.revanced.extension.youtube.patches.MiniplayerPatch.MiniplayerType;
+import static app.revanced.extension.youtube.patches.MiniplayerPatch.MiniplayerType.*;
+import static app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour.*;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.settings.*;
+import app.revanced.extension.shared.settings.preference.SharedPrefCategory;
+import app.revanced.extension.youtube.patches.AlternativeThumbnailsPatch.DeArrowAvailability;
+import app.revanced.extension.youtube.patches.AlternativeThumbnailsPatch.StillImagesAvailability;
+import app.revanced.extension.youtube.patches.AlternativeThumbnailsPatch.ThumbnailOption;
+import app.revanced.extension.youtube.patches.AlternativeThumbnailsPatch.ThumbnailStillTime;
+import app.revanced.extension.youtube.patches.spoof.ClientType;
+import app.revanced.extension.youtube.patches.spoof.SpoofAppVersionPatch;
+import app.revanced.extension.youtube.patches.spoof.SpoofVideoStreamsPatch;
+import app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings;
+
+@SuppressWarnings("deprecation")
+public class Settings extends BaseSettings {
+ // Video
+ public static final BooleanSetting RESTORE_OLD_VIDEO_QUALITY_MENU = new BooleanSetting("revanced_restore_old_video_quality_menu", TRUE);
+ public static final BooleanSetting REMEMBER_VIDEO_QUALITY_LAST_SELECTED = new BooleanSetting("revanced_remember_video_quality_last_selected", FALSE);
+ public static final IntegerSetting VIDEO_QUALITY_DEFAULT_WIFI = new IntegerSetting("revanced_video_quality_default_wifi", -2);
+ public static final IntegerSetting VIDEO_QUALITY_DEFAULT_MOBILE = new IntegerSetting("revanced_video_quality_default_mobile", -2);
+ public static final BooleanSetting REMEMBER_PLAYBACK_SPEED_LAST_SELECTED = new BooleanSetting("revanced_remember_playback_speed_last_selected", FALSE);
+ public static final FloatSetting PLAYBACK_SPEED_DEFAULT = new FloatSetting("revanced_playback_speed_default", 1.0f);
+ public static final StringSetting CUSTOM_PLAYBACK_SPEEDS = new StringSetting("revanced_custom_playback_speeds",
+ "0.25\n0.5\n0.75\n0.9\n0.95\n1.0\n1.05\n1.1\n1.25\n1.5\n1.75\n2.0\n3.0\n4.0\n5.0", true);
+ @Deprecated // Patch is obsolete and no longer works with 19.09+
+ public static final BooleanSetting HDR_AUTO_BRIGHTNESS = new BooleanSetting("revanced_hdr_auto_brightness", TRUE);
+
+ // Ads
+ public static final BooleanSetting HIDE_FULLSCREEN_ADS = new BooleanSetting("revanced_hide_fullscreen_ads", TRUE);
+ public static final BooleanSetting HIDE_BUTTONED_ADS = new BooleanSetting("revanced_hide_buttoned_ads", TRUE);
+ public static final BooleanSetting HIDE_GENERAL_ADS = new BooleanSetting("revanced_hide_general_ads", TRUE);
+ public static final BooleanSetting HIDE_GET_PREMIUM = new BooleanSetting("revanced_hide_get_premium", TRUE);
+ public static final BooleanSetting HIDE_HIDE_LATEST_POSTS = new BooleanSetting("revanced_hide_latest_posts_ads", TRUE);
+ public static final BooleanSetting HIDE_MERCHANDISE_BANNERS = new BooleanSetting("revanced_hide_merchandise_banners", TRUE);
+ public static final BooleanSetting HIDE_PAID_PROMOTION_LABEL = new BooleanSetting("revanced_hide_paid_promotion_label", TRUE);
+ public static final BooleanSetting HIDE_PRODUCTS_BANNER = new BooleanSetting("revanced_hide_products_banner", TRUE);
+ public static final BooleanSetting HIDE_PLAYER_STORE_SHELF = new BooleanSetting("revanced_hide_player_store_shelf", TRUE);
+ public static final BooleanSetting HIDE_SHOPPING_LINKS = new BooleanSetting("revanced_hide_shopping_links", TRUE);
+ public static final BooleanSetting HIDE_SELF_SPONSOR = new BooleanSetting("revanced_hide_self_sponsor_ads", TRUE);
+ public static final BooleanSetting HIDE_VIDEO_ADS = new BooleanSetting("revanced_hide_video_ads", TRUE, true);
+ public static final BooleanSetting HIDE_VISIT_STORE_BUTTON = new BooleanSetting("revanced_hide_visit_store_button", TRUE);
+ public static final BooleanSetting HIDE_WEB_SEARCH_RESULTS = new BooleanSetting("revanced_hide_web_search_results", TRUE);
+
+ // Feed
+ public static final BooleanSetting HIDE_ALBUM_CARDS = new BooleanSetting("revanced_hide_album_cards", FALSE, true);
+ public static final BooleanSetting HIDE_ARTIST_CARDS = new BooleanSetting("revanced_hide_artist_cards", FALSE);
+ public static final BooleanSetting HIDE_EXPANDABLE_CHIP = new BooleanSetting("revanced_hide_expandable_chip", TRUE);
+ public static final BooleanSetting HIDE_DOODLES = new BooleanSetting("revanced_hide_doodles", FALSE, true, "revanced_hide_doodles_user_dialog_message");
+
+ // Alternative thumbnails
+ public static final EnumSetting ALT_THUMBNAIL_HOME = new EnumSetting<>("revanced_alt_thumbnail_home", ThumbnailOption.ORIGINAL);
+ public static final EnumSetting ALT_THUMBNAIL_SUBSCRIPTIONS = new EnumSetting<>("revanced_alt_thumbnail_subscription", ThumbnailOption.ORIGINAL);
+ public static final EnumSetting ALT_THUMBNAIL_LIBRARY = new EnumSetting<>("revanced_alt_thumbnail_library", ThumbnailOption.ORIGINAL);
+ public static final EnumSetting ALT_THUMBNAIL_PLAYER = new EnumSetting<>("revanced_alt_thumbnail_player", ThumbnailOption.ORIGINAL);
+ public static final EnumSetting ALT_THUMBNAIL_SEARCH = new EnumSetting<>("revanced_alt_thumbnail_search", ThumbnailOption.ORIGINAL);
+ public static final StringSetting ALT_THUMBNAIL_DEARROW_API_URL = new StringSetting("revanced_alt_thumbnail_dearrow_api_url",
+ "https://dearrow-thumb.ajay.app/api/v1/getThumbnail", true, new DeArrowAvailability());
+ public static final BooleanSetting ALT_THUMBNAIL_DEARROW_CONNECTION_TOAST = new BooleanSetting("revanced_alt_thumbnail_dearrow_connection_toast", TRUE, new DeArrowAvailability());
+ public static final EnumSetting ALT_THUMBNAIL_STILLS_TIME = new EnumSetting<>("revanced_alt_thumbnail_stills_time", ThumbnailStillTime.MIDDLE, new StillImagesAvailability());
+ public static final BooleanSetting ALT_THUMBNAIL_STILLS_FAST = new BooleanSetting("revanced_alt_thumbnail_stills_fast", FALSE, new StillImagesAvailability());
+
+ // Hide keyword content
+ public static final BooleanSetting HIDE_KEYWORD_CONTENT_HOME = new BooleanSetting("revanced_hide_keyword_content_home", FALSE);
+ public static final BooleanSetting HIDE_KEYWORD_CONTENT_SUBSCRIPTIONS = new BooleanSetting("revanced_hide_keyword_content_subscriptions", FALSE);
+ public static final BooleanSetting HIDE_KEYWORD_CONTENT_SEARCH = new BooleanSetting("revanced_hide_keyword_content_search", FALSE);
+ public static final StringSetting HIDE_KEYWORD_CONTENT_PHRASES = new StringSetting("revanced_hide_keyword_content_phrases", "",
+ parentsAny(HIDE_KEYWORD_CONTENT_HOME, HIDE_KEYWORD_CONTENT_SUBSCRIPTIONS, HIDE_KEYWORD_CONTENT_SEARCH));
+
+ // Uncategorized layout related settings. Do not add to this section, and instead move these out and categorize them.
+ public static final BooleanSetting DISABLE_SUGGESTED_VIDEO_END_SCREEN = new BooleanSetting("revanced_disable_suggested_video_end_screen", FALSE, true);
+ public static final BooleanSetting GRADIENT_LOADING_SCREEN = new BooleanSetting("revanced_gradient_loading_screen", FALSE);
+ public static final BooleanSetting HIDE_HORIZONTAL_SHELVES = new BooleanSetting("revanced_hide_horizontal_shelves", TRUE);
+ public static final BooleanSetting HIDE_CAPTIONS_BUTTON = new BooleanSetting("revanced_hide_captions_button", FALSE);
+ public static final BooleanSetting HIDE_CHANNEL_BAR = new BooleanSetting("revanced_hide_channel_bar", FALSE);
+ public static final BooleanSetting HIDE_CHANNEL_MEMBER_SHELF = new BooleanSetting("revanced_hide_channel_member_shelf", TRUE);
+ public static final BooleanSetting HIDE_CHIPS_SHELF = new BooleanSetting("revanced_hide_chips_shelf", TRUE);
+ public static final BooleanSetting HIDE_COMMUNITY_GUIDELINES = new BooleanSetting("revanced_hide_community_guidelines", TRUE);
+ public static final BooleanSetting HIDE_COMMUNITY_POSTS = new BooleanSetting("revanced_hide_community_posts", FALSE);
+ public static final BooleanSetting HIDE_COMPACT_BANNER = new BooleanSetting("revanced_hide_compact_banner", TRUE);
+ public static final BooleanSetting HIDE_CROWDFUNDING_BOX = new BooleanSetting("revanced_hide_crowdfunding_box", FALSE, true);
+ @Deprecated public static final BooleanSetting HIDE_EMAIL_ADDRESS = new BooleanSetting("revanced_hide_email_address", FALSE);
+ public static final BooleanSetting HIDE_EMERGENCY_BOX = new BooleanSetting("revanced_hide_emergency_box", TRUE);
+ public static final BooleanSetting HIDE_ENDSCREEN_CARDS = new BooleanSetting("revanced_hide_endscreen_cards", FALSE);
+ public static final BooleanSetting HIDE_FEED_SURVEY = new BooleanSetting("revanced_hide_feed_survey", TRUE);
+ public static final BooleanSetting HIDE_FILTER_BAR_FEED_IN_FEED = new BooleanSetting("revanced_hide_filter_bar_feed_in_feed", FALSE, true);
+ public static final BooleanSetting HIDE_FILTER_BAR_FEED_IN_RELATED_VIDEOS = new BooleanSetting("revanced_hide_filter_bar_feed_in_related_videos", FALSE, true);
+ public static final BooleanSetting HIDE_FILTER_BAR_FEED_IN_SEARCH = new BooleanSetting("revanced_hide_filter_bar_feed_in_search", FALSE, true);
+ public static final BooleanSetting HIDE_FLOATING_MICROPHONE_BUTTON = new BooleanSetting("revanced_hide_floating_microphone_button", TRUE, true);
+ public static final BooleanSetting HIDE_FULLSCREEN_PANELS = new BooleanSetting("revanced_hide_fullscreen_panels", TRUE, true);
+ public static final BooleanSetting HIDE_GRAY_SEPARATOR = new BooleanSetting("revanced_hide_gray_separator", TRUE);
+ public static final BooleanSetting HIDE_HIDE_CHANNEL_GUIDELINES = new BooleanSetting("revanced_hide_channel_guidelines", TRUE);
+ public static final BooleanSetting HIDE_HIDE_INFO_PANELS = new BooleanSetting("revanced_hide_info_panels", TRUE);
+ public static final BooleanSetting HIDE_IMAGE_SHELF = new BooleanSetting("revanced_hide_image_shelf", TRUE);
+ public static final BooleanSetting HIDE_INFO_CARDS = new BooleanSetting("revanced_hide_info_cards", FALSE);
+ public static final BooleanSetting HIDE_JOIN_MEMBERSHIP_BUTTON = new BooleanSetting("revanced_hide_join_membership_button", TRUE);
+ @Deprecated public static final BooleanSetting HIDE_LOAD_MORE_BUTTON = new BooleanSetting("revanced_hide_load_more_button", TRUE);
+ public static final BooleanSetting HIDE_SHOW_MORE_BUTTON = new BooleanSetting("revanced_hide_show_more_button", TRUE, true);
+ public static final BooleanSetting HIDE_MEDICAL_PANELS = new BooleanSetting("revanced_hide_medical_panels", TRUE);
+ public static final BooleanSetting HIDE_MIX_PLAYLISTS = new BooleanSetting("revanced_hide_mix_playlists", TRUE);
+ public static final BooleanSetting HIDE_MOVIES_SECTION = new BooleanSetting("revanced_hide_movies_section", TRUE);
+ public static final BooleanSetting HIDE_NOTIFY_ME_BUTTON = new BooleanSetting("revanced_hide_notify_me_button", TRUE);
+ public static final BooleanSetting HIDE_PLAYABLES = new BooleanSetting("revanced_hide_playables", TRUE);
+ public static final BooleanSetting HIDE_QUICK_ACTIONS = new BooleanSetting("revanced_hide_quick_actions", FALSE);
+ public static final BooleanSetting HIDE_RELATED_VIDEOS = new BooleanSetting("revanced_hide_related_videos", FALSE);
+ public static final BooleanSetting HIDE_SEARCH_RESULT_SHELF_HEADER = new BooleanSetting("revanced_hide_search_result_shelf_header", FALSE);
+ public static final BooleanSetting HIDE_SUBSCRIBERS_COMMUNITY_GUIDELINES = new BooleanSetting("revanced_hide_subscribers_community_guidelines", TRUE);
+ public static final BooleanSetting HIDE_TIMED_REACTIONS = new BooleanSetting("revanced_hide_timed_reactions", TRUE);
+ public static final BooleanSetting HIDE_TIMESTAMP = new BooleanSetting("revanced_hide_timestamp", FALSE);
+ public static final BooleanSetting HIDE_VIDEO_CHANNEL_WATERMARK = new BooleanSetting("revanced_hide_channel_watermark", TRUE);
+ public static final BooleanSetting HIDE_FOR_YOU_SHELF = new BooleanSetting("revanced_hide_for_you_shelf", TRUE);
+ public static final BooleanSetting HIDE_SEARCH_RESULT_RECOMMENDATIONS = new BooleanSetting("revanced_hide_search_result_recommendations", TRUE);
+ public static final IntegerSetting PLAYER_OVERLAY_OPACITY = new IntegerSetting("revanced_player_overlay_opacity",100, true);
+ public static final BooleanSetting PLAYER_POPUP_PANELS = new BooleanSetting("revanced_hide_player_popup_panels", FALSE);
+
+ // Player
+ public static final BooleanSetting DISABLE_FULLSCREEN_AMBIENT_MODE = new BooleanSetting("revanced_disable_fullscreen_ambient_mode", TRUE, true);
+ public static final BooleanSetting DISABLE_ROLLING_NUMBER_ANIMATIONS = new BooleanSetting("revanced_disable_rolling_number_animations", FALSE);
+ public static final BooleanSetting DISABLE_LIKE_SUBSCRIBE_GLOW = new BooleanSetting("revanced_disable_like_subscribe_glow", FALSE);
+ public static final BooleanSetting HIDE_AUTOPLAY_BUTTON = new BooleanSetting("revanced_hide_autoplay_button", TRUE, true);
+ public static final BooleanSetting HIDE_CAST_BUTTON = new BooleanSetting("revanced_hide_cast_button", TRUE, true);
+ public static final BooleanSetting HIDE_PLAYER_PREVIOUS_NEXT_BUTTONS = new BooleanSetting("revanced_hide_player_previous_next_buttons", FALSE, true);
+ @Deprecated
+ public static final BooleanSetting HIDE_PLAYER_BUTTONS = new BooleanSetting("revanced_hide_player_buttons", FALSE, true);
+ public static final BooleanSetting COPY_VIDEO_URL = new BooleanSetting("revanced_copy_video_url", FALSE);
+ public static final BooleanSetting COPY_VIDEO_URL_TIMESTAMP = new BooleanSetting("revanced_copy_video_url_timestamp", TRUE);
+ public static final BooleanSetting PLAYBACK_SPEED_DIALOG_BUTTON = new BooleanSetting("revanced_playback_speed_dialog_button", FALSE);
+
+ // Miniplayer
+ public static final EnumSetting MINIPLAYER_TYPE = new EnumSetting<>("revanced_miniplayer_type", MiniplayerType.ORIGINAL, true);
+ private static final Availability MINIPLAYER_ANY_MODERN = MINIPLAYER_TYPE.availability(MODERN_1, MODERN_2, MODERN_3, MODERN_4);
+ public static final BooleanSetting MINIPLAYER_DOUBLE_TAP_ACTION = new BooleanSetting("revanced_miniplayer_double_tap_action", TRUE, true, MINIPLAYER_ANY_MODERN);
+ public static final BooleanSetting MINIPLAYER_DRAG_AND_DROP = new BooleanSetting("revanced_miniplayer_drag_and_drop", TRUE, true, MINIPLAYER_ANY_MODERN);
+ public static final BooleanSetting MINIPLAYER_HIDE_EXPAND_CLOSE = new BooleanSetting("revanced_miniplayer_hide_expand_close", FALSE, true, new MiniplayerHideExpandCloseAvailability());
+ public static final BooleanSetting MINIPLAYER_HIDE_SUBTEXT = new BooleanSetting("revanced_miniplayer_hide_subtext", FALSE, true, MINIPLAYER_TYPE.availability(MODERN_1, MODERN_3));
+ public static final BooleanSetting MINIPLAYER_HIDE_REWIND_FORWARD = new BooleanSetting("revanced_miniplayer_hide_rewind_forward", FALSE, true, MINIPLAYER_TYPE.availability(MODERN_1));
+ public static final BooleanSetting MINIPLAYER_ROUNDED_CORNERS = new BooleanSetting("revanced_miniplayer_rounded_corners", TRUE, true, MINIPLAYER_ANY_MODERN);
+ public static final IntegerSetting MINIPLAYER_WIDTH_DIP = new IntegerSetting("revanced_miniplayer_width_dip", 192, true, MINIPLAYER_ANY_MODERN);
+ public static final IntegerSetting MINIPLAYER_OPACITY = new IntegerSetting("revanced_miniplayer_opacity", 100, true, MINIPLAYER_TYPE.availability(MODERN_1));
+
+ // External downloader
+ public static final BooleanSetting EXTERNAL_DOWNLOADER = new BooleanSetting("revanced_external_downloader", FALSE);
+ public static final BooleanSetting EXTERNAL_DOWNLOADER_ACTION_BUTTON = new BooleanSetting("revanced_external_downloader_action_button", FALSE);
+ public static final StringSetting EXTERNAL_DOWNLOADER_PACKAGE_NAME = new StringSetting("revanced_external_downloader_name",
+ "org.schabi.newpipe" /* NewPipe */, parentsAny(EXTERNAL_DOWNLOADER, EXTERNAL_DOWNLOADER_ACTION_BUTTON));
+
+ // Comments
+ public static final BooleanSetting HIDE_COMMENTS_BY_MEMBERS_HEADER = new BooleanSetting("revanced_hide_comments_by_members_header", FALSE);
+ public static final BooleanSetting HIDE_COMMENTS_SECTION = new BooleanSetting("revanced_hide_comments_section", FALSE);
+ public static final BooleanSetting HIDE_COMMENTS_CREATE_A_SHORT_BUTTON = new BooleanSetting("revanced_hide_comments_create_a_short_button", TRUE);
+ public static final BooleanSetting HIDE_COMMENTS_PREVIEW_COMMENT = new BooleanSetting("revanced_hide_comments_preview_comment", FALSE);
+ public static final BooleanSetting HIDE_COMMENTS_THANKS_BUTTON = new BooleanSetting("revanced_hide_comments_thanks_button", TRUE);
+ public static final BooleanSetting HIDE_COMMENTS_TIMESTAMP_AND_EMOJI_BUTTONS = new BooleanSetting("revanced_hide_comments_timestamp_and_emoji_buttons", TRUE);
+
+ // Description
+ public static final BooleanSetting HIDE_ATTRIBUTES_SECTION = new BooleanSetting("revanced_hide_attributes_section", FALSE);
+ public static final BooleanSetting HIDE_CHAPTERS_SECTION = new BooleanSetting("revanced_hide_chapters_section", TRUE);
+ public static final BooleanSetting HIDE_INFO_CARDS_SECTION = new BooleanSetting("revanced_hide_info_cards_section", TRUE);
+ public static final BooleanSetting HIDE_KEY_CONCEPTS_SECTION = new BooleanSetting("revanced_hide_key_concepts_section", FALSE);
+ public static final BooleanSetting HIDE_PODCAST_SECTION = new BooleanSetting("revanced_hide_podcast_section", TRUE);
+ public static final BooleanSetting HIDE_TRANSCRIPT_SECTION = new BooleanSetting("revanced_hide_transcript_section", TRUE);
+
+ // Action buttons
+ public static final BooleanSetting HIDE_LIKE_DISLIKE_BUTTON = new BooleanSetting("revanced_hide_like_dislike_button", FALSE);
+ public static final BooleanSetting HIDE_SHARE_BUTTON = new BooleanSetting("revanced_hide_share_button", FALSE);
+ public static final BooleanSetting HIDE_REPORT_BUTTON = new BooleanSetting("revanced_hide_report_button", FALSE);
+ public static final BooleanSetting HIDE_REMIX_BUTTON = new BooleanSetting("revanced_hide_remix_button", TRUE);
+ public static final BooleanSetting HIDE_DOWNLOAD_BUTTON = new BooleanSetting("revanced_hide_download_button", FALSE);
+ public static final BooleanSetting HIDE_THANKS_BUTTON = new BooleanSetting("revanced_hide_thanks_button", TRUE);
+ public static final BooleanSetting HIDE_CLIP_BUTTON = new BooleanSetting("revanced_hide_clip_button", TRUE);
+ public static final BooleanSetting HIDE_PLAYLIST_BUTTON = new BooleanSetting("revanced_hide_playlist_button", FALSE);
+
+ // Player flyout menu items
+ public static final BooleanSetting HIDE_CAPTIONS_MENU = new BooleanSetting("revanced_hide_player_flyout_captions", FALSE);
+ public static final BooleanSetting HIDE_ADDITIONAL_SETTINGS_MENU = new BooleanSetting("revanced_hide_player_flyout_additional_settings", FALSE);
+ public static final BooleanSetting HIDE_LOOP_VIDEO_MENU = new BooleanSetting("revanced_hide_player_flyout_loop_video", FALSE);
+ public static final BooleanSetting HIDE_AMBIENT_MODE_MENU = new BooleanSetting("revanced_hide_player_flyout_ambient_mode", FALSE);
+ public static final BooleanSetting HIDE_HELP_MENU = new BooleanSetting("revanced_hide_player_flyout_help", TRUE);
+ public static final BooleanSetting HIDE_SPEED_MENU = new BooleanSetting("revanced_hide_player_flyout_speed", FALSE);
+ public static final BooleanSetting HIDE_MORE_INFO_MENU = new BooleanSetting("revanced_hide_player_flyout_more_info", TRUE);
+ public static final BooleanSetting HIDE_LOCK_SCREEN_MENU = new BooleanSetting("revanced_hide_player_flyout_lock_screen", FALSE);
+ public static final BooleanSetting HIDE_AUDIO_TRACK_MENU = new BooleanSetting("revanced_hide_player_flyout_audio_track", FALSE);
+ public static final BooleanSetting HIDE_WATCH_IN_VR_MENU = new BooleanSetting("revanced_hide_player_flyout_watch_in_vr", TRUE);
+ public static final BooleanSetting HIDE_VIDEO_QUALITY_MENU_FOOTER = new BooleanSetting("revanced_hide_video_quality_menu_footer", FALSE);
+
+ // General layout
+ public static final EnumSetting CHANGE_START_PAGE = new EnumSetting<>("revanced_change_start_page", StartPage.ORIGINAL, true);
+ public static final BooleanSetting SPOOF_APP_VERSION = new BooleanSetting("revanced_spoof_app_version", FALSE, true, "revanced_spoof_app_version_user_dialog_message");
+ public static final StringSetting SPOOF_APP_VERSION_TARGET = new StringSetting("revanced_spoof_app_version_target", "17.33.42", true, parent(SPOOF_APP_VERSION));
+ public static final BooleanSetting TABLET_LAYOUT = new BooleanSetting("revanced_tablet_layout", FALSE, true, "revanced_tablet_layout_user_dialog_message");
+ public static final BooleanSetting WIDE_SEARCHBAR = new BooleanSetting("revanced_wide_searchbar", FALSE, true);
+ public static final BooleanSetting BYPASS_IMAGE_REGION_RESTRICTIONS = new BooleanSetting("revanced_bypass_image_region_restrictions", FALSE, true);
+ public static final BooleanSetting REMOVE_VIEWER_DISCRETION_DIALOG = new BooleanSetting("revanced_remove_viewer_discretion_dialog", FALSE,
+ "revanced_remove_viewer_discretion_dialog_user_dialog_message");
+
+ // Custom filter
+ public static final BooleanSetting CUSTOM_FILTER = new BooleanSetting("revanced_custom_filter", FALSE);
+ public static final StringSetting CUSTOM_FILTER_STRINGS = new StringSetting("revanced_custom_filter_strings", "", true, parent(CUSTOM_FILTER));
+
+ // Navigation buttons
+ public static final BooleanSetting HIDE_HOME_BUTTON = new BooleanSetting("revanced_hide_home_button", FALSE, true);
+ public static final BooleanSetting HIDE_CREATE_BUTTON = new BooleanSetting("revanced_hide_create_button", TRUE, true);
+ public static final BooleanSetting HIDE_SHORTS_BUTTON = new BooleanSetting("revanced_hide_shorts_button", TRUE, true);
+ public static final BooleanSetting HIDE_SUBSCRIPTIONS_BUTTON = new BooleanSetting("revanced_hide_subscriptions_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_BUTTON_LABELS = new BooleanSetting("revanced_hide_navigation_button_labels", FALSE, true);
+ public static final BooleanSetting SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON = new BooleanSetting("revanced_switch_create_with_notifications_button", TRUE, true);
+
+ // Shorts
+ public static final BooleanSetting DISABLE_RESUMING_SHORTS_PLAYER = new BooleanSetting("revanced_disable_resuming_shorts_player", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_HOME = new BooleanSetting("revanced_hide_shorts_home", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_SUBSCRIPTIONS = new BooleanSetting("revanced_hide_shorts_subscriptions", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_SEARCH = new BooleanSetting("revanced_hide_shorts_search", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_JOIN_BUTTON = new BooleanSetting("revanced_hide_shorts_join_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_SUBSCRIBE_BUTTON = new BooleanSetting("revanced_hide_shorts_subscribe_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_PAUSED_OVERLAY_BUTTONS = new BooleanSetting("revanced_hide_shorts_paused_overlay_buttons", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_SHOP_BUTTON = new BooleanSetting("revanced_hide_shorts_shop_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_TAGGED_PRODUCTS = new BooleanSetting("revanced_hide_shorts_tagged_products", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_LOCATION_LABEL = new BooleanSetting("revanced_hide_shorts_location_label", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_SAVE_SOUND_BUTTON = new BooleanSetting("revanced_hide_shorts_save_sound_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_USE_TEMPLATE_BUTTON = new BooleanSetting("revanced_hide_shorts_use_template_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_UPCOMING_BUTTON = new BooleanSetting("revanced_hide_shorts_upcoming_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_GREEN_SCREEN_BUTTON = new BooleanSetting("revanced_hide_shorts_green_screen_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_HASHTAG_BUTTON = new BooleanSetting("revanced_hide_shorts_hashtag_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_SEARCH_SUGGESTIONS = new BooleanSetting("revanced_hide_shorts_search_suggestions", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_STICKERS = new BooleanSetting("revanced_hide_shorts_stickers", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_SUPER_THANKS_BUTTON = new BooleanSetting("revanced_hide_shorts_super_thanks_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_LIKE_FOUNTAIN = new BooleanSetting("revanced_hide_shorts_like_fountain", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_LIKE_BUTTON = new BooleanSetting("revanced_hide_shorts_like_button", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_DISLIKE_BUTTON = new BooleanSetting("revanced_hide_shorts_dislike_button", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_COMMENTS_BUTTON = new BooleanSetting("revanced_hide_shorts_comments_button", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_REMIX_BUTTON = new BooleanSetting("revanced_hide_shorts_remix_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_SHARE_BUTTON = new BooleanSetting("revanced_hide_shorts_share_button", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_INFO_PANEL = new BooleanSetting("revanced_hide_shorts_info_panel", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_SOUND_BUTTON = new BooleanSetting("revanced_hide_shorts_sound_button", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_CHANNEL_BAR = new BooleanSetting("revanced_hide_shorts_channel_bar", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_VIDEO_TITLE = new BooleanSetting("revanced_hide_shorts_video_title", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_SOUND_METADATA_LABEL = new BooleanSetting("revanced_hide_shorts_sound_metadata_label", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_FULL_VIDEO_LINK_LABEL = new BooleanSetting("revanced_hide_shorts_full_video_link_label", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_NAVIGATION_BAR = new BooleanSetting("revanced_hide_shorts_navigation_bar", FALSE, true);
+ public static final BooleanSetting SHORTS_AUTOPLAY = new BooleanSetting("revanced_shorts_autoplay", FALSE);
+ public static final BooleanSetting SHORTS_AUTOPLAY_BACKGROUND = new BooleanSetting("revanced_shorts_autoplay_background", TRUE);
+
+ // Seekbar
+ public static final BooleanSetting DISABLE_PRECISE_SEEKING_GESTURE = new BooleanSetting("revanced_disable_precise_seeking_gesture", TRUE);
+ public static final BooleanSetting SEEKBAR_TAPPING = new BooleanSetting("revanced_seekbar_tapping", TRUE);
+ public static final BooleanSetting SLIDE_TO_SEEK = new BooleanSetting("revanced_slide_to_seek", FALSE, true);
+ public static final BooleanSetting RESTORE_OLD_SEEKBAR_THUMBNAILS = new BooleanSetting("revanced_restore_old_seekbar_thumbnails", TRUE);
+ public static final BooleanSetting HIDE_SEEKBAR = new BooleanSetting("revanced_hide_seekbar", FALSE, true);
+ public static final BooleanSetting HIDE_SEEKBAR_THUMBNAIL = new BooleanSetting("revanced_hide_seekbar_thumbnail", FALSE);
+ public static final BooleanSetting SEEKBAR_CUSTOM_COLOR = new BooleanSetting("revanced_seekbar_custom_color", FALSE, true);
+ public static final StringSetting SEEKBAR_CUSTOM_COLOR_VALUE = new StringSetting("revanced_seekbar_custom_color_value", "#FF0000", true, parent(SEEKBAR_CUSTOM_COLOR));
+
+ // Misc
+ public static final BooleanSetting AUTO_CAPTIONS = new BooleanSetting("revanced_auto_captions", FALSE);
+ public static final BooleanSetting DISABLE_ZOOM_HAPTICS = new BooleanSetting("revanced_disable_zoom_haptics", TRUE);
+ public static final BooleanSetting EXTERNAL_BROWSER = new BooleanSetting("revanced_external_browser", TRUE, true);
+ public static final BooleanSetting AUTO_REPEAT = new BooleanSetting("revanced_auto_repeat", FALSE);
+ public static final BooleanSetting SPOOF_DEVICE_DIMENSIONS = new BooleanSetting("revanced_spoof_device_dimensions", FALSE, true,
+ "revanced_spoof_device_dimensions_user_dialog_message");
+ public static final BooleanSetting BYPASS_URL_REDIRECTS = new BooleanSetting("revanced_bypass_url_redirects", TRUE);
+ public static final BooleanSetting ANNOUNCEMENTS = new BooleanSetting("revanced_announcements", TRUE);
+ public static final BooleanSetting SPOOF_VIDEO_STREAMS = new BooleanSetting("revanced_spoof_video_streams", TRUE, true,"revanced_spoof_video_streams_user_dialog_message");
+ public static final BooleanSetting SPOOF_VIDEO_STREAMS_IOS_FORCE_AVC = new BooleanSetting("revanced_spoof_video_streams_ios_force_avc", FALSE, true,
+ "revanced_spoof_video_streams_ios_force_avc_user_dialog_message", new SpoofVideoStreamsPatch.ForceiOSAVCAvailability());
+ public static final EnumSetting SPOOF_VIDEO_STREAMS_CLIENT_TYPE = new EnumSetting<>("revanced_spoof_video_streams_client", ClientType.ANDROID_VR, true, parent(SPOOF_VIDEO_STREAMS));
+ @Deprecated
+ public static final StringSetting DEPRECATED_ANNOUNCEMENT_LAST_HASH = new StringSetting("revanced_announcement_last_hash", "");
+ public static final IntegerSetting ANNOUNCEMENT_LAST_ID = new IntegerSetting("revanced_announcement_last_id", -1);
+ public static final BooleanSetting CHECK_WATCH_HISTORY_DOMAIN_NAME = new BooleanSetting("revanced_check_watch_history_domain_name", TRUE, false, false);
+ public static final BooleanSetting REMOVE_TRACKING_QUERY_PARAMETER = new BooleanSetting("revanced_remove_tracking_query_parameter", TRUE);
+ public static final IntegerSetting CHECK_ENVIRONMENT_WARNINGS_ISSUED = new IntegerSetting("revanced_check_environment_warnings_issued", 0, true, false);
+
+ // Debugging
+ /**
+ * When enabled, share the debug logs with care.
+ * The buffer contains select user data, including the client ip address and information that could identify the end user.
+ */
+ public static final BooleanSetting DEBUG_PROTOBUFFER = new BooleanSetting("revanced_debug_protobuffer", FALSE, parent(BaseSettings.DEBUG));
+
+ // Old deprecated signature spoofing
+ @Deprecated public static final BooleanSetting SPOOF_SIGNATURE = new BooleanSetting("revanced_spoof_signature_verification_enabled", TRUE, true, false,
+ "revanced_spoof_signature_verification_enabled_user_dialog_message", null);
+ @Deprecated public static final BooleanSetting SPOOF_SIGNATURE_IN_FEED = new BooleanSetting("revanced_spoof_signature_in_feed_enabled", FALSE, false, false, null,
+ parent(SPOOF_SIGNATURE));
+ @Deprecated public static final BooleanSetting SPOOF_STORYBOARD_RENDERER = new BooleanSetting("revanced_spoof_storyboard", TRUE, true, false, null,
+ parent(SPOOF_SIGNATURE));
+
+ // Swipe controls
+ public static final BooleanSetting SWIPE_BRIGHTNESS = new BooleanSetting("revanced_swipe_brightness", TRUE);
+ public static final BooleanSetting SWIPE_VOLUME = new BooleanSetting("revanced_swipe_volume", TRUE);
+ public static final BooleanSetting SWIPE_PRESS_TO_ENGAGE = new BooleanSetting("revanced_swipe_press_to_engage", FALSE, true,
+ parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME));
+ public static final BooleanSetting SWIPE_HAPTIC_FEEDBACK = new BooleanSetting("revanced_swipe_haptic_feedback", TRUE, true,
+ parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME));
+ public static final IntegerSetting SWIPE_MAGNITUDE_THRESHOLD = new IntegerSetting("revanced_swipe_threshold", 30, true,
+ parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME));
+ public static final IntegerSetting SWIPE_OVERLAY_BACKGROUND_ALPHA = new IntegerSetting("revanced_swipe_overlay_background_alpha", 127, true,
+ parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME));
+ public static final IntegerSetting SWIPE_OVERLAY_TEXT_SIZE = new IntegerSetting("revanced_swipe_text_overlay_size", 22, true,
+ parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME));
+ public static final LongSetting SWIPE_OVERLAY_TIMEOUT = new LongSetting("revanced_swipe_overlay_timeout", 500L, true,
+ parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME));
+ public static final BooleanSetting SWIPE_SAVE_AND_RESTORE_BRIGHTNESS = new BooleanSetting("revanced_swipe_save_and_restore_brightness", TRUE, true, parent(SWIPE_BRIGHTNESS));
+ public static final FloatSetting SWIPE_BRIGHTNESS_VALUE = new FloatSetting("revanced_swipe_brightness_value", -1f);
+ public static final BooleanSetting SWIPE_LOWEST_VALUE_ENABLE_AUTO_BRIGHTNESS = new BooleanSetting("revanced_swipe_lowest_value_enable_auto_brightness", FALSE, true, parent(SWIPE_BRIGHTNESS));
+
+ // ReturnYoutubeDislike
+ public static final BooleanSetting RYD_ENABLED = new BooleanSetting("ryd_enabled", TRUE);
+ public static final StringSetting RYD_USER_ID = new StringSetting("ryd_user_id", "", false, false);
+ public static final BooleanSetting RYD_SHORTS = new BooleanSetting("ryd_shorts", TRUE, parent(RYD_ENABLED));
+ public static final BooleanSetting RYD_DISLIKE_PERCENTAGE = new BooleanSetting("ryd_dislike_percentage", FALSE, parent(RYD_ENABLED));
+ public static final BooleanSetting RYD_COMPACT_LAYOUT = new BooleanSetting("ryd_compact_layout", FALSE, parent(RYD_ENABLED));
+ public static final BooleanSetting RYD_TOAST_ON_CONNECTION_ERROR = new BooleanSetting("ryd_toast_on_connection_error", TRUE, parent(RYD_ENABLED));
+
+ // SponsorBlock
+ public static final BooleanSetting SB_ENABLED = new BooleanSetting("sb_enabled", TRUE);
+ /**
+ * Do not use directly, instead use {@link SponsorBlockSettings}
+ */
+ public static final StringSetting SB_PRIVATE_USER_ID = new StringSetting("sb_private_user_id_Do_Not_Share", "");
+ @Deprecated
+ public static final StringSetting DEPRECATED_SB_UUID_OLD_MIGRATION_SETTING = new StringSetting("uuid", ""); // Delete sometime in 2024
+ public static final IntegerSetting SB_CREATE_NEW_SEGMENT_STEP = new IntegerSetting("sb_create_new_segment_step", 150, parent(SB_ENABLED));
+ public static final BooleanSetting SB_VOTING_BUTTON = new BooleanSetting("sb_voting_button", FALSE, parent(SB_ENABLED));
+ public static final BooleanSetting SB_CREATE_NEW_SEGMENT = new BooleanSetting("sb_create_new_segment", FALSE, parent(SB_ENABLED));
+ public static final BooleanSetting SB_COMPACT_SKIP_BUTTON = new BooleanSetting("sb_compact_skip_button", FALSE, parent(SB_ENABLED));
+ public static final BooleanSetting SB_AUTO_HIDE_SKIP_BUTTON = new BooleanSetting("sb_auto_hide_skip_button", TRUE, parent(SB_ENABLED));
+ public static final BooleanSetting SB_TOAST_ON_SKIP = new BooleanSetting("sb_toast_on_skip", TRUE, parent(SB_ENABLED));
+ public static final BooleanSetting SB_TOAST_ON_CONNECTION_ERROR = new BooleanSetting("sb_toast_on_connection_error", TRUE, parent(SB_ENABLED));
+ public static final BooleanSetting SB_TRACK_SKIP_COUNT = new BooleanSetting("sb_track_skip_count", TRUE, parent(SB_ENABLED));
+ public static final FloatSetting SB_SEGMENT_MIN_DURATION = new FloatSetting("sb_min_segment_duration", 0F, parent(SB_ENABLED));
+ public static final BooleanSetting SB_VIDEO_LENGTH_WITHOUT_SEGMENTS = new BooleanSetting("sb_video_length_without_segments", TRUE, parent(SB_ENABLED));
+ public static final StringSetting SB_API_URL = new StringSetting("sb_api_url","https://sponsor.ajay.app");
+ public static final BooleanSetting SB_USER_IS_VIP = new BooleanSetting("sb_user_is_vip", FALSE);
+ public static final IntegerSetting SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS = new IntegerSetting("sb_local_time_saved_number_segments", 0);
+ public static final LongSetting SB_LOCAL_TIME_SAVED_MILLISECONDS = new LongSetting("sb_local_time_saved_milliseconds", 0L);
+ public static final LongSetting SB_LAST_VIP_CHECK = new LongSetting("sb_last_vip_check", 0L, false, false);
+ public static final BooleanSetting SB_HIDE_EXPORT_WARNING = new BooleanSetting("sb_hide_export_warning", FALSE, false, false);
+ public static final BooleanSetting SB_SEEN_GUIDELINES = new BooleanSetting("sb_seen_guidelines", FALSE, false, false);
+
+ public static final StringSetting SB_CATEGORY_SPONSOR = new StringSetting("sb_sponsor", SKIP_AUTOMATICALLY_ONCE.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_SPONSOR_COLOR = new StringSetting("sb_sponsor_color","#00D400");
+ public static final StringSetting SB_CATEGORY_SELF_PROMO = new StringSetting("sb_selfpromo", MANUAL_SKIP.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_SELF_PROMO_COLOR = new StringSetting("sb_selfpromo_color","#FFFF00");
+ public static final StringSetting SB_CATEGORY_INTERACTION = new StringSetting("sb_interaction", MANUAL_SKIP.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_INTERACTION_COLOR = new StringSetting("sb_interaction_color","#CC00FF");
+ public static final StringSetting SB_CATEGORY_HIGHLIGHT = new StringSetting("sb_highlight", MANUAL_SKIP.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_HIGHLIGHT_COLOR = new StringSetting("sb_highlight_color","#FF1684");
+ public static final StringSetting SB_CATEGORY_INTRO = new StringSetting("sb_intro", MANUAL_SKIP.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_INTRO_COLOR = new StringSetting("sb_intro_color","#00FFFF");
+ public static final StringSetting SB_CATEGORY_OUTRO = new StringSetting("sb_outro", MANUAL_SKIP.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_OUTRO_COLOR = new StringSetting("sb_outro_color","#0202ED");
+ public static final StringSetting SB_CATEGORY_PREVIEW = new StringSetting("sb_preview", IGNORE.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_PREVIEW_COLOR = new StringSetting("sb_preview_color","#008FD6");
+ public static final StringSetting SB_CATEGORY_FILLER = new StringSetting("sb_filler", IGNORE.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_FILLER_COLOR = new StringSetting("sb_filler_color","#7300FF");
+ public static final StringSetting SB_CATEGORY_MUSIC_OFFTOPIC = new StringSetting("sb_music_offtopic", MANUAL_SKIP.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_MUSIC_OFFTOPIC_COLOR = new StringSetting("sb_music_offtopic_color","#FF9900");
+ public static final StringSetting SB_CATEGORY_UNSUBMITTED = new StringSetting("sb_unsubmitted", SKIP_AUTOMATICALLY.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_UNSUBMITTED_COLOR = new StringSetting("sb_unsubmitted_color","#FFFFFF");
+
+ static {
+ // region Migration
+
+ // Migrate settings from old Preference categories into replacement "revanced_prefs" category.
+ // This region must run before all other migration code.
+
+ // The YT and RYD migration portion of this can be removed anytime,
+ // but the SB migration should remain until late 2024 or early 2025
+ // because it migrates the SB private user id which cannot be recovered if lost.
+
+ // Categories were previously saved without a 'sb_' key prefix, so they need an additional adjustment.
+ Set> sbCategories = new HashSet<>(Arrays.asList(
+ SB_CATEGORY_SPONSOR,
+ SB_CATEGORY_SPONSOR_COLOR,
+ SB_CATEGORY_SELF_PROMO,
+ SB_CATEGORY_SELF_PROMO_COLOR,
+ SB_CATEGORY_INTERACTION,
+ SB_CATEGORY_INTERACTION_COLOR,
+ SB_CATEGORY_HIGHLIGHT,
+ SB_CATEGORY_HIGHLIGHT_COLOR,
+ SB_CATEGORY_INTRO,
+ SB_CATEGORY_INTRO_COLOR,
+ SB_CATEGORY_OUTRO,
+ SB_CATEGORY_OUTRO_COLOR,
+ SB_CATEGORY_PREVIEW,
+ SB_CATEGORY_PREVIEW_COLOR,
+ SB_CATEGORY_FILLER,
+ SB_CATEGORY_FILLER_COLOR,
+ SB_CATEGORY_MUSIC_OFFTOPIC,
+ SB_CATEGORY_MUSIC_OFFTOPIC_COLOR,
+ SB_CATEGORY_UNSUBMITTED,
+ SB_CATEGORY_UNSUBMITTED_COLOR));
+
+ SharedPrefCategory ytPrefs = new SharedPrefCategory("youtube");
+ SharedPrefCategory rydPrefs = new SharedPrefCategory("ryd");
+ SharedPrefCategory sbPrefs = new SharedPrefCategory("sponsor-block");
+ for (Setting> setting : Setting.allLoadedSettings()) {
+ String key = setting.key;
+ if (setting.key.startsWith("sb_")) {
+ if (sbCategories.contains(setting)) {
+ key = key.substring(3); // Remove the "sb_" prefix, as old categories are saved without it.
+ }
+ migrateFromOldPreferences(sbPrefs, setting, key);
+ } else if (setting.key.startsWith("ryd_")) {
+ migrateFromOldPreferences(rydPrefs, setting, key);
+ } else {
+ migrateFromOldPreferences(ytPrefs, setting, key);
+ }
+ }
+
+
+ // Do _not_ delete this SB private user id migration property until sometime in 2024.
+ // This is the only setting that cannot be reconfigured if lost,
+ // and more time should be given for users who rarely upgrade.
+ migrateOldSettingToNew(DEPRECATED_SB_UUID_OLD_MIGRATION_SETTING, SB_PRIVATE_USER_ID);
+
+
+ // Old spoof versions that no longer work reliably.
+ if (SpoofAppVersionPatch.isSpoofingToLessThan("17.33.00")) {
+ Logger.printInfo(() -> "Resetting spoof app version target");
+ Settings.SPOOF_APP_VERSION_TARGET.resetToDefault();
+ }
+
+
+ // Remove any previously saved announcement consumer (a random generated string).
+ Setting.preferences.removeKey("revanced_announcement_consumer");
+
+ migrateOldSettingToNew(HIDE_LOAD_MORE_BUTTON, HIDE_SHOW_MORE_BUTTON);
+
+ migrateOldSettingToNew(HIDE_PLAYER_BUTTONS, HIDE_PLAYER_PREVIOUS_NEXT_BUTTONS);
+
+ // endregion
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/AlternativeThumbnailsAboutDeArrowPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/AlternativeThumbnailsAboutDeArrowPreference.java
new file mode 100644
index 000000000..5ca2e65dc
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/AlternativeThumbnailsAboutDeArrowPreference.java
@@ -0,0 +1,35 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.preference.Preference;
+import android.util.AttributeSet;
+
+/**
+ * Allows tapping the DeArrow about preference to open the DeArrow website.
+ */
+@SuppressWarnings({"unused", "deprecation"})
+public class AlternativeThumbnailsAboutDeArrowPreference extends Preference {
+ {
+ setOnPreferenceClickListener(pref -> {
+ Intent i = new Intent(Intent.ACTION_VIEW);
+ i.setData(Uri.parse("https://dearrow.ajay.app"));
+ pref.getContext().startActivity(i);
+ return false;
+ });
+ }
+
+ public AlternativeThumbnailsAboutDeArrowPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+ public AlternativeThumbnailsAboutDeArrowPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+ public AlternativeThumbnailsAboutDeArrowPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public AlternativeThumbnailsAboutDeArrowPreference(Context context) {
+ super(context);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ForceAVCSpoofingPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ForceAVCSpoofingPreference.java
new file mode 100644
index 000000000..558175675
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ForceAVCSpoofingPreference.java
@@ -0,0 +1,61 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.youtube.patches.spoof.DeviceHardwareSupport.DEVICE_HAS_HARDWARE_DECODING_VP9;
+
+import android.content.Context;
+import android.preference.SwitchPreference;
+import android.util.AttributeSet;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class ForceAVCSpoofingPreference extends SwitchPreference {
+ {
+ if (!DEVICE_HAS_HARDWARE_DECODING_VP9) {
+ setSummaryOn(str("revanced_spoof_video_streams_ios_force_avc_no_hardware_vp9_summary_on"));
+ }
+ }
+
+ public ForceAVCSpoofingPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+ public ForceAVCSpoofingPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+ public ForceAVCSpoofingPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public ForceAVCSpoofingPreference(Context context) {
+ super(context);
+ }
+
+ private void updateUI() {
+ if (DEVICE_HAS_HARDWARE_DECODING_VP9) {
+ return;
+ }
+
+ // Temporarily remove the preference key to allow changing this preference without
+ // causing the settings UI listeners from showing reboot dialogs by the changes made here.
+ String key = getKey();
+ setKey(null);
+
+ // This setting cannot be changed by the user.
+ super.setEnabled(false);
+ super.setChecked(true);
+
+ setKey(key);
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+
+ updateUI();
+ }
+
+ @Override
+ public void setChecked(boolean checked) {
+ super.setChecked(checked);
+
+ updateUI();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/HtmlPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/HtmlPreference.java
new file mode 100644
index 000000000..bd9db08f5
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/HtmlPreference.java
@@ -0,0 +1,35 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import static android.text.Html.FROM_HTML_MODE_COMPACT;
+
+import android.content.Context;
+import android.os.Build;
+import android.preference.Preference;
+import android.text.Html;
+import android.util.AttributeSet;
+
+import androidx.annotation.RequiresApi;
+
+/**
+ * Allows using basic html for the summary text.
+ */
+@SuppressWarnings({"unused", "deprecation"})
+@RequiresApi(api = Build.VERSION_CODES.O)
+public class HtmlPreference extends Preference {
+ {
+ setSummary(Html.fromHtml(getSummary().toString(), FROM_HTML_MODE_COMPACT));
+ }
+
+ public HtmlPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+ public HtmlPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+ public HtmlPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public HtmlPreference(Context context) {
+ super(context);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedPreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedPreferenceFragment.java
new file mode 100644
index 000000000..a22206f22
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedPreferenceFragment.java
@@ -0,0 +1,36 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import android.os.Build;
+import android.preference.ListPreference;
+import android.preference.Preference;
+
+import androidx.annotation.RequiresApi;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment;
+import app.revanced.extension.youtube.patches.playback.speed.CustomPlaybackSpeedPatch;
+import app.revanced.extension.youtube.settings.Settings;
+
+/**
+ * Preference fragment for ReVanced settings.
+ *
+ * @noinspection deprecation
+ */
+public class ReVancedPreferenceFragment extends AbstractPreferenceFragment {
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ @Override
+ protected void initialize() {
+ super.initialize();
+
+ try {
+ // If the preference was included, then initialize it based on the available playback speed.
+ Preference defaultSpeedPreference = findPreference(Settings.PLAYBACK_SPEED_DEFAULT.key);
+ if (defaultSpeedPreference instanceof ListPreference) {
+ CustomPlaybackSpeedPatch.initializeListPreference((ListPreference) defaultSpeedPreference);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "initialize failure", ex);
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedYouTubeAboutPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedYouTubeAboutPreference.java
new file mode 100644
index 000000000..17b667e78
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedYouTubeAboutPreference.java
@@ -0,0 +1,32 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import android.content.Context;
+import android.util.AttributeSet;
+
+import app.revanced.extension.shared.settings.preference.ReVancedAboutPreference;
+import app.revanced.extension.youtube.ThemeHelper;
+
+@SuppressWarnings("unused")
+public class ReVancedYouTubeAboutPreference extends ReVancedAboutPreference {
+
+ public int getLightColor() {
+ return ThemeHelper.getLightThemeColor();
+ }
+
+ public int getDarkColor() {
+ return ThemeHelper.getDarkThemeColor();
+ }
+
+ public ReVancedYouTubeAboutPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+ public ReVancedYouTubeAboutPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+ public ReVancedYouTubeAboutPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public ReVancedYouTubeAboutPreference(Context context) {
+ super(context);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReturnYouTubeDislikePreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReturnYouTubeDislikePreferenceFragment.java
new file mode 100644
index 000000000..66bcf29e2
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReturnYouTubeDislikePreferenceFragment.java
@@ -0,0 +1,237 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.preference.Preference;
+import android.preference.PreferenceCategory;
+import android.preference.PreferenceFragment;
+import android.preference.PreferenceManager;
+import android.preference.PreferenceScreen;
+import android.preference.SwitchPreference;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.youtube.patches.ReturnYouTubeDislikePatch;
+import app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike;
+import app.revanced.extension.youtube.returnyoutubedislike.requests.ReturnYouTubeDislikeApi;
+import app.revanced.extension.youtube.settings.Settings;
+
+/** @noinspection deprecation*/
+public class ReturnYouTubeDislikePreferenceFragment extends PreferenceFragment {
+
+ /**
+ * If dislikes are shown on Shorts.
+ */
+ private SwitchPreference shortsPreference;
+
+ /**
+ * If dislikes are shown as percentage.
+ */
+ private SwitchPreference percentagePreference;
+
+ /**
+ * If segmented like/dislike button uses smaller compact layout.
+ */
+ private SwitchPreference compactLayoutPreference;
+
+ /**
+ * If segmented like/dislike button uses smaller compact layout.
+ */
+ private SwitchPreference toastOnRYDNotAvailable;
+
+ private void updateUIState() {
+ shortsPreference.setEnabled(Settings.RYD_SHORTS.isAvailable());
+ percentagePreference.setEnabled(Settings.RYD_DISLIKE_PERCENTAGE.isAvailable());
+ compactLayoutPreference.setEnabled(Settings.RYD_COMPACT_LAYOUT.isAvailable());
+ toastOnRYDNotAvailable.setEnabled(Settings.RYD_TOAST_ON_CONNECTION_ERROR.isAvailable());
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ try {
+ Activity context = getActivity();
+ PreferenceManager manager = getPreferenceManager();
+ manager.setSharedPreferencesName(Setting.preferences.name);
+ PreferenceScreen preferenceScreen = manager.createPreferenceScreen(context);
+ setPreferenceScreen(preferenceScreen);
+
+ SwitchPreference enabledPreference = new SwitchPreference(context);
+ enabledPreference.setChecked(Settings.RYD_ENABLED.get());
+ enabledPreference.setTitle(str("revanced_ryd_enable_title"));
+ enabledPreference.setSummaryOn(str("revanced_ryd_enable_summary_on"));
+ enabledPreference.setSummaryOff(str("revanced_ryd_enable_summary_off"));
+ enabledPreference.setOnPreferenceChangeListener((pref, newValue) -> {
+ final Boolean rydIsEnabled = (Boolean) newValue;
+ Settings.RYD_ENABLED.save(rydIsEnabled);
+ ReturnYouTubeDislikePatch.onRYDStatusChange(rydIsEnabled);
+
+ updateUIState();
+ return true;
+ });
+ preferenceScreen.addPreference(enabledPreference);
+
+ shortsPreference = new SwitchPreference(context);
+ shortsPreference.setChecked(Settings.RYD_SHORTS.get());
+ shortsPreference.setTitle(str("revanced_ryd_shorts_title"));
+ String shortsSummary = ReturnYouTubeDislikePatch.IS_SPOOFING_TO_NON_LITHO_SHORTS_PLAYER
+ ? str("revanced_ryd_shorts_summary_on")
+ : str("revanced_ryd_shorts_summary_on_disclaimer");
+ shortsPreference.setSummaryOn(shortsSummary);
+ shortsPreference.setSummaryOff(str("revanced_ryd_shorts_summary_off"));
+ shortsPreference.setOnPreferenceChangeListener((pref, newValue) -> {
+ Settings.RYD_SHORTS.save((Boolean) newValue);
+ updateUIState();
+ return true;
+ });
+ preferenceScreen.addPreference(shortsPreference);
+
+ percentagePreference = new SwitchPreference(context);
+ percentagePreference.setChecked(Settings.RYD_DISLIKE_PERCENTAGE.get());
+ percentagePreference.setTitle(str("revanced_ryd_dislike_percentage_title"));
+ percentagePreference.setSummaryOn(str("revanced_ryd_dislike_percentage_summary_on"));
+ percentagePreference.setSummaryOff(str("revanced_ryd_dislike_percentage_summary_off"));
+ percentagePreference.setOnPreferenceChangeListener((pref, newValue) -> {
+ Settings.RYD_DISLIKE_PERCENTAGE.save((Boolean) newValue);
+ ReturnYouTubeDislike.clearAllUICaches();
+ updateUIState();
+ return true;
+ });
+ preferenceScreen.addPreference(percentagePreference);
+
+ compactLayoutPreference = new SwitchPreference(context);
+ compactLayoutPreference.setChecked(Settings.RYD_COMPACT_LAYOUT.get());
+ compactLayoutPreference.setTitle(str("revanced_ryd_compact_layout_title"));
+ compactLayoutPreference.setSummaryOn(str("revanced_ryd_compact_layout_summary_on"));
+ compactLayoutPreference.setSummaryOff(str("revanced_ryd_compact_layout_summary_off"));
+ compactLayoutPreference.setOnPreferenceChangeListener((pref, newValue) -> {
+ Settings.RYD_COMPACT_LAYOUT.save((Boolean) newValue);
+ ReturnYouTubeDislike.clearAllUICaches();
+ updateUIState();
+ return true;
+ });
+ preferenceScreen.addPreference(compactLayoutPreference);
+
+ toastOnRYDNotAvailable = new SwitchPreference(context);
+ toastOnRYDNotAvailable.setChecked(Settings.RYD_TOAST_ON_CONNECTION_ERROR.get());
+ toastOnRYDNotAvailable.setTitle(str("revanced_ryd_toast_on_connection_error_title"));
+ toastOnRYDNotAvailable.setSummaryOn(str("revanced_ryd_toast_on_connection_error_summary_on"));
+ toastOnRYDNotAvailable.setSummaryOff(str("revanced_ryd_toast_on_connection_error_summary_off"));
+ toastOnRYDNotAvailable.setOnPreferenceChangeListener((pref, newValue) -> {
+ Settings.RYD_TOAST_ON_CONNECTION_ERROR.save((Boolean) newValue);
+ updateUIState();
+ return true;
+ });
+ preferenceScreen.addPreference(toastOnRYDNotAvailable);
+
+ updateUIState();
+
+
+ // About category
+
+ PreferenceCategory aboutCategory = new PreferenceCategory(context);
+ aboutCategory.setTitle(str("revanced_ryd_about"));
+ preferenceScreen.addPreference(aboutCategory);
+
+ // ReturnYouTubeDislike Website
+
+ Preference aboutWebsitePreference = new Preference(context);
+ aboutWebsitePreference.setTitle(str("revanced_ryd_attribution_title"));
+ aboutWebsitePreference.setSummary(str("revanced_ryd_attribution_summary"));
+ aboutWebsitePreference.setOnPreferenceClickListener(pref -> {
+ Intent i = new Intent(Intent.ACTION_VIEW);
+ i.setData(Uri.parse("https://returnyoutubedislike.com"));
+ pref.getContext().startActivity(i);
+ return false;
+ });
+ aboutCategory.addPreference(aboutWebsitePreference);
+
+ // RYD API connection statistics
+
+ if (BaseSettings.DEBUG.get()) {
+ PreferenceCategory emptyCategory = new PreferenceCategory(context); // vertical padding
+ preferenceScreen.addPreference(emptyCategory);
+
+ PreferenceCategory statisticsCategory = new PreferenceCategory(context);
+ statisticsCategory.setTitle(str("revanced_ryd_statistics_category_title"));
+ preferenceScreen.addPreference(statisticsCategory);
+
+ Preference statisticPreference;
+
+ statisticPreference = new Preference(context);
+ statisticPreference.setSelectable(false);
+ statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallResponseTimeAverage_title"));
+ statisticPreference.setSummary(createMillisecondStringFromNumber(ReturnYouTubeDislikeApi.getFetchCallResponseTimeAverage()));
+ preferenceScreen.addPreference(statisticPreference);
+
+ statisticPreference = new Preference(context);
+ statisticPreference.setSelectable(false);
+ statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallResponseTimeMin_title"));
+ statisticPreference.setSummary(createMillisecondStringFromNumber(ReturnYouTubeDislikeApi.getFetchCallResponseTimeMin()));
+ preferenceScreen.addPreference(statisticPreference);
+
+ statisticPreference = new Preference(context);
+ statisticPreference.setSelectable(false);
+ statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallResponseTimeMax_title"));
+ statisticPreference.setSummary(createMillisecondStringFromNumber(ReturnYouTubeDislikeApi.getFetchCallResponseTimeMax()));
+ preferenceScreen.addPreference(statisticPreference);
+
+ String fetchCallTimeWaitingLastSummary;
+ final long fetchCallTimeWaitingLast = ReturnYouTubeDislikeApi.getFetchCallResponseTimeLast();
+ if (fetchCallTimeWaitingLast == ReturnYouTubeDislikeApi.FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT) {
+ fetchCallTimeWaitingLastSummary = str("revanced_ryd_statistics_getFetchCallResponseTimeLast_rate_limit_summary");
+ } else {
+ fetchCallTimeWaitingLastSummary = createMillisecondStringFromNumber(fetchCallTimeWaitingLast);
+ }
+ statisticPreference = new Preference(context);
+ statisticPreference.setSelectable(false);
+ statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallResponseTimeLast_title"));
+ statisticPreference.setSummary(fetchCallTimeWaitingLastSummary);
+ preferenceScreen.addPreference(statisticPreference);
+
+ statisticPreference = new Preference(context);
+ statisticPreference.setSelectable(false);
+ statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallCount_title"));
+ statisticPreference.setSummary(createSummaryText(ReturnYouTubeDislikeApi.getFetchCallCount(),
+ "revanced_ryd_statistics_getFetchCallCount_zero_summary",
+ "revanced_ryd_statistics_getFetchCallCount_non_zero_summary"));
+ preferenceScreen.addPreference(statisticPreference);
+
+ statisticPreference = new Preference(context);
+ statisticPreference.setSelectable(false);
+ statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallNumberOfFailures_title"));
+ statisticPreference.setSummary(createSummaryText(ReturnYouTubeDislikeApi.getFetchCallNumberOfFailures(),
+ "revanced_ryd_statistics_getFetchCallNumberOfFailures_zero_summary",
+ "revanced_ryd_statistics_getFetchCallNumberOfFailures_non_zero_summary"));
+ preferenceScreen.addPreference(statisticPreference);
+
+ statisticPreference = new Preference(context);
+ statisticPreference.setSelectable(false);
+ statisticPreference.setTitle(str("revanced_ryd_statistics_getNumberOfRateLimitRequestsEncountered_title"));
+ statisticPreference.setSummary(createSummaryText(ReturnYouTubeDislikeApi.getNumberOfRateLimitRequestsEncountered(),
+ "revanced_ryd_statistics_getNumberOfRateLimitRequestsEncountered_zero_summary",
+ "revanced_ryd_statistics_getNumberOfRateLimitRequestsEncountered_non_zero_summary"));
+ preferenceScreen.addPreference(statisticPreference);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onCreate failure", ex);
+ }
+ }
+
+ private static String createSummaryText(int value, String summaryStringZeroKey, String summaryStringOneOrMoreKey) {
+ if (value == 0) {
+ return str(summaryStringZeroKey);
+ }
+ return String.format(str(summaryStringOneOrMoreKey), value);
+ }
+
+ private static String createMillisecondStringFromNumber(long number) {
+ return String.format(str("revanced_ryd_statistics_millisecond_text"), number);
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockPreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockPreferenceFragment.java
new file mode 100644
index 000000000..9fa4a942a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockPreferenceFragment.java
@@ -0,0 +1,602 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.preference.*;
+import android.text.Html;
+import android.text.InputType;
+import android.util.TypedValue;
+import android.widget.EditText;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.sponsorblock.SegmentPlaybackController;
+import app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings;
+import app.revanced.extension.youtube.sponsorblock.SponsorBlockUtils;
+import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory;
+import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategoryListPreference;
+import app.revanced.extension.youtube.sponsorblock.objects.UserStats;
+import app.revanced.extension.youtube.sponsorblock.requests.SBRequester;
+import app.revanced.extension.youtube.sponsorblock.ui.SponsorBlockViewController;
+
+import static android.text.Html.fromHtml;
+import static app.revanced.extension.shared.StringRef.str;
+
+@SuppressWarnings("deprecation")
+public class SponsorBlockPreferenceFragment extends PreferenceFragment {
+
+ private SwitchPreference sbEnabled;
+ private SwitchPreference addNewSegment;
+ private SwitchPreference votingEnabled;
+ private SwitchPreference compactSkipButton;
+ private SwitchPreference autoHideSkipSegmentButton;
+ private SwitchPreference showSkipToast;
+ private SwitchPreference trackSkips;
+ private SwitchPreference showTimeWithoutSegments;
+ private SwitchPreference toastOnConnectionError;
+
+ private EditTextPreference newSegmentStep;
+ private EditTextPreference minSegmentDuration;
+ private EditTextPreference privateUserId;
+ private EditTextPreference importExport;
+ private Preference apiUrl;
+
+ private PreferenceCategory statsCategory;
+ private PreferenceCategory segmentCategory;
+
+ private void updateUI() {
+ try {
+ final boolean enabled = Settings.SB_ENABLED.get();
+ if (!enabled) {
+ SponsorBlockViewController.hideAll();
+ SegmentPlaybackController.setCurrentVideoId(null);
+ } else if (!Settings.SB_CREATE_NEW_SEGMENT.get()) {
+ SponsorBlockViewController.hideNewSegmentLayout();
+ }
+ // Voting and add new segment buttons automatically shows/hide themselves.
+
+ sbEnabled.setChecked(enabled);
+
+ addNewSegment.setChecked(Settings.SB_CREATE_NEW_SEGMENT.get());
+ addNewSegment.setEnabled(enabled);
+
+ votingEnabled.setChecked(Settings.SB_VOTING_BUTTON.get());
+ votingEnabled.setEnabled(enabled);
+
+ compactSkipButton.setChecked(Settings.SB_COMPACT_SKIP_BUTTON.get());
+ compactSkipButton.setEnabled(enabled);
+
+ autoHideSkipSegmentButton.setChecked(Settings.SB_AUTO_HIDE_SKIP_BUTTON.get());
+ autoHideSkipSegmentButton.setEnabled(enabled);
+
+ showSkipToast.setChecked(Settings.SB_TOAST_ON_SKIP.get());
+ showSkipToast.setEnabled(enabled);
+
+ toastOnConnectionError.setChecked(Settings.SB_TOAST_ON_CONNECTION_ERROR.get());
+ toastOnConnectionError.setEnabled(enabled);
+
+ trackSkips.setChecked(Settings.SB_TRACK_SKIP_COUNT.get());
+ trackSkips.setEnabled(enabled);
+
+ showTimeWithoutSegments.setChecked(Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.get());
+ showTimeWithoutSegments.setEnabled(enabled);
+
+ newSegmentStep.setText((Settings.SB_CREATE_NEW_SEGMENT_STEP.get()).toString());
+ newSegmentStep.setEnabled(enabled);
+
+ minSegmentDuration.setText((Settings.SB_SEGMENT_MIN_DURATION.get()).toString());
+ minSegmentDuration.setEnabled(enabled);
+
+ privateUserId.setText(Settings.SB_PRIVATE_USER_ID.get());
+ privateUserId.setEnabled(enabled);
+
+ // If the user has a private user id, then include a subtext that mentions not to share it.
+ String importExportSummary = SponsorBlockSettings.userHasSBPrivateId()
+ ? str("revanced_sb_settings_ie_sum_warning")
+ : str("revanced_sb_settings_ie_sum");
+ importExport.setSummary(importExportSummary);
+
+ apiUrl.setEnabled(enabled);
+ importExport.setEnabled(enabled);
+ segmentCategory.setEnabled(enabled);
+ statsCategory.setEnabled(enabled);
+ } catch (Exception ex) {
+ Logger.printException(() -> "update settings UI failure", ex);
+ }
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ try {
+ Activity context = getActivity();
+ PreferenceManager manager = getPreferenceManager();
+ manager.setSharedPreferencesName(Setting.preferences.name);
+ PreferenceScreen preferenceScreen = manager.createPreferenceScreen(context);
+ setPreferenceScreen(preferenceScreen);
+
+ SponsorBlockSettings.initialize();
+
+ sbEnabled = new SwitchPreference(context);
+ sbEnabled.setTitle(str("revanced_sb_enable_sb"));
+ sbEnabled.setSummary(str("revanced_sb_enable_sb_sum"));
+ preferenceScreen.addPreference(sbEnabled);
+ sbEnabled.setOnPreferenceChangeListener((preference1, newValue) -> {
+ Settings.SB_ENABLED.save((Boolean) newValue);
+ updateUI();
+ return true;
+ });
+
+ addAppearanceCategory(context, preferenceScreen);
+
+ segmentCategory = new PreferenceCategory(context);
+ segmentCategory.setTitle(str("revanced_sb_diff_segments"));
+ preferenceScreen.addPreference(segmentCategory);
+ updateSegmentCategories();
+
+ addCreateSegmentCategory(context, preferenceScreen);
+
+ addGeneralCategory(context, preferenceScreen);
+
+ statsCategory = new PreferenceCategory(context);
+ statsCategory.setTitle(str("revanced_sb_stats"));
+ preferenceScreen.addPreference(statsCategory);
+ fetchAndDisplayStats();
+
+ addAboutCategory(context, preferenceScreen);
+
+ updateUI();
+ } catch (Exception ex) {
+ Logger.printException(() -> "onCreate failure", ex);
+ }
+ }
+
+ private void addAppearanceCategory(Context context, PreferenceScreen screen) {
+ PreferenceCategory category = new PreferenceCategory(context);
+ screen.addPreference(category);
+ category.setTitle(str("revanced_sb_appearance_category"));
+
+ votingEnabled = new SwitchPreference(context);
+ votingEnabled.setTitle(str("revanced_sb_enable_voting"));
+ votingEnabled.setSummaryOn(str("revanced_sb_enable_voting_sum_on"));
+ votingEnabled.setSummaryOff(str("revanced_sb_enable_voting_sum_off"));
+ category.addPreference(votingEnabled);
+ votingEnabled.setOnPreferenceChangeListener((preference1, newValue) -> {
+ Settings.SB_VOTING_BUTTON.save((Boolean) newValue);
+ updateUI();
+ return true;
+ });
+
+ compactSkipButton = new SwitchPreference(context);
+ compactSkipButton.setTitle(str("revanced_sb_enable_compact_skip_button"));
+ compactSkipButton.setSummaryOn(str("revanced_sb_enable_compact_skip_button_sum_on"));
+ compactSkipButton.setSummaryOff(str("revanced_sb_enable_compact_skip_button_sum_off"));
+ category.addPreference(compactSkipButton);
+ compactSkipButton.setOnPreferenceChangeListener((preference1, newValue) -> {
+ Settings.SB_COMPACT_SKIP_BUTTON.save((Boolean) newValue);
+ updateUI();
+ return true;
+ });
+
+ autoHideSkipSegmentButton = new SwitchPreference(context);
+ autoHideSkipSegmentButton.setTitle(str("revanced_sb_enable_auto_hide_skip_segment_button"));
+ autoHideSkipSegmentButton.setSummaryOn(str("revanced_sb_enable_auto_hide_skip_segment_button_sum_on"));
+ autoHideSkipSegmentButton.setSummaryOff(str("revanced_sb_enable_auto_hide_skip_segment_button_sum_off"));
+ category.addPreference(autoHideSkipSegmentButton);
+ autoHideSkipSegmentButton.setOnPreferenceChangeListener((preference1, newValue) -> {
+ Settings.SB_AUTO_HIDE_SKIP_BUTTON.save((Boolean) newValue);
+ updateUI();
+ return true;
+ });
+
+ showSkipToast = new SwitchPreference(context);
+ showSkipToast.setTitle(str("revanced_sb_general_skiptoast"));
+ showSkipToast.setSummaryOn(str("revanced_sb_general_skiptoast_sum_on"));
+ showSkipToast.setSummaryOff(str("revanced_sb_general_skiptoast_sum_off"));
+ showSkipToast.setOnPreferenceClickListener(preference1 -> {
+ Utils.showToastShort(str("revanced_sb_skipped_sponsor"));
+ return false;
+ });
+ showSkipToast.setOnPreferenceChangeListener((preference1, newValue) -> {
+ Settings.SB_TOAST_ON_SKIP.save((Boolean) newValue);
+ updateUI();
+ return true;
+ });
+ category.addPreference(showSkipToast);
+
+ showTimeWithoutSegments = new SwitchPreference(context);
+ showTimeWithoutSegments.setTitle(str("revanced_sb_general_time_without"));
+ showTimeWithoutSegments.setSummaryOn(str("revanced_sb_general_time_without_sum_on"));
+ showTimeWithoutSegments.setSummaryOff(str("revanced_sb_general_time_without_sum_off"));
+ showTimeWithoutSegments.setOnPreferenceChangeListener((preference1, newValue) -> {
+ Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.save((Boolean) newValue);
+ updateUI();
+ return true;
+ });
+ category.addPreference(showTimeWithoutSegments);
+ }
+
+ private void addCreateSegmentCategory(Context context, PreferenceScreen screen) {
+ PreferenceCategory category = new PreferenceCategory(context);
+ screen.addPreference(category);
+ category.setTitle(str("revanced_sb_create_segment_category"));
+
+ addNewSegment = new SwitchPreference(context);
+ addNewSegment.setTitle(str("revanced_sb_enable_create_segment"));
+ addNewSegment.setSummaryOn(str("revanced_sb_enable_create_segment_sum_on"));
+ addNewSegment.setSummaryOff(str("revanced_sb_enable_create_segment_sum_off"));
+ category.addPreference(addNewSegment);
+ addNewSegment.setOnPreferenceChangeListener((preference1, o) -> {
+ Boolean newValue = (Boolean) o;
+ if (newValue && !Settings.SB_SEEN_GUIDELINES.get()) {
+ new AlertDialog.Builder(preference1.getContext())
+ .setTitle(str("revanced_sb_guidelines_popup_title"))
+ .setMessage(str("revanced_sb_guidelines_popup_content"))
+ .setNegativeButton(str("revanced_sb_guidelines_popup_already_read"), null)
+ .setPositiveButton(str("revanced_sb_guidelines_popup_open"), (dialogInterface, i) -> openGuidelines())
+ .setOnDismissListener(dialog -> Settings.SB_SEEN_GUIDELINES.save(true))
+ .setCancelable(false)
+ .show();
+ }
+ Settings.SB_CREATE_NEW_SEGMENT.save(newValue);
+ updateUI();
+ return true;
+ });
+
+ newSegmentStep = new EditTextPreference(context);
+ newSegmentStep.setTitle(str("revanced_sb_general_adjusting"));
+ newSegmentStep.setSummary(str("revanced_sb_general_adjusting_sum"));
+ newSegmentStep.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER);
+ newSegmentStep.setOnPreferenceChangeListener((preference1, newValue) -> {
+ try {
+ final int newAdjustmentValue = Integer.parseInt(newValue.toString());
+ if (newAdjustmentValue != 0) {
+ Settings.SB_CREATE_NEW_SEGMENT_STEP.save(newAdjustmentValue);
+ return true;
+ }
+ } catch (NumberFormatException ex) {
+ Logger.printInfo(() -> "Invalid new segment step", ex);
+ }
+
+ Utils.showToastLong(str("revanced_sb_general_adjusting_invalid"));
+ updateUI();
+ return false;
+ });
+ category.addPreference(newSegmentStep);
+
+ Preference guidelinePreferences = new Preference(context);
+ guidelinePreferences.setTitle(str("revanced_sb_guidelines_preference_title"));
+ guidelinePreferences.setSummary(str("revanced_sb_guidelines_preference_sum"));
+ guidelinePreferences.setOnPreferenceClickListener(preference1 -> {
+ openGuidelines();
+ return true;
+ });
+ category.addPreference(guidelinePreferences);
+ }
+
+ private void addGeneralCategory(final Context context, PreferenceScreen screen) {
+ PreferenceCategory category = new PreferenceCategory(context);
+ screen.addPreference(category);
+ category.setTitle(str("revanced_sb_general"));
+
+ toastOnConnectionError = new SwitchPreference(context);
+ toastOnConnectionError.setTitle(str("revanced_sb_toast_on_connection_error_title"));
+ toastOnConnectionError.setSummaryOn(str("revanced_sb_toast_on_connection_error_summary_on"));
+ toastOnConnectionError.setSummaryOff(str("revanced_sb_toast_on_connection_error_summary_off"));
+ toastOnConnectionError.setOnPreferenceChangeListener((preference1, newValue) -> {
+ Settings.SB_TOAST_ON_CONNECTION_ERROR.save((Boolean) newValue);
+ updateUI();
+ return true;
+ });
+ category.addPreference(toastOnConnectionError);
+
+ trackSkips = new SwitchPreference(context);
+ trackSkips.setTitle(str("revanced_sb_general_skipcount"));
+ trackSkips.setSummaryOn(str("revanced_sb_general_skipcount_sum_on"));
+ trackSkips.setSummaryOff(str("revanced_sb_general_skipcount_sum_off"));
+ trackSkips.setOnPreferenceChangeListener((preference1, newValue) -> {
+ Settings.SB_TRACK_SKIP_COUNT.save((Boolean) newValue);
+ updateUI();
+ return true;
+ });
+ category.addPreference(trackSkips);
+
+ minSegmentDuration = new EditTextPreference(context);
+ minSegmentDuration.setTitle(str("revanced_sb_general_min_duration"));
+ minSegmentDuration.setSummary(str("revanced_sb_general_min_duration_sum"));
+ minSegmentDuration.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL);
+ minSegmentDuration.setOnPreferenceChangeListener((preference1, newValue) -> {
+ try {
+ Float minTimeDuration = Float.valueOf(newValue.toString());
+ Settings.SB_SEGMENT_MIN_DURATION.save(minTimeDuration);
+ return true;
+ } catch (NumberFormatException ex) {
+ Logger.printInfo(() -> "Invalid minimum segment duration", ex);
+ }
+
+ Utils.showToastLong(str("revanced_sb_general_min_duration_invalid"));
+ updateUI();
+ return false;
+ });
+ category.addPreference(minSegmentDuration);
+
+ privateUserId = new EditTextPreference(context);
+ privateUserId.setTitle(str("revanced_sb_general_uuid"));
+ privateUserId.setSummary(str("revanced_sb_general_uuid_sum"));
+ privateUserId.setOnPreferenceChangeListener((preference1, newValue) -> {
+ String newUUID = newValue.toString();
+ if (!SponsorBlockSettings.isValidSBUserId(newUUID)) {
+ Utils.showToastLong(str("revanced_sb_general_uuid_invalid"));
+ return false;
+ }
+
+ Settings.SB_PRIVATE_USER_ID.save(newUUID);
+ updateUI();
+ fetchAndDisplayStats();
+ return true;
+ });
+ category.addPreference(privateUserId);
+
+ apiUrl = new Preference(context);
+ apiUrl.setTitle(str("revanced_sb_general_api_url"));
+ apiUrl.setSummary(Html.fromHtml(str("revanced_sb_general_api_url_sum")));
+ apiUrl.setOnPreferenceClickListener(preference1 -> {
+ EditText editText = new EditText(context);
+ editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
+ editText.setText(Settings.SB_API_URL.get());
+
+ DialogInterface.OnClickListener urlChangeListener = (dialog, buttonPressed) -> {
+ if (buttonPressed == DialogInterface.BUTTON_NEUTRAL) {
+ Settings.SB_API_URL.resetToDefault();
+ Utils.showToastLong(str("revanced_sb_api_url_reset"));
+ } else if (buttonPressed == DialogInterface.BUTTON_POSITIVE) {
+ String serverAddress = editText.getText().toString();
+ if (!SponsorBlockSettings.isValidSBServerAddress(serverAddress)) {
+ Utils.showToastLong(str("revanced_sb_api_url_invalid"));
+ } else if (!serverAddress.equals(Settings.SB_API_URL.get())) {
+ Settings.SB_API_URL.save(serverAddress);
+ Utils.showToastLong(str("revanced_sb_api_url_changed"));
+ }
+ }
+ };
+ new AlertDialog.Builder(context)
+ .setTitle(apiUrl.getTitle())
+ .setView(editText)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setNeutralButton(str("revanced_sb_reset"), urlChangeListener)
+ .setPositiveButton(android.R.string.ok, urlChangeListener)
+ .show();
+ return true;
+ });
+ category.addPreference(apiUrl);
+
+ importExport = new EditTextPreference(context) {
+ protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
+ Utils.setEditTextDialogTheme(builder);
+
+ builder.setNeutralButton(str("revanced_sb_settings_copy"), (dialog, which) -> {
+ Utils.setClipboard(getEditText().getText().toString());
+ });
+ }
+ };
+ importExport.setTitle(str("revanced_sb_settings_ie"));
+ // Summary is set in updateUI()
+ importExport.getEditText().setInputType(InputType.TYPE_CLASS_TEXT
+ | InputType.TYPE_TEXT_FLAG_MULTI_LINE
+ | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ importExport.getEditText().setAutofillHints((String) null);
+ }
+ importExport.getEditText().setTextSize(TypedValue.COMPLEX_UNIT_PT, 8);
+ importExport.setOnPreferenceClickListener(preference1 -> {
+ importExport.getEditText().setText(SponsorBlockSettings.exportDesktopSettings());
+ return true;
+ });
+ importExport.setOnPreferenceChangeListener((preference1, newValue) -> {
+ SponsorBlockSettings.importDesktopSettings((String) newValue);
+ updateSegmentCategories();
+ fetchAndDisplayStats();
+ updateUI();
+ return true;
+ });
+ category.addPreference(importExport);
+ }
+
+ private void updateSegmentCategories() {
+ try {
+ segmentCategory.removeAll();
+
+ Activity activity = getActivity();
+ for (SegmentCategory category : SegmentCategory.categoriesWithoutUnsubmitted()) {
+ segmentCategory.addPreference(new SegmentCategoryListPreference(activity, category));
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "updateSegmentCategories failure", ex);
+ }
+ }
+
+ private void addAboutCategory(Context context, PreferenceScreen screen) {
+ PreferenceCategory category = new PreferenceCategory(context);
+ screen.addPreference(category);
+ category.setTitle(str("revanced_sb_about"));
+
+ {
+ Preference preference = new Preference(context);
+ category.addPreference(preference);
+ preference.setTitle(str("revanced_sb_about_api"));
+ preference.setSummary(str("revanced_sb_about_api_sum"));
+ preference.setOnPreferenceClickListener(preference1 -> {
+ Intent i = new Intent(Intent.ACTION_VIEW);
+ i.setData(Uri.parse("https://sponsor.ajay.app"));
+ preference1.getContext().startActivity(i);
+ return false;
+ });
+ }
+ }
+
+ private void openGuidelines() {
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(Uri.parse("https://wiki.sponsor.ajay.app/w/Guidelines"));
+ getActivity().startActivity(intent);
+ }
+
+ private void fetchAndDisplayStats() {
+ try {
+ statsCategory.removeAll();
+ if (!SponsorBlockSettings.userHasSBPrivateId()) {
+ // User has never voted or created any segments. No stats to show.
+ addLocalUserStats();
+ return;
+ }
+
+ Preference loadingPlaceholderPreference = new Preference(this.getActivity());
+ loadingPlaceholderPreference.setEnabled(false);
+ statsCategory.addPreference(loadingPlaceholderPreference);
+ if (Settings.SB_ENABLED.get()) {
+ loadingPlaceholderPreference.setTitle(str("revanced_sb_stats_loading"));
+ Utils.runOnBackgroundThread(() -> {
+ UserStats stats = SBRequester.retrieveUserStats();
+ Utils.runOnMainThread(() -> { // get back on main thread to modify UI elements
+ addUserStats(loadingPlaceholderPreference, stats);
+ addLocalUserStats();
+ });
+ });
+ } else {
+ loadingPlaceholderPreference.setTitle(str("revanced_sb_stats_sb_disabled"));
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "fetchAndDisplayStats failure", ex);
+ }
+ }
+
+ private void addUserStats(@NonNull Preference loadingPlaceholder, @Nullable UserStats stats) {
+ Utils.verifyOnMainThread();
+ try {
+ if (stats == null) {
+ loadingPlaceholder.setTitle(str("revanced_sb_stats_connection_failure"));
+ return;
+ }
+ statsCategory.removeAll();
+ Context context = statsCategory.getContext();
+
+ if (stats.totalSegmentCountIncludingIgnored > 0) {
+ // If user has not created any segments, there's no reason to set a username.
+ EditTextPreference preference = new EditTextPreference(context);
+ statsCategory.addPreference(preference);
+ String userName = stats.userName;
+ preference.setTitle(fromHtml(str("revanced_sb_stats_username", userName)));
+ preference.setSummary(str("revanced_sb_stats_username_change"));
+ preference.setText(userName);
+ preference.setOnPreferenceChangeListener((preference1, value) -> {
+ Utils.runOnBackgroundThread(() -> {
+ String newUserName = (String) value;
+ String errorMessage = SBRequester.setUsername(newUserName);
+ Utils.runOnMainThread(() -> {
+ if (errorMessage == null) {
+ preference.setTitle(fromHtml(str("revanced_sb_stats_username", newUserName)));
+ preference.setText(newUserName);
+ Utils.showToastLong(str("revanced_sb_stats_username_changed"));
+ } else {
+ preference.setText(userName); // revert to previous
+ Utils.showToastLong(errorMessage);
+ }
+ });
+ });
+ return true;
+ });
+ }
+
+ {
+ // number of segment submissions (does not include ignored segments)
+ Preference preference = new Preference(context);
+ statsCategory.addPreference(preference);
+ String formatted = SponsorBlockUtils.getNumberOfSkipsString(stats.segmentCount);
+ preference.setTitle(fromHtml(str("revanced_sb_stats_submissions", formatted)));
+ preference.setSummary(str("revanced_sb_stats_submissions_sum"));
+ if (stats.totalSegmentCountIncludingIgnored == 0) {
+ preference.setSelectable(false);
+ } else {
+ preference.setOnPreferenceClickListener(preference1 -> {
+ Intent i = new Intent(Intent.ACTION_VIEW);
+ i.setData(Uri.parse("https://sb.ltn.fi/userid/" + stats.publicUserId));
+ preference1.getContext().startActivity(i);
+ return true;
+ });
+ }
+ }
+
+ {
+ // "user reputation". Usually not useful, since it appears most users have zero reputation.
+ // But if there is a reputation, then show it here
+ Preference preference = new Preference(context);
+ preference.setTitle(fromHtml(str("revanced_sb_stats_reputation", stats.reputation)));
+ preference.setSelectable(false);
+ if (stats.reputation != 0) {
+ statsCategory.addPreference(preference);
+ }
+ }
+
+ {
+ // time saved for other users
+ Preference preference = new Preference(context);
+ statsCategory.addPreference(preference);
+
+ String stats_saved;
+ String stats_saved_sum;
+ if (stats.totalSegmentCountIncludingIgnored == 0) {
+ stats_saved = str("revanced_sb_stats_saved_zero");
+ stats_saved_sum = str("revanced_sb_stats_saved_sum_zero");
+ } else {
+ stats_saved = str("revanced_sb_stats_saved",
+ SponsorBlockUtils.getNumberOfSkipsString(stats.viewCount));
+ stats_saved_sum = str("revanced_sb_stats_saved_sum", SponsorBlockUtils.getTimeSavedString((long) (60 * stats.minutesSaved)));
+ }
+ preference.setTitle(fromHtml(stats_saved));
+ preference.setSummary(fromHtml(stats_saved_sum));
+ preference.setOnPreferenceClickListener(preference1 -> {
+ Intent i = new Intent(Intent.ACTION_VIEW);
+ i.setData(Uri.parse("https://sponsor.ajay.app/stats/"));
+ preference1.getContext().startActivity(i);
+ return false;
+ });
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "addUserStats failure", ex);
+ }
+ }
+
+ private void addLocalUserStats() {
+ // time the user saved by using SB
+ Preference preference = new Preference(statsCategory.getContext());
+ statsCategory.addPreference(preference);
+
+ Runnable updateStatsSelfSaved = () -> {
+ String formatted = SponsorBlockUtils.getNumberOfSkipsString(Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.get());
+ preference.setTitle(fromHtml(str("revanced_sb_stats_self_saved", formatted)));
+ String formattedSaved = SponsorBlockUtils.getTimeSavedString(Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.get() / 1000);
+ preference.setSummary(fromHtml(str("revanced_sb_stats_self_saved_sum", formattedSaved)));
+ };
+ updateStatsSelfSaved.run();
+ preference.setOnPreferenceClickListener(preference1 -> {
+ new AlertDialog.Builder(preference1.getContext())
+ .setTitle(str("revanced_sb_stats_self_saved_reset_title"))
+ .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> {
+ Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.resetToDefault();
+ Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.resetToDefault();
+ updateStatsSelfSaved.run();
+ })
+ .setNegativeButton(android.R.string.no, null).show();
+ return true;
+ });
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/NavigationBar.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/NavigationBar.java
new file mode 100644
index 000000000..960df3bf0
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/NavigationBar.java
@@ -0,0 +1,309 @@
+package app.revanced.extension.youtube.shared;
+
+import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton.CREATE;
+
+import android.app.Activity;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+import java.lang.ref.WeakReference;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.WeakHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class NavigationBar {
+
+ //
+ // Search bar
+ //
+
+ private static volatile WeakReference searchBarResultsRef = new WeakReference<>(null);
+
+ /**
+ * Injection point.
+ */
+ public static void searchBarResultsViewLoaded(View searchbarResults) {
+ searchBarResultsRef = new WeakReference<>(searchbarResults);
+ }
+
+ /**
+ * @return If the search bar is on screen. This includes if the player
+ * is on screen and the search results are behind the player (and not visible).
+ * Detecting the search is covered by the player can be done by checking {@link PlayerType#isMaximizedOrFullscreen()}.
+ */
+ public static boolean isSearchBarActive() {
+ View searchbarResults = searchBarResultsRef.get();
+ return searchbarResults != null && searchbarResults.getParent() != null;
+ }
+
+ //
+ // Navigation bar buttons
+ //
+
+ /**
+ * How long to wait for the set nav button latch to be released. Maximum wait time must
+ * be as small as possible while still allowing enough time for the nav bar to update.
+ *
+ * YT calls it's back button handlers out of order,
+ * and litho starts filtering before the navigation bar is updated.
+ *
+ * Fixing this situation and not needlessly waiting requires somehow
+ * detecting if a back button key-press will cause a tab change.
+ *
+ * Typically after pressing the back button, the time between the first litho event and
+ * when the nav button is updated is about 10-20ms. Using 50-100ms here should be enough time
+ * and not noticeable, since YT typically takes 100-200ms (or more) to update the view anyways.
+ *
+ * This issue can also be avoided on a patch by patch basis, by avoiding calls to
+ * {@link NavigationButton#getSelectedNavigationButton()} unless absolutely necessary.
+ */
+ private static final long LATCH_AWAIT_TIMEOUT_MILLISECONDS = 75;
+
+ /**
+ * Used as a workaround to fix the issue of YT calling back button handlers out of order.
+ * Used to hold calls to {@link NavigationButton#getSelectedNavigationButton()}
+ * until the current navigation button can be determined.
+ *
+ * Only used when the hardware back button is pressed.
+ */
+ @Nullable
+ private static volatile CountDownLatch navButtonLatch;
+
+ /**
+ * Map of nav button layout views to Enum type.
+ * No synchronization is needed, and this is always accessed from the main thread.
+ */
+ private static final Map viewToButtonMap = new WeakHashMap<>();
+
+ static {
+ // On app startup litho can start before the navigation bar is initialized.
+ // Force it to wait until the nav bar is updated.
+ createNavButtonLatch();
+ }
+
+ private static void createNavButtonLatch() {
+ navButtonLatch = new CountDownLatch(1);
+ }
+
+ private static void releaseNavButtonLatch() {
+ CountDownLatch latch = navButtonLatch;
+ if (latch != null) {
+ navButtonLatch = null;
+ latch.countDown();
+ }
+ }
+
+ private static void waitForNavButtonLatchIfNeeded() {
+ CountDownLatch latch = navButtonLatch;
+ if (latch == null) {
+ return;
+ }
+
+ if (Utils.isCurrentlyOnMainThread()) {
+ // The latch is released from the main thread, and waiting from the main thread will always timeout.
+ // This situation has only been observed when navigating out of a submenu and not changing tabs.
+ // and for that use case the nav bar does not change so it's safe to return here.
+ Logger.printDebug(() -> "Cannot block main thread waiting for nav button. Using last known navbar button status.");
+ return;
+ }
+
+ try {
+ Logger.printDebug(() -> "Latch wait started");
+ if (latch.await(LATCH_AWAIT_TIMEOUT_MILLISECONDS, TimeUnit.MILLISECONDS)) {
+ // Back button changed the navigation tab.
+ Logger.printDebug(() -> "Latch wait complete");
+ return;
+ }
+
+ // Timeout occurred, and a normal event when pressing the physical back button
+ // does not change navigation tabs.
+ releaseNavButtonLatch(); // Prevent other threads from waiting for no reason.
+ Logger.printDebug(() -> "Latch wait timed out");
+
+ } catch (InterruptedException ex) {
+ Logger.printException(() -> "Latch wait interrupted failure", ex); // Will never happen.
+ }
+ }
+
+ /**
+ * Last YT navigation enum loaded. Not necessarily the active navigation tab.
+ * Always accessed from the main thread.
+ */
+ @Nullable
+ private static String lastYTNavigationEnumName;
+
+ /**
+ * Injection point.
+ */
+ public static void setLastAppNavigationEnum(@Nullable Enum> ytNavigationEnumName) {
+ if (ytNavigationEnumName != null) {
+ lastYTNavigationEnumName = ytNavigationEnumName.name();
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void navigationTabLoaded(final View navigationButtonGroup) {
+ try {
+ String lastEnumName = lastYTNavigationEnumName;
+
+ for (NavigationButton buttonType : NavigationButton.values()) {
+ if (buttonType.ytEnumNames.contains(lastEnumName)) {
+ Logger.printDebug(() -> "navigationTabLoaded: " + lastEnumName);
+ viewToButtonMap.put(navigationButtonGroup, buttonType);
+ navigationTabCreatedCallback(buttonType, navigationButtonGroup);
+ return;
+ }
+ }
+
+ // Log the unknown tab as exception level, only if debug is enabled.
+ // This is because unknown tabs do no harm, and it's only relevant to developers.
+ if (Settings.DEBUG.get()) {
+ Logger.printException(() -> "Unknown tab: " + lastEnumName
+ + " view: " + navigationButtonGroup.getClass());
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "navigationTabLoaded failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ *
+ * Unique hook just for the 'Create' and 'You' tab.
+ */
+ public static void navigationImageResourceTabLoaded(View view) {
+ // 'You' tab has no YT enum name and the enum hook is not called for it.
+ // Compare the last enum to figure out which tab this actually is.
+ if (CREATE.ytEnumNames.contains(lastYTNavigationEnumName)) {
+ navigationTabLoaded(view);
+ } else {
+ lastYTNavigationEnumName = NavigationButton.LIBRARY.ytEnumNames.get(0);
+ navigationTabLoaded(view);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void navigationTabSelected(View navButtonImageView, boolean isSelected) {
+ try {
+ if (!isSelected) {
+ return;
+ }
+
+ NavigationButton button = viewToButtonMap.get(navButtonImageView);
+
+ if (button == null) { // An unknown tab was selected.
+ // Show a toast only if debug mode is enabled.
+ if (BaseSettings.DEBUG.get()) {
+ Logger.printException(() -> "Unknown navigation view selected: " + navButtonImageView);
+ }
+
+ NavigationButton.selectedNavigationButton = null;
+ return;
+ }
+
+ NavigationButton.selectedNavigationButton = button;
+ Logger.printDebug(() -> "Changed to navigation button: " + button);
+
+ // Release any threads waiting for the selected nav button.
+ releaseNavButtonLatch();
+ } catch (Exception ex) {
+ Logger.printException(() -> "navigationTabSelected failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void onBackPressed(Activity activity) {
+ Logger.printDebug(() -> "Back button pressed");
+ createNavButtonLatch();
+ }
+
+ /** @noinspection EmptyMethod*/
+ private static void navigationTabCreatedCallback(NavigationButton button, View tabView) {
+ // Code is added during patching.
+ }
+
+ public enum NavigationButton {
+ HOME("PIVOT_HOME", "TAB_HOME_CAIRO"),
+ SHORTS("TAB_SHORTS", "TAB_SHORTS_CAIRO"),
+ /**
+ * Create new video tab.
+ * This tab will never be in a selected state, even if the create video UI is on screen.
+ */
+ CREATE("CREATION_TAB_LARGE", "CREATION_TAB_LARGE_CAIRO"),
+ SUBSCRIPTIONS("PIVOT_SUBSCRIPTIONS", "TAB_SUBSCRIPTIONS_CAIRO"),
+ /**
+ * Notifications tab. Only present when
+ * {@link Settings#SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON} is active.
+ */
+ NOTIFICATIONS("TAB_ACTIVITY", "TAB_ACTIVITY_CAIRO"),
+ /**
+ * Library tab, including if the user is in incognito mode or when logged out.
+ */
+ LIBRARY(
+ // Modern library tab with 'You' layout.
+ // The hooked YT code does not use an enum, and a dummy name is used here.
+ "YOU_LIBRARY_DUMMY_PLACEHOLDER_NAME",
+ // User is logged out.
+ "ACCOUNT_CIRCLE",
+ "ACCOUNT_CIRCLE_CAIRO",
+ // User is logged in with incognito mode enabled.
+ "INCOGNITO_CIRCLE",
+ "INCOGNITO_CAIRO",
+ // Old library tab (pre 'You' layout), only present when version spoofing.
+ "VIDEO_LIBRARY_WHITE",
+ // 'You' library tab that is sometimes momentarily loaded.
+ // This might be a temporary tab while the user profile photo is loading,
+ // but its exact purpose is not entirely clear.
+ "PIVOT_LIBRARY"
+ );
+
+ @Nullable
+ private static volatile NavigationButton selectedNavigationButton;
+
+ /**
+ * This will return null only if the currently selected tab is unknown.
+ * This scenario will only happen if the UI has different tabs due to an A/B user test
+ * or YT abruptly changes the navigation layout for some other reason.
+ *
+ * All code calling this method should handle a null return value.
+ *
+ * Due to issues with how YT processes physical back button events,
+ * this patch uses workarounds that can cause this method to take up to 75ms
+ * if the device back button was recently pressed.
+ *
+ * @return The active navigation tab.
+ * If the user is in the upload video UI, this returns tab that is still visually
+ * selected on screen (whatever tab the user was on before tapping the upload button).
+ */
+ @Nullable
+ public static NavigationButton getSelectedNavigationButton() {
+ waitForNavButtonLatchIfNeeded();
+ return selectedNavigationButton;
+ }
+
+ /**
+ * YouTube enum name for this tab.
+ */
+ private final List ytEnumNames;
+
+ NavigationButton(String... ytEnumNames) {
+ this.ytEnumNames = Arrays.asList(ytEnumNames);
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerControlsVisibilityObserver.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerControlsVisibilityObserver.kt
new file mode 100644
index 000000000..26745755d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerControlsVisibilityObserver.kt
@@ -0,0 +1,84 @@
+package app.revanced.extension.youtube.shared
+
+import android.app.Activity
+import android.view.View
+import android.view.ViewGroup
+import app.revanced.extension.shared.Utils
+import java.lang.ref.WeakReference
+
+/**
+ * default implementation of [PlayerControlsVisibilityObserver]
+ *
+ * @param activity activity that contains the controls_layout view
+ */
+class PlayerControlsVisibilityObserverImpl(
+ private val activity: Activity,
+) : PlayerControlsVisibilityObserver {
+
+ /**
+ * id of the direct parent of controls_layout, R.id.youtube_controls_overlay
+ */
+ private val controlsLayoutParentId =
+ Utils.getResourceIdentifier(activity, "youtube_controls_overlay", "id")
+
+ /**
+ * id of R.id.controls_layout
+ */
+ private val controlsLayoutId =
+ Utils.getResourceIdentifier(activity, "controls_layout", "id")
+
+ /**
+ * reference to the controls layout view
+ */
+ private var controlsLayoutView = WeakReference(null)
+
+ /**
+ * is the [controlsLayoutView] set to a valid reference of a view?
+ */
+ private val isAttached: Boolean
+ get() {
+ val view = controlsLayoutView.get()
+ return view != null && view.parent != null
+ }
+
+ /**
+ * find and attach the controls_layout view if needed
+ */
+ private fun maybeAttach() {
+ if (isAttached) return
+
+ // find parent, then controls_layout view
+ // this is needed because there may be two views where id=R.id.controls_layout
+ // because why should google confine themselves to their own guidelines...
+ activity.findViewById(controlsLayoutParentId)?.let { parent ->
+ parent.findViewById(controlsLayoutId)?.let {
+ controlsLayoutView = WeakReference(it)
+ }
+ }
+ }
+
+ override val playerControlsVisibility: Int
+ get() {
+ maybeAttach()
+ return controlsLayoutView.get()?.visibility ?: View.GONE
+ }
+
+ override val arePlayerControlsVisible: Boolean
+ get() = playerControlsVisibility == View.VISIBLE
+}
+
+/**
+ * provides the visibility status of the fullscreen player controls_layout view.
+ * this can be used for detecting when the player controls are shown
+ */
+interface PlayerControlsVisibilityObserver {
+ /**
+ * current visibility int of the controls_layout view
+ */
+ val playerControlsVisibility: Int
+
+ /**
+ * is the value of [playerControlsVisibility] equal to [View.VISIBLE]?
+ */
+ val arePlayerControlsVisible: Boolean
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerOverlays.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerOverlays.kt
new file mode 100644
index 000000000..ec82053fa
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerOverlays.kt
@@ -0,0 +1,97 @@
+package app.revanced.extension.youtube.shared
+
+import android.view.View
+import android.view.ViewGroup
+import app.revanced.extension.youtube.Event
+import app.revanced.extension.youtube.swipecontrols.misc.Rectangle
+
+/**
+ * hooking class for player overlays
+ */
+@Suppress("MemberVisibilityCanBePrivate")
+object PlayerOverlays {
+
+ /**
+ * called when the overlays finished inflating
+ */
+ val onInflate = Event()
+
+ /**
+ * called when new children are added or removed from the overlay
+ */
+ val onChildrenChange = Event()
+
+ /**
+ * called when the overlay layout changes
+ */
+ val onLayoutChange = Event()
+
+ /**
+ * start listening for events on the provided view group
+ *
+ * @param overlaysLayout the overlays view group
+ */
+ @JvmStatic
+ fun attach(overlaysLayout: ViewGroup) {
+ onInflate.invoke(overlaysLayout)
+ overlaysLayout.setOnHierarchyChangeListener(object :
+ ViewGroup.OnHierarchyChangeListener {
+ override fun onChildViewAdded(parent: View?, child: View?) {
+ if (parent is ViewGroup && child is View) {
+ onChildrenChange(
+ ChildrenChangeEventArgs(
+ parent,
+ child,
+ false,
+ ),
+ )
+ }
+ }
+
+ override fun onChildViewRemoved(parent: View?, child: View?) {
+ if (parent is ViewGroup && child is View) {
+ onChildrenChange(
+ ChildrenChangeEventArgs(
+ parent,
+ child,
+ true,
+ ),
+ )
+ }
+ }
+ })
+ overlaysLayout.addOnLayoutChangeListener { view, newLeft, newTop, newRight, newBottom, oldLeft, oldTop, oldRight, oldBottom ->
+ if (view is ViewGroup) {
+ onLayoutChange(
+ LayoutChangeEventArgs(
+ view,
+ Rectangle(
+ oldLeft,
+ oldTop,
+ oldRight - oldLeft,
+ oldBottom - oldTop,
+ ),
+ Rectangle(
+ newLeft,
+ newTop,
+ newRight - newLeft,
+ newBottom - newTop,
+ ),
+ ),
+ )
+ }
+ }
+ }
+}
+
+data class ChildrenChangeEventArgs(
+ val overlaysLayout: ViewGroup,
+ val childView: View,
+ val wasChildRemoved: Boolean,
+)
+
+data class LayoutChangeEventArgs(
+ val overlaysLayout: ViewGroup,
+ val oldRect: Rectangle,
+ val newRect: Rectangle,
+)
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerType.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerType.kt
new file mode 100644
index 000000000..dc3fd8ca2
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerType.kt
@@ -0,0 +1,139 @@
+package app.revanced.extension.youtube.shared
+
+import app.revanced.extension.shared.Logger
+import app.revanced.extension.youtube.Event
+import app.revanced.extension.youtube.patches.VideoInformation
+
+/**
+ * Main player type.
+ */
+enum class PlayerType {
+ /**
+ * Either no video, or a Short is playing.
+ */
+ NONE,
+
+ /**
+ * A Short is playing. Occurs if a regular video is first opened
+ * and then a Short is opened (without first closing the regular video).
+ */
+ HIDDEN,
+
+ /**
+ * A regular video is minimized.
+ *
+ * When spoofing to 16.x YouTube and watching a short with a regular video in the background,
+ * the type can be this (and not [HIDDEN]).
+ */
+ WATCH_WHILE_MINIMIZED,
+ WATCH_WHILE_MAXIMIZED,
+ WATCH_WHILE_FULLSCREEN,
+ WATCH_WHILE_SLIDING_MAXIMIZED_FULLSCREEN,
+ WATCH_WHILE_SLIDING_MINIMIZED_MAXIMIZED,
+
+ /**
+ * Player is either sliding to [HIDDEN] state because a Short was opened while a regular video is on screen.
+ * OR
+ * The user has swiped a minimized player away to be closed (and no Short is being opened).
+ */
+ WATCH_WHILE_SLIDING_MINIMIZED_DISMISSED,
+ WATCH_WHILE_SLIDING_FULLSCREEN_DISMISSED,
+
+ /**
+ * Home feed video playback.
+ */
+ INLINE_MINIMAL,
+ VIRTUAL_REALITY_FULLSCREEN,
+ WATCH_WHILE_PICTURE_IN_PICTURE,
+ ;
+
+ companion object {
+
+ private val nameToPlayerType = values().associateBy { it.name }
+
+ @JvmStatic
+ fun setFromString(enumName: String) {
+ val newType = nameToPlayerType[enumName]
+ if (newType == null) {
+ Logger.printException { "Unknown PlayerType encountered: $enumName" }
+ } else if (current != newType) {
+ Logger.printDebug { "PlayerType changed to: $newType" }
+ current = newType
+ }
+ }
+
+ /**
+ * The current player type.
+ */
+ @JvmStatic
+ var current
+ get() = currentPlayerType
+ private set(value) {
+ currentPlayerType = value
+ onChange(currentPlayerType)
+ }
+
+ @Volatile // value is read/write from different threads
+ private var currentPlayerType = NONE
+
+ /**
+ * player type change listener
+ */
+ @JvmStatic
+ val onChange = Event()
+ }
+
+ /**
+ * Check if the current player type is [NONE] or [HIDDEN].
+ * Useful to check if a short is currently playing.
+ *
+ * Does not include the first moment after a short is opened when a regular video is minimized on screen,
+ * or while watching a short with a regular video present on a spoofed 16.x version of YouTube.
+ * To include those situations instead use [isNoneHiddenOrMinimized].
+ *
+ * @see VideoInformation
+ */
+ fun isNoneOrHidden(): Boolean {
+ return this == NONE || this == HIDDEN
+ }
+
+ /**
+ * Check if the current player type is
+ * [NONE], [HIDDEN], [WATCH_WHILE_SLIDING_MINIMIZED_DISMISSED].
+ *
+ * Useful to check if a Short is being played or opened.
+ *
+ * Usually covers all use cases with no false positives, except if called from some hooks
+ * when spoofing to an old version this will return false even
+ * though a Short is being opened or is on screen (see [isNoneHiddenOrMinimized]).
+ *
+ * @return If nothing, a Short, or a regular video is sliding off screen to a dismissed or hidden state.
+ * @see VideoInformation
+ */
+ fun isNoneHiddenOrSlidingMinimized(): Boolean {
+ return isNoneOrHidden() || this == WATCH_WHILE_SLIDING_MINIMIZED_DISMISSED
+ }
+
+ /**
+ * Check if the current player type is
+ * [NONE], [HIDDEN], [WATCH_WHILE_MINIMIZED], [WATCH_WHILE_SLIDING_MINIMIZED_DISMISSED].
+ *
+ * Useful to check if a Short is being played,
+ * although will return false positive if a regular video is
+ * opened and minimized (and a Short is not playing or being opened).
+ *
+ * Typically used to detect if a Short is playing when the player cannot be in a minimized state,
+ * such as the user interacting with a button or element of the player.
+ *
+ * @return If nothing, a Short, a regular video is sliding off screen to a dismissed or hidden state,
+ * a regular video is minimized (and a new video is not being opened).
+ * @see VideoInformation
+ */
+ fun isNoneHiddenOrMinimized(): Boolean {
+ return isNoneHiddenOrSlidingMinimized() || this == WATCH_WHILE_MINIMIZED
+ }
+
+ fun isMaximizedOrFullscreen(): Boolean {
+ return this == WATCH_WHILE_MAXIMIZED || this == WATCH_WHILE_FULLSCREEN
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/VideoState.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/VideoState.kt
new file mode 100644
index 000000000..e01cb0249
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/VideoState.kt
@@ -0,0 +1,51 @@
+package app.revanced.extension.youtube.shared
+
+import app.revanced.extension.shared.Logger
+import app.revanced.extension.youtube.patches.VideoInformation
+
+/**
+ * VideoState playback state.
+ */
+enum class VideoState {
+ NEW,
+ PLAYING,
+ PAUSED,
+ RECOVERABLE_ERROR,
+ UNRECOVERABLE_ERROR,
+
+ /**
+ * @see [VideoInformation.isAtEndOfVideo]
+ */
+ ENDED,
+
+ ;
+
+ companion object {
+
+ private val nameToVideoState = values().associateBy { it.name }
+
+ @JvmStatic
+ fun setFromString(enumName: String) {
+ val state = nameToVideoState[enumName]
+ if (state == null) {
+ Logger.printException { "Unknown VideoState encountered: $enumName" }
+ } else if (currentVideoState != state) {
+ Logger.printDebug { "VideoState changed to: $state" }
+ currentVideoState = state
+ }
+ }
+
+ /**
+ * Depending on which hook this is called from,
+ * this value may not be up to date with the actual playback state.
+ */
+ @JvmStatic
+ var current: VideoState?
+ get() = currentVideoState
+ private set(value) {
+ currentVideoState = value
+ }
+
+ private var currentVideoState: VideoState? = null
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SegmentPlaybackController.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SegmentPlaybackController.java
new file mode 100644
index 000000000..3f48930e3
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SegmentPlaybackController.java
@@ -0,0 +1,771 @@
+package app.revanced.extension.youtube.sponsorblock;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.text.TextUtils;
+import android.util.TypedValue;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.lang.reflect.Field;
+import java.util.*;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.youtube.patches.VideoInformation;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.PlayerType;
+import app.revanced.extension.youtube.shared.VideoState;
+import app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour;
+import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory;
+import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment;
+import app.revanced.extension.youtube.sponsorblock.requests.SBRequester;
+import app.revanced.extension.youtube.sponsorblock.ui.SponsorBlockViewController;
+
+/**
+ * Handles showing, scheduling, and skipping of all {@link SponsorSegment} for the current video.
+ *
+ * Class is not thread safe. All methods must be called on the main thread unless otherwise specified.
+ */
+public class SegmentPlaybackController {
+ /**
+ * Length of time to show a skip button for a highlight segment,
+ * or a regular segment if {@link Settings#SB_AUTO_HIDE_SKIP_BUTTON} is enabled.
+ *
+ * Effectively this value is rounded up to the next second.
+ */
+ private static final long DURATION_TO_SHOW_SKIP_BUTTON = 3800;
+
+ /*
+ * Highlight segments have zero length as they are a point in time.
+ * Draw them on screen using a fixed width bar.
+ * Value is independent of device dpi.
+ */
+ private static final int HIGHLIGHT_SEGMENT_DRAW_BAR_WIDTH = 7;
+
+ @Nullable
+ private static String currentVideoId;
+ @Nullable
+ private static SponsorSegment[] segments;
+
+ /**
+ * Highlight segment, if one exists and the skip behavior is not set to {@link CategoryBehaviour#SHOW_IN_SEEKBAR}.
+ */
+ @Nullable
+ private static SponsorSegment highlightSegment;
+
+ /**
+ * Because loading can take time, show the skip to highlight for a few seconds after the segments load.
+ * This is the system time (in milliseconds) to no longer show the initial display skip to highlight.
+ * Value will be zero if no highlight segment exists, or if the system time to show the highlight has passed.
+ */
+ private static long highlightSegmentInitialShowEndTime;
+
+ /**
+ * Currently playing (non-highlight) segment that user can manually skip.
+ */
+ @Nullable
+ private static SponsorSegment segmentCurrentlyPlaying;
+ /**
+ * Currently playing manual skip segment that is scheduled to hide.
+ * This will always be NULL or equal to {@link #segmentCurrentlyPlaying}.
+ */
+ @Nullable
+ private static SponsorSegment scheduledHideSegment;
+ /**
+ * Upcoming segment that is scheduled to either autoskip or show the manual skip button.
+ */
+ @Nullable
+ private static SponsorSegment scheduledUpcomingSegment;
+
+ /**
+ * Used to prevent re-showing a previously hidden skip button when exiting an embedded segment.
+ * Only used when {@link Settings#SB_AUTO_HIDE_SKIP_BUTTON} is enabled.
+ *
+ * A collection of segments that have automatically hidden the skip button for, and all segments in this list
+ * contain the current video time. Segment are removed when playback exits the segment.
+ */
+ private static final List