diff --git a/extensions/remove-screen-capture-restriction/build.gradle.kts b/extensions/remove-screen-capture-restriction/build.gradle.kts new file mode 100644 index 0000000000..46f94dac8f --- /dev/null +++ b/extensions/remove-screen-capture-restriction/build.gradle.kts @@ -0,0 +1,11 @@ +extension { + name = "extensions/all/screencapture/remove-screen-capture-restriction.rve" +} + +android { + namespace = "app.revanced.extension" +} + +dependencies { + compileOnly(libs.annotation) +} diff --git a/extensions/remove-screen-capture-restriction/src/main/AndroidManifest.xml b/extensions/remove-screen-capture-restriction/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..15e7c2ae67 --- /dev/null +++ b/extensions/remove-screen-capture-restriction/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/extensions/remove-screen-capture-restriction/src/main/java/app/revanced/extension/all/screencapture/removerestriction/RemoveScreencaptureRestrictionPatch.java b/extensions/remove-screen-capture-restriction/src/main/java/app/revanced/extension/all/screencapture/removerestriction/RemoveScreencaptureRestrictionPatch.java new file mode 100644 index 0000000000..1dac341441 --- /dev/null +++ b/extensions/remove-screen-capture-restriction/src/main/java/app/revanced/extension/all/screencapture/removerestriction/RemoveScreencaptureRestrictionPatch.java @@ -0,0 +1,21 @@ +package app.revanced.extension.all.screencapture.removerestriction; + +import android.media.AudioAttributes; +import android.os.Build; + +import androidx.annotation.RequiresApi; + +public final class RemoveScreencaptureRestrictionPatch { + // Member of AudioAttributes.Builder + @RequiresApi(api = Build.VERSION_CODES.Q) + public static AudioAttributes.Builder setAllowedCapturePolicy(final AudioAttributes.Builder builder, final int capturePolicy) { + builder.setAllowedCapturePolicy(AudioAttributes.ALLOW_CAPTURE_BY_ALL); + + return builder; + } + + // Member of AudioManager static class + public static void setAllowedCapturePolicy(final int capturePolicy) { + // Ignore request + } +} diff --git a/extensions/remove-screenshot-restriction/build.gradle.kts b/extensions/remove-screenshot-restriction/build.gradle.kts new file mode 100644 index 0000000000..cdbad5e1eb --- /dev/null +++ b/extensions/remove-screenshot-restriction/build.gradle.kts @@ -0,0 +1,7 @@ +extension { + name = "extensions/all/screenshot/remove-screenshot-restriction.rve" +} + +android { + namespace = "app.revanced.extension" +} diff --git a/extensions/remove-screenshot-restriction/src/main/AndroidManifest.xml b/extensions/remove-screenshot-restriction/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..15e7c2ae67 --- /dev/null +++ b/extensions/remove-screenshot-restriction/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/extensions/remove-screenshot-restriction/src/main/java/app/revanced/extension/all/screenshot/removerestriction/RemoveScreenshotRestrictionPatch.java b/extensions/remove-screenshot-restriction/src/main/java/app/revanced/extension/all/screenshot/removerestriction/RemoveScreenshotRestrictionPatch.java new file mode 100644 index 0000000000..fd5c427d37 --- /dev/null +++ b/extensions/remove-screenshot-restriction/src/main/java/app/revanced/extension/all/screenshot/removerestriction/RemoveScreenshotRestrictionPatch.java @@ -0,0 +1,15 @@ +package app.revanced.extension.all.screenshot.removerestriction; + +import android.view.Window; +import android.view.WindowManager; + +public class RemoveScreenshotRestrictionPatch { + + public static void addFlags(Window window, int flags) { + window.addFlags(flags & ~WindowManager.LayoutParams.FLAG_SECURE); + } + + public static void setFlags(Window window, int flags, int mask) { + window.setFlags(flags & ~WindowManager.LayoutParams.FLAG_SECURE, mask & ~WindowManager.LayoutParams.FLAG_SECURE); + } +} diff --git a/extensions/shared/build.gradle.kts b/extensions/shared/build.gradle.kts new file mode 100644 index 0000000000..5abaf83aba --- /dev/null +++ b/extensions/shared/build.gradle.kts @@ -0,0 +1,22 @@ +extension { + name = "extensions/shared.rve" +} + +android { + namespace = "app.revanced.extension" + + buildTypes { + release { + isMinifyEnabled = true + } + } +} + +dependencies { + compileOnly(libs.appcompat) + compileOnly(libs.annotation) + compileOnly(libs.okhttp) + compileOnly(libs.retrofit) + + compileOnly(project(":extensions:shared:stub")) +} diff --git a/extensions/shared/proguard-rules.pro b/extensions/shared/proguard-rules.pro new file mode 100644 index 0000000000..8f804140d6 --- /dev/null +++ b/extensions/shared/proguard-rules.pro @@ -0,0 +1,9 @@ +-dontobfuscate +-dontoptimize +-keepattributes * +-keep class app.revanced.** { + *; +} +-keep class com.google.** { + *; +} diff --git a/extensions/shared/src/main/AndroidManifest.xml b/extensions/shared/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e960b00030 --- /dev/null +++ b/extensions/shared/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/extensions/shared/src/main/java/app/revanced/extension/boostforreddit/FixSLinksPatch.java b/extensions/shared/src/main/java/app/revanced/extension/boostforreddit/FixSLinksPatch.java new file mode 100644 index 0000000000..b7a150fb1b --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/boostforreddit/FixSLinksPatch.java @@ -0,0 +1,24 @@ +package app.revanced.extension.boostforreddit; + +import com.rubenmayayo.reddit.ui.activities.WebViewActivity; + +import app.revanced.extension.shared.fixes.slink.BaseFixSLinksPatch; + +/** @noinspection unused*/ +public class FixSLinksPatch extends BaseFixSLinksPatch { + static { + INSTANCE = new FixSLinksPatch(); + } + + private FixSLinksPatch() { + webViewActivityClass = WebViewActivity.class; + } + + public static boolean patchResolveSLink(String link) { + return INSTANCE.resolveSLink(link); + } + + public static void patchSetAccessToken(String accessToken) { + INSTANCE.setAccessToken(accessToken); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/FilterPromotedLinksPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/FilterPromotedLinksPatch.java new file mode 100644 index 0000000000..7534d69283 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/FilterPromotedLinksPatch.java @@ -0,0 +1,23 @@ +package app.revanced.extension.reddit.patches; + +import com.reddit.domain.model.ILink; + +import java.util.ArrayList; +import java.util.List; + +public final class FilterPromotedLinksPatch { + /** + * Filters list from promoted links. + **/ + public static List filterChildren(final Iterable links) { + final List filteredList = new ArrayList<>(); + + for (Object item : links) { + if (item instanceof ILink && ((ILink) item).getPromoted()) continue; + + filteredList.add(item); + } + + return filteredList; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/GmsCoreSupport.java b/extensions/shared/src/main/java/app/revanced/extension/shared/GmsCoreSupport.java new file mode 100644 index 0000000000..1e2586b2bc --- /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 0000000000..3ac7438b90 --- /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 0000000000..4390137de7 --- /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 0000000000..45cf5616e5 --- /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 0000000000..855e6003b7 --- /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 0000000000..d63f8b7e3f --- /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 0000000000..62144d753d --- /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 0000000000..a8a6bf504e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/fixes/slink/BaseFixSLinksPatch.java @@ -0,0 +1,208 @@ +package app.revanced.extension.shared.fixes.slink; + + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import androidx.annotation.NonNull; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.util.Objects; + +import static app.revanced.extension.shared.Utils.getContext; + + +/** + * Base class to implement /s/ link resolution in 3rd party Reddit apps. + *
+ *
+ * Usage: + *
+ *
+ * An implementation of this class must have two static methods that are called by the app: + *
    + *
  • public static boolean patchResolveSLink(String link)
  • + *
  • public static void patchSetAccessToken(String accessToken)
  • + *
+ * The static methods must call the instance methods of the base class. + *
+ * The singleton pattern can be used to access the instance of the class: + *
+ * {@code
+ * {
+ *     INSTANCE = new FixSLinksPatch();
+ * }
+ * }
+ * 
+ * Set the app's web view activity class as a fallback to open /s/ links if the resolution fails: + *
+ * {@code
+ * private FixSLinksPatch() {
+ *     webViewActivityClass = WebViewActivity.class;
+ * }
+ * }
+ * 
+ * Hook the app's navigation handler to call this method before doing any of its own resolution: + *
+ * {@code
+ * public static boolean patchResolveSLink(Context context, String link) {
+ *     return INSTANCE.resolveSLink(context, link);
+ * }
+ * }
+ * 
+ * If this method returns true, the app should early return and not do any of its own resolution. + *
+ *
+ * Hook the app's access token so that this class can use it to resolve /s/ links: + *
+ * {@code
+ * public static void patchSetAccessToken(String accessToken) {
+ *     INSTANCE.setAccessToken(access_token);
+ * }
+ * }
+ * 
+ */ +public abstract class BaseFixSLinksPatch { + /** + * The class of the activity used to open links in a web view if resolving them fails. + */ + protected Class webViewActivityClass; + + /** + * The access token used to resolve the /s/ link. + */ + protected String accessToken; + + /** + * The URL that was trying to be resolved before the access token was set. + * If this is not null, the URL will be resolved right after the access token is set. + */ + protected String pendingUrl; + + /** + * The singleton instance of the class. + */ + protected static BaseFixSLinksPatch INSTANCE; + + public boolean resolveSLink(String link) { + switch (resolveLink(link)) { + case ACCESS_TOKEN_START: { + pendingUrl = link; + return true; + } + case DO_NOTHING: + return true; + default: + return false; + } + } + + private ResolveResult resolveLink(String link) { + Context context = getContext(); + if (link.matches(".*reddit\\.com/r/[^/]+/s/[^/]+")) { + // A link ends with #bypass if it failed to resolve below. + // resolveLink is called with the same link again but this time with #bypass + // so that the link is opened in the app browser instead of trying to resolve it again. + if (link.endsWith("#bypass")) { + openInAppBrowser(context, link); + + return ResolveResult.DO_NOTHING; + } + + Logger.printDebug(() -> "Resolving " + link); + + if (accessToken == null) { + // This is not optimal. + // However, an accessToken is necessary to make an authenticated request to Reddit. + // in case Reddit has banned the IP - e.g. VPN. + Intent startIntent = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName()); + context.startActivity(startIntent); + + return ResolveResult.ACCESS_TOKEN_START; + } + + + Utils.runOnBackgroundThread(() -> { + String bypassLink = link + "#bypass"; + + String finalLocation = bypassLink; + try { + HttpURLConnection connection = getHttpURLConnection(link, accessToken); + connection.connect(); + String location = connection.getHeaderField("location"); + connection.disconnect(); + + Objects.requireNonNull(location, "Location is null"); + + finalLocation = location; + Logger.printDebug(() -> "Resolved " + link + " to " + location); + } catch (SocketTimeoutException e) { + Logger.printException(() -> "Timeout when trying to resolve " + link, e); + finalLocation = bypassLink; + } catch (Exception e) { + Logger.printException(() -> "Failed to resolve " + link, e); + finalLocation = bypassLink; + } finally { + Intent startIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(finalLocation)); + startIntent.setPackage(context.getPackageName()); + startIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(startIntent); + } + }); + + return ResolveResult.DO_NOTHING; + } + + return ResolveResult.CONTINUE; + } + + public void setAccessToken(String accessToken) { + Logger.printDebug(() -> "Setting access token"); + + this.accessToken = accessToken; + + // In case a link was trying to be resolved before access token was set. + // The link is resolved now, after the access token is set. + if (pendingUrl != null) { + String link = pendingUrl; + pendingUrl = null; + + Logger.printDebug(() -> "Opening pending URL"); + + resolveLink(link); + } + } + + private void openInAppBrowser(Context context, String link) { + Intent intent = new Intent(context, webViewActivityClass); + intent.putExtra("url", link); + context.startActivity(intent); + } + + @NonNull + private HttpURLConnection getHttpURLConnection(String link, String accessToken) throws IOException { + URL url = new URL(link); + + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setInstanceFollowRedirects(false); + connection.setRequestMethod("HEAD"); + connection.setConnectTimeout(2000); + connection.setReadTimeout(2000); + + if (accessToken != null) { + Logger.printDebug(() -> "Setting access token to make /s/ request"); + + connection.setRequestProperty("Authorization", "Bearer " + accessToken); + } else { + Logger.printDebug(() -> "Not setting access token to make /s/ request, because it is null"); + } + + return connection; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/fixes/slink/ResolveResult.java b/extensions/shared/src/main/java/app/revanced/extension/shared/fixes/slink/ResolveResult.java new file mode 100644 index 0000000000..8026c20585 --- /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 0000000000..70d7589e89 --- /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 0000000000..7e84034d06 --- /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 0000000000..a2b82dd215 --- /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 0000000000..7419741e03 --- /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 0000000000..58f39a9107 --- /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 0000000000..4d7f8114f2 --- /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 0000000000..7507d802a8 --- /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 0000000000..0fa5e03fc1 --- /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 0000000000..3c1ad706ae --- /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 0000000000..c750ca3f1b --- /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 0000000000..89fbe80e90 --- /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 0000000000..3e9a969611 --- /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 0000000000..4e9c1f2e0b --- /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 0000000000..e006e31e66 --- /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 0000000000..de6a96c124 --- /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 0000000000..2779659210 --- /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 0000000000..e436b5dcd0 --- /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 0000000000..c55d62878c --- /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 0000000000..31a982c684 --- /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 0000000000..e1e0add8e4 --- /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 0000000000..57639258d8 --- /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 0000000000..ed3e7cdb92 --- /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 0000000000..57eb665ea1 --- /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 0000000000..db6ab0af06 --- /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 0000000000..85d0a70883 --- /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 0000000000..ca9156f84e --- /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 0000000000..11304eb1ec --- /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 0000000000..22a2d84d92 --- /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 0000000000..7333b1798a --- /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 0000000000..ae4759c793 --- /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 0000000000..b80380e512 --- /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 0000000000..8eaf98ac52 --- /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 0000000000..43ab69297e --- /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 0000000000..788b0d67d0 --- /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 0000000000..d9f865ee91 --- /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 0000000000..1ba3defa41 --- /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 0000000000..ad49df688b --- /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 0000000000..bcd56bc7ee --- /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 0000000000..0a820dc398 --- /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 0000000000..3b078ab896 --- /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 0000000000..94910bdb6f --- /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 0000000000..f2868cf4e2 --- /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 0000000000..bb3a2473d3 --- /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 0000000000..73c363ff88 --- /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 0000000000..457ecdf974 --- /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 0000000000..ef217daf04 --- /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 0000000000..ba1bd183a4 --- /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 0000000000..519a3fe8a9 --- /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 0000000000..2309bcf64a --- /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 0000000000..24f4060b6f --- /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 0000000000..77b7cbd5ab --- /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 0000000000..55c32c7ea5 --- /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 0000000000..dc4ab8094e --- /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 0000000000..bb172d1a8e --- /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 0000000000..747a6b94d9 --- /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 0000000000..6c7b739af1 --- /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 0000000000..e617cf9b20 --- /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 0000000000..aa5fed4b21 --- /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 0000000000..5cef4a0b70 --- /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 0000000000..d77d06e194 --- /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 0000000000..306b58e0a1 --- /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 0000000000..2d6441be79 --- /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 0000000000..4d82c8b4e8 --- /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 0000000000..3211e40e85 --- /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 0000000000..de2f7b2faa --- /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 0000000000..9dd620d918 --- /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 0000000000..161801dc2f --- /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 0000000000..af5b0312ea --- /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 0000000000..4872d95aa6 --- /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 0000000000..808a8de03c --- /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 0000000000..2b4bdc124a --- /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 0000000000..d046c63705 --- /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 0000000000..ff33c44095 --- /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 0000000000..162e0b0405 --- /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 0000000000..72323949ce --- /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 0000000000..fbff9bebac --- /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 0000000000..6e0ea59742 --- /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 0000000000..74fb4685d2 --- /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 0000000000..92be08433e --- /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 0000000000..21409e7390 --- /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 0000000000..d3fc82ae2c --- /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 0000000000..ccc853d4c3 --- /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 0000000000..ebce7cd354 --- /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 0000000000..350e5787d0 --- /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 0000000000..ccc2cc8c91 --- /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 0000000000..db3338f527 --- /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 0000000000..4f13deaac4 --- /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 0000000000..9d43159a5f --- /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 0000000000..962a0d7b74 --- /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 0000000000..dd2e0ca8f9 --- /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 0000000000..c6a80c6a0c --- /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 0000000000..938ac54583 --- /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 0000000000..f600f391aa --- /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 0000000000..7cd91d9c23 --- /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 0000000000..6da31b6a4c --- /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 0000000000..8fe4dbb187 --- /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 0000000000..f454b361c0 --- /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 0000000000..1aa9650cf0 --- /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 0000000000..656f36c389 --- /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 0000000000..89261d1190 --- /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 0000000000..35592c0ff8 --- /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 0000000000..e01c4a3947 --- /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 0000000000..c1a0065dbe --- /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 0000000000..98065d7ec0 --- /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 0000000000..a502bb690f --- /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 0000000000..782ca25409 --- /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 0000000000..8c581fc1c4 --- /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 0000000000..1f5d52c5bb --- /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 0000000000..5dc3dde266 --- /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 0000000000..cf44c9dd5c --- /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 0000000000..3f15919505 --- /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 0000000000..3b05a239a9 --- /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 0000000000..0260b2c69d --- /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 0000000000..8322ea70b0 --- /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 0000000000..6a554fbd68 --- /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 0000000000..dbbb363e1f --- /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 0000000000..32576479d8 --- /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 0000000000..d17a1f8660 --- /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 0000000000..f2ae035980 --- /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 0000000000..2d3817ce99 --- /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 0000000000..9950de5e0b --- /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 0000000000..6b64ade12d --- /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 0000000000..57ec7442f6 --- /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 0000000000..0367eb8684 --- /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 0000000000..0bea72373d --- /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 0000000000..94d340e6e9 --- /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 0000000000..0bf9e9c3fd --- /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 0000000000..35337bee0c --- /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 0000000000..0e7ebc4407 --- /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 0000000000..37062d6e28 --- /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 0000000000..2ddd8489cd --- /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 0000000000..42b86d589f --- /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 0000000000..4e20bc82ae --- /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 0000000000..ac0e23ca83 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FilterGroupList.java @@ -0,0 +1,85 @@ +package app.revanced.extension.youtube.patches.components; + +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import java.util.*; +import java.util.function.Consumer; + +import app.revanced.extension.youtube.ByteTrieSearch; +import app.revanced.extension.youtube.StringTrieSearch; +import app.revanced.extension.youtube.TrieSearch; + +abstract class FilterGroupList> implements Iterable { + + private final List filterGroups = new ArrayList<>(); + private final TrieSearch search = createSearchGraph(); + + @SafeVarargs + protected final void addAll(final T... groups) { + filterGroups.addAll(Arrays.asList(groups)); + + for (T group : groups) { + if (!group.includeInSearch()) { + continue; + } + for (V pattern : group.filters) { + search.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> { + if (group.isEnabled()) { + FilterGroup.FilterGroupResult result = (FilterGroup.FilterGroupResult) callbackParameter; + result.setValues(group.setting, matchedStartIndex, matchedLength); + return true; + } + return false; + }); + } + } + } + + @NonNull + @Override + public Iterator iterator() { + return filterGroups.iterator(); + } + + @RequiresApi(api = Build.VERSION_CODES.N) + @Override + public void forEach(@NonNull Consumer action) { + filterGroups.forEach(action); + } + + @RequiresApi(api = Build.VERSION_CODES.N) + @NonNull + @Override + public Spliterator spliterator() { + return filterGroups.spliterator(); + } + + protected FilterGroup.FilterGroupResult check(V stack) { + FilterGroup.FilterGroupResult result = new FilterGroup.FilterGroupResult(); + search.matches(stack, result); + return result; + + } + + protected abstract TrieSearch createSearchGraph(); +} + +final class StringFilterGroupList extends FilterGroupList { + protected StringTrieSearch createSearchGraph() { + return new StringTrieSearch(); + } +} + +/** + * If searching for a single byte pattern, then it is slightly better to use + * {@link ByteArrayFilterGroup#check(byte[])} as it uses KMP which is faster + * than a prefix tree to search for only 1 pattern. + */ +final class ByteArrayFilterGroupList extends FilterGroupList { + protected ByteTrieSearch createSearchGraph() { + return new ByteTrieSearch(); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/HideInfoCardsFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/HideInfoCardsFilterPatch.java new file mode 100644 index 0000000000..ce92b592ee --- /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 0000000000..b451fd282f --- /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 0000000000..8911249876
--- /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 0000000000..0e7ac8ab47
--- /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 0000000000..e49ff08532
--- /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 0000000000..3469bbb856
--- /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 0000000000..bac1d4ce72
--- /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 0000000000..5e994a0036
--- /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 0000000000..7ee3cab777
--- /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 0000000000..785603895e
--- /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 0000000000..ac74bc810f
--- /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 0000000000..cad6050fba
--- /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 0000000000..2d6d0f781a
--- /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 0000000000..de6a2a12c7
--- /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 0000000000..3adc6befb3
--- /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 0000000000..25ad35d64c
--- /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 0000000000..6df52a4a37
--- /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 0000000000..b50e3f4ffa
--- /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 0000000000..364dc173a6 --- /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 0000000000..e66f4d885d --- /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 0000000000..bf0284f790 --- /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 0000000000..aea5c227c8 --- /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 0000000000..77372e400e --- /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 0000000000..69d43a4beb --- /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 0000000000..c25d108b9d --- /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 0000000000..a67a96fa8e --- /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 0000000000..b57eadcfd0 --- /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 0000000000..07c8f3c555 --- /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 0000000000..2c2ae72559 --- /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 0000000000..acf5657124 --- /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 0000000000..479080623c --- /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 0000000000..5ca2e65dcb --- /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 0000000000..5581756758 --- /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 0000000000..bd9db08f55 --- /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 0000000000..a22206f22c --- /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 0000000000..17b667e783 --- /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 0000000000..66bcf29e27 --- /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 0000000000..9fa4a942a2 --- /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 0000000000..960df3bf07 --- /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 0000000000..26745755db --- /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 0000000000..ec82053fa1 --- /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 0000000000..dc3fd8ca26 --- /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 0000000000..e01cb02495 --- /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 0000000000..3f48930e39 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SegmentPlaybackController.java @@ -0,0 +1,771 @@ +package app.revanced.extension.youtube.sponsorblock; + +import static app.revanced.extension.shared.StringRef.str; + +import android.graphics.Canvas; +import android.graphics.Rect; +import android.text.TextUtils; +import android.util.TypedValue; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.reflect.Field; +import java.util.*; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.patches.VideoInformation; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.PlayerType; +import app.revanced.extension.youtube.shared.VideoState; +import app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour; +import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory; +import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment; +import app.revanced.extension.youtube.sponsorblock.requests.SBRequester; +import app.revanced.extension.youtube.sponsorblock.ui.SponsorBlockViewController; + +/** + * Handles showing, scheduling, and skipping of all {@link SponsorSegment} for the current video. + * + * Class is not thread safe. All methods must be called on the main thread unless otherwise specified. + */ +public class SegmentPlaybackController { + /** + * Length of time to show a skip button for a highlight segment, + * or a regular segment if {@link Settings#SB_AUTO_HIDE_SKIP_BUTTON} is enabled. + * + * Effectively this value is rounded up to the next second. + */ + private static final long DURATION_TO_SHOW_SKIP_BUTTON = 3800; + + /* + * Highlight segments have zero length as they are a point in time. + * Draw them on screen using a fixed width bar. + * Value is independent of device dpi. + */ + private static final int HIGHLIGHT_SEGMENT_DRAW_BAR_WIDTH = 7; + + @Nullable + private static String currentVideoId; + @Nullable + private static SponsorSegment[] segments; + + /** + * Highlight segment, if one exists and the skip behavior is not set to {@link CategoryBehaviour#SHOW_IN_SEEKBAR}. + */ + @Nullable + private static SponsorSegment highlightSegment; + + /** + * Because loading can take time, show the skip to highlight for a few seconds after the segments load. + * This is the system time (in milliseconds) to no longer show the initial display skip to highlight. + * Value will be zero if no highlight segment exists, or if the system time to show the highlight has passed. + */ + private static long highlightSegmentInitialShowEndTime; + + /** + * Currently playing (non-highlight) segment that user can manually skip. + */ + @Nullable + private static SponsorSegment segmentCurrentlyPlaying; + /** + * Currently playing manual skip segment that is scheduled to hide. + * This will always be NULL or equal to {@link #segmentCurrentlyPlaying}. + */ + @Nullable + private static SponsorSegment scheduledHideSegment; + /** + * Upcoming segment that is scheduled to either autoskip or show the manual skip button. + */ + @Nullable + private static SponsorSegment scheduledUpcomingSegment; + + /** + * Used to prevent re-showing a previously hidden skip button when exiting an embedded segment. + * Only used when {@link Settings#SB_AUTO_HIDE_SKIP_BUTTON} is enabled. + * + * A collection of segments that have automatically hidden the skip button for, and all segments in this list + * contain the current video time. Segment are removed when playback exits the segment. + */ + private static final List hiddenSkipSegmentsForCurrentVideoTime = new ArrayList<>(); + + /** + * System time (in milliseconds) of when to hide the skip button of {@link #segmentCurrentlyPlaying}. + * Value is zero if playback is not inside a segment ({@link #segmentCurrentlyPlaying} is null), + * or if {@link Settings#SB_AUTO_HIDE_SKIP_BUTTON} is not enabled. + */ + private static long skipSegmentButtonEndTime; + + @Nullable + private static String timeWithoutSegments; + + private static int sponsorBarAbsoluteLeft; + private static int sponsorAbsoluteBarRight; + private static int sponsorBarThickness; + + @Nullable + static SponsorSegment[] getSegments() { + return segments; + } + + private static void setSegments(@NonNull SponsorSegment[] videoSegments) { + Arrays.sort(videoSegments); + segments = videoSegments; + calculateTimeWithoutSegments(); + + if (SegmentCategory.HIGHLIGHT.behaviour == CategoryBehaviour.SKIP_AUTOMATICALLY + || SegmentCategory.HIGHLIGHT.behaviour == CategoryBehaviour.MANUAL_SKIP) { + for (SponsorSegment segment : videoSegments) { + if (segment.category == SegmentCategory.HIGHLIGHT) { + highlightSegment = segment; + return; + } + } + } + highlightSegment = null; + } + + static void addUnsubmittedSegment(@NonNull SponsorSegment segment) { + Objects.requireNonNull(segment); + if (segments == null) { + segments = new SponsorSegment[1]; + } else { + segments = Arrays.copyOf(segments, segments.length + 1); + } + segments[segments.length - 1] = segment; + setSegments(segments); + } + + static void removeUnsubmittedSegments() { + if (segments == null || segments.length == 0) { + return; + } + List replacement = new ArrayList<>(); + for (SponsorSegment segment : segments) { + if (segment.category != SegmentCategory.UNSUBMITTED) { + replacement.add(segment); + } + } + if (replacement.size() != segments.length) { + setSegments(replacement.toArray(new SponsorSegment[0])); + } + } + + public static boolean videoHasSegments() { + return segments != null && segments.length > 0; + } + + /** + * Clears all downloaded data. + */ + private static void clearData() { + currentVideoId = null; + segments = null; + highlightSegment = null; + highlightSegmentInitialShowEndTime = 0; + timeWithoutSegments = null; + segmentCurrentlyPlaying = null; + scheduledUpcomingSegment = null; + scheduledHideSegment = null; + skipSegmentButtonEndTime = 0; + toastSegmentSkipped = null; + toastNumberOfSegmentsSkipped = 0; + hiddenSkipSegmentsForCurrentVideoTime.clear(); + } + + /** + * Injection point. + * Initializes SponsorBlock when the video player starts playing a new video. + */ + public static void initialize(VideoInformation.PlaybackController ignoredPlayerController) { + try { + Utils.verifyOnMainThread(); + SponsorBlockSettings.initialize(); + clearData(); + SponsorBlockViewController.hideAll(); + SponsorBlockUtils.clearUnsubmittedSegmentTimes(); + Logger.printDebug(() -> "Initialized SponsorBlock"); + } catch (Exception ex) { + Logger.printException(() -> "Failed to initialize SponsorBlock", ex); + } + } + + /** + * Injection point. + */ + public static void setCurrentVideoId(@Nullable String videoId) { + try { + if (Objects.equals(currentVideoId, videoId)) { + return; + } + clearData(); + if (videoId == null || !Settings.SB_ENABLED.get()) { + return; + } + if (PlayerType.getCurrent().isNoneOrHidden()) { + Logger.printDebug(() -> "ignoring Short"); + return; + } + if (!Utils.isNetworkConnected()) { + Logger.printDebug(() -> "Network not connected, ignoring video"); + return; + } + + currentVideoId = videoId; + Logger.printDebug(() -> "setCurrentVideoId: " + videoId); + + Utils.runOnBackgroundThread(() -> { + try { + executeDownloadSegments(videoId); + } catch (Exception e) { + Logger.printException(() -> "Failed to download segments", e); + } + }); + } catch (Exception ex) { + Logger.printException(() -> "setCurrentVideoId failure", ex); + } + } + + /** + * Must be called off main thread + */ + static void executeDownloadSegments(@NonNull String videoId) { + Objects.requireNonNull(videoId); + try { + SponsorSegment[] segments = SBRequester.getSegments(videoId); + + Utils.runOnMainThread(()-> { + if (!videoId.equals(currentVideoId)) { + // user changed videos before get segments network call could complete + Logger.printDebug(() -> "Ignoring segments for prior video: " + videoId); + return; + } + setSegments(segments); + + final long videoTime = VideoInformation.getVideoTime(); + if (highlightSegment != null) { + // If the current video time is before the highlight. + final long timeUntilHighlight = highlightSegment.start - videoTime; + if (timeUntilHighlight > 0) { + if (highlightSegment.shouldAutoSkip()) { + skipSegment(highlightSegment, false); + return; + } + highlightSegmentInitialShowEndTime = System.currentTimeMillis() + Math.min( + (long) (timeUntilHighlight / VideoInformation.getPlaybackSpeed()), + DURATION_TO_SHOW_SKIP_BUTTON); + } + } + + // check for any skips now, instead of waiting for the next update to setVideoTime() + setVideoTime(videoTime); + }); + } catch (Exception ex) { + Logger.printException(() -> "executeDownloadSegments failure", ex); + } + } + + /** + * Injection point. + * Updates SponsorBlock every 1000ms. + * When changing videos, this is first called with value 0 and then the video is changed. + */ + public static void setVideoTime(long millis) { + try { + if (!Settings.SB_ENABLED.get() + || PlayerType.getCurrent().isNoneOrHidden() // Shorts playback. + || segments == null || segments.length == 0) { + return; + } + Logger.printDebug(() -> "setVideoTime: " + millis); + + updateHiddenSegments(millis); + + final float playbackSpeed = VideoInformation.getPlaybackSpeed(); + // Amount of time to look ahead for the next segment, + // and the threshold to determine if a scheduled show/hide is at the correct video time when it's run. + // + // This value must be greater than largest time between calls to this method (1000ms), + // and must be adjusted for the video speed. + // + // To debug the stale skip logic, set this to a very large value (5000 or more) + // then try manually seeking just before playback reaches a segment skip. + final long speedAdjustedTimeThreshold = (long)(playbackSpeed * 1200); + final long startTimerLookAheadThreshold = millis + speedAdjustedTimeThreshold; + + SponsorSegment foundSegmentCurrentlyPlaying = null; + SponsorSegment foundUpcomingSegment = null; + + for (final SponsorSegment segment : segments) { + if (segment.category.behaviour == CategoryBehaviour.SHOW_IN_SEEKBAR + || segment.category.behaviour == CategoryBehaviour.IGNORE + || segment.category == SegmentCategory.HIGHLIGHT) { + continue; + } + if (segment.end <= millis) { + continue; // past this segment + } + + if (segment.start <= millis) { + // we are in the segment! + if (segment.shouldAutoSkip()) { + skipSegment(segment, false); + return; // must return, as skipping causes a recursive call back into this method + } + + // first found segment, or it's an embedded segment and fully inside the outer segment + if (foundSegmentCurrentlyPlaying == null || foundSegmentCurrentlyPlaying.containsSegment(segment)) { + // If the found segment is not currently displayed, then do not show if the segment is nearly over. + // This check prevents the skip button text from rapidly changing when multiple segments end at nearly the same time. + // Also prevents showing the skip button if user seeks into the last 800ms of the segment. + final long minMillisOfSegmentRemainingThreshold = 800; + if (segmentCurrentlyPlaying == segment + || !segment.endIsNear(millis, minMillisOfSegmentRemainingThreshold)) { + foundSegmentCurrentlyPlaying = segment; + } else { + Logger.printDebug(() -> "Ignoring segment that ends very soon: " + segment); + } + } + // Keep iterating and looking. There may be an upcoming autoskip, + // or there may be another smaller segment nested inside this segment + continue; + } + + // segment is upcoming + if (startTimerLookAheadThreshold < segment.start) { + break; // segment is not close enough to schedule, and no segments after this are of interest + } + if (segment.shouldAutoSkip()) { // upcoming autoskip + foundUpcomingSegment = segment; + break; // must stop here + } + + // upcoming manual skip + + // do not schedule upcoming segment, if it is not fully contained inside the current segment + if ((foundSegmentCurrentlyPlaying == null || foundSegmentCurrentlyPlaying.containsSegment(segment)) + // use the most inner upcoming segment + && (foundUpcomingSegment == null || foundUpcomingSegment.containsSegment(segment))) { + + // Only schedule, if the segment start time is not near the end time of the current segment. + // This check is needed to prevent scheduled hide and show from clashing with each other. + // Instead the upcoming segment will be handled when the current segment scheduled hide calls back into this method. + final long minTimeBetweenStartEndOfSegments = 1000; + if (foundSegmentCurrentlyPlaying == null + || !foundSegmentCurrentlyPlaying.endIsNear(segment.start, minTimeBetweenStartEndOfSegments)) { + foundUpcomingSegment = segment; + } else { + Logger.printDebug(() -> "Not scheduling segment (start time is near end of current segment): " + segment); + } + } + } + + if (highlightSegment != null) { + if (millis < DURATION_TO_SHOW_SKIP_BUTTON || (highlightSegmentInitialShowEndTime != 0 + && System.currentTimeMillis() < highlightSegmentInitialShowEndTime)) { + SponsorBlockViewController.showSkipHighlightButton(highlightSegment); + } else { + highlightSegmentInitialShowEndTime = 0; + SponsorBlockViewController.hideSkipHighlightButton(); + } + } + + if (segmentCurrentlyPlaying != foundSegmentCurrentlyPlaying) { + setSegmentCurrentlyPlaying(foundSegmentCurrentlyPlaying); + } else if (foundSegmentCurrentlyPlaying != null + && skipSegmentButtonEndTime != 0 && skipSegmentButtonEndTime <= System.currentTimeMillis()) { + Logger.printDebug(() -> "Auto hiding skip button for segment: " + segmentCurrentlyPlaying); + skipSegmentButtonEndTime = 0; + hiddenSkipSegmentsForCurrentVideoTime.add(foundSegmentCurrentlyPlaying); + SponsorBlockViewController.hideSkipSegmentButton(); + } + + // schedule a hide, only if the segment end is near + final SponsorSegment segmentToHide = + (foundSegmentCurrentlyPlaying != null && foundSegmentCurrentlyPlaying.endIsNear(millis, speedAdjustedTimeThreshold)) + ? foundSegmentCurrentlyPlaying + : null; + + if (scheduledHideSegment != segmentToHide) { + if (segmentToHide == null) { + Logger.printDebug(() -> "Clearing scheduled hide: " + scheduledHideSegment); + scheduledHideSegment = null; + } else { + scheduledHideSegment = segmentToHide; + Logger.printDebug(() -> "Scheduling hide segment: " + segmentToHide + " playbackSpeed: " + playbackSpeed); + final long delayUntilHide = (long) ((segmentToHide.end - millis) / playbackSpeed); + Utils.runOnMainThreadDelayed(() -> { + if (scheduledHideSegment != segmentToHide) { + Logger.printDebug(() -> "Ignoring old scheduled hide segment: " + segmentToHide); + return; + } + scheduledHideSegment = null; + if (VideoState.getCurrent() != VideoState.PLAYING) { + Logger.printDebug(() -> "Ignoring scheduled hide segment as video is paused: " + segmentToHide); + return; + } + + final long videoTime = VideoInformation.getVideoTime(); + if (!segmentToHide.endIsNear(videoTime, speedAdjustedTimeThreshold)) { + // current video time is not what's expected. User paused playback + Logger.printDebug(() -> "Ignoring outdated scheduled hide: " + segmentToHide + + " videoInformation time: " + videoTime); + return; + } + Logger.printDebug(() -> "Running scheduled hide segment: " + segmentToHide); + // Need more than just hide the skip button, as this may have been an embedded segment + // Instead call back into setVideoTime to check everything again. + // Should not use VideoInformation time as it is less accurate, + // but this scheduled handler was scheduled precisely so we can just use the segment end time + setSegmentCurrentlyPlaying(null); + setVideoTime(segmentToHide.end); + }, delayUntilHide); + } + } + + if (scheduledUpcomingSegment != foundUpcomingSegment) { + if (foundUpcomingSegment == null) { + Logger.printDebug(() -> "Clearing scheduled segment: " + scheduledUpcomingSegment); + scheduledUpcomingSegment = null; + } else { + scheduledUpcomingSegment = foundUpcomingSegment; + final SponsorSegment segmentToSkip = foundUpcomingSegment; + + Logger.printDebug(() -> "Scheduling segment: " + segmentToSkip + " playbackSpeed: " + playbackSpeed); + final long delayUntilSkip = (long) ((segmentToSkip.start - millis) / playbackSpeed); + Utils.runOnMainThreadDelayed(() -> { + if (scheduledUpcomingSegment != segmentToSkip) { + Logger.printDebug(() -> "Ignoring old scheduled segment: " + segmentToSkip); + return; + } + scheduledUpcomingSegment = null; + if (VideoState.getCurrent() != VideoState.PLAYING) { + Logger.printDebug(() -> "Ignoring scheduled hide segment as video is paused: " + segmentToSkip); + return; + } + + final long videoTime = VideoInformation.getVideoTime(); + if (!segmentToSkip.startIsNear(videoTime, speedAdjustedTimeThreshold)) { + // current video time is not what's expected. User paused playback + Logger.printDebug(() -> "Ignoring outdated scheduled segment: " + segmentToSkip + + " videoInformation time: " + videoTime); + return; + } + if (segmentToSkip.shouldAutoSkip()) { + Logger.printDebug(() -> "Running scheduled skip segment: " + segmentToSkip); + skipSegment(segmentToSkip, false); + } else { + Logger.printDebug(() -> "Running scheduled show segment: " + segmentToSkip); + setSegmentCurrentlyPlaying(segmentToSkip); + } + }, delayUntilSkip); + } + } + } catch (Exception e) { + Logger.printException(() -> "setVideoTime failure", e); + } + } + + /** + * Removes all previously hidden segments that are not longer contained in the given video time. + */ + private static void updateHiddenSegments(long currentVideoTime) { + Iterator i = hiddenSkipSegmentsForCurrentVideoTime.iterator(); + while (i.hasNext()) { + SponsorSegment hiddenSegment = i.next(); + if (!hiddenSegment.containsTime(currentVideoTime)) { + Logger.printDebug(() -> "Resetting hide skip button: " + hiddenSegment); + i.remove(); + } + } + } + + private static void setSegmentCurrentlyPlaying(@Nullable SponsorSegment segment) { + if (segment == null) { + if (segmentCurrentlyPlaying != null) Logger.printDebug(() -> "Hiding segment: " + segmentCurrentlyPlaying); + segmentCurrentlyPlaying = null; + skipSegmentButtonEndTime = 0; + SponsorBlockViewController.hideSkipSegmentButton(); + return; + } + segmentCurrentlyPlaying = segment; + skipSegmentButtonEndTime = 0; + if (Settings.SB_AUTO_HIDE_SKIP_BUTTON.get()) { + if (hiddenSkipSegmentsForCurrentVideoTime.contains(segment)) { + // Playback exited a nested segment and the outer segment skip button was previously hidden. + Logger.printDebug(() -> "Ignoring previously auto-hidden segment: " + segment); + SponsorBlockViewController.hideSkipSegmentButton(); + return; + } + skipSegmentButtonEndTime = System.currentTimeMillis() + DURATION_TO_SHOW_SKIP_BUTTON; + } + Logger.printDebug(() -> "Showing segment: " + segment); + SponsorBlockViewController.showSkipSegmentButton(segment); + } + + private static SponsorSegment lastSegmentSkipped; + private static long lastSegmentSkippedTime; + + private static void skipSegment(@NonNull SponsorSegment segmentToSkip, boolean userManuallySkipped) { + try { + SponsorBlockViewController.hideSkipHighlightButton(); + SponsorBlockViewController.hideSkipSegmentButton(); + + final long now = System.currentTimeMillis(); + if (lastSegmentSkipped == segmentToSkip) { + // If trying to seek to end of the video, YouTube can seek just before of the actual end. + // (especially if the video does not end on a whole second boundary). + // This causes additional segment skip attempts, even though it cannot seek any closer to the desired time. + // Check for and ignore repeated skip attempts of the same segment over a small time period. + final long minTimeBetweenSkippingSameSegment = Math.max(500, + (long) (500 / VideoInformation.getPlaybackSpeed())); + if (now - lastSegmentSkippedTime < minTimeBetweenSkippingSameSegment) { + Logger.printDebug(() -> "Ignoring skip segment request (already skipped as close as possible): " + segmentToSkip); + return; + } + } + + Logger.printDebug(() -> "Skipping segment: " + segmentToSkip); + lastSegmentSkipped = segmentToSkip; + lastSegmentSkippedTime = now; + setSegmentCurrentlyPlaying(null); + scheduledHideSegment = null; + scheduledUpcomingSegment = null; + if (segmentToSkip == highlightSegment) { + highlightSegmentInitialShowEndTime = 0; + } + + // If the seek is successful, then the seek causes a recursive call back into this class. + final boolean seekSuccessful = VideoInformation.seekTo(segmentToSkip.end); + if (!seekSuccessful) { + // can happen when switching videos and is normal + Logger.printDebug(() -> "Could not skip segment (seek unsuccessful): " + segmentToSkip); + return; + } + + final boolean videoIsPaused = VideoState.getCurrent() == VideoState.PAUSED; + if (!userManuallySkipped) { + // check for any smaller embedded segments, and count those as autoskipped + final boolean showSkipToast = Settings.SB_TOAST_ON_SKIP.get(); + for (final SponsorSegment otherSegment : Objects.requireNonNull(segments)) { + if (segmentToSkip.end < otherSegment.start) { + break; // no other segments can be contained + } + if (otherSegment == segmentToSkip || + (otherSegment.category != SegmentCategory.HIGHLIGHT && segmentToSkip.containsSegment(otherSegment))) { + otherSegment.didAutoSkipped = true; + // Do not show a toast if the user is scrubbing thru a paused video. + // Cannot do this video state check in setTime or earlier in this method, as the video state may not be up to date. + // So instead, only hide toasts because all other skip logic done while paused causes no harm. + if (showSkipToast && !videoIsPaused) { + showSkippedSegmentToast(otherSegment); + } + } + } + } + + if (segmentToSkip.category == SegmentCategory.UNSUBMITTED) { + removeUnsubmittedSegments(); + SponsorBlockUtils.setNewSponsorSegmentPreviewed(); + } else if (!videoIsPaused) { + SponsorBlockUtils.sendViewRequestAsync(segmentToSkip); + } + } catch (Exception ex) { + Logger.printException(() -> "skipSegment failure", ex); + } + } + + + private static int toastNumberOfSegmentsSkipped; + @Nullable + private static SponsorSegment toastSegmentSkipped; + + private static void showSkippedSegmentToast(@NonNull SponsorSegment segment) { + Utils.verifyOnMainThread(); + toastNumberOfSegmentsSkipped++; + if (toastNumberOfSegmentsSkipped > 1) { + return; // toast already scheduled + } + toastSegmentSkipped = segment; + + final long delayToToastMilliseconds = 250; // also the maximum time between skips to be considered skipping multiple segments + Utils.runOnMainThreadDelayed(() -> { + try { + if (toastSegmentSkipped == null) { // video was changed just after skipping segment + Logger.printDebug(() -> "Ignoring old scheduled show toast"); + return; + } + Utils.showToastShort(toastNumberOfSegmentsSkipped == 1 + ? toastSegmentSkipped.getSkippedToastText() + : str("revanced_sb_skipped_multiple_segments")); + } catch (Exception ex) { + Logger.printException(() -> "showSkippedSegmentToast failure", ex); + } finally { + toastNumberOfSegmentsSkipped = 0; + toastSegmentSkipped = null; + } + }, delayToToastMilliseconds); + } + + /** + * @param segment can be either a highlight or a regular manual skip segment. + */ + public static void onSkipSegmentClicked(@NonNull SponsorSegment segment) { + try { + if (segment != highlightSegment && segment != segmentCurrentlyPlaying) { + Logger.printException(() -> "error: segment not available to skip"); // should never happen + SponsorBlockViewController.hideSkipSegmentButton(); + SponsorBlockViewController.hideSkipHighlightButton(); + return; + } + skipSegment(segment, true); + } catch (Exception ex) { + Logger.printException(() -> "onSkipSegmentClicked failure", ex); + } + } + + /** + * Injection point + */ + @SuppressWarnings("unused") + public static void setSponsorBarRect(final Object self) { + try { + Field field = self.getClass().getDeclaredField("replaceMeWithsetSponsorBarRect"); + field.setAccessible(true); + Rect rect = (Rect) Objects.requireNonNull(field.get(self)); + setSponsorBarAbsoluteLeft(rect); + setSponsorBarAbsoluteRight(rect); + } catch (Exception ex) { + Logger.printException(() -> "setSponsorBarRect failure", ex); + } + } + + private static void setSponsorBarAbsoluteLeft(Rect rect) { + final int left = rect.left; + if (sponsorBarAbsoluteLeft != left) { + Logger.printDebug(() -> "setSponsorBarAbsoluteLeft: " + left); + sponsorBarAbsoluteLeft = left; + } + } + + private static void setSponsorBarAbsoluteRight(Rect rect) { + final int right = rect.right; + if (sponsorAbsoluteBarRight != right) { + Logger.printDebug(() -> "setSponsorBarAbsoluteRight: " + right); + sponsorAbsoluteBarRight = right; + } + } + + /** + * Injection point + */ + @SuppressWarnings("unused") + public static void setSponsorBarThickness(int thickness) { + sponsorBarThickness = thickness; + } + + /** + * Injection point. + */ + @SuppressWarnings("unused") + public static String appendTimeWithoutSegments(String totalTime) { + try { + if (Settings.SB_ENABLED.get() && Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.get() + && !TextUtils.isEmpty(totalTime) && !TextUtils.isEmpty(timeWithoutSegments)) { + // Force LTR layout, to match the same LTR video time/length layout YouTube uses for all languages + return "\u202D" + totalTime + timeWithoutSegments; // u202D = left to right override + } + } catch (Exception ex) { + Logger.printException(() -> "appendTimeWithoutSegments failure", ex); + } + + return totalTime; + } + + private static void calculateTimeWithoutSegments() { + final long currentVideoLength = VideoInformation.getVideoLength(); + if (!Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.get() || currentVideoLength <= 0 + || segments == null || segments.length == 0) { + timeWithoutSegments = null; + return; + } + + boolean foundNonhighlightSegments = false; + long timeWithoutSegmentsValue = currentVideoLength; + + for (int i = 0, length = segments.length; i < length; i++) { + SponsorSegment segment = segments[i]; + if (segment.category == SegmentCategory.HIGHLIGHT) { + continue; + } + foundNonhighlightSegments = true; + long start = segment.start; + final long end = segment.end; + // To prevent nested segments from incorrectly counting additional time, + // check if the segment overlaps any earlier segments. + for (int j = 0; j < i; j++) { + start = Math.max(start, segments[j].end); + } + if (start < end) { + timeWithoutSegmentsValue -= (end - start); + } + } + + if (!foundNonhighlightSegments) { + timeWithoutSegments = null; + return; + } + + final long hours = timeWithoutSegmentsValue / 3600000; + final long minutes = (timeWithoutSegmentsValue / 60000) % 60; + final long seconds = (timeWithoutSegmentsValue / 1000) % 60; + if (hours > 0) { + timeWithoutSegments = String.format(Locale.ENGLISH, "\u2009(%d:%02d:%02d)", hours, minutes, seconds); + } else { + timeWithoutSegments = String.format(Locale.ENGLISH, "\u2009(%d:%02d)", minutes, seconds); + } + } + + private static int highlightSegmentTimeBarScreenWidth = -1; // actual pixel width to use + private static int getHighlightSegmentTimeBarScreenWidth() { + if (highlightSegmentTimeBarScreenWidth == -1) { + highlightSegmentTimeBarScreenWidth = (int) TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, HIGHLIGHT_SEGMENT_DRAW_BAR_WIDTH, + Objects.requireNonNull(Utils.getContext()).getResources().getDisplayMetrics()); + } + return highlightSegmentTimeBarScreenWidth; + } + + /** + * Injection point. + */ + @SuppressWarnings("unused") + public static void drawSponsorTimeBars(final Canvas canvas, final float posY) { + try { + if (segments == null) return; + final long videoLength = VideoInformation.getVideoLength(); + if (videoLength <= 0) return; + + final int thicknessDiv2 = sponsorBarThickness / 2; // rounds down + final float top = posY - (sponsorBarThickness - thicknessDiv2); + final float bottom = posY + thicknessDiv2; + final float videoMillisecondsToPixels = (1f / videoLength) * (sponsorAbsoluteBarRight - sponsorBarAbsoluteLeft); + final float leftPadding = sponsorBarAbsoluteLeft; + + for (SponsorSegment segment : segments) { + final float left = leftPadding + segment.start * videoMillisecondsToPixels; + final float right; + if (segment.category == SegmentCategory.HIGHLIGHT) { + right = left + getHighlightSegmentTimeBarScreenWidth(); + } else { + right = leftPadding + segment.end * videoMillisecondsToPixels; + } + canvas.drawRect(left, top, right, bottom, segment.category.paint); + } + } catch (Exception ex) { + Logger.printException(() -> "drawSponsorTimeBars failure", ex); + } + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockSettings.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockSettings.java new file mode 100644 index 0000000000..0edc054c77 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockSettings.java @@ -0,0 +1,244 @@ +package app.revanced.extension.youtube.sponsorblock; + +import static app.revanced.extension.shared.StringRef.str; + +import android.app.AlertDialog; +import android.content.Context; +import android.util.Patterns; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.UUID; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.shared.settings.Setting; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour; +import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory; + +public class SponsorBlockSettings { + /** + * Minimum length a SB user id must be, as set by SB API. + */ + private static final int SB_PRIVATE_USER_ID_MINIMUM_LENGTH = 30; + + public static void importDesktopSettings(@NonNull String json) { + Utils.verifyOnMainThread(); + try { + JSONObject settingsJson = new JSONObject(json); + JSONObject barTypesObject = settingsJson.getJSONObject("barTypes"); + JSONArray categorySelectionsArray = settingsJson.getJSONArray("categorySelections"); + + for (SegmentCategory category : SegmentCategory.categoriesWithoutUnsubmitted()) { + // clear existing behavior, as browser plugin exports no behavior for ignored categories + category.setBehaviour(CategoryBehaviour.IGNORE); + if (barTypesObject.has(category.keyValue)) { + JSONObject categoryObject = barTypesObject.getJSONObject(category.keyValue); + category.setColor(categoryObject.getString("color")); + } + } + + for (int i = 0; i < categorySelectionsArray.length(); i++) { + JSONObject categorySelectionObject = categorySelectionsArray.getJSONObject(i); + + String categoryKey = categorySelectionObject.getString("name"); + SegmentCategory category = SegmentCategory.byCategoryKey(categoryKey); + if (category == null) { + continue; // unsupported category, ignore + } + + final int desktopValue = categorySelectionObject.getInt("option"); + CategoryBehaviour behaviour = CategoryBehaviour.byDesktopKeyValue(desktopValue); + if (behaviour == null) { + Utils.showToastLong(categoryKey + " unknown behavior key: " + categoryKey); + } else if (category == SegmentCategory.HIGHLIGHT && behaviour == CategoryBehaviour.SKIP_AUTOMATICALLY_ONCE) { + Utils.showToastLong("Skip-once behavior not allowed for " + category.keyValue); + category.setBehaviour(CategoryBehaviour.SKIP_AUTOMATICALLY); // use closest match + } else { + category.setBehaviour(behaviour); + } + } + SegmentCategory.updateEnabledCategories(); + + if (settingsJson.has("userID")) { + // User id does not exist if user never voted or created any segments. + String userID = settingsJson.getString("userID"); + if (isValidSBUserId(userID)) { + Settings.SB_PRIVATE_USER_ID.save(userID); + } + } + Settings.SB_USER_IS_VIP.save(settingsJson.getBoolean("isVip")); + Settings.SB_TOAST_ON_SKIP.save(!settingsJson.getBoolean("dontShowNotice")); + Settings.SB_TRACK_SKIP_COUNT.save(settingsJson.getBoolean("trackViewCount")); + Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.save(settingsJson.getBoolean("showTimeWithSkips")); + + String serverAddress = settingsJson.getString("serverAddress"); + if (isValidSBServerAddress(serverAddress)) { // Old versions of ReVanced exported wrong url format + Settings.SB_API_URL.save(serverAddress); + } + + final float minDuration = (float) settingsJson.getDouble("minDuration"); + if (minDuration < 0) { + throw new IllegalArgumentException("invalid minDuration: " + minDuration); + } + Settings.SB_SEGMENT_MIN_DURATION.save(minDuration); + + if (settingsJson.has("skipCount")) { // Value not exported in old versions of ReVanced + int skipCount = settingsJson.getInt("skipCount"); + if (skipCount < 0) { + throw new IllegalArgumentException("invalid skipCount: " + skipCount); + } + Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.save(skipCount); + } + + if (settingsJson.has("minutesSaved")) { + final double minutesSaved = settingsJson.getDouble("minutesSaved"); + if (minutesSaved < 0) { + throw new IllegalArgumentException("invalid minutesSaved: " + minutesSaved); + } + Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.save((long) (minutesSaved * 60 * 1000)); + } + + Utils.showToastLong(str("revanced_sb_settings_import_successful")); + } catch (Exception ex) { + Logger.printInfo(() -> "failed to import settings", ex); // use info level, as we are showing our own toast + Utils.showToastLong(str("revanced_sb_settings_import_failed", ex.getMessage())); + } + } + + @NonNull + public static String exportDesktopSettings() { + Utils.verifyOnMainThread(); + try { + Logger.printDebug(() -> "Creating SponsorBlock export settings string"); + JSONObject json = new JSONObject(); + + JSONObject barTypesObject = new JSONObject(); // categories' colors + JSONArray categorySelectionsArray = new JSONArray(); // categories' behavior + + SegmentCategory[] categories = SegmentCategory.categoriesWithoutUnsubmitted(); + for (SegmentCategory category : categories) { + JSONObject categoryObject = new JSONObject(); + String categoryKey = category.keyValue; + categoryObject.put("color", category.colorString()); + barTypesObject.put(categoryKey, categoryObject); + + if (category.behaviour != CategoryBehaviour.IGNORE) { + JSONObject behaviorObject = new JSONObject(); + behaviorObject.put("name", categoryKey); + behaviorObject.put("option", category.behaviour.desktopKeyValue); + categorySelectionsArray.put(behaviorObject); + } + } + if (SponsorBlockSettings.userHasSBPrivateId()) { + json.put("userID", Settings.SB_PRIVATE_USER_ID.get()); + } + json.put("isVip", Settings.SB_USER_IS_VIP.get()); + json.put("serverAddress", Settings.SB_API_URL.get()); + json.put("dontShowNotice", !Settings.SB_TOAST_ON_SKIP.get()); + json.put("showTimeWithSkips", Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.get()); + json.put("minDuration", Settings.SB_SEGMENT_MIN_DURATION.get()); + json.put("trackViewCount", Settings.SB_TRACK_SKIP_COUNT.get()); + json.put("skipCount", Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.get()); + json.put("minutesSaved", Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.get() / (60f * 1000)); + + json.put("categorySelections", categorySelectionsArray); + json.put("barTypes", barTypesObject); + + return json.toString(2); + } catch (Exception ex) { + Logger.printInfo(() -> "failed to export settings", ex); // use info level, as we are showing our own toast + Utils.showToastLong(str("revanced_sb_settings_export_failed", ex)); + return ""; + } + } + + /** + * Export the categories using flatten json (no embedded dictionaries or arrays). + */ + public static void showExportWarningIfNeeded(@Nullable Context dialogContext) { + Utils.verifyOnMainThread(); + initialize(); + + // If user has a SponsorBlock user id then show a warning. + if (dialogContext != null && SponsorBlockSettings.userHasSBPrivateId() + && !Settings.SB_HIDE_EXPORT_WARNING.get()) { + new AlertDialog.Builder(dialogContext) + .setMessage(str("revanced_sb_settings_revanced_export_user_id_warning")) + .setNeutralButton(str("revanced_sb_settings_revanced_export_user_id_warning_dismiss"), + (dialog, which) -> Settings.SB_HIDE_EXPORT_WARNING.save(true)) + .setPositiveButton(android.R.string.ok, null) + .setCancelable(false) + .show(); + } + } + + public static boolean isValidSBUserId(@NonNull String userId) { + return !userId.isEmpty() && userId.length() >= SB_PRIVATE_USER_ID_MINIMUM_LENGTH; + } + + /** + * A non comprehensive check if a SB api server address is valid. + */ + public static boolean isValidSBServerAddress(@NonNull String serverAddress) { + if (!Patterns.WEB_URL.matcher(serverAddress).matches()) { + return false; + } + // Verify url is only the server address and does not contain a path such as: "https://sponsor.ajay.app/api/" + // Could use Patterns.compile, but this is simpler + final int lastDotIndex = serverAddress.lastIndexOf('.'); + if (lastDotIndex != -1 && serverAddress.substring(lastDotIndex).contains("/")) { + return false; + } + // Optionally, could also verify the domain exists using "InetAddress.getByName(serverAddress)" + // but that should not be done on the main thread. + // Instead, assume the domain exists and the user knows what they're doing. + return true; + } + + /** + * @return if the user has ever voted, created a segment, or imported existing SB settings. + */ + public static boolean userHasSBPrivateId() { + return !Settings.SB_PRIVATE_USER_ID.get().isEmpty(); + } + + /** + * Use this only if a user id is required (creating segments, voting). + */ + @NonNull + public static String getSBPrivateUserID() { + String uuid = Settings.SB_PRIVATE_USER_ID.get(); + if (uuid.isEmpty()) { + uuid = (UUID.randomUUID().toString() + + UUID.randomUUID().toString() + + UUID.randomUUID().toString()) + .replace("-", ""); + Settings.SB_PRIVATE_USER_ID.save(uuid); + } + return uuid; + } + + private static boolean initialized; + + public static void initialize() { + if (initialized) { + return; + } + initialized = true; + + SegmentCategory.updateEnabledCategories(); + } + + /** + * Updates internal data based on {@link Setting} values. + */ + public static void updateFromImportedSettings() { + SegmentCategory.loadAllCategoriesFromSettings(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockUtils.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockUtils.java new file mode 100644 index 0000000000..d3f851f6e0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockUtils.java @@ -0,0 +1,503 @@ +package app.revanced.extension.youtube.sponsorblock; + +import static app.revanced.extension.shared.StringRef.str; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.text.Html; +import android.widget.EditText; + +import androidx.annotation.NonNull; + +import java.lang.ref.WeakReference; +import java.text.NumberFormat; +import java.time.Duration; +import java.util.Locale; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.patches.VideoInformation; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour; +import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory; +import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment; +import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment.SegmentVote; +import app.revanced.extension.youtube.sponsorblock.requests.SBRequester; +import app.revanced.extension.youtube.sponsorblock.ui.SponsorBlockViewController; + +/** + * Not thread safe. All fields/methods must be accessed from the main thread. + */ +public class SponsorBlockUtils { + private static final String LOCKED_COLOR = "#FFC83D"; + private static final String MANUAL_EDIT_TIME_TEXT_HINT = "hh:mm:ss.sss"; + private static final Pattern manualEditTimePattern + = Pattern.compile("((\\d{1,2}):)?(\\d{1,2}):(\\d{2})(\\.(\\d{1,3}))?"); + private static final NumberFormat statsNumberFormatter = NumberFormat.getNumberInstance(); + + private static long newSponsorSegmentDialogShownMillis; + private static long newSponsorSegmentStartMillis = -1; + private static long newSponsorSegmentEndMillis = -1; + private static boolean newSponsorSegmentPreviewed; + private static final DialogInterface.OnClickListener newSponsorSegmentDialogListener = new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + switch (which) { + case DialogInterface.BUTTON_NEGATIVE: + // start + newSponsorSegmentStartMillis = newSponsorSegmentDialogShownMillis; + break; + case DialogInterface.BUTTON_POSITIVE: + // end + newSponsorSegmentEndMillis = newSponsorSegmentDialogShownMillis; + break; + } + dialog.dismiss(); + } + }; + private static SegmentCategory newUserCreatedSegmentCategory; + private static final DialogInterface.OnClickListener segmentTypeListener = new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + try { + SegmentCategory category = SegmentCategory.categoriesWithoutHighlights()[which]; + final boolean enableButton; + if (category.behaviour == CategoryBehaviour.IGNORE) { + Utils.showToastLong(str("revanced_sb_new_segment_disabled_category")); + enableButton = false; + } else { + newUserCreatedSegmentCategory = category; + enableButton = true; + } + + ((AlertDialog) dialog) + .getButton(DialogInterface.BUTTON_POSITIVE) + .setEnabled(enableButton); + } catch (Exception ex) { + Logger.printException(() -> "segmentTypeListener failure", ex); + } + } + }; + private static final DialogInterface.OnClickListener segmentReadyDialogButtonListener = new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + try { + SponsorBlockViewController.hideNewSegmentLayout(); + Context context = ((AlertDialog) dialog).getContext(); + dialog.dismiss(); + + SegmentCategory[] categories = SegmentCategory.categoriesWithoutHighlights(); + CharSequence[] titles = new CharSequence[categories.length]; + for (int i = 0, length = categories.length; i < length; i++) { + titles[i] = categories[i].getTitleWithColorDot(); + } + + newUserCreatedSegmentCategory = null; + new AlertDialog.Builder(context) + .setTitle(str("revanced_sb_new_segment_choose_category")) + .setSingleChoiceItems(titles, -1, segmentTypeListener) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, segmentCategorySelectedDialogListener) + .show() + .getButton(DialogInterface.BUTTON_POSITIVE) + .setEnabled(false); + } catch (Exception ex) { + Logger.printException(() -> "segmentReadyDialogButtonListener failure", ex); + } + } + }; + private static final DialogInterface.OnClickListener segmentCategorySelectedDialogListener = (dialog, which) -> { + dialog.dismiss(); + submitNewSegment(); + }; + private static final EditByHandSaveDialogListener editByHandSaveDialogListener = new EditByHandSaveDialogListener(); + private static final DialogInterface.OnClickListener editByHandDialogListener = (dialog, which) -> { + try { + Context context = ((AlertDialog) dialog).getContext(); + + final boolean isStart = DialogInterface.BUTTON_NEGATIVE == which; + + final EditText textView = new EditText(context); + textView.setHint(MANUAL_EDIT_TIME_TEXT_HINT); + if (isStart) { + if (newSponsorSegmentStartMillis >= 0) + textView.setText(formatSegmentTime(newSponsorSegmentStartMillis)); + } else { + if (newSponsorSegmentEndMillis >= 0) + textView.setText(formatSegmentTime(newSponsorSegmentEndMillis)); + } + + editByHandSaveDialogListener.settingStart = isStart; + editByHandSaveDialogListener.editTextRef = new WeakReference<>(textView); + new AlertDialog.Builder(context) + .setTitle(str(isStart ? "revanced_sb_new_segment_time_start" : "revanced_sb_new_segment_time_end")) + .setView(textView) + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(str("revanced_sb_new_segment_now"), editByHandSaveDialogListener) + .setPositiveButton(android.R.string.ok, editByHandSaveDialogListener) + .show(); + + dialog.dismiss(); + } catch (Exception ex) { + Logger.printException(() -> "editByHandDialogListener failure", ex); + } + }; + private static final DialogInterface.OnClickListener segmentVoteClickListener = (dialog, which) -> { + try { + final Context context = ((AlertDialog) dialog).getContext(); + SponsorSegment[] segments = SegmentPlaybackController.getSegments(); + if (segments == null || segments.length == 0) { + // should never be reached + Logger.printException(() -> "Segment is no longer available on the client"); + return; + } + SponsorSegment segment = segments[which]; + + SegmentVote[] voteOptions = (segment.category == SegmentCategory.HIGHLIGHT) + ? SegmentVote.voteTypesWithoutCategoryChange // highlight segments cannot change category + : SegmentVote.values(); + CharSequence[] items = new CharSequence[voteOptions.length]; + + for (int i = 0; i < voteOptions.length; i++) { + SegmentVote voteOption = voteOptions[i]; + String title = voteOption.title.toString(); + if (Settings.SB_USER_IS_VIP.get() && segment.isLocked && voteOption.shouldHighlight) { + items[i] = Html.fromHtml(String.format("%s", LOCKED_COLOR, title)); + } else { + items[i] = title; + } + } + + new AlertDialog.Builder(context) + .setItems(items, (dialog1, which1) -> { + SegmentVote voteOption = voteOptions[which1]; + switch (voteOption) { + case UPVOTE: + case DOWNVOTE: + SBRequester.voteForSegmentOnBackgroundThread(segment, voteOption); + break; + case CATEGORY_CHANGE: + onNewCategorySelect(segment, context); + break; + } + }) + .show(); + } catch (Exception ex) { + Logger.printException(() -> "segmentVoteClickListener failure", ex); + } + }; + + private SponsorBlockUtils() { + } + + static void setNewSponsorSegmentPreviewed() { + newSponsorSegmentPreviewed = true; + } + + static void clearUnsubmittedSegmentTimes() { + newSponsorSegmentDialogShownMillis = 0; + newSponsorSegmentEndMillis = newSponsorSegmentStartMillis = -1; + newSponsorSegmentPreviewed = false; + } + + private static void submitNewSegment() { + try { + Utils.verifyOnMainThread(); + final long start = newSponsorSegmentStartMillis; + final long end = newSponsorSegmentEndMillis; + final String videoId = VideoInformation.getVideoId(); + final long videoLength = VideoInformation.getVideoLength(); + final SegmentCategory segmentCategory = newUserCreatedSegmentCategory; + if (start < 0 || end < 0 || start >= end || videoLength <= 0 || videoId.isEmpty() || segmentCategory == null) { + Logger.printException(() -> "invalid parameters"); + return; + } + clearUnsubmittedSegmentTimes(); + Utils.runOnBackgroundThread(() -> { + SBRequester.submitSegments(videoId, segmentCategory.keyValue, start, end, videoLength); + SegmentPlaybackController.executeDownloadSegments(videoId); + }); + } catch (Exception e) { + Logger.printException(() -> "Unable to submit segment", e); + } + } + + public static void onMarkLocationClicked() { + try { + Utils.verifyOnMainThread(); + newSponsorSegmentDialogShownMillis = VideoInformation.getVideoTime(); + + new AlertDialog.Builder(SponsorBlockViewController.getOverLaysViewGroupContext()) + .setTitle(str("revanced_sb_new_segment_title")) + .setMessage(str("revanced_sb_new_segment_mark_time_as_question", + formatSegmentTime(newSponsorSegmentDialogShownMillis))) + .setNeutralButton(android.R.string.cancel, null) + .setNegativeButton(str("revanced_sb_new_segment_mark_start"), newSponsorSegmentDialogListener) + .setPositiveButton(str("revanced_sb_new_segment_mark_end"), newSponsorSegmentDialogListener) + .show(); + } catch (Exception ex) { + Logger.printException(() -> "onMarkLocationClicked failure", ex); + } + } + + public static void onPublishClicked() { + try { + Utils.verifyOnMainThread(); + if (newSponsorSegmentStartMillis < 0 || newSponsorSegmentEndMillis < 0) { + Utils.showToastShort(str("revanced_sb_new_segment_mark_locations_first")); + } else if (newSponsorSegmentStartMillis >= newSponsorSegmentEndMillis) { + Utils.showToastShort(str("revanced_sb_new_segment_start_is_before_end")); + } else if (!newSponsorSegmentPreviewed && newSponsorSegmentStartMillis != 0) { + Utils.showToastLong(str("revanced_sb_new_segment_preview_segment_first")); + } else { + final long segmentLength = (newSponsorSegmentEndMillis - newSponsorSegmentStartMillis) / 1000; + new AlertDialog.Builder(SponsorBlockViewController.getOverLaysViewGroupContext()) + .setTitle(str("revanced_sb_new_segment_confirm_title")) + .setMessage(str("revanced_sb_new_segment_confirm_content", + formatSegmentTime(newSponsorSegmentStartMillis), + formatSegmentTime(newSponsorSegmentEndMillis), + getTimeSavedString(segmentLength))) + .setNegativeButton(android.R.string.no, null) + .setPositiveButton(android.R.string.yes, segmentReadyDialogButtonListener) + .show(); + } + } catch (Exception ex) { + Logger.printException(() -> "onPublishClicked failure", ex); + } + } + + public static void onVotingClicked(@NonNull Context context) { + try { + Utils.verifyOnMainThread(); + SponsorSegment[] segments = SegmentPlaybackController.getSegments(); + if (segments == null || segments.length == 0) { + // Button is hidden if no segments exist. + // But if prior video had segments, and current video does not, + // then the button persists until the overlay fades out (this is intentional, as abruptly hiding the button is jarring). + Utils.showToastShort(str("revanced_sb_vote_no_segments")); + return; + } + + + final int numberOfSegments = segments.length; + CharSequence[] titles = new CharSequence[numberOfSegments]; + for (int i = 0; i < numberOfSegments; i++) { + SponsorSegment segment = segments[i]; + if (segment.category == SegmentCategory.UNSUBMITTED) { + continue; + } + StringBuilder htmlBuilder = new StringBuilder(); + htmlBuilder.append(String.format(" %s
", + segment.category.color, segment.category.title)); + htmlBuilder.append(formatSegmentTime(segment.start)); + if (segment.category != SegmentCategory.HIGHLIGHT) { + htmlBuilder.append(" to ").append(formatSegmentTime(segment.end)); + } + htmlBuilder.append("
"); + if (i + 1 != numberOfSegments) // prevents trailing new line after last segment + htmlBuilder.append("
"); + titles[i] = Html.fromHtml(htmlBuilder.toString()); + } + + new AlertDialog.Builder(context) + .setItems(titles, segmentVoteClickListener) + .show(); + } catch (Exception ex) { + Logger.printException(() -> "onVotingClicked failure", ex); + } + } + + private static void onNewCategorySelect(@NonNull SponsorSegment segment, @NonNull Context context) { + try { + Utils.verifyOnMainThread(); + final SegmentCategory[] values = SegmentCategory.categoriesWithoutHighlights(); + CharSequence[] titles = new CharSequence[values.length]; + for (int i = 0; i < values.length; i++) { + titles[i] = values[i].getTitleWithColorDot(); + } + + new AlertDialog.Builder(context) + .setTitle(str("revanced_sb_new_segment_choose_category")) + .setItems(titles, (dialog, which) -> SBRequester.voteToChangeCategoryOnBackgroundThread(segment, values[which])) + .show(); + } catch (Exception ex) { + Logger.printException(() -> "onNewCategorySelect failure", ex); + } + } + + public static void onPreviewClicked() { + try { + Utils.verifyOnMainThread(); + if (newSponsorSegmentStartMillis < 0 || newSponsorSegmentEndMillis < 0) { + Utils.showToastShort(str("revanced_sb_new_segment_mark_locations_first")); + } else if (newSponsorSegmentStartMillis >= newSponsorSegmentEndMillis) { + Utils.showToastShort(str("revanced_sb_new_segment_start_is_before_end")); + } else { + SegmentPlaybackController.removeUnsubmittedSegments(); // If user hits preview more than once before playing. + SegmentPlaybackController.addUnsubmittedSegment( + new SponsorSegment(SegmentCategory.UNSUBMITTED, null, + newSponsorSegmentStartMillis, newSponsorSegmentEndMillis, false)); + VideoInformation.seekTo(newSponsorSegmentStartMillis - 2000); + } + } catch (Exception ex) { + Logger.printException(() -> "onPreviewClicked failure", ex); + } + } + + + static void sendViewRequestAsync(@NonNull SponsorSegment segment) { + if (segment.recordedAsSkipped || segment.category == SegmentCategory.UNSUBMITTED) { + return; + } + segment.recordedAsSkipped = true; + final long totalTimeSkipped = Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.get() + segment.length(); + Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.save(totalTimeSkipped); + Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.save(Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.get() + 1); + + if (Settings.SB_TRACK_SKIP_COUNT.get()) { + Utils.runOnBackgroundThread(() -> SBRequester.sendSegmentSkippedViewedRequest(segment)); + } + } + + public static void onEditByHandClicked() { + try { + Utils.verifyOnMainThread(); + new AlertDialog.Builder(SponsorBlockViewController.getOverLaysViewGroupContext()) + .setTitle(str("revanced_sb_new_segment_edit_by_hand_title")) + .setMessage(str("revanced_sb_new_segment_edit_by_hand_content")) + .setNeutralButton(android.R.string.cancel, null) + .setNegativeButton(str("revanced_sb_new_segment_mark_start"), editByHandDialogListener) + .setPositiveButton(str("revanced_sb_new_segment_mark_end"), editByHandDialogListener) + .show(); + } catch (Exception ex) { + Logger.printException(() -> "onEditByHandClicked failure", ex); + } + } + + public static String getNumberOfSkipsString(int viewCount) { + return statsNumberFormatter.format(viewCount); + } + + @SuppressWarnings("ConstantConditions") + private static long parseSegmentTime(@NonNull String time) { + Matcher matcher = manualEditTimePattern.matcher(time); + if (!matcher.matches()) { + return -1; + } + String hoursStr = matcher.group(2); // Hours is optional. + String minutesStr = matcher.group(3); + String secondsStr = matcher.group(4); + String millisecondsStr = matcher.group(6); // Milliseconds is optional. + + try { + final int hours = (hoursStr != null) ? Integer.parseInt(hoursStr) : 0; + final int minutes = Integer.parseInt(minutesStr); + final int seconds = Integer.parseInt(secondsStr); + final int milliseconds; + if (millisecondsStr != null) { + // Pad out with zeros if not all decimal places were used. + millisecondsStr = String.format(Locale.US, "%-3s", millisecondsStr).replace(' ', '0'); + milliseconds = Integer.parseInt(millisecondsStr); + } else { + milliseconds = 0; + } + + return (hours * 3600000L) + (minutes * 60000L) + (seconds * 1000L) + milliseconds; + } catch (NumberFormatException ex) { + Logger.printInfo(() -> "Time format exception: " + time, ex); + return -1; + } + } + + private static String formatSegmentTime(long segmentTime) { + // Use same time formatting as shown in the video player. + final long videoLength = VideoInformation.getVideoLength(); + + // Cannot use DateFormatter, as videos over 24 hours will rollover and not display correctly. + final long hours = TimeUnit.MILLISECONDS.toHours(segmentTime); + final long minutes = TimeUnit.MILLISECONDS.toMinutes(segmentTime) % 60; + final long seconds = TimeUnit.MILLISECONDS.toSeconds(segmentTime) % 60; + final long milliseconds = segmentTime % 1000; + + final String formatPattern; + Object[] formatArgs = {minutes, seconds, milliseconds}; + + if (videoLength < (10 * 60 * 1000)) { + formatPattern = "%01d:%02d.%03d"; // Less than 10 minutes. + } else if (videoLength < (60 * 60 * 1000)) { + formatPattern = "%02d:%02d.%03d"; // Less than 1 hour. + } else if (videoLength < (10 * 60 * 60 * 1000)) { + formatPattern = "%01d:%02d:%02d.%03d"; // Less than 10 hours. + formatArgs = new Object[]{hours, minutes, seconds, milliseconds}; + } else { + formatPattern = "%02d:%02d:%02d.%03d"; // Why is this on YouTube? + formatArgs = new Object[]{hours, minutes, seconds, milliseconds}; + } + + return String.format(Locale.US, formatPattern, formatArgs); + } + + public static String getTimeSavedString(long totalSecondsSaved) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + Duration duration = Duration.ofSeconds(totalSecondsSaved); + final long hours = duration.toHours(); + final long minutes = duration.toMinutes() % 60; + + // Format all numbers so non-western numbers use a consistent appearance. + String minutesFormatted = statsNumberFormatter.format(minutes); + if (hours > 0) { + String hoursFormatted = statsNumberFormatter.format(hours); + return str("revanced_sb_stats_saved_hour_format", hoursFormatted, minutesFormatted); + } + + final long seconds = duration.getSeconds() % 60; + String secondsFormatted = statsNumberFormatter.format(seconds); + if (minutes > 0) { + return str("revanced_sb_stats_saved_minute_format", minutesFormatted, secondsFormatted); + } + + return str("revanced_sb_stats_saved_second_format", secondsFormatted); + } + return "error"; // will never be reached. YouTube requires Android O or greater + } + + private static class EditByHandSaveDialogListener implements DialogInterface.OnClickListener { + boolean settingStart; + WeakReference editTextRef = new WeakReference<>(null); + + @Override + public void onClick(DialogInterface dialog, int which) { + try { + final EditText editText = editTextRef.get(); + if (editText == null) return; + + final long time; + if (which == DialogInterface.BUTTON_NEUTRAL) { + time = VideoInformation.getVideoTime(); + } else { + time = parseSegmentTime(editText.getText().toString()); + if (time < 0) { + Utils.showToastLong(str("revanced_sb_new_segment_edit_by_hand_parse_error")); + return; + } + } + + if (settingStart) + newSponsorSegmentStartMillis = Math.max(time, 0); + else + newSponsorSegmentEndMillis = time; + + if (which == DialogInterface.BUTTON_NEUTRAL) + editByHandDialogListener.onClick(dialog, settingStart ? + DialogInterface.BUTTON_NEGATIVE : + DialogInterface.BUTTON_POSITIVE); + } catch (Exception ex) { + Logger.printException(() -> "EditByHandSaveDialogListener failure", ex); + } + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/CategoryBehaviour.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/CategoryBehaviour.java new file mode 100644 index 0000000000..7cd4a44c55 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/CategoryBehaviour.java @@ -0,0 +1,122 @@ +package app.revanced.extension.youtube.sponsorblock.objects; + +import static app.revanced.extension.shared.StringRef.sf; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Objects; + +import app.revanced.extension.shared.Utils; +import app.revanced.extension.shared.StringRef; + +public enum CategoryBehaviour { + SKIP_AUTOMATICALLY("skip", 2, true, sf("revanced_sb_skip_automatically")), + // desktop does not have skip-once behavior. Key is unique to ReVanced + SKIP_AUTOMATICALLY_ONCE("skip-once", 3, true, sf("revanced_sb_skip_automatically_once")), + MANUAL_SKIP("manual-skip", 1, false, sf("revanced_sb_skip_showbutton")), + SHOW_IN_SEEKBAR("seekbar-only", 0, false, sf("revanced_sb_skip_seekbaronly")), + // ignored categories are not exported to json, and ignore is the default behavior when importing + IGNORE("ignore", -1, false, sf("revanced_sb_skip_ignore")); + + /** + * ReVanced specific value. + */ + @NonNull + public final String reVancedKeyValue; + /** + * Desktop specific value. + */ + public final int desktopKeyValue; + /** + * If the segment should skip automatically + */ + public final boolean skipAutomatically; + @NonNull + public final StringRef description; + + CategoryBehaviour(String reVancedKeyValue, int desktopKeyValue, boolean skipAutomatically, StringRef description) { + this.reVancedKeyValue = Objects.requireNonNull(reVancedKeyValue); + this.desktopKeyValue = desktopKeyValue; + this.skipAutomatically = skipAutomatically; + this.description = Objects.requireNonNull(description); + } + + @Nullable + public static CategoryBehaviour byReVancedKeyValue(@NonNull String keyValue) { + for (CategoryBehaviour behaviour : values()){ + if (behaviour.reVancedKeyValue.equals(keyValue)) { + return behaviour; + } + } + return null; + } + + @Nullable + public static CategoryBehaviour byDesktopKeyValue(int desktopKeyValue) { + for (CategoryBehaviour behaviour : values()) { + if (behaviour.desktopKeyValue == desktopKeyValue) { + return behaviour; + } + } + return null; + } + + private static String[] behaviorKeyValues; + private static String[] behaviorDescriptions; + + private static String[] behaviorKeyValuesWithoutSkipOnce; + private static String[] behaviorDescriptionsWithoutSkipOnce; + + private static void createNameAndKeyArrays() { + Utils.verifyOnMainThread(); + + CategoryBehaviour[] behaviours = values(); + final int behaviorLength = behaviours.length; + behaviorKeyValues = new String[behaviorLength]; + behaviorDescriptions = new String[behaviorLength]; + behaviorKeyValuesWithoutSkipOnce = new String[behaviorLength - 1]; + behaviorDescriptionsWithoutSkipOnce = new String[behaviorLength - 1]; + + int behaviorIndex = 0, behaviorHighlightIndex = 0; + while (behaviorIndex < behaviorLength) { + CategoryBehaviour behaviour = behaviours[behaviorIndex]; + String value = behaviour.reVancedKeyValue; + String description = behaviour.description.toString(); + behaviorKeyValues[behaviorIndex] = value; + behaviorDescriptions[behaviorIndex] = description; + behaviorIndex++; + if (behaviour != SKIP_AUTOMATICALLY_ONCE) { + behaviorKeyValuesWithoutSkipOnce[behaviorHighlightIndex] = value; + behaviorDescriptionsWithoutSkipOnce[behaviorHighlightIndex] = description; + behaviorHighlightIndex++; + } + } + } + + static String[] getBehaviorKeyValues() { + if (behaviorKeyValues == null) { + createNameAndKeyArrays(); + } + return behaviorKeyValues; + } + static String[] getBehaviorKeyValuesWithoutSkipOnce() { + if (behaviorKeyValuesWithoutSkipOnce == null) { + createNameAndKeyArrays(); + } + return behaviorKeyValuesWithoutSkipOnce; + } + + static String[] getBehaviorDescriptions() { + if (behaviorDescriptions == null) { + createNameAndKeyArrays(); + } + return behaviorDescriptions; + } + static String[] getBehaviorDescriptionsWithoutSkipOnce() { + if (behaviorDescriptionsWithoutSkipOnce == null) { + createNameAndKeyArrays(); + } + return behaviorDescriptionsWithoutSkipOnce; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategory.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategory.java new file mode 100644 index 0000000000..7518b2cc34 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategory.java @@ -0,0 +1,332 @@ +package app.revanced.extension.youtube.sponsorblock.objects; + +import static app.revanced.extension.youtube.settings.Settings.*; +import static app.revanced.extension.shared.StringRef.sf; + +import android.graphics.Color; +import android.graphics.Paint; +import android.text.Html; +import android.text.Spanned; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import app.revanced.extension.shared.Utils; +import app.revanced.extension.shared.settings.StringSetting; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.StringRef; + +public enum SegmentCategory { + SPONSOR("sponsor", sf("revanced_sb_segments_sponsor"), sf("revanced_sb_segments_sponsor_sum"), sf("revanced_sb_skip_button_sponsor"), sf("revanced_sb_skipped_sponsor"), + SB_CATEGORY_SPONSOR, SB_CATEGORY_SPONSOR_COLOR), + SELF_PROMO("selfpromo", sf("revanced_sb_segments_selfpromo"), sf("revanced_sb_segments_selfpromo_sum"), sf("revanced_sb_skip_button_selfpromo"), sf("revanced_sb_skipped_selfpromo"), + SB_CATEGORY_SELF_PROMO, SB_CATEGORY_SELF_PROMO_COLOR), + INTERACTION("interaction", sf("revanced_sb_segments_interaction"), sf("revanced_sb_segments_interaction_sum"), sf("revanced_sb_skip_button_interaction"), sf("revanced_sb_skipped_interaction"), + SB_CATEGORY_INTERACTION, SB_CATEGORY_INTERACTION_COLOR), + /** + * Unique category that is treated differently than the rest. + */ + HIGHLIGHT("poi_highlight", sf("revanced_sb_segments_highlight"), sf("revanced_sb_segments_highlight_sum"), sf("revanced_sb_skip_button_highlight"), sf("revanced_sb_skipped_highlight"), + SB_CATEGORY_HIGHLIGHT, SB_CATEGORY_HIGHLIGHT_COLOR), + INTRO("intro", sf("revanced_sb_segments_intro"), sf("revanced_sb_segments_intro_sum"), + sf("revanced_sb_skip_button_intro_beginning"), sf("revanced_sb_skip_button_intro_middle"), sf("revanced_sb_skip_button_intro_end"), + sf("revanced_sb_skipped_intro_beginning"), sf("revanced_sb_skipped_intro_middle"), sf("revanced_sb_skipped_intro_end"), + SB_CATEGORY_INTRO, SB_CATEGORY_INTRO_COLOR), + OUTRO("outro", sf("revanced_sb_segments_outro"), sf("revanced_sb_segments_outro_sum"), sf("revanced_sb_skip_button_outro"), sf("revanced_sb_skipped_outro"), + SB_CATEGORY_OUTRO, SB_CATEGORY_OUTRO_COLOR), + PREVIEW("preview", sf("revanced_sb_segments_preview"), sf("revanced_sb_segments_preview_sum"), + sf("revanced_sb_skip_button_preview_beginning"), sf("revanced_sb_skip_button_preview_middle"), sf("revanced_sb_skip_button_preview_end"), + sf("revanced_sb_skipped_preview_beginning"), sf("revanced_sb_skipped_preview_middle"), sf("revanced_sb_skipped_preview_end"), + SB_CATEGORY_PREVIEW, SB_CATEGORY_PREVIEW_COLOR), + FILLER("filler", sf("revanced_sb_segments_filler"), sf("revanced_sb_segments_filler_sum"), sf("revanced_sb_skip_button_filler"), sf("revanced_sb_skipped_filler"), + SB_CATEGORY_FILLER, SB_CATEGORY_FILLER_COLOR), + MUSIC_OFFTOPIC("music_offtopic", sf("revanced_sb_segments_nomusic"), sf("revanced_sb_segments_nomusic_sum"), sf("revanced_sb_skip_button_nomusic"), sf("revanced_sb_skipped_nomusic"), + SB_CATEGORY_MUSIC_OFFTOPIC, SB_CATEGORY_MUSIC_OFFTOPIC_COLOR), + UNSUBMITTED("unsubmitted", StringRef.empty, StringRef.empty, sf("revanced_sb_skip_button_unsubmitted"), sf("revanced_sb_skipped_unsubmitted"), + SB_CATEGORY_UNSUBMITTED, SB_CATEGORY_UNSUBMITTED_COLOR),; + + private static final StringRef skipSponsorTextCompact = sf("revanced_sb_skip_button_compact"); + private static final StringRef skipSponsorTextCompactHighlight = sf("revanced_sb_skip_button_compact_highlight"); + + private static final SegmentCategory[] categoriesWithoutHighlights = new SegmentCategory[]{ + SPONSOR, + SELF_PROMO, + INTERACTION, + INTRO, + OUTRO, + PREVIEW, + FILLER, + MUSIC_OFFTOPIC, + }; + + private static final SegmentCategory[] categoriesWithoutUnsubmitted = new SegmentCategory[]{ + SPONSOR, + SELF_PROMO, + INTERACTION, + HIGHLIGHT, + INTRO, + OUTRO, + PREVIEW, + FILLER, + MUSIC_OFFTOPIC, + }; + private static final Map mValuesMap = new HashMap<>(2 * categoriesWithoutUnsubmitted.length); + + /** + * Categories currently enabled, formatted for an API call + */ + public static String sponsorBlockAPIFetchCategories = "[]"; + + static { + for (SegmentCategory value : categoriesWithoutUnsubmitted) + mValuesMap.put(value.keyValue, value); + } + + @NonNull + public static SegmentCategory[] categoriesWithoutUnsubmitted() { + return categoriesWithoutUnsubmitted; + } + + @NonNull + public static SegmentCategory[] categoriesWithoutHighlights() { + return categoriesWithoutHighlights; + } + + @Nullable + public static SegmentCategory byCategoryKey(@NonNull String key) { + return mValuesMap.get(key); + } + + /** + * Must be called if behavior of any category is changed + */ + public static void updateEnabledCategories() { + Utils.verifyOnMainThread(); + Logger.printDebug(() -> "updateEnabledCategories"); + SegmentCategory[] categories = categoriesWithoutUnsubmitted(); + List enabledCategories = new ArrayList<>(categories.length); + for (SegmentCategory category : categories) { + if (category.behaviour != CategoryBehaviour.IGNORE) { + enabledCategories.add(category.keyValue); + } + } + + //"[%22sponsor%22,%22outro%22,%22music_offtopic%22,%22intro%22,%22selfpromo%22,%22interaction%22,%22preview%22]"; + if (enabledCategories.isEmpty()) + sponsorBlockAPIFetchCategories = "[]"; + else + sponsorBlockAPIFetchCategories = "[%22" + TextUtils.join("%22,%22", enabledCategories) + "%22]"; + } + + public static void loadAllCategoriesFromSettings() { + for (SegmentCategory category : values()) { + category.loadFromSettings(); + } + updateEnabledCategories(); + } + + @NonNull + public final String keyValue; + @NonNull + private final StringSetting behaviorSetting; + @NonNull + private final StringSetting colorSetting; + + @NonNull + public final StringRef title; + @NonNull + public final StringRef description; + + /** + * Skip button text, if the skip occurs in the first quarter of the video + */ + @NonNull + public final StringRef skipButtonTextBeginning; + /** + * Skip button text, if the skip occurs in the middle half of the video + */ + @NonNull + public final StringRef skipButtonTextMiddle; + /** + * Skip button text, if the skip occurs in the last quarter of the video + */ + @NonNull + public final StringRef skipButtonTextEnd; + /** + * Skipped segment toast, if the skip occurred in the first quarter of the video + */ + @NonNull + public final StringRef skippedToastBeginning; + /** + * Skipped segment toast, if the skip occurred in the middle half of the video + */ + @NonNull + public final StringRef skippedToastMiddle; + /** + * Skipped segment toast, if the skip occurred in the last quarter of the video + */ + @NonNull + public final StringRef skippedToastEnd; + + @NonNull + public final Paint paint; + + /** + * Value must be changed using {@link #setColor(String)}. + */ + public int color; + + /** + * Value must be changed using {@link #setBehaviour(CategoryBehaviour)}. + * Caller must also {@link #updateEnabledCategories()}. + */ + @NonNull + public CategoryBehaviour behaviour = CategoryBehaviour.IGNORE; + + SegmentCategory(String keyValue, StringRef title, StringRef description, + StringRef skipButtonText, + StringRef skippedToastText, + StringSetting behavior, StringSetting color) { + this(keyValue, title, description, + skipButtonText, skipButtonText, skipButtonText, + skippedToastText, skippedToastText, skippedToastText, + behavior, color); + } + + SegmentCategory(String keyValue, StringRef title, StringRef description, + StringRef skipButtonTextBeginning, StringRef skipButtonTextMiddle, StringRef skipButtonTextEnd, + StringRef skippedToastBeginning, StringRef skippedToastMiddle, StringRef skippedToastEnd, + StringSetting behavior, StringSetting color) { + this.keyValue = Objects.requireNonNull(keyValue); + this.title = Objects.requireNonNull(title); + this.description = Objects.requireNonNull(description); + this.skipButtonTextBeginning = Objects.requireNonNull(skipButtonTextBeginning); + this.skipButtonTextMiddle = Objects.requireNonNull(skipButtonTextMiddle); + this.skipButtonTextEnd = Objects.requireNonNull(skipButtonTextEnd); + this.skippedToastBeginning = Objects.requireNonNull(skippedToastBeginning); + this.skippedToastMiddle = Objects.requireNonNull(skippedToastMiddle); + this.skippedToastEnd = Objects.requireNonNull(skippedToastEnd); + this.behaviorSetting = Objects.requireNonNull(behavior); + this.colorSetting = Objects.requireNonNull(color); + this.paint = new Paint(); + loadFromSettings(); + } + + private void loadFromSettings() { + String behaviorString = behaviorSetting.get(); + CategoryBehaviour savedBehavior = CategoryBehaviour.byReVancedKeyValue(behaviorString); + if (savedBehavior == null) { + Logger.printException(() -> "Invalid behavior: " + behaviorString); + behaviorSetting.resetToDefault(); + loadFromSettings(); + return; + } + this.behaviour = savedBehavior; + + String colorString = colorSetting.get(); + try { + setColor(colorString); + } catch (Exception ex) { + Logger.printException(() -> "Invalid color: " + colorString, ex); + colorSetting.resetToDefault(); + loadFromSettings(); + } + } + + public void setBehaviour(@NonNull CategoryBehaviour behaviour) { + this.behaviour = Objects.requireNonNull(behaviour); + this.behaviorSetting.save(behaviour.reVancedKeyValue); + } + /** + * @return HTML color format string + */ + @NonNull + public String colorString() { + return String.format("#%06X", color); + } + + public void setColor(@NonNull String colorString) throws IllegalArgumentException { + final int color = Color.parseColor(colorString) & 0xFFFFFF; + this.color = color; + paint.setColor(color); + paint.setAlpha(255); + colorSetting.save(colorString); // Save after parsing. + } + + public void resetColor() { + setColor(colorSetting.defaultValue); + } + + @NonNull + private static String getCategoryColorDotHTML(int color) { + color &= 0xFFFFFF; + return String.format("", color); + } + + @NonNull + public static Spanned getCategoryColorDot(int color) { + return Html.fromHtml(getCategoryColorDotHTML(color)); + } + + @NonNull + public Spanned getCategoryColorDot() { + return getCategoryColorDot(color); + } + + @NonNull + public Spanned getTitleWithColorDot() { + return Html.fromHtml(getCategoryColorDotHTML(color) + " " + title); + } + + /** + * @param segmentStartTime video time the segment category started + * @param videoLength length of the video + * @return the skip button text + */ + @NonNull + StringRef getSkipButtonText(long segmentStartTime, long videoLength) { + if (Settings.SB_COMPACT_SKIP_BUTTON.get()) { + return (this == SegmentCategory.HIGHLIGHT) + ? skipSponsorTextCompactHighlight + : skipSponsorTextCompact; + } + + if (videoLength == 0) { + return skipButtonTextBeginning; // video is still loading. Assume it's the beginning + } + final float position = segmentStartTime / (float) videoLength; + if (position < 0.25f) { + return skipButtonTextBeginning; + } else if (position < 0.75f) { + return skipButtonTextMiddle; + } + return skipButtonTextEnd; + } + + /** + * @param segmentStartTime video time the segment category started + * @param videoLength length of the video + * @return 'skipped segment' toast message + */ + @NonNull + StringRef getSkippedToastText(long segmentStartTime, long videoLength) { + if (videoLength == 0) { + return skippedToastBeginning; // video is still loading. Assume it's the beginning + } + final float position = segmentStartTime / (float) videoLength; + if (position < 0.25f) { + return skippedToastBeginning; + } else if (position < 0.75f) { + return skippedToastMiddle; + } + return skippedToastEnd; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategoryListPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategoryListPreference.java new file mode 100644 index 0000000000..a0410f0989 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategoryListPreference.java @@ -0,0 +1,155 @@ +package app.revanced.extension.youtube.sponsorblock.objects; + +import static app.revanced.extension.shared.StringRef.str; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.graphics.Color; +import android.preference.ListPreference; +import android.text.Editable; +import android.text.InputType; +import android.text.TextWatcher; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TableLayout; +import android.widget.TableRow; +import android.widget.TextView; + +import java.util.Objects; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; + +@SuppressWarnings("deprecation") +public class SegmentCategoryListPreference extends ListPreference { + private final SegmentCategory category; + private EditText mEditText; + private int mClickedDialogEntryIndex; + + public SegmentCategoryListPreference(Context context, SegmentCategory category) { + super(context); + final boolean isHighlightCategory = category == SegmentCategory.HIGHLIGHT; + this.category = Objects.requireNonNull(category); + setKey(category.keyValue); + setDefaultValue(category.behaviour.reVancedKeyValue); + setEntries(isHighlightCategory + ? CategoryBehaviour.getBehaviorDescriptionsWithoutSkipOnce() + : CategoryBehaviour.getBehaviorDescriptions()); + setEntryValues(isHighlightCategory + ? CategoryBehaviour.getBehaviorKeyValuesWithoutSkipOnce() + : CategoryBehaviour.getBehaviorKeyValues()); + setSummary(category.description.toString()); + updateTitle(); + } + + @Override + protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { + try { + Utils.setEditTextDialogTheme(builder); + + Context context = builder.getContext(); + TableLayout table = new TableLayout(context); + table.setOrientation(LinearLayout.HORIZONTAL); + table.setPadding(70, 0, 150, 0); + + TableRow row = new TableRow(context); + + TextView colorTextLabel = new TextView(context); + colorTextLabel.setText(str("revanced_sb_color_dot_label")); + row.addView(colorTextLabel); + + TextView colorDotView = new TextView(context); + colorDotView.setText(category.getCategoryColorDot()); + colorDotView.setPadding(30, 0, 30, 0); + row.addView(colorDotView); + + mEditText = new EditText(context); + mEditText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS); + mEditText.setText(category.colorString()); + mEditText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable s) { + try { + String colorString = s.toString(); + if (!colorString.startsWith("#")) { + s.insert(0, "#"); // recursively calls back into this method + return; + } + if (colorString.length() > 7) { + s.delete(7, colorString.length()); + return; + } + final int color = Color.parseColor(colorString); + colorDotView.setText(SegmentCategory.getCategoryColorDot(color)); + } catch (IllegalArgumentException ex) { + // ignore + } + } + }); + mEditText.setLayoutParams(new TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 1f)); + row.addView(mEditText); + + table.addView(row); + builder.setView(table); + builder.setTitle(category.title.toString()); + + builder.setPositiveButton(android.R.string.ok, (dialog, which) -> { + onClick(dialog, DialogInterface.BUTTON_POSITIVE); + }); + builder.setNeutralButton(str("revanced_sb_reset_color"), (dialog, which) -> { + try { + category.resetColor(); + updateTitle(); + Utils.showToastShort(str("revanced_sb_color_reset")); + } catch (Exception ex) { + Logger.printException(() -> "setNeutralButton failure", ex); + } + }); + builder.setNegativeButton(android.R.string.cancel, null); + + mClickedDialogEntryIndex = findIndexOfValue(getValue()); + builder.setSingleChoiceItems(getEntries(), mClickedDialogEntryIndex, (dialog, which) -> mClickedDialogEntryIndex = which); + } catch (Exception ex) { + Logger.printException(() -> "onPrepareDialogBuilder failure", ex); + } + } + + @Override + protected void onDialogClosed(boolean positiveResult) { + try { + if (positiveResult && mClickedDialogEntryIndex >= 0 && getEntryValues() != null) { + String value = getEntryValues()[mClickedDialogEntryIndex].toString(); + if (callChangeListener(value)) { + setValue(value); + category.setBehaviour(Objects.requireNonNull(CategoryBehaviour.byReVancedKeyValue(value))); + SegmentCategory.updateEnabledCategories(); + } + String colorString = mEditText.getText().toString(); + try { + if (!colorString.equals(category.colorString())) { + category.setColor(colorString); + Utils.showToastShort(str("revanced_sb_color_changed")); + } + } catch (IllegalArgumentException ex) { + Utils.showToastShort(str("revanced_sb_color_invalid")); + } + updateTitle(); + } + } catch (Exception ex) { + Logger.printException(() -> "onDialogClosed failure", ex); + } + } + + private void updateTitle() { + setTitle(category.getTitleWithColorDot()); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SponsorSegment.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SponsorSegment.java new file mode 100644 index 0000000000..811cb87c42 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SponsorSegment.java @@ -0,0 +1,146 @@ +package app.revanced.extension.youtube.sponsorblock.objects; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import app.revanced.extension.youtube.patches.VideoInformation; +import app.revanced.extension.shared.StringRef; + +import java.util.Objects; + +import static app.revanced.extension.shared.StringRef.sf; + +public class SponsorSegment implements Comparable { + public enum SegmentVote { + UPVOTE(sf("revanced_sb_vote_upvote"), 1,false), + DOWNVOTE(sf("revanced_sb_vote_downvote"), 0, true), + CATEGORY_CHANGE(sf("revanced_sb_vote_category"), -1, true); // apiVoteType is not used for category change + + public static final SegmentVote[] voteTypesWithoutCategoryChange = { + UPVOTE, + DOWNVOTE, + }; + + @NonNull + public final StringRef title; + public final int apiVoteType; + public final boolean shouldHighlight; + + SegmentVote(@NonNull StringRef title, int apiVoteType, boolean shouldHighlight) { + this.title = title; + this.apiVoteType = apiVoteType; + this.shouldHighlight = shouldHighlight; + } + } + + @NonNull + public final SegmentCategory category; + /** + * NULL if segment is unsubmitted + */ + @Nullable + public final String UUID; + public final long start; + public final long end; + public final boolean isLocked; + public boolean didAutoSkipped = false; + /** + * If this segment has been counted as 'skipped' + */ + public boolean recordedAsSkipped = false; + + public SponsorSegment(@NonNull SegmentCategory category, @Nullable String UUID, long start, long end, boolean isLocked) { + this.category = category; + this.UUID = UUID; + this.start = start; + this.end = end; + this.isLocked = isLocked; + } + + public boolean shouldAutoSkip() { + return category.behaviour.skipAutomatically && !(didAutoSkipped && category.behaviour == CategoryBehaviour.SKIP_AUTOMATICALLY_ONCE); + } + + /** + * @param nearThreshold threshold to declare the time parameter is near this segment. Must be a positive number + */ + public boolean startIsNear(long videoTime, long nearThreshold) { + return Math.abs(start - videoTime) <= nearThreshold; + } + + /** + * @param nearThreshold threshold to declare the time parameter is near this segment. Must be a positive number + */ + public boolean endIsNear(long videoTime, long nearThreshold) { + return Math.abs(end - videoTime) <= nearThreshold; + } + + /** + * @return if the time parameter is within this segment + */ + public boolean containsTime(long videoTime) { + return start <= videoTime && videoTime < end; + } + + /** + * @return if the segment is completely contained inside this segment + */ + public boolean containsSegment(SponsorSegment other) { + return start <= other.start && other.end <= end; + } + + /** + * @return the length of this segment, in milliseconds. Always a positive number. + */ + public long length() { + return end - start; + } + + /** + * @return 'skip segment' UI overlay button text + */ + @NonNull + public String getSkipButtonText() { + return category.getSkipButtonText(start, VideoInformation.getVideoLength()).toString(); + } + + /** + * @return 'skipped segment' toast message + */ + @NonNull + public String getSkippedToastText() { + return category.getSkippedToastText(start, VideoInformation.getVideoLength()).toString(); + } + + @Override + public int compareTo(SponsorSegment o) { + // If both segments start at the same time, then sort with the longer segment first. + // This keeps the seekbar drawing correct since it draws the segments using the sorted order. + return start == o.start ? Long.compare(o.length(), length()) : Long.compare(start, o.start); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SponsorSegment)) return false; + SponsorSegment other = (SponsorSegment) o; + return Objects.equals(UUID, other.UUID) + && category == other.category + && start == other.start + && end == other.end; + } + + @Override + public int hashCode() { + return Objects.hashCode(UUID); + } + + @NonNull + @Override + public String toString() { + return "SponsorSegment{" + + "category=" + category + + ", start=" + start + + ", end=" + end + + '}'; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/UserStats.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/UserStats.java new file mode 100644 index 0000000000..4889c7671d --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/UserStats.java @@ -0,0 +1,53 @@ +package app.revanced.extension.youtube.sponsorblock.objects; + +import androidx.annotation.NonNull; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * SponsorBlock user stats + */ +public class UserStats { + @NonNull + public final String publicUserId; + @NonNull + public final String userName; + /** + * "User reputation". Unclear how SB determines this value. + */ + public final float reputation; + /** + * {@link #segmentCount} plus {@link #ignoredSegmentCount} + */ + public final int totalSegmentCountIncludingIgnored; + public final int segmentCount; + public final int ignoredSegmentCount; + public final int viewCount; + public final double minutesSaved; + + public UserStats(@NonNull JSONObject json) throws JSONException { + publicUserId = json.getString("userID"); + userName = json.getString("userName"); + reputation = (float)json.getDouble("reputation"); + segmentCount = json.getInt("segmentCount"); + ignoredSegmentCount = json.getInt("ignoredSegmentCount"); + totalSegmentCountIncludingIgnored = segmentCount + ignoredSegmentCount; + viewCount = json.getInt("viewCount"); + minutesSaved = json.getDouble("minutesSaved"); + } + + @NonNull + @Override + public String toString() { + return "UserStats{" + + "publicUserId='" + publicUserId + '\'' + + ", userName='" + userName + '\'' + + ", reputation=" + reputation + + ", segmentCount=" + segmentCount + + ", ignoredSegmentCount=" + ignoredSegmentCount + + ", viewCount=" + viewCount + + ", minutesSaved=" + minutesSaved + + '}'; + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/requests/SBRequester.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/requests/SBRequester.java new file mode 100644 index 0000000000..7cb9122d98 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/requests/SBRequester.java @@ -0,0 +1,315 @@ +package app.revanced.extension.youtube.sponsorblock.requests; + +import static app.revanced.extension.shared.StringRef.str; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +import app.revanced.extension.youtube.requests.Requester; +import app.revanced.extension.youtube.requests.Route; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings; +import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory; +import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment; +import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment.SegmentVote; +import app.revanced.extension.youtube.sponsorblock.objects.UserStats; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; + +public class SBRequester { + private static final String TIME_TEMPLATE = "%.3f"; + + /** + * TCP timeout + */ + private static final int TIMEOUT_TCP_DEFAULT_MILLISECONDS = 7000; + + /** + * HTTP response timeout + */ + private static final int TIMEOUT_HTTP_DEFAULT_MILLISECONDS = 10000; + + /** + * Response code of a successful API call + */ + private static final int HTTP_STATUS_CODE_SUCCESS = 200; + + private SBRequester() { + } + + private static void handleConnectionError(@NonNull String toastMessage, @Nullable Exception ex) { + if (Settings.SB_TOAST_ON_CONNECTION_ERROR.get()) { + Utils.showToastShort(toastMessage); + } + if (ex != null) { + Logger.printInfo(() -> toastMessage, ex); + } + } + + @NonNull + public static SponsorSegment[] getSegments(@NonNull String videoId) { + Utils.verifyOffMainThread(); + List segments = new ArrayList<>(); + try { + HttpURLConnection connection = getConnectionFromRoute(SBRoutes.GET_SEGMENTS, videoId, SegmentCategory.sponsorBlockAPIFetchCategories); + final int responseCode = connection.getResponseCode(); + + if (responseCode == HTTP_STATUS_CODE_SUCCESS) { + JSONArray responseArray = Requester.parseJSONArray(connection); + final long minSegmentDuration = (long) (Settings.SB_SEGMENT_MIN_DURATION.get() * 1000); + for (int i = 0, length = responseArray.length(); i < length; i++) { + JSONObject obj = (JSONObject) responseArray.get(i); + JSONArray segment = obj.getJSONArray("segment"); + final long start = (long) (segment.getDouble(0) * 1000); + final long end = (long) (segment.getDouble(1) * 1000); + + String uuid = obj.getString("UUID"); + final boolean locked = obj.getInt("locked") == 1; + String categoryKey = obj.getString("category"); + SegmentCategory category = SegmentCategory.byCategoryKey(categoryKey); + if (category == null) { + Logger.printException(() -> "Received unknown category: " + categoryKey); // should never happen + } else if ((end - start) >= minSegmentDuration || category == SegmentCategory.HIGHLIGHT) { + segments.add(new SponsorSegment(category, uuid, start, end, locked)); + } + } + Logger.printDebug(() -> { + StringBuilder builder = new StringBuilder("Downloaded segments:"); + for (SponsorSegment segment : segments) { + builder.append('\n').append(segment); + } + return builder.toString(); + }); + runVipCheckInBackgroundIfNeeded(); + } else if (responseCode == 404) { + // no segments are found. a normal response + Logger.printDebug(() -> "No segments found for video: " + videoId); + } else { + handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_status", responseCode), null); + connection.disconnect(); // something went wrong, might as well disconnect + } + } catch (SocketTimeoutException ex) { + handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_timeout"), ex); + } catch (IOException ex) { + handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_generic"), ex); + } catch (Exception ex) { + // Should never happen + Logger.printException(() -> "getSegments failure", ex); + } + + // Crude debug tests to verify random features + // Could benefit from: + // 1) collection of YouTube videos with test segment times (verify client skip timing matches the video, verify seekbar draws correctly) + // 2) unit tests (verify everything else) + if (false) { + segments.clear(); + // Test auto-hide skip button: + // Button should appear only once + segments.add(new SponsorSegment(SegmentCategory.INTRO, "debug", 5000, 120000, false)); + // Button should appear only once + segments.add(new SponsorSegment(SegmentCategory.SELF_PROMO, "debug", 10000, 60000, false)); + // Button should appear only once + segments.add(new SponsorSegment(SegmentCategory.INTERACTION, "debug", 15000, 20000, false)); + // Button should appear _twice_ (at 21s and 27s) + segments.add(new SponsorSegment(SegmentCategory.SPONSOR, "debug", 21000, 30000, false)); + // Button should appear only once + segments.add(new SponsorSegment(SegmentCategory.OUTRO, "debug", 24000, 27000, false)); + + + // Test seekbar visibility: + // All three segments should be viewable on the seekbar + segments.add(new SponsorSegment(SegmentCategory.MUSIC_OFFTOPIC, "debug", 200000, 300000, false)); + segments.add(new SponsorSegment(SegmentCategory.SPONSOR, "debug", 200000, 250000, false)); + segments.add(new SponsorSegment(SegmentCategory.SELF_PROMO, "debug", 200000, 330000, false)); + } + + return segments.toArray(new SponsorSegment[0]); + } + + public static void submitSegments(@NonNull String videoId, @NonNull String category, + long startTime, long endTime, long videoLength) { + Utils.verifyOffMainThread(); + try { + String privateUserId = SponsorBlockSettings.getSBPrivateUserID(); + String start = String.format(Locale.US, TIME_TEMPLATE, startTime / 1000f); + String end = String.format(Locale.US, TIME_TEMPLATE, endTime / 1000f); + String duration = String.format(Locale.US, TIME_TEMPLATE, videoLength / 1000f); + + HttpURLConnection connection = getConnectionFromRoute(SBRoutes.SUBMIT_SEGMENTS, privateUserId, videoId, category, start, end, duration); + final int responseCode = connection.getResponseCode(); + + final String messageToToast; + switch (responseCode) { + case HTTP_STATUS_CODE_SUCCESS: + messageToToast = str("revanced_sb_submit_succeeded"); + break; + case 409: + messageToToast = str("revanced_sb_submit_failed_duplicate"); + break; + case 403: + messageToToast = str("revanced_sb_submit_failed_forbidden", Requester.parseErrorStringAndDisconnect(connection)); + break; + case 429: + messageToToast = str("revanced_sb_submit_failed_rate_limit"); + break; + case 400: + messageToToast = str("revanced_sb_submit_failed_invalid", Requester.parseErrorStringAndDisconnect(connection)); + break; + default: + messageToToast = str("revanced_sb_submit_failed_unknown_error", responseCode, connection.getResponseMessage()); + break; + } + Utils.showToastLong(messageToToast); + } catch (SocketTimeoutException ex) { + // Always show, even if show connection toasts is turned off + Utils.showToastLong(str("revanced_sb_submit_failed_timeout")); + } catch (IOException ex) { + Utils.showToastLong(str("revanced_sb_submit_failed_unknown_error", 0, ex.getMessage())); + } catch (Exception ex) { + Logger.printException(() -> "failed to submit segments", ex); + } + } + + public static void sendSegmentSkippedViewedRequest(@NonNull SponsorSegment segment) { + Utils.verifyOffMainThread(); + try { + HttpURLConnection connection = getConnectionFromRoute(SBRoutes.VIEWED_SEGMENT, segment.UUID); + final int responseCode = connection.getResponseCode(); + + if (responseCode == HTTP_STATUS_CODE_SUCCESS) { + Logger.printDebug(() -> "Successfully sent view count for segment: " + segment); + } else { + Logger.printDebug(() -> "Failed to sent view count for segment: " + segment.UUID + + " responseCode: " + responseCode); // debug level, no toast is shown + } + } catch (IOException ex) { + Logger.printInfo(() -> "Failed to send view count", ex); // do not show a toast + } catch (Exception ex) { + Logger.printException(() -> "Failed to send view count request", ex); // should never happen + } + } + + public static void voteForSegmentOnBackgroundThread(@NonNull SponsorSegment segment, @NonNull SegmentVote voteOption) { + voteOrRequestCategoryChange(segment, voteOption, null); + } + public static void voteToChangeCategoryOnBackgroundThread(@NonNull SponsorSegment segment, @NonNull SegmentCategory categoryToVoteFor) { + voteOrRequestCategoryChange(segment, SegmentVote.CATEGORY_CHANGE, categoryToVoteFor); + } + private static void voteOrRequestCategoryChange(@NonNull SponsorSegment segment, @NonNull SegmentVote voteOption, SegmentCategory categoryToVoteFor) { + Utils.runOnBackgroundThread(() -> { + try { + String segmentUuid = segment.UUID; + String uuid = SponsorBlockSettings.getSBPrivateUserID(); + HttpURLConnection connection = (voteOption == SegmentVote.CATEGORY_CHANGE) + ? getConnectionFromRoute(SBRoutes.VOTE_ON_SEGMENT_CATEGORY, uuid, segmentUuid, categoryToVoteFor.keyValue) + : getConnectionFromRoute(SBRoutes.VOTE_ON_SEGMENT_QUALITY, uuid, segmentUuid, String.valueOf(voteOption.apiVoteType)); + final int responseCode = connection.getResponseCode(); + + switch (responseCode) { + case HTTP_STATUS_CODE_SUCCESS: + Logger.printDebug(() -> "Vote success for segment: " + segment); + break; + case 403: + Utils.showToastLong( + str("revanced_sb_vote_failed_forbidden", Requester.parseErrorStringAndDisconnect(connection))); + break; + default: + Utils.showToastLong( + str("revanced_sb_vote_failed_unknown_error", responseCode, connection.getResponseMessage())); + break; + } + } catch (SocketTimeoutException ex) { + Utils.showToastShort(str("revanced_sb_vote_failed_timeout")); + } catch (IOException ex) { + Utils.showToastShort(str("revanced_sb_vote_failed_unknown_error", 0, ex.getMessage())); + } catch (Exception ex) { + Logger.printException(() -> "failed to vote for segment", ex); // should never happen + } + }); + } + + /** + * @return NULL, if stats fetch failed + */ + @Nullable + public static UserStats retrieveUserStats() { + Utils.verifyOffMainThread(); + try { + UserStats stats = new UserStats(getJSONObject(SBRoutes.GET_USER_STATS, SponsorBlockSettings.getSBPrivateUserID())); + Logger.printDebug(() -> "user stats: " + stats); + return stats; + } catch (IOException ex) { + Logger.printInfo(() -> "failed to retrieve user stats", ex); // info level, do not show a toast + } catch (Exception ex) { + Logger.printException(() -> "failure retrieving user stats", ex); // should never happen + } + return null; + } + + /** + * @return NULL if the call was successful. If unsuccessful, an error message is returned. + */ + @Nullable + public static String setUsername(@NonNull String username) { + Utils.verifyOffMainThread(); + try { + HttpURLConnection connection = getConnectionFromRoute(SBRoutes.CHANGE_USERNAME, SponsorBlockSettings.getSBPrivateUserID(), username); + final int responseCode = connection.getResponseCode(); + String responseMessage = connection.getResponseMessage(); + if (responseCode == HTTP_STATUS_CODE_SUCCESS) { + return null; + } + return str("revanced_sb_stats_username_change_unknown_error", responseCode, responseMessage); + } catch (Exception ex) { // should never happen + Logger.printInfo(() -> "failed to set username", ex); // do not toast + return str("revanced_sb_stats_username_change_unknown_error", 0, ex.getMessage()); + } + } + + public static void runVipCheckInBackgroundIfNeeded() { + if (!SponsorBlockSettings.userHasSBPrivateId()) { + return; // User cannot be a VIP. User has never voted, created any segments, or has imported a SB user id. + } + long now = System.currentTimeMillis(); + if (now < (Settings.SB_LAST_VIP_CHECK.get() + TimeUnit.DAYS.toMillis(3))) { + return; + } + Utils.runOnBackgroundThread(() -> { + try { + JSONObject json = getJSONObject(SBRoutes.IS_USER_VIP, SponsorBlockSettings.getSBPrivateUserID()); + boolean vip = json.getBoolean("vip"); + Settings.SB_USER_IS_VIP.save(vip); + Settings.SB_LAST_VIP_CHECK.save(now); + } catch (IOException ex) { + Logger.printInfo(() -> "Failed to check VIP (network error)", ex); // info, so no error toast is shown + } catch (Exception ex) { + Logger.printException(() -> "Failed to check VIP", ex); // should never happen + } + }); + } + + // helpers + + private static HttpURLConnection getConnectionFromRoute(@NonNull Route route, String... params) throws IOException { + HttpURLConnection connection = Requester.getConnectionFromRoute(Settings.SB_API_URL.get(), route, params); + connection.setConnectTimeout(TIMEOUT_TCP_DEFAULT_MILLISECONDS); + connection.setReadTimeout(TIMEOUT_HTTP_DEFAULT_MILLISECONDS); + return connection; + } + + private static JSONObject getJSONObject(@NonNull Route route, String... params) throws IOException, JSONException { + return Requester.parseJSONObject(getConnectionFromRoute(route, params)); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/requests/SBRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/requests/SBRoutes.java new file mode 100644 index 0000000000..fe3403e566 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/requests/SBRoutes.java @@ -0,0 +1,20 @@ +package app.revanced.extension.youtube.sponsorblock.requests; + +import static app.revanced.extension.youtube.requests.Route.Method.GET; +import static app.revanced.extension.youtube.requests.Route.Method.POST; + +import app.revanced.extension.youtube.requests.Route; + +class SBRoutes { + static final Route IS_USER_VIP = new Route(GET, "/api/isUserVIP?userID={user_id}"); + static final Route GET_SEGMENTS = new Route(GET, "/api/skipSegments?videoID={video_id}&categories={categories}"); + static final Route VIEWED_SEGMENT = new Route(POST, "/api/viewedVideoSponsorTime?UUID={segment_id}"); + static final Route GET_USER_STATS = new Route(GET, "/api/userInfo?userID={user_id}&values=[\"userID\",\"userName\",\"reputation\",\"segmentCount\",\"ignoredSegmentCount\",\"viewCount\",\"minutesSaved\"]"); + static final Route CHANGE_USERNAME = new Route(POST, "/api/setUsername?userID={user_id}&username={username}"); + static final Route SUBMIT_SEGMENTS = new Route(POST, "/api/skipSegments?userID={user_id}&videoID={video_id}&category={category}&startTime={start_time}&endTime={end_time}&videoDuration={duration}"); + static final Route VOTE_ON_SEGMENT_QUALITY = new Route(POST, "/api/voteOnSponsorTime?userID={user_id}&UUID={segment_id}&type={type}"); + static final Route VOTE_ON_SEGMENT_CATEGORY = new Route(POST, "/api/voteOnSponsorTime?userID={user_id}&UUID={segment_id}&category={category}"); + + private SBRoutes() { + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/CreateSegmentButtonController.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/CreateSegmentButtonController.java new file mode 100644 index 0000000000..4ec6c35b7e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/CreateSegmentButtonController.java @@ -0,0 +1,111 @@ +package app.revanced.extension.youtube.sponsorblock.ui; + +import static app.revanced.extension.shared.Utils.getResourceIdentifier; + +import android.view.View; +import android.widget.ImageView; + +import java.lang.ref.WeakReference; +import java.util.Objects; + +import app.revanced.extension.youtube.patches.VideoInformation; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.videoplayer.PlayerControlButton; + +// Edit: This should be a subclass of PlayerControlButton +public class CreateSegmentButtonController { + private static WeakReference buttonReference = new WeakReference<>(null); + private static boolean isShowing; + + /** + * injection point + */ + public static void initialize(View youtubeControlsLayout) { + try { + Logger.printDebug(() -> "initializing new segment button"); + ImageView imageView = Objects.requireNonNull(Utils.getChildViewByResourceName( + youtubeControlsLayout, "revanced_sb_create_segment_button")); + imageView.setVisibility(View.GONE); + imageView.setOnClickListener(v -> SponsorBlockViewController.toggleNewSegmentLayoutVisibility()); + + buttonReference = new WeakReference<>(imageView); + } catch (Exception ex) { + Logger.printException(() -> "initialize failure", ex); + } + } + + /** + * injection point + */ + public static void changeVisibilityImmediate(boolean visible) { + if (visible) { + // Fix button flickering, by pushing this call to the back of + // the main thread and letting other layout code run first. + Utils.runOnMainThread(() -> setVisibility(true, false)); + } else { + setVisibility(false, false); + } + } + + /** + * injection point + */ + public static void changeVisibility(boolean visible, boolean animated) { + // Ignore this call, otherwise with full screen thumbnails the buttons are visible while seeking. + if (visible && !animated) return; + + setVisibility(visible, animated); + } + + private static void setVisibility(boolean visible, boolean animated) { + try { + if (isShowing == visible) return; + isShowing = visible; + + ImageView iView = buttonReference.get(); + if (iView == null) return; + + if (visible) { + iView.clearAnimation(); + if (!shouldBeShown()) { + return; + } + if (animated) { + iView.startAnimation(PlayerControlButton.getButtonFadeIn()); + } + iView.setVisibility(View.VISIBLE); + return; + } + + if (iView.getVisibility() == View.VISIBLE) { + iView.clearAnimation(); + if (animated) { + iView.startAnimation(PlayerControlButton.getButtonFadeOut()); + } + iView.setVisibility(View.GONE); + } + } catch (Exception ex) { + Logger.printException(() -> "changeVisibility failure", ex); + } + } + + private static boolean shouldBeShown() { + return Settings.SB_ENABLED.get() && Settings.SB_CREATE_NEW_SEGMENT.get() + && !VideoInformation.isAtEndOfVideo(); + } + + public static void hide() { + if (!isShowing) { + return; + } + Utils.verifyOnMainThread(); + View v = buttonReference.get(); + if (v == null) { + return; + } + v.setVisibility(View.GONE); + isShowing = false; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/NewSegmentLayout.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/NewSegmentLayout.java new file mode 100644 index 0000000000..38e1f113c1 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/NewSegmentLayout.java @@ -0,0 +1,127 @@ +package app.revanced.extension.youtube.sponsorblock.ui; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.drawable.RippleDrawable; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.widget.FrameLayout; +import android.widget.ImageButton; + +import app.revanced.extension.youtube.patches.VideoInformation; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.sponsorblock.SponsorBlockUtils; +import app.revanced.extension.shared.Logger; + +import static app.revanced.extension.shared.Utils.getResourceDimensionPixelSize; +import static app.revanced.extension.shared.Utils.getResourceIdentifier; + +public final class NewSegmentLayout extends FrameLayout { + private static final ColorStateList rippleColorStateList = new ColorStateList( + new int[][]{new int[]{android.R.attr.state_enabled}}, + new int[]{0x33ffffff} // sets the ripple color to white + ); + private final int rippleEffectId; + + final int defaultBottomMargin; + final int ctaBottomMargin; + + public NewSegmentLayout(final Context context) { + this(context, null); + } + + public NewSegmentLayout(final Context context, final AttributeSet attributeSet) { + this(context, attributeSet, 0); + } + + public NewSegmentLayout(final Context context, final AttributeSet attributeSet, final int defStyleAttr) { + this(context, attributeSet, defStyleAttr, 0); + } + + public NewSegmentLayout(final Context context, final AttributeSet attributeSet, + final int defStyleAttr, final int defStyleRes) { + super(context, attributeSet, defStyleAttr, defStyleRes); + + LayoutInflater.from(context).inflate( + getResourceIdentifier(context, "revanced_sb_new_segment", "layout"), this, true + ); + + TypedValue rippleEffect = new TypedValue(); + context.getTheme().resolveAttribute(android.R.attr.selectableItemBackground, rippleEffect, true); + rippleEffectId = rippleEffect.resourceId; + + initializeButton( + context, + "revanced_sb_new_segment_rewind", + () -> VideoInformation.seekToRelative(-Settings.SB_CREATE_NEW_SEGMENT_STEP.get()), + "Rewind button clicked" + ); + + initializeButton( + context, + "revanced_sb_new_segment_forward", + () -> VideoInformation.seekToRelative(Settings.SB_CREATE_NEW_SEGMENT_STEP.get()), + "Forward button clicked" + ); + + initializeButton( + context, + "revanced_sb_new_segment_adjust", + SponsorBlockUtils::onMarkLocationClicked, + "Adjust button clicked" + ); + + initializeButton( + context, + "revanced_sb_new_segment_compare", + SponsorBlockUtils::onPreviewClicked, + "Compare button clicked" + ); + + initializeButton( + context, + "revanced_sb_new_segment_edit", + SponsorBlockUtils::onEditByHandClicked, + "Edit button clicked" + ); + + initializeButton( + context, + "revanced_sb_new_segment_publish", + SponsorBlockUtils::onPublishClicked, + "Publish button clicked" + ); + + defaultBottomMargin = getResourceDimensionPixelSize("brand_interaction_default_bottom_margin"); + ctaBottomMargin = getResourceDimensionPixelSize("brand_interaction_cta_bottom_margin"); + } + + /** + * Initializes a segment button with the given resource identifier name with the given handler and a ripple effect. + * + * @param context The context. + * @param resourceIdentifierName The resource identifier name for the button. + * @param handler The handler for the button's click event. + * @param debugMessage The debug message to print when the button is clicked. + */ + private void initializeButton(final Context context, final String resourceIdentifierName, + final ButtonOnClickHandlerFunction handler, final String debugMessage) { + final ImageButton button = findViewById(getResourceIdentifier(context, resourceIdentifierName, "id")); + + // Add ripple effect + button.setBackgroundResource(rippleEffectId); + RippleDrawable rippleDrawable = (RippleDrawable) button.getBackground(); + rippleDrawable.setColor(rippleColorStateList); + + button.setOnClickListener((v) -> { + handler.apply(); + Logger.printDebug(() -> debugMessage); + }); + } + + @FunctionalInterface + public interface ButtonOnClickHandlerFunction { + void apply(); + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SkipSponsorButton.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SkipSponsorButton.java new file mode 100644 index 0000000000..11813aa849 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SkipSponsorButton.java @@ -0,0 +1,101 @@ +package app.revanced.extension.youtube.sponsorblock.ui; + +import static app.revanced.extension.shared.Utils.getResourceColor; +import static app.revanced.extension.shared.Utils.getResourceDimension; +import static app.revanced.extension.shared.Utils.getResourceDimensionPixelSize; +import static app.revanced.extension.shared.Utils.getResourceIdentifier; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import java.util.Objects; + +import app.revanced.extension.youtube.sponsorblock.SegmentPlaybackController; +import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment; + +public class SkipSponsorButton extends FrameLayout { + private static final boolean highContrast = true; + private final LinearLayout skipSponsorBtnContainer; + private final TextView skipSponsorTextView; + private final Paint background; + private final Paint border; + private SponsorSegment segment; + final int defaultBottomMargin; + final int ctaBottomMargin; + + public SkipSponsorButton(Context context) { + this(context, null); + } + + public SkipSponsorButton(Context context, AttributeSet attributeSet) { + this(context, attributeSet, 0); + } + + public SkipSponsorButton(Context context, AttributeSet attributeSet, int defStyleAttr) { + this(context, attributeSet, defStyleAttr, 0); + } + + public SkipSponsorButton(Context context, AttributeSet attributeSet, int defStyleAttr, int defStyleRes) { + super(context, attributeSet, defStyleAttr, defStyleRes); + + LayoutInflater.from(context).inflate(getResourceIdentifier(context, "revanced_sb_skip_sponsor_button", "layout"), this, true); // layout:skip_ad_button + setMinimumHeight(getResourceDimensionPixelSize("ad_skip_ad_button_min_height")); // dimen:ad_skip_ad_button_min_height + skipSponsorBtnContainer = Objects.requireNonNull((LinearLayout) findViewById(getResourceIdentifier(context, "revanced_sb_skip_sponsor_button_container", "id"))); // id:skip_ad_button_container + background = new Paint(); + background.setColor(getResourceColor("skip_ad_button_background_color")); // color:skip_ad_button_background_color); + background.setStyle(Paint.Style.FILL); + border = new Paint(); + border.setColor(getResourceColor("skip_ad_button_border_color")); // color:skip_ad_button_border_color); + border.setStrokeWidth(getResourceDimension("ad_skip_ad_button_border_width")); // dimen:ad_skip_ad_button_border_width); + border.setStyle(Paint.Style.STROKE); + skipSponsorTextView = Objects.requireNonNull((TextView) findViewById(getResourceIdentifier(context, "revanced_sb_skip_sponsor_button_text", "id"))); // id:skip_ad_button_text; + defaultBottomMargin = getResourceDimensionPixelSize("skip_button_default_bottom_margin"); // dimen:skip_button_default_bottom_margin + ctaBottomMargin = getResourceDimensionPixelSize("skip_button_cta_bottom_margin"); // dimen:skip_button_cta_bottom_margin + + skipSponsorBtnContainer.setOnClickListener(v -> { + // The view controller handles hiding this button, but hide it here as well just in case something goofs. + setVisibility(View.GONE); + SegmentPlaybackController.onSkipSegmentClicked(segment); + }); + } + + @Override // android.view.ViewGroup + protected final void dispatchDraw(Canvas canvas) { + final int left = skipSponsorBtnContainer.getLeft(); + final int top = skipSponsorBtnContainer.getTop(); + final int leftPlusWidth = (left + skipSponsorBtnContainer.getWidth()); + final int topPlusHeight = (top + skipSponsorBtnContainer.getHeight()); + canvas.drawRect(left, top, leftPlusWidth, topPlusHeight, background); + if (!highContrast) { + canvas.drawLines(new float[]{ + leftPlusWidth, top, left, top, + left, top, left, topPlusHeight, + left, topPlusHeight, leftPlusWidth, topPlusHeight}, + border); + } + + super.dispatchDraw(canvas); + } + + /** + * @return true, if this button state was changed + */ + public boolean updateSkipButtonText(@NonNull SponsorSegment segment) { + this.segment = segment; + CharSequence newText = segment.getSkipButtonText(); + if (newText.equals(skipSponsorTextView.getText())) { + return false; + } + skipSponsorTextView.setText(newText); + return true; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SponsorBlockViewController.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SponsorBlockViewController.java new file mode 100644 index 0000000000..099f0d56e5 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SponsorBlockViewController.java @@ -0,0 +1,224 @@ +package app.revanced.extension.youtube.sponsorblock.ui; + +import static app.revanced.extension.shared.Utils.getResourceIdentifier; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.RelativeLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.ref.WeakReference; +import java.util.Objects; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.shared.PlayerType; +import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment; + +public class SponsorBlockViewController { + private static WeakReference inlineSponsorOverlayRef = new WeakReference<>(null); + private static WeakReference youtubeOverlaysLayoutRef = new WeakReference<>(null); + private static WeakReference skipHighlightButtonRef = new WeakReference<>(null); + private static WeakReference skipSponsorButtonRef = new WeakReference<>(null); + private static WeakReference newSegmentLayoutRef = new WeakReference<>(null); + private static boolean canShowViewElements; + private static boolean newSegmentLayoutVisible; + @Nullable + private static SponsorSegment skipHighlight; + @Nullable + private static SponsorSegment skipSegment; + + static { + PlayerType.getOnChange().addObserver((PlayerType type) -> { + playerTypeChanged(type); + return null; + }); + } + + public static Context getOverLaysViewGroupContext() { + ViewGroup group = youtubeOverlaysLayoutRef.get(); + if (group == null) { + return null; + } + return group.getContext(); + } + + /** + * Injection point. + */ + public static void initialize(ViewGroup viewGroup) { + try { + Logger.printDebug(() -> "initializing"); + + // hide any old components, just in case they somehow are still hanging around + hideAll(); + + Context context = Utils.getContext(); + RelativeLayout layout = new RelativeLayout(context); + layout.setLayoutParams(new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT,RelativeLayout.LayoutParams.MATCH_PARENT)); + LayoutInflater.from(context).inflate(getResourceIdentifier("revanced_sb_inline_sponsor_overlay", "layout"), layout); + inlineSponsorOverlayRef = new WeakReference<>(layout); + + viewGroup.addView(layout); + viewGroup.setOnHierarchyChangeListener(new ViewGroup.OnHierarchyChangeListener() { + @Override + public void onChildViewAdded(View parent, View child) { + // ensure SB buttons and controls are always on top, otherwise the endscreen cards can cover the skip button + RelativeLayout layout = inlineSponsorOverlayRef.get(); + if (layout != null) { + layout.bringToFront(); + } + } + @Override + public void onChildViewRemoved(View parent, View child) { + } + }); + youtubeOverlaysLayoutRef = new WeakReference<>(viewGroup); + + skipHighlightButtonRef = new WeakReference<>( + Objects.requireNonNull(layout.findViewById(getResourceIdentifier("revanced_sb_skip_highlight_button", "id")))); + skipSponsorButtonRef = new WeakReference<>( + Objects.requireNonNull(layout.findViewById(getResourceIdentifier("revanced_sb_skip_sponsor_button", "id")))); + newSegmentLayoutRef = new WeakReference<>( + Objects.requireNonNull(layout.findViewById(getResourceIdentifier("revanced_sb_new_segment_view", "id")))); + + newSegmentLayoutVisible = false; + skipHighlight = null; + skipSegment = null; + } catch (Exception ex) { + Logger.printException(() -> "initialize failure", ex); + } + } + + public static void hideAll() { + hideSkipHighlightButton(); + hideSkipSegmentButton(); + hideNewSegmentLayout(); + } + + public static void showSkipHighlightButton(@NonNull SponsorSegment segment) { + skipHighlight = Objects.requireNonNull(segment); + NewSegmentLayout newSegmentLayout = newSegmentLayoutRef.get(); + // don't show highlight button if create new segment is visible + final boolean buttonVisibility = newSegmentLayout == null || newSegmentLayout.getVisibility() != View.VISIBLE; + updateSkipButton(skipHighlightButtonRef.get(), segment, buttonVisibility); + } + public static void showSkipSegmentButton(@NonNull SponsorSegment segment) { + skipSegment = Objects.requireNonNull(segment); + updateSkipButton(skipSponsorButtonRef.get(), segment, true); + } + + public static void hideSkipHighlightButton() { + skipHighlight = null; + updateSkipButton(skipHighlightButtonRef.get(), null, false); + } + public static void hideSkipSegmentButton() { + skipSegment = null; + updateSkipButton(skipSponsorButtonRef.get(), null, false); + } + + private static void updateSkipButton(@Nullable SkipSponsorButton button, + @Nullable SponsorSegment segment, boolean visible) { + if (button == null) { + return; + } + if (segment != null) { + button.updateSkipButtonText(segment); + } + setViewVisibility(button, visible); + } + + public static void toggleNewSegmentLayoutVisibility() { + NewSegmentLayout newSegmentLayout = newSegmentLayoutRef.get(); + if (newSegmentLayout == null) { // should never happen + Logger.printException(() -> "toggleNewSegmentLayoutVisibility failure"); + return; + } + newSegmentLayoutVisible = (newSegmentLayout.getVisibility() != View.VISIBLE); + if (skipHighlight != null) { + setViewVisibility(skipHighlightButtonRef.get(), !newSegmentLayoutVisible); + } + setViewVisibility(newSegmentLayout, newSegmentLayoutVisible); + } + + public static void hideNewSegmentLayout() { + newSegmentLayoutVisible = false; + setViewVisibility(newSegmentLayoutRef.get(), false); + } + + private static void setViewVisibility(@Nullable View view, boolean visible) { + if (view == null) { + return; + } + visible &= canShowViewElements; + final int desiredVisibility = visible ? View.VISIBLE : View.GONE; + if (view.getVisibility() != desiredVisibility) { + view.setVisibility(desiredVisibility); + } + } + + private static void playerTypeChanged(@NonNull PlayerType playerType) { + try { + final boolean isWatchFullScreen = playerType == PlayerType.WATCH_WHILE_FULLSCREEN; + canShowViewElements = (isWatchFullScreen || playerType == PlayerType.WATCH_WHILE_MAXIMIZED); + + NewSegmentLayout newSegmentLayout = newSegmentLayoutRef.get(); + setNewSegmentLayoutMargins(newSegmentLayout, isWatchFullScreen); + setViewVisibility(newSegmentLayoutRef.get(), newSegmentLayoutVisible); + + SkipSponsorButton skipHighlightButton = skipHighlightButtonRef.get(); + setSkipButtonMargins(skipHighlightButton, isWatchFullScreen); + setViewVisibility(skipHighlightButton, skipHighlight != null); + + SkipSponsorButton skipSponsorButton = skipSponsorButtonRef.get(); + setSkipButtonMargins(skipSponsorButton, isWatchFullScreen); + setViewVisibility(skipSponsorButton, skipSegment != null); + } catch (Exception ex) { + Logger.printException(() -> "Player type changed failure", ex); + } + } + + private static void setNewSegmentLayoutMargins(@Nullable NewSegmentLayout layout, boolean fullScreen) { + if (layout != null) { + setLayoutMargins(layout, fullScreen, layout.defaultBottomMargin, layout.ctaBottomMargin); + } + } + private static void setSkipButtonMargins(@Nullable SkipSponsorButton button, boolean fullScreen) { + if (button != null) { + setLayoutMargins(button, fullScreen, button.defaultBottomMargin, button.ctaBottomMargin); + } + } + private static void setLayoutMargins(@NonNull View view, boolean fullScreen, + int defaultBottomMargin, int ctaBottomMargin) { + RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) view.getLayoutParams(); + if (params == null) { + Logger.printException(() -> "Unable to setNewSegmentLayoutMargins (params are null)"); + return; + } + params.bottomMargin = fullScreen ? ctaBottomMargin : defaultBottomMargin; + view.setLayoutParams(params); + } + + /** + * Injection point. + */ + public static void endOfVideoReached() { + try { + Logger.printDebug(() -> "endOfVideoReached"); + // the buttons automatically set themselves to visible when appropriate, + // but if buttons are showing when the end of the video is reached then they need + // to be forcefully hidden + if (!Settings.AUTO_REPEAT.get()) { + CreateSegmentButtonController.hide(); + VotingButtonController.hide(); + } + } catch (Exception ex) { + Logger.printException(() -> "endOfVideoReached failure", ex); + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/VotingButtonController.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/VotingButtonController.java new file mode 100644 index 0000000000..bad5f24846 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/VotingButtonController.java @@ -0,0 +1,116 @@ +package app.revanced.extension.youtube.sponsorblock.ui; + +import static app.revanced.extension.shared.Utils.getResourceIdentifier; + +import android.view.View; +import android.widget.ImageView; + +import java.lang.ref.WeakReference; +import java.util.Objects; + +import app.revanced.extension.youtube.patches.VideoInformation; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.sponsorblock.SegmentPlaybackController; +import app.revanced.extension.youtube.sponsorblock.SponsorBlockUtils; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.youtube.videoplayer.PlayerControlButton; + +// Edit: This should be a subclass of PlayerControlButton +public class VotingButtonController { + private static WeakReference buttonReference = new WeakReference<>(null); + private static boolean isShowing; + + /** + * injection point + */ + public static void initialize(View youtubeControlsLayout) { + try { + Logger.printDebug(() -> "initializing voting button"); + ImageView imageView = Objects.requireNonNull(Utils.getChildViewByResourceName( + youtubeControlsLayout, "revanced_sb_voting_button")); + imageView.setVisibility(View.GONE); + imageView.setOnClickListener(v -> SponsorBlockUtils.onVotingClicked(v.getContext())); + + buttonReference = new WeakReference<>(imageView); + } catch (Exception ex) { + Logger.printException(() -> "initialize failure", ex); + } + } + + /** + * injection point + */ + public static void changeVisibilityImmediate(boolean visible) { + if (visible) { + // Fix button flickering, by pushing this call to the back of + // the main thread and letting other layout code run first. + Utils.runOnMainThread(() -> setVisibility(true, false)); + } else { + setVisibility(false, false); + } + } + + /** + * injection point + */ + public static void changeVisibility(boolean visible, boolean animated) { + // Ignore this call, otherwise with full screen thumbnails the buttons are visible while seeking. + if (visible && !animated) return; + + setVisibility(visible, animated); + } + + /** + * injection point + */ + private static void setVisibility(boolean visible, boolean animated) { + try { + if (isShowing == visible) return; + isShowing = visible; + + ImageView iView = buttonReference.get(); + if (iView == null) return; + + if (visible) { + iView.clearAnimation(); + if (!shouldBeShown()) { + return; + } + if (animated) { + iView.startAnimation(PlayerControlButton.getButtonFadeIn()); + } + iView.setVisibility(View.VISIBLE); + return; + } + + if (iView.getVisibility() == View.VISIBLE) { + iView.clearAnimation(); + if (animated) { + iView.startAnimation(PlayerControlButton.getButtonFadeOut()); + } + iView.setVisibility(View.GONE); + } + } catch (Exception ex) { + Logger.printException(() -> "changeVisibility failure", ex); + } + } + + private static boolean shouldBeShown() { + return Settings.SB_ENABLED.get() && Settings.SB_VOTING_BUTTON.get() + && SegmentPlaybackController.videoHasSegments() && !VideoInformation.isAtEndOfVideo(); + } + + public static void hide() { + if (!isShowing) { + return; + } + Utils.verifyOnMainThread(); + View v = buttonReference.get(); + if (v == null) { + return; + } + v.setVisibility(View.GONE); + isShowing = false; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/SwipeControlsConfigurationProvider.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/SwipeControlsConfigurationProvider.kt new file mode 100644 index 0000000000..f9850d99ad --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/SwipeControlsConfigurationProvider.kt @@ -0,0 +1,120 @@ +package app.revanced.extension.youtube.swipecontrols + +import android.content.Context +import android.graphics.Color +import app.revanced.extension.youtube.settings.Settings +import app.revanced.extension.youtube.shared.PlayerType + +/** + * provider for configuration for volume and brightness swipe controls + * + * @param context the context to create in + */ +class SwipeControlsConfigurationProvider( + private val context: Context, +) { +//region swipe enable + /** + * should swipe controls be enabled? (global setting) + */ + val enableSwipeControls: Boolean + get() = isFullscreenVideo && (enableVolumeControls || enableBrightnessControl) + + /** + * should swipe controls for volume be enabled? + */ + val enableVolumeControls: Boolean + get() = Settings.SWIPE_VOLUME.get() + + /** + * should swipe controls for volume be enabled? + */ + val enableBrightnessControl: Boolean + get() = Settings.SWIPE_BRIGHTNESS.get() + + /** + * is the video player currently in fullscreen mode? + */ + private val isFullscreenVideo: Boolean + get() = PlayerType.current == PlayerType.WATCH_WHILE_FULLSCREEN +//endregion + +//region keys enable + /** + * should volume key controls be overwritten? (global setting) + */ + val overwriteVolumeKeyControls: Boolean + get() = isFullscreenVideo && enableVolumeControls +//endregion + +//region gesture adjustments + /** + * should press-to-swipe be enabled? + */ + val shouldEnablePressToSwipe: Boolean + get() = Settings.SWIPE_PRESS_TO_ENGAGE.get() + + /** + * threshold for swipe detection + * this may be called rapidly in onScroll, so we have to load it once and then leave it constant + */ + val swipeMagnitudeThreshold: Int + get() = Settings.SWIPE_MAGNITUDE_THRESHOLD.get() +//endregion + +//region overlay adjustments + + /** + * should the overlay enable haptic feedback? + */ + val shouldEnableHapticFeedback: Boolean + get() = Settings.SWIPE_HAPTIC_FEEDBACK.get() + + /** + * how long the overlay should be shown on changes + */ + val overlayShowTimeoutMillis: Long + get() = Settings.SWIPE_OVERLAY_TIMEOUT.get() + + /** + * text size for the overlay, in sp + */ + val overlayTextSize: Int + get() = Settings.SWIPE_OVERLAY_TEXT_SIZE.get() + + /** + * get the background color for text on the overlay, as a color int + */ + val overlayTextBackgroundColor: Int + get() = Color.argb(Settings.SWIPE_OVERLAY_BACKGROUND_ALPHA.get(), 0, 0, 0) + + /** + * get the foreground color for text on the overlay, as a color int + */ + val overlayForegroundColor: Int + get() = Color.WHITE + +//endregion + +//region behaviour + + /** + * should the brightness be saved and restored when exiting or entering fullscreen + */ + val shouldSaveAndRestoreBrightness: Boolean + get() = Settings.SWIPE_SAVE_AND_RESTORE_BRIGHTNESS.get() + + /** + * should auto-brightness be enabled at the lowest value of the brightness gesture + */ + val shouldLowestValueEnableAutoBrightness: Boolean + get() = Settings.SWIPE_LOWEST_VALUE_ENABLE_AUTO_BRIGHTNESS.get() + + /** + * variable that stores the brightness gesture value in the settings + */ + var savedScreenBrightnessValue: Float + get() = Settings.SWIPE_BRIGHTNESS_VALUE.get() + set(value) = Settings.SWIPE_BRIGHTNESS_VALUE.save(value) +//endregion +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/SwipeControlsHostActivity.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/SwipeControlsHostActivity.kt new file mode 100644 index 0000000000..afb55d74bb --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/SwipeControlsHostActivity.kt @@ -0,0 +1,236 @@ +package app.revanced.extension.youtube.swipecontrols + +import android.app.Activity +import android.os.Build +import android.os.Bundle +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.ViewGroup +import app.revanced.extension.shared.Logger.printDebug +import app.revanced.extension.shared.Logger.printException +import app.revanced.extension.youtube.shared.PlayerType +import app.revanced.extension.youtube.swipecontrols.controller.AudioVolumeController +import app.revanced.extension.youtube.swipecontrols.controller.ScreenBrightnessController +import app.revanced.extension.youtube.swipecontrols.controller.SwipeZonesController +import app.revanced.extension.youtube.swipecontrols.controller.VolumeKeysController +import app.revanced.extension.youtube.swipecontrols.controller.gesture.ClassicSwipeController +import app.revanced.extension.youtube.swipecontrols.controller.gesture.PressToSwipeController +import app.revanced.extension.youtube.swipecontrols.controller.gesture.core.GestureController +import app.revanced.extension.youtube.swipecontrols.misc.Rectangle +import app.revanced.extension.youtube.swipecontrols.views.SwipeControlsOverlayLayout +import java.lang.ref.WeakReference + +/** + * The main controller for volume and brightness swipe controls. + * note that the superclass is overwritten to the superclass of the MainActivity at patch time + * + * @smali Lapp/revanced/extension/swipecontrols/SwipeControlsHostActivity; + */ +class SwipeControlsHostActivity : Activity() { + /** + * current instance of [AudioVolumeController] + */ + var audio: AudioVolumeController? = null + + /** + * current instance of [ScreenBrightnessController] + */ + var screen: ScreenBrightnessController? = null + + /** + * current instance of [SwipeControlsConfigurationProvider] + */ + lateinit var config: SwipeControlsConfigurationProvider + + /** + * current instance of [SwipeControlsOverlayLayout] + */ + lateinit var overlay: SwipeControlsOverlayLayout + + /** + * current instance of [SwipeZonesController] + */ + lateinit var zones: SwipeZonesController + + /** + * main gesture controller + */ + private lateinit var gesture: GestureController + + /** + * main volume keys controller + */ + private lateinit var keys: VolumeKeysController + + /** + * current content view with id [android.R.id.content] + */ + private val contentRoot + get() = window.decorView.findViewById(android.R.id.content) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initialize() + } + + override fun onStart() { + super.onStart() + reAttachOverlays() + } + + override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { + ensureInitialized() + return if ((ev != null) && gesture.submitTouchEvent(ev)) { + true + } else { + super.dispatchTouchEvent(ev) + } + } + + override fun dispatchKeyEvent(ev: KeyEvent?): Boolean { + ensureInitialized() + return if ((ev != null) && keys.onKeyEvent(ev)) { + true + } else { + super.dispatchKeyEvent(ev) + } + } + + /** + * dispatch a touch event to downstream views + * + * @param event the event to dispatch + * @return was the event consumed? + */ + fun dispatchDownstreamTouchEvent(event: MotionEvent) = + super.dispatchTouchEvent(event) + + /** + * ensures that swipe controllers are initialized and attached. + * on some ROMs with SDK <= 23, [onCreate] and [onStart] may not be called correctly. + * see https://github.com/revanced/revanced-patches/issues/446 + */ + private fun ensureInitialized() { + if (!this::config.isInitialized) { + printException { + "swipe controls were not initialized in onCreate, initializing on-the-fly (SDK is ${Build.VERSION.SDK_INT})" + } + initialize() + reAttachOverlays() + } + } + + /** + * initializes controllers, only call once + */ + private fun initialize() { + // create controllers + printDebug { "initializing swipe controls controllers" } + config = SwipeControlsConfigurationProvider(this) + keys = VolumeKeysController(this) + audio = createAudioController() + screen = createScreenController() + + // create overlay + SwipeControlsOverlayLayout(this, config).let { + overlay = it + contentRoot.addView(it) + } + + // create swipe zone controller + zones = SwipeZonesController(this) { + Rectangle( + contentRoot.x.toInt(), + contentRoot.y.toInt(), + contentRoot.width, + contentRoot.height, + ) + } + + // create the gesture controller + gesture = createGestureController() + + // listen for changes in the player type + PlayerType.onChange += this::onPlayerTypeChanged + + // set current instance reference + currentHost = WeakReference(this) + } + + /** + * (re) attaches swipe overlays + */ + private fun reAttachOverlays() { + printDebug { "attaching swipe controls overlay" } + contentRoot.removeView(overlay) + contentRoot.addView(overlay) + } + + // Flag that indicates whether the brightness has been saved and restored default brightness + private var isBrightnessSaved = false + + /** + * called when the player type changes + * + * @param type the new player type + */ + private fun onPlayerTypeChanged(type: PlayerType) { + when { + // If saving and restoring brightness is enabled, and the player type is WATCH_WHILE_FULLSCREEN, + // and brightness has already been saved, then restore the screen brightness + config.shouldSaveAndRestoreBrightness && type == PlayerType.WATCH_WHILE_FULLSCREEN && isBrightnessSaved -> { + screen?.restore() + isBrightnessSaved = false + } + // If saving and restoring brightness is enabled, and brightness has not been saved, + // then save the current screen state, restore default brightness, and mark brightness as saved + config.shouldSaveAndRestoreBrightness && !isBrightnessSaved -> { + screen?.save() + screen?.restoreDefaultBrightness() + isBrightnessSaved = true + } + // If saving and restoring brightness is disabled, simply keep the default brightness + else -> screen?.restoreDefaultBrightness() + } + } + + /** + * create the audio volume controller + */ + private fun createAudioController() = + if (config.enableVolumeControls) { + AudioVolumeController(this) + } else { + null + } + + /** + * create the screen brightness controller instance + */ + private fun createScreenController() = + if (config.enableBrightnessControl) { + ScreenBrightnessController(this) + } else { + null + } + + /** + * create the gesture controller based on settings + */ + private fun createGestureController() = + if (config.shouldEnablePressToSwipe) { + PressToSwipeController(this) + } else { + ClassicSwipeController(this) + } + + companion object { + /** + * the currently active swipe controls host. + * the reference may be null! + */ + @JvmStatic + var currentHost: WeakReference = WeakReference(null) + private set + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/AudioVolumeController.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/AudioVolumeController.kt new file mode 100644 index 0000000000..c8b1884bdd --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/AudioVolumeController.kt @@ -0,0 +1,79 @@ +package app.revanced.extension.youtube.swipecontrols.controller + +import android.content.Context +import android.media.AudioManager +import android.os.Build +import app.revanced.extension.shared.Logger.printException +import app.revanced.extension.youtube.swipecontrols.misc.clamp +import kotlin.properties.Delegates + +/** + * controller to adjust the device volume level + * + * @param context the context to bind the audio service in + * @param targetStream the stream that is being controlled. Must be one of the STREAM_* constants in [AudioManager] + */ +class AudioVolumeController( + context: Context, + private val targetStream: Int = AudioManager.STREAM_MUSIC, +) { + + /** + * audio service connection + */ + private lateinit var audioManager: AudioManager + private var minimumVolumeIndex by Delegates.notNull() + private var maximumVolumeIndex by Delegates.notNull() + + init { + // bind audio service + val mgr = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + if (mgr == null) { + printException { "failed to acquire AUDIO_SERVICE" } + } else { + audioManager = mgr + maximumVolumeIndex = audioManager.getStreamMaxVolume(targetStream) + minimumVolumeIndex = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + audioManager.getStreamMinVolume( + targetStream, + ) + } else { + 0 + } + } + } + + /** + * the current volume, ranging from 0.0 to [maxVolume] + */ + var volume: Int + get() { + // check if initialized correctly + if (!this::audioManager.isInitialized) return 0 + + // get current volume + return currentVolumeIndex - minimumVolumeIndex + } + set(value) { + // check if initialized correctly + if (!this::audioManager.isInitialized) return + + // set new volume + currentVolumeIndex = + (value + minimumVolumeIndex).clamp(minimumVolumeIndex, maximumVolumeIndex) + } + + /** + * the maximum possible volume + */ + val maxVolume: Int + get() = maximumVolumeIndex - minimumVolumeIndex + + /** + * the current volume index of the target stream + */ + private var currentVolumeIndex: Int + get() = audioManager.getStreamVolume(targetStream) + set(value) = audioManager.setStreamVolume(targetStream, value, 0) +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/ScreenBrightnessController.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/ScreenBrightnessController.kt new file mode 100644 index 0000000000..f291bcb415 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/ScreenBrightnessController.kt @@ -0,0 +1,73 @@ +package app.revanced.extension.youtube.swipecontrols.controller + +import android.view.WindowManager +import app.revanced.extension.youtube.swipecontrols.SwipeControlsHostActivity +import app.revanced.extension.youtube.swipecontrols.misc.clamp + +/** + * controller to adjust the screen brightness level + * + * @param host the host activity of which the brightness is adjusted, the main controller instance + */ +class ScreenBrightnessController( + val host: SwipeControlsHostActivity, +) { + + /** + * the current screen brightness in percent, ranging from 0.0 to 100.0 + */ + var screenBrightness: Double + get() = rawScreenBrightness * 100.0 + set(value) { + rawScreenBrightness = (value.toFloat() / 100f).clamp(0f, 1f) + } + + /** + * is the screen brightness set to device- default? + */ + val isDefaultBrightness + get() = (rawScreenBrightness == WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE) + + /** + * restore the screen brightness to the default device brightness + */ + fun restoreDefaultBrightness() { + rawScreenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + } + + // Flag that indicates whether the brightness has been restored + private var isBrightnessRestored = false + + /** + * save the current screen brightness into settings, to be brought back using [restore] + */ + fun save() { + if (isBrightnessRestored) { + // Saves the current screen brightness value into settings + host.config.savedScreenBrightnessValue = rawScreenBrightness + // Reset the flag + isBrightnessRestored = false + } + } + + /** + * restore the screen brightness from settings saved using [save] + */ + fun restore() { + // Restores the screen brightness value from the saved settings + rawScreenBrightness = host.config.savedScreenBrightnessValue + // Mark that brightness has been restored + isBrightnessRestored = true + } + + /** + * wrapper for the raw screen brightness in [WindowManager.LayoutParams.screenBrightness] + */ + var rawScreenBrightness: Float + get() = host.window.attributes.screenBrightness + private set(value) { + val attr = host.window.attributes + attr.screenBrightness = value + host.window.attributes = attr + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/SwipeZonesController.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/SwipeZonesController.kt new file mode 100644 index 0000000000..2c2edb9591 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/SwipeZonesController.kt @@ -0,0 +1,144 @@ +package app.revanced.extension.youtube.swipecontrols.controller + +import android.app.Activity +import android.util.TypedValue +import android.view.ViewGroup +import app.revanced.extension.shared.Utils +import app.revanced.extension.youtube.swipecontrols.misc.Rectangle +import app.revanced.extension.youtube.swipecontrols.misc.applyDimension +import kotlin.math.min + +/** + * Y- Axis: + * -------- 0 + * ^ + * dead | 40dp + * v + * -------- yDeadTop + * ^ + * swipe | + * v + * -------- yDeadBtm + * ^ + * dead | 80dp + * v + * -------- screenHeight + * + * X- Axis: + * 0 xBrigStart xBrigEnd xVolStart xVolEnd screenWidth + * | | | | | | + * | 20dp | 3/8 | 2/8 | 3/8 | 20dp | + * | <------> | <------> | <------> | <------> | <------> | + * | dead | brightness | dead | volume | dead | + * | <--------------------------------> | + * 1/1 + */ +@Suppress("PrivatePropertyName") +class SwipeZonesController( + private val host: Activity, + private val fallbackScreenRect: () -> Rectangle, +) { + /** + * 20dp, in pixels + */ + private val _20dp = 20.applyDimension(host, TypedValue.COMPLEX_UNIT_DIP) + + /** + * 40dp, in pixels + */ + private val _40dp = 40.applyDimension(host, TypedValue.COMPLEX_UNIT_DIP) + + /** + * 80dp, in pixels + */ + private val _80dp = 80.applyDimension(host, TypedValue.COMPLEX_UNIT_DIP) + + /** + * id for R.id.player_view + */ + private val playerViewId = Utils.getResourceIdentifier(host, "player_view", "id") + + /** + * current bounding rectangle of the player + */ + private var playerRect: Rectangle? = null + + /** + * rectangle of the area that is effectively usable for swipe controls + */ + private val effectiveSwipeRect: Rectangle + get() { + maybeAttachPlayerBoundsListener() + val p = if (playerRect != null) playerRect!! else fallbackScreenRect() + return Rectangle( + p.x + _20dp, + p.y + _40dp, + p.width - _20dp, + p.height - _20dp - _80dp, + ) + } + + /** + * the rectangle of the volume control zone + */ + val volume: Rectangle + get() { + val eRect = effectiveSwipeRect + val zoneWidth = (eRect.width * 3) / 8 + return Rectangle( + eRect.right - zoneWidth, + eRect.top, + zoneWidth, + eRect.height, + ) + } + + /** + * the rectangle of the screen brightness control zone + */ + val brightness: Rectangle + get() { + val zoneWidth = (effectiveSwipeRect.width * 3) / 8 + return Rectangle( + effectiveSwipeRect.left, + effectiveSwipeRect.top, + zoneWidth, + effectiveSwipeRect.height, + ) + } + + /** + * try to attach a listener to the player_view and update the player rectangle. + * once a listener is attached, this function does nothing + */ + private fun maybeAttachPlayerBoundsListener() { + if (playerRect != null) return + host.findViewById(playerViewId)?.let { + onPlayerViewLayout(it) + it.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + onPlayerViewLayout(it) + } + } + } + + /** + * update the player rectangle on player_view layout + * + * @param playerView the player view + */ + private fun onPlayerViewLayout(playerView: ViewGroup) { + playerView.getChildAt(0)?.let { playerSurface -> + // the player surface is centered in the player view + // figure out the width of the surface including the padding (same on the left and right side) + // and use that width for the player rectangle size + // this automatically excludes any engagement panel from the rect + val playerWidthWithPadding = playerSurface.width + (playerSurface.x.toInt() * 2) + playerRect = Rectangle( + playerView.x.toInt(), + playerView.y.toInt(), + min(playerView.width, playerWidthWithPadding), + playerView.height, + ) + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/VolumeKeysController.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/VolumeKeysController.kt new file mode 100644 index 0000000000..d2b8788dfd --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/VolumeKeysController.kt @@ -0,0 +1,51 @@ +package app.revanced.extension.youtube.swipecontrols.controller + +import android.view.KeyEvent +import app.revanced.extension.youtube.swipecontrols.SwipeControlsHostActivity + +/** + * controller for custom volume button behaviour + * + * @param controller main controller instance + */ +class VolumeKeysController( + private val controller: SwipeControlsHostActivity, +) { + /** + * key event handler + * + * @param event the key event + * @return consume the event? + */ + fun onKeyEvent(event: KeyEvent): Boolean { + if (!controller.config.overwriteVolumeKeyControls) { + return false + } + + return when (event.keyCode) { + KeyEvent.KEYCODE_VOLUME_DOWN -> + handleVolumeKeyEvent(event, false) + KeyEvent.KEYCODE_VOLUME_UP -> + handleVolumeKeyEvent(event, true) + else -> false + } + } + + /** + * handle a volume up / down key event + * + * @param event the key event + * @param volumeUp was the key pressed the volume up key? + * @return consume the event? + */ + private fun handleVolumeKeyEvent(event: KeyEvent, volumeUp: Boolean): Boolean { + if (event.action == KeyEvent.ACTION_DOWN) { + controller.audio?.apply { + volume += if (volumeUp) 1 else -1 + controller.overlay.onVolumeChanged(volume, maxVolume) + } + } + + return true + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/ClassicSwipeController.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/ClassicSwipeController.kt new file mode 100644 index 0000000000..a0d2c6db64 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/ClassicSwipeController.kt @@ -0,0 +1,117 @@ +package app.revanced.extension.youtube.swipecontrols.controller.gesture + +import android.view.MotionEvent +import app.revanced.extension.youtube.shared.PlayerControlsVisibilityObserver +import app.revanced.extension.youtube.shared.PlayerControlsVisibilityObserverImpl +import app.revanced.extension.youtube.swipecontrols.SwipeControlsHostActivity +import app.revanced.extension.youtube.swipecontrols.controller.gesture.core.BaseGestureController +import app.revanced.extension.youtube.swipecontrols.controller.gesture.core.SwipeDetector +import app.revanced.extension.youtube.swipecontrols.misc.contains +import app.revanced.extension.youtube.swipecontrols.misc.toPoint + +/** + * provides the classic swipe controls experience, as it was with 'XFenster' + * + * @param controller reference to the main swipe controller + */ +class ClassicSwipeController( + private val controller: SwipeControlsHostActivity, +) : BaseGestureController(controller), + PlayerControlsVisibilityObserver by PlayerControlsVisibilityObserverImpl(controller) { + /** + * the last event captured in [onDown] + */ + private var lastOnDownEvent: MotionEvent? = null + + override val shouldForceInterceptEvents: Boolean + get() = currentSwipe == SwipeDetector.SwipeDirection.VERTICAL + + override fun isInSwipeZone(motionEvent: MotionEvent): Boolean { + val inVolumeZone = if (controller.config.enableVolumeControls) { + (motionEvent.toPoint() in controller.zones.volume) + } else { + false + } + val inBrightnessZone = if (controller.config.enableBrightnessControl) { + (motionEvent.toPoint() in controller.zones.brightness) + } else { + false + } + + return inVolumeZone || inBrightnessZone + } + + override fun shouldDropMotion(motionEvent: MotionEvent): Boolean { + // ignore gestures with more than one pointer + // when such a gesture is detected, dispatch the first event of the gesture to downstream + if (motionEvent.pointerCount > 1) { + lastOnDownEvent?.let { + controller.dispatchDownstreamTouchEvent(it) + it.recycle() + } + lastOnDownEvent = null + return true + } + + // ignore gestures when player controls are visible + return arePlayerControlsVisible + } + + override fun onDown(motionEvent: MotionEvent): Boolean { + // save the event for later + lastOnDownEvent?.recycle() + lastOnDownEvent = MotionEvent.obtain(motionEvent) + + // must be inside swipe zone + return isInSwipeZone(motionEvent) + } + + override fun onSingleTapUp(motionEvent: MotionEvent): Boolean { + MotionEvent.obtain(motionEvent).let { + it.action = MotionEvent.ACTION_DOWN + controller.dispatchDownstreamTouchEvent(it) + it.recycle() + } + + return false + } + + override fun onDoubleTapEvent(motionEvent: MotionEvent): Boolean { + MotionEvent.obtain(motionEvent).let { + controller.dispatchDownstreamTouchEvent(it) + it.recycle() + } + + return super.onDoubleTapEvent(motionEvent) + } + + override fun onLongPress(motionEvent: MotionEvent) { + MotionEvent.obtain(motionEvent).let { + controller.dispatchDownstreamTouchEvent(it) + it.recycle() + } + + super.onLongPress(motionEvent) + } + + override fun onSwipe( + from: MotionEvent, + to: MotionEvent, + distanceX: Double, + distanceY: Double, + ): Boolean { + // cancel if not vertical + if (currentSwipe != SwipeDetector.SwipeDirection.VERTICAL) return false + return when (from.toPoint()) { + in controller.zones.volume -> { + scrollVolume(distanceY) + true + } + in controller.zones.brightness -> { + scrollBrightness(distanceY) + true + } + else -> false + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/PressToSwipeController.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/PressToSwipeController.kt new file mode 100644 index 0000000000..12c5b30254 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/PressToSwipeController.kt @@ -0,0 +1,78 @@ +package app.revanced.extension.youtube.swipecontrols.controller.gesture + +import android.view.MotionEvent +import app.revanced.extension.youtube.swipecontrols.SwipeControlsHostActivity +import app.revanced.extension.youtube.swipecontrols.controller.gesture.core.BaseGestureController +import app.revanced.extension.youtube.swipecontrols.controller.gesture.core.SwipeDetector +import app.revanced.extension.youtube.swipecontrols.misc.contains +import app.revanced.extension.youtube.swipecontrols.misc.toPoint + +/** + * provides the press-to-swipe (PtS) swipe controls experience + * + * @param controller reference to the main swipe controller + */ +class PressToSwipeController( + private val controller: SwipeControlsHostActivity, +) : BaseGestureController(controller) { + /** + * monitors if the user is currently in a swipe session. + */ + private var isInSwipeSession = false + + override val shouldForceInterceptEvents: Boolean + get() = currentSwipe == SwipeDetector.SwipeDirection.VERTICAL && isInSwipeSession + + override fun shouldDropMotion(motionEvent: MotionEvent): Boolean = false + + override fun isInSwipeZone(motionEvent: MotionEvent): Boolean { + val inVolumeZone = if (controller.config.enableVolumeControls) { + (motionEvent.toPoint() in controller.zones.volume) + } else { + false + } + val inBrightnessZone = if (controller.config.enableBrightnessControl) { + (motionEvent.toPoint() in controller.zones.brightness) + } else { + false + } + + return inVolumeZone || inBrightnessZone + } + + override fun onUp(motionEvent: MotionEvent) { + super.onUp(motionEvent) + isInSwipeSession = false + } + + override fun onLongPress(motionEvent: MotionEvent) { + // enter swipe session with feedback + isInSwipeSession = true + controller.overlay.onEnterSwipeSession() + + // send GestureDetector a ACTION_CANCEL event so it will handle further events + motionEvent.action = MotionEvent.ACTION_CANCEL + detector.onTouchEvent(motionEvent) + } + + override fun onSwipe( + from: MotionEvent, + to: MotionEvent, + distanceX: Double, + distanceY: Double, + ): Boolean { + // cancel if not in swipe session or vertical + if (!isInSwipeSession || currentSwipe != SwipeDetector.SwipeDirection.VERTICAL) return false + return when (from.toPoint()) { + in controller.zones.volume -> { + scrollVolume(distanceY) + true + } + in controller.zones.brightness -> { + scrollBrightness(distanceY) + true + } + else -> false + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/BaseGestureController.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/BaseGestureController.kt new file mode 100644 index 0000000000..fca1da9f86 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/BaseGestureController.kt @@ -0,0 +1,156 @@ +package app.revanced.extension.youtube.swipecontrols.controller.gesture.core + +import android.view.GestureDetector +import android.view.MotionEvent +import app.revanced.extension.youtube.swipecontrols.SwipeControlsHostActivity + +/** + * the common base of all [GestureController] classes. + * handles most of the boilerplate code needed for gesture detection + * + * @param controller reference to the main swipe controller + */ +abstract class BaseGestureController( + private val controller: SwipeControlsHostActivity, +) : GestureController, + GestureDetector.SimpleOnGestureListener(), + SwipeDetector by SwipeDetectorImpl( + controller.config.swipeMagnitudeThreshold.toDouble(), + ), + VolumeAndBrightnessScroller by VolumeAndBrightnessScrollerImpl( + controller, + controller.audio, + controller.screen, + controller.overlay, + 10, + 1, + ) { + + /** + * the main gesture detector that powers everything + */ + @Suppress("LeakingThis") + protected val detector = GestureDetector(controller, this) + + /** + * were downstream event cancelled already? used in [onScroll] + */ + private var didCancelDownstream = false + + override fun submitTouchEvent(motionEvent: MotionEvent): Boolean { + // ignore if swipe is disabled + if (!controller.config.enableSwipeControls) { + return false + } + + // create a copy of the event so we can modify it + // without causing any issues downstream + val me = MotionEvent.obtain(motionEvent) + + // check if we should drop this motion + val dropped = shouldDropMotion(me) + if (dropped) { + me.action = MotionEvent.ACTION_CANCEL + } + + // send the event to the detector + // if we force intercept events, the event is always consumed + val consumed = detector.onTouchEvent(me) || shouldForceInterceptEvents + + // invoke the custom onUp handler + if (me.action == MotionEvent.ACTION_UP || me.action == MotionEvent.ACTION_CANCEL) { + onUp(me) + } + + // recycle the copy + me.recycle() + + // do not consume dropped events + // or events outside of any swipe zone + return !dropped && consumed && isInSwipeZone(me) + } + + /** + * custom handler for [MotionEvent.ACTION_UP] event, because GestureDetector doesn't offer that :| + * + * @param motionEvent the motion event + */ + open fun onUp(motionEvent: MotionEvent) { + didCancelDownstream = false + resetSwipe() + resetScroller() + } + + override fun onScroll( + from: MotionEvent, + to: MotionEvent, + distanceX: Float, + distanceY: Float, + ): Boolean { + // submit to swipe detector + submitForSwipe(from, to, distanceX, distanceY) + + // call swipe callback if in a swipe + return if (currentSwipe != SwipeDetector.SwipeDirection.NONE) { + val consumed = onSwipe( + from, + to, + distanceX.toDouble(), + distanceY.toDouble(), + ) + + // if the swipe was consumed, cancel downstream events once + if (consumed && !didCancelDownstream) { + didCancelDownstream = true + MotionEvent.obtain(from).let { + it.action = MotionEvent.ACTION_CANCEL + controller.dispatchDownstreamTouchEvent(it) + it.recycle() + } + } + + consumed + } else { + false + } + } + + /** + * should [submitTouchEvent] force- intercept all touch events? + */ + abstract val shouldForceInterceptEvents: Boolean + + /** + * check if provided motion event is in any active swipe zone? + * + * @param motionEvent the event to check + * @return is the event in any active swipe zone? + */ + abstract fun isInSwipeZone(motionEvent: MotionEvent): Boolean + + /** + * check if a touch event should be dropped. + * when a event is dropped, the gesture detector received a [MotionEvent.ACTION_CANCEL] event and the event is not consumed + * + * @param motionEvent the event to check + * @return should the event be dropped? + */ + abstract fun shouldDropMotion(motionEvent: MotionEvent): Boolean + + /** + * handler for swipe events, once a swipe is detected. + * the direction of the swipe can be accessed in [currentSwipe] + * + * @param from start event of the swipe + * @param to end event of the swipe + * @param distanceX the horizontal distance of the swipe + * @param distanceY the vertical distance of the swipe + * @return was the event consumed? + */ + abstract fun onSwipe( + from: MotionEvent, + to: MotionEvent, + distanceX: Double, + distanceY: Double, + ): Boolean +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/GestureController.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/GestureController.kt new file mode 100644 index 0000000000..49da1f210e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/GestureController.kt @@ -0,0 +1,16 @@ +package app.revanced.extension.youtube.swipecontrols.controller.gesture.core + +import android.view.MotionEvent + +/** + * describes a class that accepts motion events and detects gestures + */ +interface GestureController { + /** + * accept a touch event and try to detect the desired gestures using it + * + * @param motionEvent the motion event that was submitted + * @return was a gesture detected? + */ + fun submitTouchEvent(motionEvent: MotionEvent): Boolean +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/SwipeDetector.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/SwipeDetector.kt new file mode 100644 index 0000000000..7d6fa4501f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/SwipeDetector.kt @@ -0,0 +1,94 @@ +package app.revanced.extension.youtube.swipecontrols.controller.gesture.core + +import android.view.MotionEvent +import kotlin.math.abs +import kotlin.math.pow + +/** + * describes a class that can detect swipes and their directionality + */ +interface SwipeDetector { + /** + * the currently detected swipe + */ + val currentSwipe: SwipeDirection + + /** + * submit a onScroll event for swipe detection + * + * @param from start event + * @param to end event + * @param distanceX horizontal scroll distance + * @param distanceY vertical scroll distance + */ + fun submitForSwipe( + from: MotionEvent, + to: MotionEvent, + distanceX: Float, + distanceY: Float, + ) + + /** + * reset the swipe detection + */ + fun resetSwipe() + + /** + * direction of a swipe + */ + enum class SwipeDirection { + /** + * swipe has no direction or no swipe + */ + NONE, + + /** + * swipe along the X- Axes + */ + HORIZONTAL, + + /** + * swipe along the Y- Axes + */ + VERTICAL, + } +} + +/** + * detector that can detect swipes and their directionality + * + * @param swipeMagnitudeThreshold minimum magnitude before a swipe is detected as such + */ +class SwipeDetectorImpl( + private val swipeMagnitudeThreshold: Double, +) : SwipeDetector { + override var currentSwipe = SwipeDetector.SwipeDirection.NONE + + override fun submitForSwipe( + from: MotionEvent, + to: MotionEvent, + distanceX: Float, + distanceY: Float, + ) { + if (currentSwipe == SwipeDetector.SwipeDirection.NONE) { + // no swipe direction was detected yet, try to detect one + // if the user did not swipe far enough, we cannot detect what direction they swiped + // so we wait until a greater distance was swiped + // NOTE: sqrt() can be high- cost, so using squared magnitudes here + val deltaX = abs(to.x - from.x) + val deltaY = abs(to.y - from.y) + val swipeMagnitudeSquared = deltaX.pow(2) + deltaY.pow(2) + if (swipeMagnitudeSquared > swipeMagnitudeThreshold.pow(2)) { + currentSwipe = if (deltaY > deltaX) { + SwipeDetector.SwipeDirection.VERTICAL + } else { + SwipeDetector.SwipeDirection.HORIZONTAL + } + } + } + } + + override fun resetSwipe() { + currentSwipe = SwipeDetector.SwipeDirection.NONE + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/VolumeAndBrightnessScroller.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/VolumeAndBrightnessScroller.kt new file mode 100644 index 0000000000..e398696df6 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/controller/gesture/core/VolumeAndBrightnessScroller.kt @@ -0,0 +1,102 @@ +package app.revanced.extension.youtube.swipecontrols.controller.gesture.core + +import android.content.Context +import android.util.TypedValue +import app.revanced.extension.youtube.swipecontrols.controller.AudioVolumeController +import app.revanced.extension.youtube.swipecontrols.controller.ScreenBrightnessController +import app.revanced.extension.youtube.swipecontrols.misc.ScrollDistanceHelper +import app.revanced.extension.youtube.swipecontrols.misc.SwipeControlsOverlay +import app.revanced.extension.youtube.swipecontrols.misc.applyDimension + +/** + * describes a class that controls volume and brightness based on scrolling events + */ +interface VolumeAndBrightnessScroller { + /** + * submit a scroll for volume adjustment + * + * @param distance the scroll distance + */ + fun scrollVolume(distance: Double) + + /** + * submit a scroll for brightness adjustment + * + * @param distance the scroll distance + */ + fun scrollBrightness(distance: Double) + + /** + * reset all scroll distances to zero + */ + fun resetScroller() +} + +/** + * handles scrolling of volume and brightness, adjusts them using the provided controllers and updates the overlay + * + * @param context context to create the scrollers in + * @param volumeController volume controller instance. if null, volume control is disabled + * @param screenController screen brightness controller instance. if null, brightness control is disabled + * @param overlayController overlay controller instance + * @param volumeDistance unit distance for volume scrolling, in dp + * @param brightnessDistance unit distance for brightness scrolling, in dp + */ +class VolumeAndBrightnessScrollerImpl( + context: Context, + private val volumeController: AudioVolumeController?, + private val screenController: ScreenBrightnessController?, + private val overlayController: SwipeControlsOverlay, + volumeDistance: Int = 10, + brightnessDistance: Int = 1, +) : VolumeAndBrightnessScroller { + + // region volume + private val volumeScroller = + ScrollDistanceHelper( + volumeDistance.applyDimension( + context, + TypedValue.COMPLEX_UNIT_DIP, + ), + ) { _, _, direction -> + volumeController?.run { + volume += direction + overlayController.onVolumeChanged(volume, maxVolume) + } + } + + override fun scrollVolume(distance: Double) = volumeScroller.add(distance) + //endregion + + //region brightness + private val brightnessScroller = + ScrollDistanceHelper( + brightnessDistance.applyDimension( + context, + TypedValue.COMPLEX_UNIT_DIP, + ), + ) { _, _, direction -> + screenController?.run { + val shouldAdjustBrightness = if (host.config.shouldLowestValueEnableAutoBrightness) { + screenBrightness > 0 || direction > 0 + } else { + screenBrightness >= 0 || direction >= 0 + } + + if (shouldAdjustBrightness) { + screenBrightness += direction + } else { + restoreDefaultBrightness() + } + overlayController.onBrightnessChanged(screenBrightness) + } + } + + override fun scrollBrightness(distance: Double) = brightnessScroller.add(distance) + //endregion + + override fun resetScroller() { + volumeScroller.reset() + brightnessScroller.reset() + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/Point.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/Point.kt new file mode 100644 index 0000000000..8400fedaf6 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/Point.kt @@ -0,0 +1,17 @@ +package app.revanced.extension.youtube.swipecontrols.misc + +import android.view.MotionEvent + +/** + * a simple 2D point class + */ +data class Point( + val x: Int, + val y: Int, +) + +/** + * convert the motion event coordinates to a point + */ +fun MotionEvent.toPoint(): Point = + Point(x.toInt(), y.toInt()) diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/Rectangle.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/Rectangle.kt new file mode 100644 index 0000000000..723834318f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/Rectangle.kt @@ -0,0 +1,22 @@ +package app.revanced.extension.youtube.swipecontrols.misc + +/** + * a simple rectangle class + */ +data class Rectangle( + val x: Int, + val y: Int, + val width: Int, + val height: Int, +) { + val left = x + val right = x + width + val top = y + val bottom = y + height +} + +/** + * is the point within this rectangle? + */ +operator fun Rectangle.contains(p: Point): Boolean = + p.x in left..right && p.y in top..bottom diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/ScrollDistanceHelper.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/ScrollDistanceHelper.kt new file mode 100644 index 0000000000..67512f0f43 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/ScrollDistanceHelper.kt @@ -0,0 +1,56 @@ +package app.revanced.extension.youtube.swipecontrols.misc + +import kotlin.math.abs +import kotlin.math.sign + +/** + * helper for scaling onScroll handler + * + * @param unitDistance absolute distance after which the callback is invoked + * @param callback callback function for when unit distance is reached + */ +class ScrollDistanceHelper( + private val unitDistance: Int, + private val callback: (oldDistance: Double, newDistance: Double, direction: Int) -> Unit, +) { + + /** + * total distance scrolled + */ + private var scrolledDistance: Double = 0.0 + + /** + * add a scrolled distance to the total. + * if the [unitDistance] is reached, this function will also invoke the callback + * + * @param distance the distance to add + */ + fun add(distance: Double) { + scrolledDistance += distance + + // invoke the callback if we scrolled far enough + while (abs(scrolledDistance) >= unitDistance) { + val oldDistance = scrolledDistance + subtractUnitDistance() + callback.invoke( + oldDistance, + scrolledDistance, + sign(scrolledDistance).toInt(), + ) + } + } + + /** + * reset the distance scrolled to zero + */ + fun reset() { + scrolledDistance = 0.0 + } + + /** + * subtract the [unitDistance] from the total [scrolledDistance] + */ + private fun subtractUnitDistance() { + scrolledDistance -= (unitDistance * sign(scrolledDistance)) + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/SwipeControlsOverlay.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/SwipeControlsOverlay.kt new file mode 100644 index 0000000000..5e863a3c58 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/SwipeControlsOverlay.kt @@ -0,0 +1,26 @@ +package app.revanced.extension.youtube.swipecontrols.misc + +/** + * Interface for all overlays for swipe controls + */ +interface SwipeControlsOverlay { + /** + * called when the currently set volume level was changed + * + * @param newVolume the new volume level + * @param maximumVolume the maximum volume index + */ + fun onVolumeChanged(newVolume: Int, maximumVolume: Int) + + /** + * called when the currently set screen brightness was changed + * + * @param brightness the new screen brightness, in percent (range 0.0 - 100.0) + */ + fun onBrightnessChanged(brightness: Double) + + /** + * called when a new swipe- session has started + */ + fun onEnterSwipeSession() +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/SwipeControlsUtils.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/SwipeControlsUtils.kt new file mode 100644 index 0000000000..409d7ad4bc --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/misc/SwipeControlsUtils.kt @@ -0,0 +1,25 @@ +package app.revanced.extension.youtube.swipecontrols.misc + +import android.content.Context +import android.util.TypedValue +import kotlin.math.roundToInt + +fun Float.clamp(min: Float, max: Float): Float { + if (this < min) return min + if (this > max) return max + return this +} + +fun Int.clamp(min: Int, max: Int): Int { + if (this < min) return min + if (this > max) return max + return this +} + +fun Int.applyDimension(context: Context, unit: Int): Int { + return TypedValue.applyDimension( + unit, + this.toFloat(), + context.resources.displayMetrics, + ).roundToInt() +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/views/SwipeControlsOverlayLayout.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/views/SwipeControlsOverlayLayout.kt new file mode 100644 index 0000000000..9972c5233e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/swipecontrols/views/SwipeControlsOverlayLayout.kt @@ -0,0 +1,145 @@ +package app.revanced.extension.youtube.swipecontrols.views + +import android.content.Context +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.os.Handler +import android.os.Looper +import android.util.TypedValue +import android.view.HapticFeedbackConstants +import android.view.View +import android.view.ViewGroup +import android.widget.RelativeLayout +import android.widget.TextView +import app.revanced.extension.shared.StringRef.str +import app.revanced.extension.shared.Utils +import app.revanced.extension.youtube.swipecontrols.SwipeControlsConfigurationProvider +import app.revanced.extension.youtube.swipecontrols.misc.SwipeControlsOverlay +import app.revanced.extension.youtube.swipecontrols.misc.applyDimension +import kotlin.math.round + +/** + * main overlay layout for volume and brightness swipe controls + * + * @param context context to create in + */ +class SwipeControlsOverlayLayout( + context: Context, + private val config: SwipeControlsConfigurationProvider, +) : RelativeLayout(context), SwipeControlsOverlay { + /** + * DO NOT use this, for tools only + */ + constructor(context: Context) : this(context, SwipeControlsConfigurationProvider(context)) + + private val feedbackTextView: TextView + private val autoBrightnessIcon: Drawable + private val manualBrightnessIcon: Drawable + private val mutedVolumeIcon: Drawable + private val normalVolumeIcon: Drawable + + private fun getDrawable(name: String, width: Int, height: Int): Drawable { + return resources.getDrawable( + Utils.getResourceIdentifier(context, name, "drawable"), + context.theme, + ).apply { + setTint(config.overlayForegroundColor) + setBounds( + 0, + 0, + width, + height, + ) + } + } + + init { + // init views + val feedbackTextViewPadding = 2.applyDimension(context, TypedValue.COMPLEX_UNIT_DIP) + val compoundIconPadding = 4.applyDimension(context, TypedValue.COMPLEX_UNIT_DIP) + feedbackTextView = TextView(context).apply { + layoutParams = LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + addRule(CENTER_IN_PARENT, TRUE) + setPadding( + feedbackTextViewPadding, + feedbackTextViewPadding, + feedbackTextViewPadding, + feedbackTextViewPadding, + ) + } + background = GradientDrawable().apply { + cornerRadius = 8f + setColor(config.overlayTextBackgroundColor) + } + setTextColor(config.overlayForegroundColor) + setTextSize(TypedValue.COMPLEX_UNIT_SP, config.overlayTextSize.toFloat()) + compoundDrawablePadding = compoundIconPadding + visibility = GONE + } + addView(feedbackTextView) + + // get icons scaled, assuming square icons + val iconHeight = round(feedbackTextView.lineHeight * .8).toInt() + autoBrightnessIcon = getDrawable("revanced_ic_sc_brightness_auto", iconHeight, iconHeight) + manualBrightnessIcon = getDrawable("revanced_ic_sc_brightness_manual", iconHeight, iconHeight) + mutedVolumeIcon = getDrawable("revanced_ic_sc_volume_mute", iconHeight, iconHeight) + normalVolumeIcon = getDrawable("revanced_ic_sc_volume_normal", iconHeight, iconHeight) + } + + private val feedbackHideHandler = Handler(Looper.getMainLooper()) + private val feedbackHideCallback = Runnable { + feedbackTextView.visibility = View.GONE + } + + /** + * show the feedback view for a given time + * + * @param message the message to show + * @param icon the icon to use + */ + private fun showFeedbackView(message: String, icon: Drawable) { + feedbackHideHandler.removeCallbacks(feedbackHideCallback) + feedbackHideHandler.postDelayed(feedbackHideCallback, config.overlayShowTimeoutMillis) + feedbackTextView.apply { + text = message + setCompoundDrawablesRelative( + icon, + null, + null, + null, + ) + visibility = VISIBLE + } + } + + override fun onVolumeChanged(newVolume: Int, maximumVolume: Int) { + showFeedbackView( + "$newVolume", + if (newVolume > 0) normalVolumeIcon else mutedVolumeIcon, + ) + } + + override fun onBrightnessChanged(brightness: Double) { + if (config.shouldLowestValueEnableAutoBrightness && brightness <= 0) { + showFeedbackView( + str("revanced_swipe_lowest_value_enable_auto_brightness_overlay_text"), + autoBrightnessIcon, + ) + } else if (brightness >= 0) { + showFeedbackView("${round(brightness).toInt()}%", manualBrightnessIcon) + } + } + + override fun onEnterSwipeSession() { + if (config.shouldEnableHapticFeedback) { + @Suppress("DEPRECATION") + performHapticFeedback( + HapticFeedbackConstants.LONG_PRESS, + HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING, + ) + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/CopyVideoUrlButton.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/CopyVideoUrlButton.java new file mode 100644 index 0000000000..5b877cdc64 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/CopyVideoUrlButton.java @@ -0,0 +1,54 @@ +package app.revanced.extension.youtube.videoplayer; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import app.revanced.extension.youtube.patches.CopyVideoUrlPatch; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.shared.Logger; + +@SuppressWarnings("unused") +public class CopyVideoUrlButton extends PlayerControlButton { + @Nullable + private static CopyVideoUrlButton instance; + + public CopyVideoUrlButton(ViewGroup viewGroup) { + super( + viewGroup, + "revanced_copy_video_url_button", + Settings.COPY_VIDEO_URL, + view -> CopyVideoUrlPatch.copyUrl(false), + view -> { + CopyVideoUrlPatch.copyUrl(true); + return true; + } + ); + } + + /** + * Injection point. + */ + public static void initializeButton(View view) { + try { + instance = new CopyVideoUrlButton((ViewGroup) view); + } catch (Exception ex) { + Logger.printException(() -> "initializeButton failure", ex); + } + } + + /** + * injection point + */ + public static void changeVisibilityImmediate(boolean visible) { + if (instance != null) instance.setVisibilityImmediate(visible); + } + + /** + * injection point + */ + public static void changeVisibility(boolean visible, boolean animated) { + if (instance != null) instance.setVisibility(visible, animated); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/CopyVideoUrlTimestampButton.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/CopyVideoUrlTimestampButton.java new file mode 100644 index 0000000000..ebb3f518a1 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/CopyVideoUrlTimestampButton.java @@ -0,0 +1,54 @@ +package app.revanced.extension.youtube.videoplayer; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import app.revanced.extension.youtube.patches.CopyVideoUrlPatch; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.shared.Logger; + +@SuppressWarnings("unused") +public class CopyVideoUrlTimestampButton extends PlayerControlButton { + @Nullable + private static CopyVideoUrlTimestampButton instance; + + public CopyVideoUrlTimestampButton(ViewGroup bottomControlsViewGroup) { + super( + bottomControlsViewGroup, + "revanced_copy_video_url_timestamp_button", + Settings.COPY_VIDEO_URL_TIMESTAMP, + view -> CopyVideoUrlPatch.copyUrl(true), + view -> { + CopyVideoUrlPatch.copyUrl(false); + return true; + } + ); + } + + /** + * Injection point. + */ + public static void initializeButton(View bottomControlsViewGroup) { + try { + instance = new CopyVideoUrlTimestampButton((ViewGroup) bottomControlsViewGroup); + } catch (Exception ex) { + Logger.printException(() -> "initializeButton failure", ex); + } + } + + /** + * injection point + */ + public static void changeVisibilityImmediate(boolean visible) { + if (instance != null) instance.setVisibilityImmediate(visible); + } + + /** + * injection point + */ + public static void changeVisibility(boolean visible, boolean animated) { + if (instance != null) instance.setVisibility(visible, animated); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/ExternalDownloadButton.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/ExternalDownloadButton.java new file mode 100644 index 0000000000..bfd6d30e5e --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/ExternalDownloadButton.java @@ -0,0 +1,60 @@ +package app.revanced.extension.youtube.videoplayer; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.youtube.patches.DownloadsPatch; +import app.revanced.extension.youtube.patches.VideoInformation; +import app.revanced.extension.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class ExternalDownloadButton extends PlayerControlButton { + @Nullable + private static ExternalDownloadButton instance; + + public ExternalDownloadButton(ViewGroup viewGroup) { + super( + viewGroup, + "revanced_external_download_button", + Settings.EXTERNAL_DOWNLOADER, + ExternalDownloadButton::onDownloadClick, + null + ); + } + + /** + * Injection point. + */ + public static void initializeButton(View view) { + try { + instance = new ExternalDownloadButton((ViewGroup) view); + } catch (Exception ex) { + Logger.printException(() -> "initializeButton failure", ex); + } + } + + /** + * injection point + */ + public static void changeVisibilityImmediate(boolean visible) { + if (instance != null) instance.setVisibilityImmediate(visible); + } + + /** + * injection point + */ + public static void changeVisibility(boolean visible, boolean animated) { + if (instance != null) instance.setVisibility(visible, animated); + } + + private static void onDownloadClick(View view) { + DownloadsPatch.launchExternalDownloader( + VideoInformation.getVideoId(), + view.getContext(), + true); + } +} + diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/PlaybackSpeedDialogButton.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/PlaybackSpeedDialogButton.java new file mode 100644 index 0000000000..2954eac154 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/PlaybackSpeedDialogButton.java @@ -0,0 +1,51 @@ +package app.revanced.extension.youtube.videoplayer; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import app.revanced.extension.youtube.patches.playback.speed.CustomPlaybackSpeedPatch; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.shared.Logger; + +@SuppressWarnings("unused") +public class PlaybackSpeedDialogButton extends PlayerControlButton { + @Nullable + private static PlaybackSpeedDialogButton instance; + + public PlaybackSpeedDialogButton(ViewGroup viewGroup) { + super( + viewGroup, + "revanced_playback_speed_dialog_button", + Settings.PLAYBACK_SPEED_DIALOG_BUTTON, + view -> CustomPlaybackSpeedPatch.showOldPlaybackSpeedMenu(), + null + ); + } + + /** + * Injection point. + */ + public static void initializeButton(View view) { + try { + instance = new PlaybackSpeedDialogButton((ViewGroup) view); + } catch (Exception ex) { + Logger.printException(() -> "initializeButton failure", ex); + } + } + + /** + * injection point + */ + public static void changeVisibilityImmediate(boolean visible) { + if (instance != null) instance.setVisibilityImmediate(visible); + } + + /** + * injection point + */ + public static void changeVisibility(boolean visible, boolean animated) { + if (instance != null) instance.setVisibility(visible, animated); + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/PlayerControlButton.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/PlayerControlButton.java new file mode 100644 index 0000000000..71e176c1f0 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/videoplayer/PlayerControlButton.java @@ -0,0 +1,117 @@ +package app.revanced.extension.youtube.videoplayer; + +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.ref.WeakReference; +import java.util.Objects; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; +import app.revanced.extension.shared.settings.BooleanSetting; + +public abstract class PlayerControlButton { + private static final Animation fadeIn; + private static final Animation fadeOut; + private static final Animation fadeOutImmediate; + + private final WeakReference buttonRef; + protected final BooleanSetting setting; + protected boolean isVisible; + + static { + // TODO: check if these durations are correct. + fadeIn = Utils.getResourceAnimation("fade_in"); + fadeIn.setDuration(Utils.getResourceInteger("fade_duration_fast")); + + fadeOut = Utils.getResourceAnimation("fade_out"); + fadeOut.setDuration(Utils.getResourceInteger("fade_duration_scheduled")); + + fadeOutImmediate = Utils.getResourceAnimation("abc_fade_out"); + fadeOutImmediate.setDuration(Utils.getResourceInteger("fade_duration_fast")); + } + + @NonNull + public static Animation getButtonFadeIn() { + return fadeIn; + } + + @NonNull + public static Animation getButtonFadeOut() { + return fadeOut; + } + + @NonNull + public static Animation getButtonFadeOutImmediately() { + return fadeOutImmediate; + } + + public PlayerControlButton(@NonNull ViewGroup bottomControlsViewGroup, @NonNull String imageViewButtonId, + @NonNull BooleanSetting booleanSetting, @NonNull View.OnClickListener onClickListener, + @Nullable View.OnLongClickListener longClickListener) { + Logger.printDebug(() -> "Initializing button: " + imageViewButtonId); + + ImageView imageView = Objects.requireNonNull(bottomControlsViewGroup.findViewById( + Utils.getResourceIdentifier(imageViewButtonId, "id") + )); + imageView.setVisibility(View.GONE); + + imageView.setOnClickListener(onClickListener); + if (longClickListener != null) { + imageView.setOnLongClickListener(longClickListener); + } + + setting = booleanSetting; + buttonRef = new WeakReference<>(imageView); + } + + public void setVisibilityImmediate(boolean visible) { + if (visible) { + // Fix button flickering, by pushing this call to the back of + // the main thread and letting other layout code run first. + Utils.runOnMainThread(() -> private_setVisibility(true, false)); + } else { + private_setVisibility(false, false); + } + } + + public void setVisibility(boolean visible, boolean animated) { + // Ignore this call, otherwise with full screen thumbnails the buttons are visible while seeking. + if (visible && !animated) return; + + private_setVisibility(visible, animated); + } + + private void private_setVisibility(boolean visible, boolean animated) { + try { + if (isVisible == visible) return; + isVisible = visible; + + ImageView iView = buttonRef.get(); + if (iView == null) { + return; + } + + if (visible && setting.get()) { + iView.clearAnimation(); + if (animated) { + iView.startAnimation(PlayerControlButton.getButtonFadeIn()); + } + iView.setVisibility(View.VISIBLE); + } else if (iView.getVisibility() == View.VISIBLE) { + iView.clearAnimation(); + if (animated) { + iView.startAnimation(PlayerControlButton.getButtonFadeOut()); + } + iView.setVisibility(View.GONE); + } + } catch (Exception ex) { + Logger.printException(() -> "setVisibility failure", ex); + } + } +} diff --git a/extensions/shared/stub/build.gradle.kts b/extensions/shared/stub/build.gradle.kts new file mode 100644 index 0000000000..c1cc5794c0 --- /dev/null +++ b/extensions/shared/stub/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id(libs.plugins.android.library.get().pluginId) +} + +android { + namespace = "app.revanced.extension" + compileSdk = 33 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} diff --git a/extensions/shared/stub/src/main/AndroidManifest.xml b/extensions/shared/stub/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..568741e54f --- /dev/null +++ b/extensions/shared/stub/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/extensions/shared/stub/src/main/java/android/support/constraint/ConstraintLayout.java b/extensions/shared/stub/src/main/java/android/support/constraint/ConstraintLayout.java new file mode 100644 index 0000000000..2861235a5f --- /dev/null +++ b/extensions/shared/stub/src/main/java/android/support/constraint/ConstraintLayout.java @@ -0,0 +1,20 @@ +package android.support.constraint; + +import android.content.Context; +import android.view.ViewGroup; + +/** + * "CompileOnly" class + * because android.support.android.support.constraint.ConstraintLayout is deprecated + * in favour of androidx.constraintlayout.widget.ConstraintLayout. + * + * This class will not be included and "replaced" by the real package's class. + */ +public class ConstraintLayout extends ViewGroup { + public ConstraintLayout(Context context) { + super(context); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { } +} diff --git a/extensions/shared/stub/src/main/java/android/support/v7/widget/RecyclerView.java b/extensions/shared/stub/src/main/java/android/support/v7/widget/RecyclerView.java new file mode 100644 index 0000000000..d902dbfc81 --- /dev/null +++ b/extensions/shared/stub/src/main/java/android/support/v7/widget/RecyclerView.java @@ -0,0 +1,19 @@ +package android.support.v7.widget; + +import android.content.Context; +import android.view.View; + +public class RecyclerView extends View { + + public RecyclerView(Context context) { + super(context); + } + + public View getChildAt(@SuppressWarnings("unused") final int index) { + return null; + } + + public int getChildCount() { + return 0; + } +} diff --git a/extensions/shared/stub/src/main/java/com/bytedance/ies/ugc/aweme/commercialize/compliance/personalization/AdPersonalizationActivity.java b/extensions/shared/stub/src/main/java/com/bytedance/ies/ugc/aweme/commercialize/compliance/personalization/AdPersonalizationActivity.java new file mode 100644 index 0000000000..ecf204d221 --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/bytedance/ies/ugc/aweme/commercialize/compliance/personalization/AdPersonalizationActivity.java @@ -0,0 +1,6 @@ +package com.bytedance.ies.ugc.aweme.commercialize.compliance.personalization; + +import android.app.Activity; + +//Dummy class +public class AdPersonalizationActivity extends Activity { } diff --git a/extensions/shared/stub/src/main/java/com/google/android/apps/youtube/app/ui/SlimMetadataScrollableButtonContainerLayout.java b/extensions/shared/stub/src/main/java/com/google/android/apps/youtube/app/ui/SlimMetadataScrollableButtonContainerLayout.java new file mode 100644 index 0000000000..9b314f9975 --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/google/android/apps/youtube/app/ui/SlimMetadataScrollableButtonContainerLayout.java @@ -0,0 +1,25 @@ +package com.google.android.apps.youtube.app.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.ViewGroup; + +public class SlimMetadataScrollableButtonContainerLayout extends ViewGroup { + + public SlimMetadataScrollableButtonContainerLayout(Context context) { + super(context); + } + + public SlimMetadataScrollableButtonContainerLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SlimMetadataScrollableButtonContainerLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onLayout(boolean b, int i, int i1, int i2, int i3) { + + } +} diff --git a/extensions/shared/stub/src/main/java/com/google/android/libraries/youtube/rendering/ui/pivotbar/PivotBar.java b/extensions/shared/stub/src/main/java/com/google/android/libraries/youtube/rendering/ui/pivotbar/PivotBar.java new file mode 100644 index 0000000000..f275effdb9 --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/google/android/libraries/youtube/rendering/ui/pivotbar/PivotBar.java @@ -0,0 +1,10 @@ +package com.google.android.libraries.youtube.rendering.ui.pivotbar; + +import android.content.Context; +import android.widget.HorizontalScrollView; + +public class PivotBar extends HorizontalScrollView { + public PivotBar(Context context) { + super(context); + } +} diff --git a/extensions/shared/stub/src/main/java/com/google/protos/youtube/api/innertube/InnertubeContext$ClientInfo.java b/extensions/shared/stub/src/main/java/com/google/protos/youtube/api/innertube/InnertubeContext$ClientInfo.java new file mode 100644 index 0000000000..f517608f20 --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/google/protos/youtube/api/innertube/InnertubeContext$ClientInfo.java @@ -0,0 +1,5 @@ +package com.google.protos.youtube.api.innertube; + +public class InnertubeContext$ClientInfo { + public int r; +} diff --git a/extensions/shared/stub/src/main/java/com/laurencedawson/reddit_sync/ui/activities/WebViewActivity.java b/extensions/shared/stub/src/main/java/com/laurencedawson/reddit_sync/ui/activities/WebViewActivity.java new file mode 100644 index 0000000000..a33226a85f --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/laurencedawson/reddit_sync/ui/activities/WebViewActivity.java @@ -0,0 +1,6 @@ +package com.laurencedawson.reddit_sync.ui.activities; + +import android.app.Activity; + +public class WebViewActivity extends Activity { +} diff --git a/extensions/shared/stub/src/main/java/com/reddit/domain/model/ILink.java b/extensions/shared/stub/src/main/java/com/reddit/domain/model/ILink.java new file mode 100644 index 0000000000..f9cbb955cb --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/reddit/domain/model/ILink.java @@ -0,0 +1,7 @@ +package com.reddit.domain.model; + +public class ILink { + public boolean getPromoted() { + throw new UnsupportedOperationException("Stub"); + } +} diff --git a/extensions/shared/stub/src/main/java/com/rubenmayayo/reddit/ui/activities/WebViewActivity.java b/extensions/shared/stub/src/main/java/com/rubenmayayo/reddit/ui/activities/WebViewActivity.java new file mode 100644 index 0000000000..d0c5850729 --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/rubenmayayo/reddit/ui/activities/WebViewActivity.java @@ -0,0 +1,6 @@ +package com.rubenmayayo.reddit.ui.activities; + +import android.app.Activity; + +public class WebViewActivity extends Activity { +} diff --git a/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/Aweme.java b/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/Aweme.java new file mode 100644 index 0000000000..e1ea9af6cf --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/Aweme.java @@ -0,0 +1,36 @@ +package com.ss.android.ugc.aweme.feed.model; + +//Dummy class +public class Aweme { + public boolean isAd() { + throw new UnsupportedOperationException("Stub"); + } + + public boolean isLive() { + throw new UnsupportedOperationException("Stub"); + } + + public boolean isLiveReplay() { + throw new UnsupportedOperationException("Stub"); + } + + public boolean isWithPromotionalMusic() { + throw new UnsupportedOperationException("Stub"); + } + + public boolean getIsTikTokStory() { + throw new UnsupportedOperationException("Stub"); + } + + public boolean isImage() { + throw new UnsupportedOperationException("Stub"); + } + + public boolean isPhotoMode() { + throw new UnsupportedOperationException("Stub"); + } + + public AwemeStatistics getStatistics() { + throw new UnsupportedOperationException("Stub"); + } +} diff --git a/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/AwemeStatistics.java b/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/AwemeStatistics.java new file mode 100644 index 0000000000..a9123e67cf --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/AwemeStatistics.java @@ -0,0 +1,10 @@ +package com.ss.android.ugc.aweme.feed.model; + +public class AwemeStatistics { + public long getPlayCount() { + throw new UnsupportedOperationException("Stub"); + } + public long getDiggCount() { + throw new UnsupportedOperationException("Stub"); + } +} diff --git a/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/FeedItemList.java b/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/FeedItemList.java new file mode 100644 index 0000000000..139159ea40 --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/ss/android/ugc/aweme/feed/model/FeedItemList.java @@ -0,0 +1,8 @@ +package com.ss.android.ugc.aweme.feed.model; + +import java.util.List; + +//Dummy class +public class FeedItemList { + public List items; +} diff --git a/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/TimelineObject.java b/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/TimelineObject.java new file mode 100644 index 0000000000..8bb2c885d4 --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/TimelineObject.java @@ -0,0 +1,8 @@ +package com.tumblr.rumblr.model; + +public class TimelineObject { + public final T getData() { + throw new UnsupportedOperationException("Stub"); + } + +} diff --git a/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/TimelineObjectType.java b/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/TimelineObjectType.java new file mode 100644 index 0000000000..f9b7d7abca --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/TimelineObjectType.java @@ -0,0 +1,4 @@ +package com.tumblr.rumblr.model; + +public enum TimelineObjectType { +} diff --git a/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/Timelineable.java b/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/Timelineable.java new file mode 100644 index 0000000000..bf84887def --- /dev/null +++ b/extensions/shared/stub/src/main/java/com/tumblr/rumblr/model/Timelineable.java @@ -0,0 +1,5 @@ +package com.tumblr.rumblr.model; + +public interface Timelineable { + TimelineObjectType getTimelineObjectType(); +} diff --git a/extensions/shared/stub/src/main/java/org/chromium/net/UrlRequest.java b/extensions/shared/stub/src/main/java/org/chromium/net/UrlRequest.java new file mode 100644 index 0000000000..4c02f1a400 --- /dev/null +++ b/extensions/shared/stub/src/main/java/org/chromium/net/UrlRequest.java @@ -0,0 +1,8 @@ +package org.chromium.net; + +public abstract class UrlRequest { + public abstract class Builder { + public abstract Builder addHeader(String name, String value); + public abstract UrlRequest build(); + } +} diff --git a/extensions/shared/stub/src/main/java/org/chromium/net/UrlResponseInfo.java b/extensions/shared/stub/src/main/java/org/chromium/net/UrlResponseInfo.java new file mode 100644 index 0000000000..8e341247dc --- /dev/null +++ b/extensions/shared/stub/src/main/java/org/chromium/net/UrlResponseInfo.java @@ -0,0 +1,12 @@ +package org.chromium.net; + +//dummy class +public abstract class UrlResponseInfo { + + public abstract String getUrl(); + + public abstract int getHttpStatusCode(); + + // Add additional existing methods, if needed. + +} diff --git a/extensions/shared/stub/src/main/java/org/chromium/net/impl/CronetUrlRequest.java b/extensions/shared/stub/src/main/java/org/chromium/net/impl/CronetUrlRequest.java new file mode 100644 index 0000000000..fa0dcacd98 --- /dev/null +++ b/extensions/shared/stub/src/main/java/org/chromium/net/impl/CronetUrlRequest.java @@ -0,0 +1,11 @@ +package org.chromium.net.impl; + +import org.chromium.net.UrlRequest; + +public abstract class CronetUrlRequest extends UrlRequest { + + /** + * Method is added by patch. + */ + public abstract String getHookedUrl(); +} diff --git a/extensions/shared/stub/src/main/java/tv/twitch/android/feature/settings/menu/SettingsMenuGroup.java b/extensions/shared/stub/src/main/java/tv/twitch/android/feature/settings/menu/SettingsMenuGroup.java new file mode 100644 index 0000000000..72495e4b53 --- /dev/null +++ b/extensions/shared/stub/src/main/java/tv/twitch/android/feature/settings/menu/SettingsMenuGroup.java @@ -0,0 +1,14 @@ +package tv.twitch.android.feature.settings.menu; + +import java.util.List; + +// Dummy +public final class SettingsMenuGroup { + public SettingsMenuGroup(List settingsMenuItems) { + throw new UnsupportedOperationException("Stub"); + } + + public List getSettingsMenuItems() { + throw new UnsupportedOperationException("Stub"); + } +} \ No newline at end of file diff --git a/extensions/shared/stub/src/main/java/tv/twitch/android/settings/SettingsActivity.java b/extensions/shared/stub/src/main/java/tv/twitch/android/settings/SettingsActivity.java new file mode 100644 index 0000000000..dcf42ab30d --- /dev/null +++ b/extensions/shared/stub/src/main/java/tv/twitch/android/settings/SettingsActivity.java @@ -0,0 +1,5 @@ +package tv.twitch.android.settings; + +import android.app.Activity; + +public class SettingsActivity extends Activity {} diff --git a/extensions/shared/stub/src/main/java/tv/twitch/android/shared/chat/util/ClickableUsernameSpan.java b/extensions/shared/stub/src/main/java/tv/twitch/android/shared/chat/util/ClickableUsernameSpan.java new file mode 100644 index 0000000000..0322bdba2a --- /dev/null +++ b/extensions/shared/stub/src/main/java/tv/twitch/android/shared/chat/util/ClickableUsernameSpan.java @@ -0,0 +1,9 @@ +package tv.twitch.android.shared.chat.util; + +import android.text.style.ClickableSpan; +import android.view.View; + +public final class ClickableUsernameSpan extends ClickableSpan { + @Override + public void onClick(View widget) {} +} \ No newline at end of file diff --git a/extensions/spoof-wifi/build.gradle.kts b/extensions/spoof-wifi/build.gradle.kts new file mode 100644 index 0000000000..9a2728690c --- /dev/null +++ b/extensions/spoof-wifi/build.gradle.kts @@ -0,0 +1,11 @@ +extension { + name = "extensions/all/connectivity/wifi/spoof/spoof-wifi.rve" +} + +android { + namespace = "app.revanced.extension" +} + +dependencies { + compileOnly(libs.annotation) +} diff --git a/extensions/spoof-wifi/src/main/AndroidManifest.xml b/extensions/spoof-wifi/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..7425a54c56 --- /dev/null +++ b/extensions/spoof-wifi/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/extensions/spoof-wifi/src/main/java/app/revanced/extension/all/connectivity/wifi/spoof/SpoofWifiPatch.java b/extensions/spoof-wifi/src/main/java/app/revanced/extension/all/connectivity/wifi/spoof/SpoofWifiPatch.java new file mode 100644 index 0000000000..5f00bd730f --- /dev/null +++ b/extensions/spoof-wifi/src/main/java/app/revanced/extension/all/connectivity/wifi/spoof/SpoofWifiPatch.java @@ -0,0 +1,424 @@ +package app.revanced.extension.all.connectivity.wifi.spoof; + +import android.app.PendingIntent; +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkInfo; +import android.net.NetworkRequest; +import android.os.Build; +import android.os.Handler; + +import androidx.annotation.RequiresApi; + +/** @noinspection deprecation, unused */ +public class SpoofWifiPatch { + + // Used to check what the (real or fake) active network is (take a look at `hasTransport`). + private static ConnectivityManager CONNECTIVITY_MANAGER; + + // If Wifi is not enabled, these are types that would pretend to be Wifi for android.net.Network (lower index = higher priority). + // This does not apply to android.net.NetworkInfo, because we can pretend that Wifi is always active there. + // + // VPN should be a fallback, because Reverse Tethering uses VPN. + private static final int[] FAKE_FALLBACK_NETWORKS = { NetworkCapabilities.TRANSPORT_ETHERNET, NetworkCapabilities.TRANSPORT_VPN }; + + // In order to initialize our own ConnectivityManager, if it isn't initialized yet. + public static Object getSystemService(Context context, String name) { + Object result = context.getSystemService(name); + if (CONNECTIVITY_MANAGER == null) { + if (Context.CONNECTIVITY_SERVICE.equals(name)) { + CONNECTIVITY_MANAGER = (ConnectivityManager) result; + } else { + CONNECTIVITY_MANAGER = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + } + } + return result; + } + + // In order to initialize our own ConnectivityManager, if it isn't initialized yet. + public static Object getSystemService(Context context, Class serviceClass) { + Object result = context.getSystemService(serviceClass); + if (CONNECTIVITY_MANAGER == null) { + if (serviceClass == ConnectivityManager.class) { + CONNECTIVITY_MANAGER = (ConnectivityManager) result; + } else { + CONNECTIVITY_MANAGER = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + } + } + return result; + } + + // Simply always return Wifi as active network. + public static NetworkInfo getActiveNetworkInfo(ConnectivityManager connectivityManager) { + for (NetworkInfo networkInfo : connectivityManager.getAllNetworkInfo()) { + if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { + return networkInfo; + } + } + return connectivityManager.getActiveNetworkInfo(); + } + + // Pretend Wifi is always connected. + public static boolean isConnected(NetworkInfo networkInfo) { + if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { + return true; + } + return networkInfo.isConnected(); + } + + // Pretend Wifi is always connected. + public static boolean isConnectedOrConnecting(NetworkInfo networkInfo) { + if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { + return true; + } + return networkInfo.isConnectedOrConnecting(); + } + + // Pretend Wifi is always available. + public static boolean isAvailable(NetworkInfo networkInfo) { + if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { + return true; + } + return networkInfo.isAvailable(); + } + + // Pretend Wifi is always connected. + public static NetworkInfo.State getState(NetworkInfo networkInfo) { + if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { + return NetworkInfo.State.CONNECTED; + } + return networkInfo.getState(); + } + + // Pretend Wifi is always connected. + public static NetworkInfo.DetailedState getDetailedState(NetworkInfo networkInfo) { + if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { + return NetworkInfo.DetailedState.CONNECTED; + } + return networkInfo.getDetailedState(); + } + + // Pretend Wifi is enabled, so connection isn't metered. + public static boolean isActiveNetworkMetered(ConnectivityManager connectivityManager) { + return false; + } + + // Returns the Wifi network, if Wifi is enabled. + // Otherwise if one of our fallbacks has a connection, return them. + // And as a last resort, return the default active network. + public static Network getActiveNetwork(ConnectivityManager connectivityManager) { + Network[] prioritizedNetworks = new Network[FAKE_FALLBACK_NETWORKS.length]; + for (Network network : connectivityManager.getAllNetworks()) { + NetworkCapabilities networkCapabilities = connectivityManager.getNetworkCapabilities(network); + if (networkCapabilities == null) { + continue; + } + if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + return network; + } + if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { + for (int i = 0; i < FAKE_FALLBACK_NETWORKS.length; i++) { + int transportType = FAKE_FALLBACK_NETWORKS[i]; + if (networkCapabilities.hasTransport(transportType)) { + prioritizedNetworks[i] = network; + break; + } + } + } + } + for (Network network : prioritizedNetworks) { + if (network != null) { + return network; + } + } + return connectivityManager.getActiveNetwork(); + } + + // If the given network is a real or fake Wifi connection, return a Wifi network. + // Otherwise fallback to default implementation. + public static NetworkInfo getNetworkInfo(ConnectivityManager connectivityManager, Network network) { + NetworkCapabilities networkCapabilities = connectivityManager.getNetworkCapabilities(network); + if (networkCapabilities != null && hasTransport(networkCapabilities, NetworkCapabilities.TRANSPORT_WIFI)) { + for (NetworkInfo networkInfo : connectivityManager.getAllNetworkInfo()) { + if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { + return networkInfo; + } + } + } + return connectivityManager.getNetworkInfo(network); + } + + // If we are checking if the NetworkCapabilities use Wifi, return yes if + // - it is a real Wifi connection, + // - or the NetworkCapabilities are from a network pretending being a Wifi network. + // Otherwise fallback to default implementation. + public static boolean hasTransport(NetworkCapabilities networkCapabilities, int transportType) { + if (transportType == NetworkCapabilities.TRANSPORT_WIFI) { + if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + return true; + } + if (CONNECTIVITY_MANAGER != null) { + Network activeNetwork = getActiveNetwork(CONNECTIVITY_MANAGER); + NetworkCapabilities activeNetworkCapabilities = CONNECTIVITY_MANAGER.getNetworkCapabilities(activeNetwork); + if (activeNetworkCapabilities != null) { + for (int fallbackTransportType : FAKE_FALLBACK_NETWORKS) { + if (activeNetworkCapabilities.hasTransport(fallbackTransportType) && networkCapabilities.hasTransport(fallbackTransportType)) { + return true; + } + } + } + } + } + return networkCapabilities.hasTransport(transportType); + } + + // If the given network is a real or fake Wifi connection, pretend it has a connection (and some other things). + public static boolean hasCapability(NetworkCapabilities networkCapabilities, int capability) { + if (hasTransport(networkCapabilities, NetworkCapabilities.TRANSPORT_WIFI) && ( + capability == NetworkCapabilities.NET_CAPABILITY_INTERNET + || capability == NetworkCapabilities.NET_CAPABILITY_FOREGROUND + || capability == NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED + || capability == NetworkCapabilities.NET_CAPABILITY_NOT_METERED + || capability == NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED + || capability == NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING + || capability == NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED + || capability == NetworkCapabilities.NET_CAPABILITY_NOT_VPN + || capability == NetworkCapabilities.NET_CAPABILITY_TRUSTED + || capability == NetworkCapabilities.NET_CAPABILITY_VALIDATED)) { + return true; + } + return networkCapabilities.hasCapability(capability); + } + + // If it waits for Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + @RequiresApi(api = Build.VERSION_CODES.S) + public static void registerBestMatchingNetworkCallback(ConnectivityManager connectivityManager, NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback, Handler handler) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.of(handler), + () -> connectivityManager.registerBestMatchingNetworkCallback(request, networkCallback, handler) + ); + } + + // If it waits for Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + @RequiresApi(api = Build.VERSION_CODES.N) + public static void registerDefaultNetworkCallback(ConnectivityManager connectivityManager, ConnectivityManager.NetworkCallback networkCallback) { + Utils.networkCallback( + connectivityManager, + Utils.Option.empty(), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.empty(), + () -> connectivityManager.registerDefaultNetworkCallback(networkCallback) + ); + } + + // If it waits for Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + @RequiresApi(api = Build.VERSION_CODES.O) + public static void registerDefaultNetworkCallback(ConnectivityManager connectivityManager, ConnectivityManager.NetworkCallback networkCallback, Handler handler) { + Utils.networkCallback( + connectivityManager, + Utils.Option.empty(), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.of(handler), + () -> connectivityManager.registerDefaultNetworkCallback(networkCallback, handler) + ); + } + + // If it waits for Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + public static void registerNetworkCallback(ConnectivityManager connectivityManager, NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.empty(), + () -> connectivityManager.registerNetworkCallback(request, networkCallback) + ); + } + + // If it waits for Wifi connectivity, pretend it is fulfilled immediately. + public static void registerNetworkCallback(ConnectivityManager connectivityManager, NetworkRequest request, PendingIntent operation) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.empty(), + Utils.Option.of(operation), + Utils.Option.empty(), + () -> connectivityManager.registerNetworkCallback(request, operation) + ); + } + + // If it waits for Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + @RequiresApi(api = Build.VERSION_CODES.O) + public static void registerNetworkCallback(ConnectivityManager connectivityManager, NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback, Handler handler) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.of(handler), + () -> connectivityManager.registerNetworkCallback(request, networkCallback, handler) + ); + } + + // If it requests Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + public static void requestNetwork(ConnectivityManager connectivityManager, NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.empty(), + () -> connectivityManager.requestNetwork(request, networkCallback) + ); + } + + // If it requests Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + @RequiresApi(api = Build.VERSION_CODES.O) + public static void requestNetwork(ConnectivityManager connectivityManager, NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback, int timeoutMs) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.empty(), + () -> connectivityManager.requestNetwork(request, networkCallback, timeoutMs) + ); + } + + // If it requests Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + @RequiresApi(api = Build.VERSION_CODES.O) + public static void requestNetwork(ConnectivityManager connectivityManager, NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback, Handler handler) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.of(handler), + () -> connectivityManager.requestNetwork(request, networkCallback, handler) + ); + } + + // If it requests Wifi connectivity, pretend it is fulfilled immediately. + public static void requestNetwork(ConnectivityManager connectivityManager, NetworkRequest request, PendingIntent operation) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.empty(), + Utils.Option.of(operation), + Utils.Option.empty(), + () -> connectivityManager.requestNetwork(request, operation) + ); + } + + // If it requests Wifi connectivity, pretend it is fulfilled immediately if we have an active network. + @RequiresApi(api = Build.VERSION_CODES.O) + public static void requestNetwork(ConnectivityManager connectivityManager, NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback, Handler handler, int timeoutMs) { + Utils.networkCallback( + connectivityManager, + Utils.Option.of(request), + Utils.Option.of(networkCallback), + Utils.Option.empty(), + Utils.Option.of(handler), + () -> connectivityManager.requestNetwork(request, networkCallback, handler, timeoutMs) + ); + } + + public static void unregisterNetworkCallback(ConnectivityManager connectivityManager, ConnectivityManager.NetworkCallback networkCallback) { + try { + connectivityManager.unregisterNetworkCallback(networkCallback); + } catch (IllegalArgumentException ignore) { + // ignore: NetworkCallback was not registered + } + } + + public static void unregisterNetworkCallback(ConnectivityManager connectivityManager, PendingIntent operation) { + try { + connectivityManager.unregisterNetworkCallback(operation); + } catch (IllegalArgumentException ignore) { + // ignore: PendingIntent was not registered + } + } + + private static class Utils { + private static class Option { + private final T value; + private final boolean isPresent; + + private Option(T value, boolean isPresent) { + this.value = value; + this.isPresent = isPresent; + } + + private static Option of(T value) { + return new Option<>(value, true); + } + + private static Option empty() { + return new Option<>(null, false); + } + } + + private static void networkCallback( + ConnectivityManager connectivityManager, + Option request, + Option networkCallback, + Option operation, + Option handler, + Runnable fallback + ) { + if(!request.isPresent || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && request.value != null && requestsWifiNetwork(request.value))) { + Runnable runnable = null; + if (networkCallback.isPresent && networkCallback.value != null) { + Network network = activeWifiNetwork(connectivityManager); + if (network != null) { + runnable = () -> networkCallback.value.onAvailable(network); + } + } else if (operation.isPresent && operation.value != null) { + runnable = () -> { + try { + operation.value.send(); + } catch (PendingIntent.CanceledException ignore) {} + }; + } + if (runnable != null) { + if (handler.isPresent) { + if (handler.value != null) { + handler.value.post(runnable); + return; + } + } else { + runnable.run(); + return; + } + } + } + fallback.run(); + } + + // Returns an active (maybe fake) Wifi network if there is one, otherwise null. + private static Network activeWifiNetwork(ConnectivityManager connectivityManager) { + Network network = getActiveNetwork(connectivityManager); + NetworkCapabilities networkCapabilities = connectivityManager.getNetworkCapabilities(network); + if (networkCapabilities != null && hasTransport(networkCapabilities, NetworkCapabilities.TRANSPORT_WIFI)) { + return network; + } + return null; + } + + // Whether a Wifi network with connection is requested. + @RequiresApi(api = Build.VERSION_CODES.P) + private static boolean requestsWifiNetwork(NetworkRequest request) { + return request.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + && (request.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + || request.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)); + } + } +}