diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d885484..0419272e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,106 @@ +# [1.14.0-dev.12](https://github.com/ReVanced/revanced-integrations/compare/v1.14.0-dev.11...v1.14.0-dev.12) (2024-09-17) + + +### Bug Fixes + +* **YouTube:** Fix issues related to playback by replace streaming data ([#680](https://github.com/ReVanced/revanced-integrations/issues/680)) ([0468235](https://github.com/ReVanced/revanced-integrations/commit/04682353af9831d312a82264a8944268c7901db7)) + +# [1.14.0-dev.11](https://github.com/ReVanced/revanced-integrations/compare/v1.14.0-dev.10...v1.14.0-dev.11) (2024-09-17) + + +### Features + +* **YouTube - Hide Shorts components:** Hide 'Use this sound' button ([#691](https://github.com/ReVanced/revanced-integrations/issues/691)) ([6f3d2ff](https://github.com/ReVanced/revanced-integrations/commit/6f3d2ffb0d65ec819038050dfabe1432f87ce360)) + +# [1.14.0-dev.10](https://github.com/ReVanced/revanced-integrations/compare/v1.14.0-dev.9...v1.14.0-dev.10) (2024-09-11) + + +### Bug Fixes + +* **YouTube - Return YouTube Dislike:** Show correct value when swiping back to prior Short and disliking ([2eb5e3a](https://github.com/ReVanced/revanced-integrations/commit/2eb5e3afebe374a86e9da521d6441402130f0fd0)) + +# [1.14.0-dev.9](https://github.com/ReVanced/revanced-integrations/compare/v1.14.0-dev.8...v1.14.0-dev.9) (2024-09-09) + + +### Bug Fixes + +* **YouTube - SponsorBlock:** Add summary text to 'view my segments' button ([0f5dfb4](https://github.com/ReVanced/revanced-integrations/commit/0f5dfb4e76337da7e086a08b59aed7881de56a31)) + + +### Features + +* **YouTube:** Add donation link to settings about screen ([#688](https://github.com/ReVanced/revanced-integrations/issues/688)) ([b816c45](https://github.com/ReVanced/revanced-integrations/commit/b816c45838769c6b3df7147d091696cb3ee9789e)) + +# [1.14.0-dev.8](https://github.com/ReVanced/revanced-integrations/compare/v1.14.0-dev.7...v1.14.0-dev.8) (2024-09-06) + + +### Bug Fixes + +* **YouTube - Check environment patch:** Show if patched apk is too old, if the install source is not Manager or ADB ([18048f3](https://github.com/ReVanced/revanced-integrations/commit/18048f33243c4a877cf8b055d89fc04c4b963e0c)) + +# [1.14.0-dev.7](https://github.com/ReVanced/revanced-integrations/compare/v1.14.0-dev.6...v1.14.0-dev.7) (2024-09-06) + + +### Bug Fixes + +* **YouTube - Check environment patch:** Allow adb installs even if patched more than 30 minutes ago ([5adf8bd](https://github.com/ReVanced/revanced-integrations/commit/5adf8bdd67c67502f5bc2912247e1eb1cec8a33d)) + +# [1.14.0-dev.6](https://github.com/ReVanced/revanced-integrations/compare/v1.14.0-dev.5...v1.14.0-dev.6) (2024-09-06) + + +### Bug Fixes + +* **YouTube - Check environment patch:** Use app install/update time instead of current time ([#687](https://github.com/ReVanced/revanced-integrations/issues/687)) ([b0d82b0](https://github.com/ReVanced/revanced-integrations/commit/b0d82b016eeacca324b906037d1857b81f577b53)) + +# [1.14.0-dev.5](https://github.com/ReVanced/revanced-integrations/compare/v1.14.0-dev.4...v1.14.0-dev.5) (2024-09-06) + + +### Features + +* Add `Check environment` patch ([#683](https://github.com/ReVanced/revanced-integrations/issues/683)) ([e856455](https://github.com/ReVanced/revanced-integrations/commit/e85645528336162e16acf89f7b9f029762972c72)) + +# [1.14.0-dev.4](https://github.com/ReVanced/revanced-integrations/compare/v1.14.0-dev.3...v1.14.0-dev.4) (2024-09-01) + + +### Bug Fixes + +* **YouTube - GmsCore support:** Show an error toast if GmsCore is included with root mounted installation ([#686](https://github.com/ReVanced/revanced-integrations/issues/686)) ([a4848be](https://github.com/ReVanced/revanced-integrations/commit/a4848be653fae3e03972254fe48a7b76e561e5a6)) + +# [1.14.0-dev.3](https://github.com/ReVanced/revanced-integrations/compare/v1.14.0-dev.2...v1.14.0-dev.3) (2024-09-01) + + +### Bug Fixes + +* **YouTube - ReturnYouTubeDislike:** Show estimated like count for videos with hidden likes ([#684](https://github.com/ReVanced/revanced-integrations/issues/684)) ([27d2b60](https://github.com/ReVanced/revanced-integrations/commit/27d2b60444ff5bcc84a1889e2cacf1750532b6ad)) + +# [1.14.0-dev.2](https://github.com/ReVanced/revanced-integrations/compare/v1.14.0-dev.1...v1.14.0-dev.2) (2024-08-30) + + +### Features + +* **YouTube - Keyword filter:** Add syntax to match whole keywords and not substrings ([#681](https://github.com/ReVanced/revanced-integrations/issues/681)) ([5314dd9](https://github.com/ReVanced/revanced-integrations/commit/5314dd90d16dc8565331c4cddce114956d85a173)) + +# [1.14.0-dev.1](https://github.com/ReVanced/revanced-integrations/compare/v1.13.1-dev.2...v1.14.0-dev.1) (2024-08-22) + + +### Features + +* **YouTube - Spoof client:** Allow forcing AVC codec with iOS ([#679](https://github.com/ReVanced/revanced-integrations/issues/679)) ([2c471f3](https://github.com/ReVanced/revanced-integrations/commit/2c471f39c229af940b7c0890a228bdf01bdc8c39)) + +## [1.13.1-dev.2](https://github.com/ReVanced/revanced-integrations/compare/v1.13.1-dev.1...v1.13.1-dev.2) (2024-08-20) + + +### Bug Fixes + +* **YouTube - Hide layout components:** Hide new kind of community post ([#678](https://github.com/ReVanced/revanced-integrations/issues/678)) ([6be257a](https://github.com/ReVanced/revanced-integrations/commit/6be257a7a66aaa67c187d71530d6773c06a41993)) + +## [1.13.1-dev.1](https://github.com/ReVanced/revanced-integrations/compare/v1.13.0...v1.13.1-dev.1) (2024-08-20) + + +### Bug Fixes + +* **YouTube - SponsorBlock:** Handle if the user enters an invalid number into any SB settings ([01f084d](https://github.com/ReVanced/revanced-integrations/commit/01f084d87af6a2b1bc0581b1adbb6dfdfff75d82)) + # [1.13.0](https://github.com/ReVanced/revanced-integrations/compare/v1.12.0...v1.13.0) (2024-08-15) diff --git a/app/src/main/java/app/revanced/integrations/shared/GmsCoreSupport.java b/app/src/main/java/app/revanced/integrations/shared/GmsCoreSupport.java index 16f0ed0c..cdd474a9 100644 --- a/app/src/main/java/app/revanced/integrations/shared/GmsCoreSupport.java +++ b/app/src/main/java/app/revanced/integrations/shared/GmsCoreSupport.java @@ -24,6 +24,7 @@ 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 @@ -53,18 +54,15 @@ public class GmsCoreSupport { String dialogMessageRef, String positiveButtonStringRef, DialogInterface.OnClickListener onPositiveClickListener) { - // Use a delay to allow the activity to finish initializing. - // Otherwise, if device is in dark mode the dialog is shown with wrong color scheme. - Utils.runOnMainThreadDelayed(() -> { - new AlertDialog.Builder(context) - .setIconAttribute(android.R.attr.alertDialogIcon) - .setTitle(str("gms_core_dialog_title")) - .setMessage(str(dialogMessageRef)) - .setPositiveButton(str(positiveButtonStringRef), onPositiveClickListener) - // Allow using back button to skip the action, just in case the check can never be satisfied. - .setCancelable(true) - .show(); - }, 100); + // 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); } /** @@ -73,6 +71,21 @@ public class GmsCoreSupport { @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(); diff --git a/app/src/main/java/app/revanced/integrations/shared/Logger.java b/app/src/main/java/app/revanced/integrations/shared/Logger.java index 25885050..b3729ef7 100644 --- a/app/src/main/java/app/revanced/integrations/shared/Logger.java +++ b/app/src/main/java/app/revanced/integrations/shared/Logger.java @@ -1,24 +1,21 @@ package app.revanced.integrations.shared; -import static app.revanced.integrations.shared.settings.BaseSettings.DEBUG; -import static app.revanced.integrations.shared.settings.BaseSettings.DEBUG_STACKTRACE; -import static app.revanced.integrations.shared.settings.BaseSettings.DEBUG_TOAST_ON_ERROR; - import android.util.Log; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import app.revanced.integrations.shared.settings.BaseSettings; import java.io.PrintWriter; import java.io.StringWriter; -import app.revanced.integrations.shared.settings.BaseSettings; +import static app.revanced.integrations.shared.settings.BaseSettings.*; public class Logger { /** * Log messages using lambdas. */ + @FunctionalInterface public interface LogMessage { @NonNull String buildMessageString(); @@ -59,19 +56,33 @@ public class Logger { * 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()) { - var messageString = message.buildMessageString(); + String logMessage = message.buildMessageString(); + String logTag = REVANCED_LOG_PREFIX + message.findOuterClassSimpleName(); if (DEBUG_STACKTRACE.get()) { - var builder = new StringBuilder(messageString); + var builder = new StringBuilder(logMessage); var sw = new StringWriter(); new Throwable().printStackTrace(new PrintWriter(sw)); builder.append('\n').append(sw); - messageString = builder.toString(); + logMessage = builder.toString(); } - Log.d(REVANCED_LOG_PREFIX + message.findOuterClassSimpleName(), messageString); + if (ex == null) { + Log.d(logTag, logMessage); + } else { + Log.d(logTag, logMessage, ex); + } } } diff --git a/app/src/main/java/app/revanced/integrations/shared/Utils.java b/app/src/main/java/app/revanced/integrations/shared/Utils.java index 4b13a787..22ed1e06 100644 --- a/app/src/main/java/app/revanced/integrations/shared/Utils.java +++ b/app/src/main/java/app/revanced/integrations/shared/Utils.java @@ -1,6 +1,10 @@ package app.revanced.integrations.shared; import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; import android.content.Context; import android.content.Intent; import android.content.pm.PackageInfo; @@ -8,6 +12,7 @@ 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; @@ -363,6 +368,99 @@ public class Utils { 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 requirement. + */ + @SuppressWarnings("deprecation") + public static 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 */ diff --git a/app/src/main/java/app/revanced/integrations/shared/checks/Check.java b/app/src/main/java/app/revanced/integrations/shared/checks/Check.java new file mode 100644 index 00000000..a9497d5b --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/shared/checks/Check.java @@ -0,0 +1,164 @@ +package app.revanced.integrations.shared.checks; + +import static android.text.Html.FROM_HTML_MODE_COMPACT; +import static app.revanced.integrations.shared.StringRef.str; +import static app.revanced.integrations.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.integrations.shared.Logger; +import app.revanced.integrations.shared.Utils; +import app.revanced.integrations.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(""); + + 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/app/src/main/java/app/revanced/integrations/shared/checks/CheckEnvironmentPatch.java b/app/src/main/java/app/revanced/integrations/shared/checks/CheckEnvironmentPatch.java new file mode 100644 index 00000000..c2106a1d --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/shared/checks/CheckEnvironmentPatch.java @@ -0,0 +1,345 @@ +package app.revanced.integrations.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.integrations.shared.Logger; +import app.revanced.integrations.shared.Utils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.*; + +import static app.revanced.integrations.shared.StringRef.str; +import static app.revanced.integrations.shared.checks.Check.debugAlwaysShowWarning; +import static app.revanced.integrations.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("ODM_SKU", Build.ODM_SKU, PATCH_ODM_SKU) & + buildFieldEqualsHash("PRODUCT", Build.PRODUCT, PATCH_PRODUCT) & + buildFieldEqualsHash("RADIO", Build.RADIO, PATCH_RADIO) & + buildFieldEqualsHash("SKU", Build.SKU, PATCH_SKU) & + buildFieldEqualsHash("SOC_MANUFACTURER", Build.SOC_MANUFACTURER, PATCH_SOC_MANUFACTURER) & + buildFieldEqualsHash("SOC_MODEL", Build.SOC_MODEL, PATCH_SOC_MODEL) & + 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/app/src/main/java/app/revanced/integrations/shared/checks/PatchInfo.java b/app/src/main/java/app/revanced/integrations/shared/checks/PatchInfo.java new file mode 100644 index 00000000..6ebf4d8f --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/shared/checks/PatchInfo.java @@ -0,0 +1,33 @@ +package app.revanced.integrations.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_ODM_SKU = ""; + static String PATCH_PRODUCT = ""; + static String PATCH_RADIO = ""; + static String PATCH_SERIAL = ""; + static String PATCH_SKU = ""; + static String PATCH_SOC_MANUFACTURER = ""; + static String PATCH_SOC_MODEL = ""; + static String PATCH_TAGS = ""; + static String PATCH_TYPE = ""; + static String PATCH_USER = ""; + } +} diff --git a/app/src/main/java/app/revanced/integrations/shared/settings/preference/ReVancedAboutPreference.java b/app/src/main/java/app/revanced/integrations/shared/settings/preference/ReVancedAboutPreference.java index f5911a01..a39b24db 100644 --- a/app/src/main/java/app/revanced/integrations/shared/settings/preference/ReVancedAboutPreference.java +++ b/app/src/main/java/app/revanced/integrations/shared/settings/preference/ReVancedAboutPreference.java @@ -1,5 +1,6 @@ package app.revanced.integrations.shared.settings.preference; +import static app.revanced.integrations.shared.StringRef.sf; import static app.revanced.integrations.shared.StringRef.str; import static app.revanced.integrations.youtube.requests.Route.Method.GET; @@ -71,7 +72,7 @@ public class ReVancedAboutPreference extends Preference { return Color.BLACK; } - private String createDialogHtml(ReVancedSocialLink[] socialLinks) { + private String createDialogHtml(WebLink[] socialLinks) { final boolean isNetworkConnected = Utils.isNetworkConnected(); StringBuilder builder = new StringBuilder(); @@ -122,7 +123,7 @@ public class ReVancedAboutPreference extends Preference { .append(""); builder.append("
"); - for (ReVancedSocialLink social : socialLinks) { + for (WebLink social : socialLinks) { builder.append("
"); builder.append(String.format("%s", social.url, social.name)); builder.append("
"); @@ -151,7 +152,7 @@ public class ReVancedAboutPreference extends Preference { } private void fetchLinksAndShowDialog(@Nullable ProgressDialog progress) { - ReVancedSocialLink[] socialLinks = SocialLinksRoutes.fetchSocialLinks(); + WebLink[] socialLinks = SocialLinksRoutes.fetchSocialLinks(); String htmlDialog = createDialogHtml(socialLinks); Utils.runOnMainThreadNowOrLater(() -> { @@ -221,19 +222,19 @@ class WebViewDialog extends Dialog { } } -class ReVancedSocialLink { +class WebLink { final boolean preferred; final String name; final String url; - ReVancedSocialLink(JSONObject json) throws JSONException { + WebLink(JSONObject json) throws JSONException { this(json.getBoolean("preferred"), json.getString("name"), json.getString("url") ); } - ReVancedSocialLink(boolean preferred, String name, String url) { + WebLink(boolean preferred, String name, String url) { this.preferred = preferred; this.name = name; this.url = url; @@ -251,24 +252,33 @@ class ReVancedSocialLink { } 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 ReVancedSocialLink[] NO_CONNECTION_STATIC_LINKS = { - new ReVancedSocialLink(true, "ReVanced.app", "https://revanced.app") + 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 ReVancedSocialLink[] fetchedLinks; + private static volatile WebLink[] fetchedLinks; static boolean hasFetchedLinks() { return fetchedLinks != null; } - static ReVancedSocialLink[] fetchSocialLinks() { + static WebLink[] fetchSocialLinks() { try { if (hasFetchedLinks()) return fetchedLinks; @@ -290,14 +300,17 @@ class SocialLinksRoutes { JSONObject json = Requester.parseJSONObjectAndDisconnect(connection); JSONArray socials = json.getJSONArray("socials"); - List links = new ArrayList<>(); + List links = new ArrayList<>(); + + links.add(DONATE_LINK); // Show donate link first. for (int i = 0, length = socials.length(); i < length; i++) { - ReVancedSocialLink link = new ReVancedSocialLink(socials.getJSONObject(i)); + WebLink link = new WebLink(socials.getJSONObject(i)); links.add(link); } + Logger.printDebug(() -> "links: " + links); - return fetchedLinks = links.toArray(new ReVancedSocialLink[0]); + return fetchedLinks = links.toArray(new WebLink[0]); } catch (SocketTimeoutException ex) { Logger.printInfo(() -> "Could not fetch social links", ex); // No toast. diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/CheckWatchHistoryDomainNameResolutionPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/CheckWatchHistoryDomainNameResolutionPatch.java index 48c8fd8c..da294d72 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/CheckWatchHistoryDomainNameResolutionPatch.java +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/CheckWatchHistoryDomainNameResolutionPatch.java @@ -55,7 +55,7 @@ public class CheckWatchHistoryDomainNameResolutionPatch { } Utils.runOnMainThread(() -> { - var alertDialog = new android.app.AlertDialog.Builder(context) + 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) @@ -64,9 +64,9 @@ public class CheckWatchHistoryDomainNameResolutionPatch { }).setNegativeButton(str("revanced_check_watch_history_domain_name_dialog_ignore"), (dialog, which) -> { Settings.CHECK_WATCH_HISTORY_DOMAIN_NAME.save(false); dialog.dismiss(); - }) - .setCancelable(false) - .show(); + }).create(); + + Utils.showDialog(context, alert, false, null); }); } catch (Exception ex) { Logger.printException(() -> "checkDnsResolver failure", ex); diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/ReturnYouTubeDislikePatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/ReturnYouTubeDislikePatch.java index 0fb84829..ec52ef07 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/ReturnYouTubeDislikePatch.java +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/ReturnYouTubeDislikePatch.java @@ -225,7 +225,6 @@ public class ReturnYouTubeDislikePatch { return original; } - final CharSequence replacement; if (conversionContextString.contains("segmented_like_dislike_button.eml")) { // Regular video. ReturnYouTubeDislike videoData = currentVideoData; @@ -235,46 +234,62 @@ public class ReturnYouTubeDislikePatch { if (!(original instanceof Spanned)) { original = new SpannableString(original); } - replacement = videoData.getDislikesSpanForRegularVideo((Spanned) original, + return videoData.getDislikesSpanForRegularVideo((Spanned) original, true, isRollingNumber); - } else if (!isRollingNumber && conversionContextString.contains("|shorts_dislike_button.eml|")) { - // Litho Shorts player. - if (!Settings.RYD_SHORTS.get() || Settings.HIDE_SHORTS_DISLIKE_BUTTON.get()) { - // Must clear the current video here, otherwise if the user opens a regular video - // then opens a litho short (while keeping the regular video on screen), then closes the short, - // the original video may show the incorrect dislike value. - clearData(); - 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) { - 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"); - } - replacement = videoData.getDislikeSpanForShort((Spanned) original); - } else { - return original; } - return replacement; + 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 // @@ -597,6 +612,7 @@ public class ReturnYouTubeDislikePatch { 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; @@ -648,6 +664,7 @@ public class ReturnYouTubeDislikePatch { 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. @@ -657,6 +674,7 @@ public class ReturnYouTubeDislikePatch { clearData(); return; } + Logger.printDebug(() -> "New litho Shorts video id: " + videoId); ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(videoId); videoData.setVideoIdIsShort(true); diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/announcements/AnnouncementsPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/announcements/AnnouncementsPatch.java index eec599ec..225dc206 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/announcements/AnnouncementsPatch.java +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/announcements/AnnouncementsPatch.java @@ -1,6 +1,7 @@ package app.revanced.integrations.youtube.patches.announcements; import android.app.Activity; +import android.app.AlertDialog; import android.os.Build; import android.text.Html; import android.text.method.LinkMovementMethod; @@ -103,8 +104,6 @@ public final class AnnouncementsPatch { // 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); @@ -112,7 +111,7 @@ public final class AnnouncementsPatch { Utils.runOnMainThread(() -> { // Show the announcement. - var alertDialog = new android.app.AlertDialog.Builder(context) + var alert = new AlertDialog.Builder(context) .setTitle(finalTitle) .setMessage(finalMessage) .setIcon(finalLevel.icon) @@ -123,11 +122,13 @@ public final class AnnouncementsPatch { dialog.dismiss(); }) .setCancelable(false) - .show(); + .create(); - // Make links clickable. - ((TextView)alertDialog.findViewById(android.R.id.message)) - .setMovementMethod(LinkMovementMethod.getInstance()); + 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"; diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/components/KeywordContentFilter.java b/app/src/main/java/app/revanced/integrations/youtube/patches/components/KeywordContentFilter.java index 4e0e6f58..3185036c 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/components/KeywordContentFilter.java +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/components/KeywordContentFilter.java @@ -2,6 +2,7 @@ package app.revanced.integrations.youtube.patches.components; import static app.revanced.integrations.shared.StringRef.str; import static app.revanced.integrations.youtube.shared.NavigationBar.NavigationButton; +import static java.lang.Character.UnicodeBlock.*; import android.os.Build; @@ -10,9 +11,8 @@ import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.LinkedHashSet; -import java.util.Set; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import app.revanced.integrations.shared.Logger; @@ -26,7 +26,7 @@ import app.revanced.integrations.youtube.shared.PlayerType; /** *
- * Allows hiding home feed and search results based on keywords and/or channel names.
+ * 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.
@@ -41,19 +41,14 @@ import app.revanced.integrations.youtube.shared.PlayerType;
  *   (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 {
 
     /**
-     * Minimum keyword/phrase length to prevent excessively broad content filtering.
-     */
-    private static final int MINIMUM_KEYWORD_LENGTH = 3;
-
-    /**
-     * Strings found in the buffer for every videos.
-     * Full strings should be specified, as they are compared using {@link String#contains(CharSequence)}.
+     * 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.
@@ -88,7 +83,7 @@ final class KeywordContentFilter extends Filter {
             "search_vwc_description_transition_key",
             "g-high-recZ",
             // Text and litho components found in the buffer that belong to path filters.
-            "metadata.eml",
+            "expandable_metadata.eml",
             "thumbnail.eml",
             "avatar.eml",
             "overflow_button.eml",
@@ -107,7 +102,8 @@ final class KeywordContentFilter extends Filter {
             "search_video_with_context.eml",
             "video_with_context.eml", // Subscription tab videos.
             "related_video_with_context.eml",
-            "video_lockup_with_attachment.eml", // A/B test for subscribed video.
+            // 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",
@@ -139,6 +135,12 @@ final class KeywordContentFilter extends Filter {
             "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.
@@ -150,6 +152,8 @@ final class KeywordContentFilter extends Filter {
 
     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}
@@ -216,23 +220,167 @@ final class KeywordContentFilter extends Filter {
                 capitalizeNext = false;
             }
         }
+
         return new String(codePoints, 0, codePoints.length);
     }
 
     /**
-     * @return If the phrase will will hide all videos. Not an exhaustive check.
+     * @return If the string contains any characters from languages that do not use spaces between words.
      */
-    private static boolean phrasesWillHideAllVideos(@NonNull String[] phrases) {
-        for (String commonString : STRINGS_IN_EVERY_BUFFER) {
-            if (Utils.containsAny(commonString, phrases)) {
+    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");
@@ -243,20 +391,33 @@ final class KeywordContentFilter extends Filter {
         String[] split = rawKeywords.split("\n");
         if (split.length != 0) {
             // Linked Set so log statement are more organized and easier to read.
-            Set keywords = new LinkedHashSet<>(10 * split.length);
+            // Map is: Phrase -> isWholeWord
+            Map keywords = new LinkedHashMap<>(10 * split.length);
 
             for (String phrase : split) {
-                // Remove any trailing white space the user may have accidentally included.
+                // Remove any trailing spaces the user may have accidentally included.
                 phrase = phrase.stripTrailing();
                 if (phrase.isBlank()) continue;
 
-                if (phrase.length() < MINIMUM_KEYWORD_LENGTH) {
+                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;
                 }
 
-                // Add common casing that might appear.
+                // 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.
@@ -265,7 +426,7 @@ final class KeywordContentFilter extends Filter {
                 // UTF-8 characters can be different byte lengths, which does
                 // not allow comparing two different byte arrays using simple plain array indexes.
                 //
-                // Instead add all common case variations of the words.
+                // Instead use all common case variations of the words.
                 String[] phraseVariations = {
                         phrase,
                         phrase.toLowerCase(),
@@ -273,20 +434,45 @@ final class KeywordContentFilter extends Filter {
                         capitalizeAllFirstLetters(phrase),
                         phrase.toUpperCase()
                 };
-                if (phrasesWillHideAllVideos(phraseVariations)) {
-                    Utils.showToastLong(str("revanced_hide_keyword_toast_invalid_common", phrase));
+
+                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;
                 }
 
-                keywords.addAll(Arrays.asList(phraseVariations));
+                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 (String keyword : keywords) {
-                // Use a callback to get the keyword that matched.
-                // TrieSearch could have this built in, but that's slightly more complicated since
-                // the strings are stored as a byte array and embedded in the search tree.
+            for (Map.Entry entry : keywords.entrySet()) {
+                String keyword = entry.getKey();
+                //noinspection ExtractMethodRecommender
+                final boolean isWholeWord = entry.getValue();
+
                 TrieSearch.TriePatternMatchedCallback callback =
-                        (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> {
+                        (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;
@@ -295,7 +481,7 @@ final class KeywordContentFilter extends Filter {
                 search.addPattern(stringBytes, callback);
             }
 
-            Logger.printDebug(() -> "Search using: (" + search.getEstimatedMemorySize() + " KB) keywords: " + keywords);
+            Logger.printDebug(() -> "Search using: (" + search.getEstimatedMemorySize() + " KB) keywords: " + keywords.keySet());
         }
 
         bufferSearch = search;
@@ -382,7 +568,7 @@ final class KeywordContentFilter extends Filter {
         // Field is intentionally compared using reference equality.
         //noinspection StringEquality
         if (Settings.HIDE_KEYWORD_CONTENT_PHRASES.get() != lastKeywordPhrasesParsed) {
-            // User changed the keywords.
+            // User changed the keywords or whole word setting.
             parseKeywords();
         }
 
diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/components/LayoutComponentsFilter.java b/app/src/main/java/app/revanced/integrations/youtube/patches/components/LayoutComponentsFilter.java
index 4d7358d4..a0ffde0b 100644
--- a/app/src/main/java/app/revanced/integrations/youtube/patches/components/LayoutComponentsFilter.java
+++ b/app/src/main/java/app/revanced/integrations/youtube/patches/components/LayoutComponentsFilter.java
@@ -81,7 +81,8 @@ public final class LayoutComponentsFilter extends Filter {
                 Settings.HIDE_COMMUNITY_POSTS,
                 "post_base_wrapper",
                 "image_post_root.eml",
-                "text_post_root.eml"
+                "text_post_root.eml",
+                "images_post_root.eml"
         );
 
         final var communityGuidelines = new StringFilterGroup(
diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java
index 927e4493..8df0d190 100644
--- a/app/src/main/java/app/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java
+++ b/app/src/main/java/app/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java
@@ -52,7 +52,7 @@ public final class ReturnYouTubeDislikeFilterPatch extends Filter {
     @SuppressWarnings("unused")
     public static void newPlayerResponseVideoId(String videoId, boolean isShortAndOpeningOrPlaying) {
         try {
-            if (!isShortAndOpeningOrPlaying || !Settings.RYD_SHORTS.get()) {
+            if (!isShortAndOpeningOrPlaying || !Settings.RYD_ENABLED.get() || !Settings.RYD_SHORTS.get()) {
                 return;
             }
             synchronized (lastVideoIds) {
@@ -68,14 +68,22 @@ public final class ReturnYouTubeDislikeFilterPatch extends Filter {
     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(Settings.RYD_SHORTS, "|shorts_dislike_button.eml|")
+                new StringFilterGroup(null, "|shorts_like_button.eml"),
+                new StringFilterGroup(null, "|shorts_dislike_button.eml")
         );
-        // After the dislikes icon name is some binary data and then the video id for that specific short.
+
+        // After the likes icon name is some binary data and then the video id for that specific short.
         videoIdFilterGroup.addAll(
-                // Video was previously disliked before video was opened.
+                // 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"),
-                // Video was not already disliked.
                 new ByteArrayFilterGroup(null, "ic_right_dislike_off_shadowed")
         );
     }
@@ -83,6 +91,10 @@ public final class ReturnYouTubeDislikeFilterPatch extends Filter {
     @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);
@@ -104,6 +116,7 @@ public final class ReturnYouTubeDislikeFilterPatch extends Filter {
                     return videoId;
                 }
             }
+
             return null;
         }
     }
@@ -125,6 +138,7 @@ public final class ReturnYouTubeDislikeFilterPatch extends Filter {
                 return true;
             }
         }
+
         return false;
     }
 }
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/components/ShortsFilter.java b/app/src/main/java/app/revanced/integrations/youtube/patches/components/ShortsFilter.java
index 8ba6c46b..a7935f71 100644
--- a/app/src/main/java/app/revanced/integrations/youtube/patches/components/ShortsFilter.java
+++ b/app/src/main/java/app/revanced/integrations/youtube/patches/components/ShortsFilter.java
@@ -202,6 +202,10 @@ public final class ShortsFilter extends Filter {
                 new ByteArrayFilterGroup(
                         Settings.HIDE_SHORTS_SUPER_THANKS_BUTTON,
                         "yt_outline_dollar_sign_heart_"
+                ),
+                new ByteArrayFilterGroup(
+                        Settings.HIDE_SHORTS_USE_THIS_SOUND_BUTTON,
+                        "yt_outline_camera_"
                 )
         );
     }
diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/ClientType.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/ClientType.java
new file mode 100644
index 00000000..f5300cb8
--- /dev/null
+++ b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/ClientType.java
@@ -0,0 +1,79 @@
+package app.revanced.integrations.youtube.patches.spoof;
+
+import static app.revanced.integrations.youtube.patches.spoof.DeviceHardwareSupport.allowAV1;
+import static app.revanced.integrations.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/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/DeviceHardwareSupport.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/DeviceHardwareSupport.java
new file mode 100644
index 00000000..6b147cd6
--- /dev/null
+++ b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/DeviceHardwareSupport.java
@@ -0,0 +1,53 @@
+package app.revanced.integrations.youtube.patches.spoof;
+
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import android.os.Build;
+
+import app.revanced.integrations.shared.Logger;
+import app.revanced.integrations.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/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofClientPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofClientPatch.java
deleted file mode 100644
index 2b29dd97..00000000
--- a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofClientPatch.java
+++ /dev/null
@@ -1,229 +0,0 @@
-package app.revanced.integrations.youtube.patches.spoof;
-
-import android.media.MediaCodecInfo;
-import android.media.MediaCodecList;
-import android.net.Uri;
-import android.os.Build;
-import app.revanced.integrations.shared.Logger;
-import app.revanced.integrations.youtube.patches.BackgroundPlaybackPatch;
-import app.revanced.integrations.youtube.settings.Settings;
-import org.chromium.net.ExperimentalUrlRequest;
-
-@SuppressWarnings("unused")
-public class SpoofClientPatch {
-    private static final boolean SPOOF_CLIENT_ENABLED = Settings.SPOOF_CLIENT.get();
-    private static final ClientType SPOOF_CLIENT_TYPE = Settings.SPOOF_CLIENT_USE_IOS.get() ? ClientType.IOS : ClientType.ANDROID_VR;
-    private static final boolean SPOOFING_TO_IOS = SPOOF_CLIENT_ENABLED && SPOOF_CLIENT_TYPE == ClientType.IOS;
-
-    /**
-     * 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_CLIENT_ENABLED) {
-            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_CLIENT_ENABLED) { - 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 int getClientTypeId(int originalClientTypeId) { - return SPOOF_CLIENT_ENABLED ? SPOOF_CLIENT_TYPE.id : originalClientTypeId; - } - - /** - * Injection point. - */ - public static String getClientVersion(String originalClientVersion) { - return SPOOF_CLIENT_ENABLED ? SPOOF_CLIENT_TYPE.version : originalClientVersion; - } - - /** - * Injection point. - */ - public static String getClientModel(String originalClientModel) { - return SPOOF_CLIENT_ENABLED ? SPOOF_CLIENT_TYPE.model : originalClientModel; - } - - /** - * Injection point. - * Fix video qualities missing, if spoofing to iOS by using the correct client OS version. - */ - public static String getOsVersion(String originalOsVersion) { - return SPOOFING_TO_IOS ? ClientType.IOS.osVersion : originalOsVersion; - } - - /** - * Injection point. - */ - public static boolean enablePlayerGesture(boolean original) { - return SPOOF_CLIENT_ENABLED || original; - } - - /** - * Injection point. - */ - public static boolean isClientSpoofingEnabled() { - return SPOOF_CLIENT_ENABLED; - } - - /** - * Injection point. - * When spoofing the client to iOS, the playback speed menu is missing from the player response. - * Return true to force create the playback speed menu. - */ - public static boolean forceCreatePlaybackSpeedMenu(boolean original) { - return SPOOFING_TO_IOS || original; - } - - /** - * Injection point. - * When spoofing the client to iOS, background audio only playback of livestreams fails. - * Return true to force enable audio background play. - */ - public static boolean overrideBackgroundAudioPlayback() { - return SPOOFING_TO_IOS && BackgroundPlaybackPatch.playbackIsNotShort(); - } - - /** - * Injection point. - * Fix video qualities missing, if spoofing to iOS by using the correct iOS user-agent. - */ - public static ExperimentalUrlRequest overrideUserAgent(ExperimentalUrlRequest.Builder builder, String url) { - if (SPOOFING_TO_IOS) { - String path = Uri.parse(url).getPath(); - if (path != null && path.contains("player")) { - return builder.addHeader("User-Agent", ClientType.IOS.userAgent).build(); - } - } - - return builder.build(); - } - - private enum ClientType { - // https://dumps.tadiphone.dev/dumps/oculus/eureka - ANDROID_VR(28, - "Quest 3", - "1.56.21", - "12", - "com.google.android.apps.youtube.vr.oculus/1.56.21 (Linux; U; Android 12; GB) gzip" - ), - // 11,4 = iPhone XS Max. - // 16,2 = iPhone 15 Pro Max. - // Since the 15 supports AV1 hardware decoding, only spoof that device if this - // Android device also has hardware decoding. - // - // Version number should be a valid iOS release. - // https://www.ipa4fun.com/history/185230 - IOS(5, - deviceHasAV1HardwareDecoding() ? "iPhone16,2" : "iPhone11,4", - "19.10.7", - "17.5.1.21F90", - "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 17_5_1 like Mac OS X)" - ); - - /** - * YouTube - * client type - */ - final int id; - - /** - * Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model) - */ - final String model; - - /** - * App version. - */ - final String version; - - /** - * Device OS version. - */ - final String osVersion; - - /** - * Player user-agent. - */ - final String userAgent; - - ClientType(int id, String model, String version, String osVersion, String userAgent) { - this.id = id; - this.model = model; - this.version = version; - this.osVersion = osVersion; - this.userAgent = userAgent; - } - } - - private static boolean deviceHasAV1HardwareDecoding() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - MediaCodecList codecList = new MediaCodecList(MediaCodecList.ALL_CODECS); - - for (MediaCodecInfo codecInfo : codecList.getCodecInfos()) { - if (codecInfo.isHardwareAccelerated() && !codecInfo.isEncoder()) { - String[] supportedTypes = codecInfo.getSupportedTypes(); - for (String type : supportedTypes) { - if (type.equalsIgnoreCase("video/av01")) { - MediaCodecInfo.CodecCapabilities capabilities = codecInfo.getCapabilitiesForType(type); - if (capabilities != null) { - Logger.printDebug(() -> "Device supports AV1 hardware decoding."); - return true; - } - } - } - } - } - } - - Logger.printDebug(() -> "Device does not support AV1 hardware decoding."); - return false; - } -} diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofSignaturePatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofSignaturePatch.java deleted file mode 100644 index 41f03ed7..00000000 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofSignaturePatch.java +++ /dev/null @@ -1,242 +0,0 @@ -package app.revanced.integrations.youtube.patches.spoof; - -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import androidx.annotation.Nullable; -import app.revanced.integrations.shared.Logger; -import app.revanced.integrations.shared.Utils; -import app.revanced.integrations.youtube.patches.VideoInformation; -import app.revanced.integrations.youtube.settings.Settings; -import app.revanced.integrations.youtube.shared.PlayerType; - -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -import static app.revanced.integrations.shared.Utils.containsAny; -import static app.revanced.integrations.youtube.patches.spoof.requests.StoryboardRendererRequester.getStoryboardRenderer; - -/** @noinspection unused*/ -@Deprecated -public class SpoofSignaturePatch { - /** - * Parameter (also used by - * yt-dlp) - * to fix playback issues. - */ - private static final String INCOGNITO_PARAMETERS = "CgIQBg=="; - - /** - * Parameters used when playing clips. - */ - private static final String CLIPS_PARAMETERS = "kAIB"; - - /** - * Parameters causing playback issues. - */ - private static final String[] AUTOPLAY_PARAMETERS = { - "YAHI", // Autoplay in feed. - "SAFg" // Autoplay in scrim. - }; - - /** - * Parameter used for autoplay in scrim. - * Prepend this parameter to mute video playback (for autoplay in feed). - */ - private static final String SCRIM_PARAMETER = "SAFgAXgB"; - - /** - * Last video id loaded. Used to prevent reloading the same spec multiple times. - */ - @Nullable - private static volatile String lastPlayerResponseVideoId; - - @Nullable - private static volatile Future rendererFuture; - - private static volatile boolean useOriginalStoryboardRenderer; - - private static volatile boolean isPlayingShorts; - - @Nullable - private static StoryboardRenderer getRenderer(boolean waitForCompletion) { - Future future = rendererFuture; - if (future != null) { - try { - if (waitForCompletion || future.isDone()) { - return future.get(20000, TimeUnit.MILLISECONDS); // Any arbitrarily large timeout. - } // else, return null. - } catch (TimeoutException ex) { - Logger.printDebug(() -> "Could not get renderer (get timed out)"); - } catch (ExecutionException | InterruptedException ex) { - // Should never happen. - Logger.printException(() -> "Could not get renderer", ex); - } - } - return null; - } - - /** - * Injection point. - * - * Called off the main thread, and called multiple times for each video. - * - * @param parameters Original protobuf parameter value. - */ - public static String spoofParameter(String parameters, String videoId, boolean isShortAndOpeningOrPlaying) { - try { - Logger.printDebug(() -> "Original protobuf parameter value: " + parameters); - - if (parameters == null || !Settings.SPOOF_SIGNATURE.get()) { - return parameters; - } - - // Clip's player parameters contain a lot of information (e.g. video start and end time or whether it loops) - // For this reason, the player parameters of a clip are usually very long (150~300 characters). - // Clips are 60 seconds or less in length, so no spoofing. - //noinspection AssignmentUsedAsCondition - if (useOriginalStoryboardRenderer = parameters.length() > 150 || parameters.startsWith(CLIPS_PARAMETERS)) { - return parameters; - } - - // Shorts do not need to be spoofed. - //noinspection AssignmentUsedAsCondition - if (useOriginalStoryboardRenderer = VideoInformation.playerParametersAreShort(parameters)) { - isPlayingShorts = true; - return parameters; - } - isPlayingShorts = false; - - boolean isPlayingFeed = PlayerType.getCurrent() == PlayerType.INLINE_MINIMAL - && containsAny(parameters, AUTOPLAY_PARAMETERS); - if (isPlayingFeed) { - //noinspection AssignmentUsedAsCondition - if (useOriginalStoryboardRenderer = !Settings.SPOOF_SIGNATURE_IN_FEED.get()) { - // Don't spoof the feed video playback. This will cause video playback issues, - // but only if user continues watching for more than 1 minute. - return parameters; - } - // Spoof the feed video. Video will show up in watch history and video subtitles are missing. - fetchStoryboardRenderer(); - return SCRIM_PARAMETER + INCOGNITO_PARAMETERS; - } - - fetchStoryboardRenderer(); - } catch (Exception ex) { - Logger.printException(() -> "spoofParameter failure", ex); - } - return INCOGNITO_PARAMETERS; - } - - private static void fetchStoryboardRenderer() { - if (!Settings.SPOOF_STORYBOARD_RENDERER.get()) { - lastPlayerResponseVideoId = null; - rendererFuture = null; - return; - } - String videoId = VideoInformation.getPlayerResponseVideoId(); - if (!videoId.equals(lastPlayerResponseVideoId)) { - rendererFuture = Utils.submitOnBackgroundThread(() -> getStoryboardRenderer(videoId)); - lastPlayerResponseVideoId = videoId; - } - // Block until the renderer fetch completes. - // This is desired because if this returns without finishing the fetch - // then video will start playback but the storyboard is not ready yet. - getRenderer(true); - } - - private static String getStoryboardRendererSpec(String originalStoryboardRendererSpec, - boolean returnNullIfLiveStream) { - if (Settings.SPOOF_SIGNATURE.get() && !useOriginalStoryboardRenderer) { - StoryboardRenderer renderer = getRenderer(false); - if (renderer != null) { - if (returnNullIfLiveStream && renderer.isLiveStream) { - return null; - } - - if (renderer.spec != null) { - return renderer.spec; - } - } - } - - return originalStoryboardRendererSpec; - } - - /** - * Injection point. - * Called from background threads and from the main thread. - */ - @Nullable - public static String getStoryboardRendererSpec(String originalStoryboardRendererSpec) { - return getStoryboardRendererSpec(originalStoryboardRendererSpec, false); - } - - /** - * Injection point. - * Uses additional check to handle live streams. - * Called from background threads and from the main thread. - */ - @Nullable - public static String getStoryboardDecoderRendererSpec(String originalStoryboardRendererSpec) { - return getStoryboardRendererSpec(originalStoryboardRendererSpec, true); - } - - /** - * Injection point. - */ - public static int getRecommendedLevel(int originalLevel) { - if (Settings.SPOOF_SIGNATURE.get() && !useOriginalStoryboardRenderer) { - StoryboardRenderer renderer = getRenderer(false); - if (renderer != null) { - if (renderer.recommendedLevel != null) { - return renderer.recommendedLevel; - } - } - } - - return originalLevel; - } - - /** - * Injection point. Forces seekbar to be shown for paid videos or - * if {@link Settings#SPOOF_STORYBOARD_RENDERER} is not enabled. - */ - public static boolean getSeekbarThumbnailOverrideValue() { - if (!Settings.SPOOF_SIGNATURE.get()) { - return false; - } - StoryboardRenderer renderer = getRenderer(false); - if (renderer == null) { - // Spoof storyboard renderer is turned off, - // video is paid, or the storyboard fetch timed out. - // Show empty thumbnails so the seek time and chapters still show up. - return true; - } - return renderer.spec != null; - } - - /** - * Injection point. - * - * @param view seekbar thumbnail view. Includes both shorts and regular videos. - */ - public static void seekbarImageViewCreated(ImageView view) { - try { - if (!Settings.SPOOF_SIGNATURE.get() - || Settings.SPOOF_STORYBOARD_RENDERER.get()) { - return; - } - if (isPlayingShorts) return; - - view.setVisibility(View.GONE); - // Also hide the border around the thumbnail (otherwise a 1 pixel wide bordered frame is visible). - ViewGroup parentLayout = (ViewGroup) view.getParent(); - parentLayout.setPadding(0, 0, 0, 0); - } catch (Exception ex) { - Logger.printException(() -> "seekbarImageViewCreated failure", ex); - } - } -} diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofVideoStreamsPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofVideoStreamsPatch.java new file mode 100644 index 00000000..d3c96407 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofVideoStreamsPatch.java @@ -0,0 +1,170 @@ +package app.revanced.integrations.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.integrations.shared.Logger; +import app.revanced.integrations.shared.Utils; +import app.revanced.integrations.shared.settings.BaseSettings; +import app.revanced.integrations.shared.settings.Setting; +import app.revanced.integrations.youtube.patches.spoof.requests.StreamingDataRequest; +import app.revanced.integrations.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(); + String clientNameQueryKey = "c"; + final boolean iosClient = "IOS".equals(uri.getQueryParameter(clientNameQueryKey)); + if (iosClient && path != null && path.contains("videoplayback")) { + return null; + } + } + } catch (Exception ex) { + Logger.printException(() -> "removeVideoPlaybackPostBody failure", ex); + } + } + + return postData; + } +} diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/StoryboardRenderer.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/StoryboardRenderer.java deleted file mode 100644 index 5014a5fc..00000000 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/StoryboardRenderer.java +++ /dev/null @@ -1,36 +0,0 @@ -package app.revanced.integrations.youtube.patches.spoof; - -import androidx.annotation.Nullable; - -import org.jetbrains.annotations.NotNull; - -@Deprecated -public final class StoryboardRenderer { - public final String videoId; - @Nullable - public final String spec; - public final boolean isLiveStream; - /** - * Recommended image quality level, or NULL if no recommendation exists. - */ - @Nullable - public final Integer recommendedLevel; - - public StoryboardRenderer(String videoId, @Nullable String spec, boolean isLiveStream, @Nullable Integer recommendedLevel) { - this.videoId = videoId; - this.spec = spec; - this.isLiveStream = isLiveStream; - this.recommendedLevel = recommendedLevel; - } - - @NotNull - @Override - public String toString() { - return "StoryboardRenderer{" + - "videoId=" + videoId + - ", isLiveStream=" + isLiveStream + - ", spec='" + spec + '\'' + - ", recommendedLevel=" + recommendedLevel + - '}'; - } -} diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/PlayerRoutes.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/PlayerRoutes.java index 1927b1d6..299110f4 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/PlayerRoutes.java +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/PlayerRoutes.java @@ -1,94 +1,68 @@ package app.revanced.integrations.youtube.patches.spoof.requests; -import app.revanced.integrations.youtube.requests.Requester; -import app.revanced.integrations.youtube.requests.Route; -import app.revanced.integrations.shared.Logger; -import app.revanced.integrations.shared.Utils; import org.json.JSONException; import org.json.JSONObject; import java.io.IOException; import java.net.HttpURLConnection; +import app.revanced.integrations.shared.Logger; +import app.revanced.integrations.youtube.patches.spoof.ClientType; +import app.revanced.integrations.youtube.requests.Requester; +import app.revanced.integrations.youtube.requests.Route; + final class PlayerRoutes { - private static final String YT_API_URL = "https://www.youtube.com/youtubei/v1/"; - static final Route.CompiledRoute GET_STORYBOARD_SPEC_RENDERER = new Route( + 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=storyboards.playerStoryboardSpecRenderer," + - "storyboards.playerLiveStoryboardSpecRenderer," + - "playabilityStatus.status" + "?fields=streamingData" + + "&alt=proto" ).compile(); - static final String ANDROID_INNER_TUBE_BODY; - static final String TV_EMBED_INNER_TUBE_BODY; - /** * TCP connection and HTTP read timeout */ private static final int CONNECTION_TIMEOUT_MILLISECONDS = 10 * 1000; // 10 Seconds. - static { - JSONObject innerTubeBody = new JSONObject(); + private PlayerRoutes() { + } + + static String createInnertubeBody(ClientType clientType) { + JSONObject innerTubeBody = new JSONObject(); try { JSONObject context = new JSONObject(); JSONObject client = new JSONObject(); - client.put("clientName", "ANDROID"); - client.put("clientVersion", Utils.getAppVersionName()); - client.put("androidSdkVersion", 34); + 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); } - ANDROID_INNER_TUBE_BODY = innerTubeBody.toString(); - - JSONObject tvEmbedInnerTubeBody = new JSONObject(); - - try { - JSONObject context = new JSONObject(); - - JSONObject client = new JSONObject(); - client.put("clientName", "TVHTML5_SIMPLY_EMBEDDED_PLAYER"); - client.put("clientVersion", "2.0"); - client.put("platform", "TV"); - client.put("clientScreen", "EMBED"); - - JSONObject thirdParty = new JSONObject(); - thirdParty.put("embedUrl", "https://www.youtube.com/watch?v=%s"); - - context.put("thirdParty", thirdParty); - context.put("client", client); - - tvEmbedInnerTubeBody.put("context", context); - tvEmbedInnerTubeBody.put("videoId", "%s"); - } catch (JSONException e) { - Logger.printException(() -> "Failed to create tvEmbedInnerTubeBody", e); - } - - TV_EMBED_INNER_TUBE_BODY = tvEmbedInnerTubeBody.toString(); - } - - private PlayerRoutes() { + return innerTubeBody.toString(); } /** @noinspection SameParameterValue*/ - static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route) throws IOException { + static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route, ClientType clientType) throws IOException { var connection = Requester.getConnectionFromCompiledRoute(YT_API_URL, route); - connection.setRequestProperty( - "User-Agent", "com.google.android.youtube/" + - Utils.getAppVersionName() + - " (Linux; U; Android 12; GB) gzip" - ); - connection.setRequestProperty("X-Goog-Api-Format-Version", "2"); connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("User-Agent", clientType.userAgent); connection.setUseCaches(false); connection.setDoOutput(true); diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StoryboardRendererRequester.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StoryboardRendererRequester.java deleted file mode 100644 index 0cbec194..00000000 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StoryboardRendererRequester.java +++ /dev/null @@ -1,153 +0,0 @@ -package app.revanced.integrations.youtube.patches.spoof.requests; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import app.revanced.integrations.shared.settings.BaseSettings; -import app.revanced.integrations.youtube.patches.spoof.StoryboardRenderer; -import app.revanced.integrations.youtube.requests.Requester; -import app.revanced.integrations.shared.Logger; -import app.revanced.integrations.shared.Utils; -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.IOException; -import java.net.HttpURLConnection; -import java.net.SocketTimeoutException; -import java.nio.charset.StandardCharsets; -import java.util.Objects; - -import static app.revanced.integrations.shared.StringRef.str; -import static app.revanced.integrations.youtube.patches.spoof.requests.PlayerRoutes.*; - -public class StoryboardRendererRequester { - - private StoryboardRendererRequester() { - } - - private static void randomlyWaitIfLocallyDebugging() { - final boolean randomlyWait = false; // Enable to simulate slow connection responses. - if (randomlyWait) { - final long maximumTimeToRandomlyWait = 10000; - Utils.doNothingForDuration(maximumTimeToRandomlyWait); - } - } - - private static void handleConnectionError(@NonNull String toastMessage, @Nullable Exception ex, - boolean showToastOnIOException) { - if (showToastOnIOException) Utils.showToastShort(toastMessage); - Logger.printInfo(() -> toastMessage, ex); - } - - @Nullable - private static JSONObject fetchPlayerResponse(@NonNull String requestBody, boolean showToastOnIOException) { - final long startTime = System.currentTimeMillis(); - try { - Utils.verifyOffMainThread(); - Objects.requireNonNull(requestBody); - - final byte[] innerTubeBody = requestBody.getBytes(StandardCharsets.UTF_8); - - HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_STORYBOARD_SPEC_RENDERER); - connection.getOutputStream().write(innerTubeBody, 0, innerTubeBody.length); - - final int responseCode = connection.getResponseCode(); - randomlyWaitIfLocallyDebugging(); - if (responseCode == 200) return Requester.parseJSONObject(connection); - - // Always show a toast for this, as a non 200 response means something is broken. - // Not a normal code path and should not be reached, so no translations are needed. - handleConnectionError("Spoof storyboard not available: " + responseCode, - null, showToastOnIOException || BaseSettings.DEBUG_TOAST_ON_ERROR.get()); - connection.disconnect(); - } catch (SocketTimeoutException ex) { - handleConnectionError(str("revanced_spoof_client_storyboard_timeout"), ex, showToastOnIOException); - } catch (IOException ex) { - handleConnectionError(str("revanced_spoof_client_storyboard_io_exception", ex.getMessage()), - ex, showToastOnIOException); - } catch (Exception ex) { - Logger.printException(() -> "Spoof storyboard fetch failed", ex); // Should never happen. - } finally { - Logger.printDebug(() -> "Request took: " + (System.currentTimeMillis() - startTime) + "ms"); - } - - return null; - } - - private static boolean isPlayabilityStatusOk(@NonNull JSONObject playerResponse) { - try { - return playerResponse.getJSONObject("playabilityStatus").getString("status").equals("OK"); - } catch (JSONException e) { - Logger.printDebug(() -> "Failed to get playabilityStatus for response: " + playerResponse); - } - - return false; - } - - /** - * Fetches the storyboardRenderer from the innerTubeBody. - * @param innerTubeBody The innerTubeBody to use to fetch the storyboardRenderer. - * @return StoryboardRenderer or null if playabilityStatus is not OK. - */ - @Nullable - private static StoryboardRenderer getStoryboardRendererUsingBody(String videoId, - @NonNull String innerTubeBody, - boolean showToastOnIOException) { - final JSONObject playerResponse = fetchPlayerResponse(innerTubeBody, showToastOnIOException); - if (playerResponse != null && isPlayabilityStatusOk(playerResponse)) - return getStoryboardRendererUsingResponse(videoId, playerResponse); - - return null; - } - - @Nullable - private static StoryboardRenderer getStoryboardRendererUsingResponse(@NonNull String videoId, @NonNull JSONObject playerResponse) { - try { - Logger.printDebug(() -> "Parsing response: " + playerResponse); - if (!playerResponse.has("storyboards")) { - Logger.printDebug(() -> "Using empty storyboard"); - return new StoryboardRenderer(videoId, null, false, null); - } - final JSONObject storyboards = playerResponse.getJSONObject("storyboards"); - final boolean isLiveStream = storyboards.has("playerLiveStoryboardSpecRenderer"); - final String storyboardsRendererTag = isLiveStream - ? "playerLiveStoryboardSpecRenderer" - : "playerStoryboardSpecRenderer"; - - final var rendererElement = storyboards.getJSONObject(storyboardsRendererTag); - StoryboardRenderer renderer = new StoryboardRenderer( - videoId, - rendererElement.getString("spec"), - isLiveStream, - rendererElement.has("recommendedLevel") - ? rendererElement.getInt("recommendedLevel") - : null - ); - - Logger.printDebug(() -> "Fetched: " + renderer); - - return renderer; - } catch (JSONException e) { - Logger.printException(() -> "Failed to get storyboardRenderer", e); - } - - return null; - } - - @Nullable - public static StoryboardRenderer getStoryboardRenderer(@NonNull String videoId) { - Objects.requireNonNull(videoId); - - var renderer = getStoryboardRendererUsingBody(videoId, - String.format(ANDROID_INNER_TUBE_BODY, videoId), false); - if (renderer == null) { - Logger.printDebug(() -> videoId + " not available using Android client"); - renderer = getStoryboardRendererUsingBody(videoId, - String.format(TV_EMBED_INNER_TUBE_BODY, videoId, videoId), true); - if (renderer == null) { - Logger.printDebug(() -> videoId + " not available using TV embedded client"); - } - } - - return renderer; - } -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StreamingDataRequest.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StreamingDataRequest.java new file mode 100644 index 00000000..c86b352f --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StreamingDataRequest.java @@ -0,0 +1,215 @@ +package app.revanced.integrations.youtube.patches.spoof.requests; + +import static app.revanced.integrations.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.integrations.shared.Logger; +import app.revanced.integrations.shared.Utils; +import app.revanced.integrations.shared.settings.BaseSettings; +import app.revanced.integrations.youtube.patches.spoof.ClientType; +import app.revanced.integrations.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 integrations replace stream hook is called only if YT + * would have used it's 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; + } + } + } + + /** + * 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); + + String authHeader = playerHeaders.get("Authorization"); + String visitorId = playerHeaders.get("X-Goog-Visitor-Id"); + connection.setRequestProperty("Authorization", authHeader); + connection.setRequestProperty("X-Goog-Visitor-Id", visitorId); + + 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/app/src/main/java/app/revanced/integrations/youtube/requests/Requester.java b/app/src/main/java/app/revanced/integrations/youtube/requests/Requester.java index ef409b52..c62e34f5 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/requests/Requester.java +++ b/app/src/main/java/app/revanced/integrations/youtube/requests/Requester.java @@ -23,6 +23,9 @@ public class Requester { 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() diff --git a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/ReturnYouTubeDislike.java b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/ReturnYouTubeDislike.java index bfff1b15..b63d0484 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/ReturnYouTubeDislike.java +++ b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/ReturnYouTubeDislike.java @@ -10,6 +10,9 @@ 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; @@ -25,17 +28,11 @@ import androidx.annotation.GuardedBy; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import java.text.NumberFormat; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.Objects; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; +import java.util.concurrent.*; import app.revanced.integrations.shared.Logger; import app.revanced.integrations.shared.Utils; @@ -223,32 +220,29 @@ public class ReturnYouTubeDislike { // 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. - String oldLikesString = oldSpannable.toString(); + 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 (!stringContainsNumber(oldLikesString)) { - // Likes are hidden. - // RYD does not provide usable data for these types of videos, - // and the API returns bogus data (zero likes and zero dislikes) - // discussion about this: https://github.com/Anarios/return-youtube-dislike/discussions/530 + 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 // - // Change the "Likes" string to show that likes and dislikes are hidden. - String hiddenMessageString = str("revanced_ryd_video_likes_hidden_by_video_owner"); - return newSpanUsingStylingOfAnotherSpan(oldSpannable, hiddenMessageString); + 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 = Utils.isRightToLeftTextLayout() - ? "\u200F" // u200F = right to left character - : "\u200E"; // u200E = left to right character + String leftSeparatorString = getTextDirectionString(); final Spannable leftSeparatorSpan; if (isRollingNumber) { leftSeparatorSpan = new SpannableString(leftSeparatorString); @@ -267,7 +261,7 @@ public class ReturnYouTubeDislike { } // likes - builder.append(newSpanUsingStylingOfAnotherSpan(oldSpannable, oldLikesString)); + builder.append(newSpanUsingStylingOfAnotherSpan(oldSpannable, oldLikes)); // middle separator String middleSeparatorString = compactLayout @@ -292,6 +286,12 @@ public class ReturnYouTubeDislike { 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. */ @@ -299,20 +299,6 @@ public class ReturnYouTubeDislike { return text.indexOf(MIDDLE_SEPARATOR_CHARACTER) >= 0; } - /** - * Correctly handles any unicode numbers (such as Arabic numbers). - * - * @return if the string contains at least 1 number. - */ - private static boolean stringContainsNumber(@NonNull String text) { - for (int index = 0, length = text.length(); index < length; index++) { - if (Character.isDigit(text.codePointAt(index))) { - return true; - } - } - return false; - } - 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. @@ -334,6 +320,10 @@ public class ReturnYouTubeDislike { 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() @@ -342,11 +332,16 @@ public class ReturnYouTubeDislike { } 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; } @@ -354,13 +349,18 @@ public class ReturnYouTubeDislike { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize if (dislikeCountFormatter == null) { - // Note: Java number formatters will use the locale specific number characters. - // such as Arabic which formats "1.234" into "۱,۲۳٤" - // But YouTube disregards locale specific number characters - // and instead shows english number characters everywhere. Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale; - Logger.printDebug(() -> "Locale: " + 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); } @@ -371,19 +371,31 @@ public class ReturnYouTubeDislike { } private static String formatDislikePercentage(float dislikePercentage) { - synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize - if (dislikePercentageFormatter == null) { - Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale; - Logger.printDebug(() -> "Locale: " + locale); - dislikePercentageFormatter = NumberFormat.getPercentInstance(locale); + 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); } - 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 @@ -484,7 +496,17 @@ public class ReturnYouTubeDislike { public synchronized Spanned getDislikesSpanForRegularVideo(@NonNull Spanned original, boolean isSegmentedButton, boolean isRollingNumber) { - return waitForFetchAndUpdateReplacementSpan(original, isSegmentedButton, isRollingNumber,false); + 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); } /** @@ -492,14 +514,16 @@ public class ReturnYouTubeDislike { */ @NonNull public synchronized Spanned getDislikeSpanForShort(@NonNull Spanned original) { - return waitForFetchAndUpdateReplacementSpan(original, false, false, true); + return waitForFetchAndUpdateReplacementSpan(original, false, + false, true, false); } @NonNull private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original, boolean isSegmentedButton, boolean isRollingNumber, - boolean spanIsForShort) { + boolean spanIsForShort, + boolean spanIsForLikes) { try { RYDVoteData votingData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH); if (votingData == null) { @@ -526,24 +550,17 @@ public class ReturnYouTubeDislike { return original; } - if (originalDislikeSpan != null && replacementLikeDislikeSpan != null) { - if (spansHaveEqualTextAndColor(original, replacementLikeDislikeSpan)) { - Logger.printDebug(() -> "Ignoring previously created dislikes span of data: " + videoId); - return original; - } - if (spansHaveEqualTextAndColor(original, originalDislikeSpan)) { - Logger.printDebug(() -> "Replacing span with previously created dislike span of data: " + videoId); - return replacementLikeDislikeSpan; - } + 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 (isSegmentedButton && isPreviouslyCreatedSegmentedSpan(original.toString())) { - // need to recreate using original, as original has prior outdated dislike values - if (originalDislikeSpan == null) { - // Should never happen. - Logger.printDebug(() -> "Cannot add dislikes - original span is null. videoId: " + videoId); - return original; - } - original = originalDislikeSpan; + + 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. @@ -558,9 +575,10 @@ public class ReturnYouTubeDislike { return replacementLikeDislikeSpan; } - } catch (Exception e) { - Logger.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", e); // should never happen + } catch (Exception ex) { + Logger.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", ex); } + return original; } diff --git a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/RYDVoteData.java b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/RYDVoteData.java index 820c0492..239ad2b0 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/RYDVoteData.java +++ b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/RYDVoteData.java @@ -3,10 +3,13 @@ package app.revanced.integrations.youtube.returnyoutubedislike.requests; import static app.revanced.integrations.youtube.returnyoutubedislike.ReturnYouTubeDislike.Vote; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.json.JSONException; import org.json.JSONObject; +import app.revanced.integrations.shared.Logger; + /** * ReturnYouTubeDislike API estimated like/dislike/view counts. * @@ -23,38 +26,65 @@ public final class RYDVoteData { public final long viewCount; private final long fetchedLikeCount; - private volatile long likeCount; // read/write from different threads + 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 + 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; - updatePercentages(); + + updateUsingVote(Vote.LIKE_REMOVE); // Calculate percentages. } /** - * Estimated like count + * 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 dislike count + * Estimated total dislike count, extrapolated from the public like count using RYD data. */ public long getDislikeCount() { return dislikeCount; @@ -79,28 +109,56 @@ public final class RYDVoteData { } public void updateUsingVote(Vote vote) { + final int likesToAdd, dislikesToAdd; + switch (vote) { case LIKE: - likeCount = fetchedLikeCount + 1; - dislikeCount = fetchedDislikeCount; + likesToAdd = 1; + dislikesToAdd = 0; break; case DISLIKE: - likeCount = fetchedLikeCount; - dislikeCount = fetchedDislikeCount + 1; + likesToAdd = 0; + dislikesToAdd = 1; break; case LIKE_REMOVE: - likeCount = fetchedLikeCount; - dislikeCount = fetchedDislikeCount; + likesToAdd = 0; + dislikesToAdd = 0; break; default: throw new IllegalStateException(); } - updatePercentages(); - } - private void updatePercentages() { - likePercentage = (likeCount == 0 ? 0 : (float) likeCount / (likeCount + dislikeCount)); - dislikePercentage = (dislikeCount == 0 ? 0 : (float) dislikeCount / (likeCount + dislikeCount)); + // 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 diff --git a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java index bc729e47..cb211ea5 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java +++ b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java @@ -197,7 +197,7 @@ public class ReturnYouTubeDislikeApi { return httpResponseCode == HTTP_STATUS_CODE_RATE_LIMIT; } - @SuppressWarnings("NonAtomicOperationOnVolatileField") // Don't care, fields are estimates. + @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(); @@ -368,10 +368,12 @@ public class ReturnYouTubeDislikeApi { applyCommonPostRequestSettings(connection); String jsonInputString = "{\"solution\": \"" + solution + "\"}"; + byte[] body = jsonInputString.getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(body.length); try (OutputStream os = connection.getOutputStream()) { - byte[] input = jsonInputString.getBytes(StandardCharsets.UTF_8); - os.write(input, 0, input.length); + 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 @@ -440,9 +442,10 @@ public class ReturnYouTubeDislikeApi { 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()) { - byte[] input = voteJsonString.getBytes(StandardCharsets.UTF_8); - os.write(input, 0, input.length); + os.write(body); } final int responseCode = connection.getResponseCode(); @@ -490,10 +493,12 @@ public class ReturnYouTubeDislikeApi { 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()) { - byte[] input = jsonInputString.getBytes(StandardCharsets.UTF_8); - os.write(input, 0, input.length); + 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 diff --git a/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java b/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java index 47ee02c8..85bc6c8e 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java +++ b/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java @@ -1,17 +1,5 @@ package app.revanced.integrations.youtube.settings; -import static java.lang.Boolean.FALSE; -import static java.lang.Boolean.TRUE; -import static app.revanced.integrations.shared.settings.Setting.*; -import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType; -import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_1; -import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_3; -import static app.revanced.integrations.youtube.sponsorblock.objects.CategoryBehaviour.*; - -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - import app.revanced.integrations.shared.Logger; import app.revanced.integrations.shared.settings.*; import app.revanced.integrations.shared.settings.preference.SharedPrefCategory; @@ -19,9 +7,23 @@ import app.revanced.integrations.youtube.patches.AlternativeThumbnailsPatch.DeAr import app.revanced.integrations.youtube.patches.AlternativeThumbnailsPatch.StillImagesAvailability; import app.revanced.integrations.youtube.patches.AlternativeThumbnailsPatch.ThumbnailOption; import app.revanced.integrations.youtube.patches.AlternativeThumbnailsPatch.ThumbnailStillTime; +import app.revanced.integrations.youtube.patches.spoof.ClientType; import app.revanced.integrations.youtube.patches.spoof.SpoofAppVersionPatch; +import app.revanced.integrations.youtube.patches.spoof.SpoofVideoStreamsPatch; import app.revanced.integrations.youtube.sponsorblock.SponsorBlockSettings; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static app.revanced.integrations.shared.settings.Setting.*; +import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType; +import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_1; +import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_3; +import static app.revanced.integrations.youtube.sponsorblock.objects.CategoryBehaviour.*; +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; + @SuppressWarnings("deprecation") public class Settings extends BaseSettings { // Video @@ -219,6 +221,7 @@ public class Settings extends BaseSettings { public static final BooleanSetting HIDE_SHORTS_LOCATION_LABEL = new BooleanSetting("revanced_hide_shorts_location_label", FALSE); // Save sound to playlist and Search suggestions may have been A/B tests that were abandoned by YT, and it's not clear if these are still used. public static final BooleanSetting HIDE_SHORTS_SAVE_SOUND_BUTTON = new BooleanSetting("revanced_hide_shorts_save_sound_button", FALSE); + public static final BooleanSetting HIDE_SHORTS_USE_THIS_SOUND_BUTTON = new BooleanSetting("revanced_hide_shorts_use_this_sound_button", TRUE); public static final BooleanSetting HIDE_SHORTS_SEARCH_SUGGESTIONS = new BooleanSetting("revanced_hide_shorts_search_suggestions", FALSE); 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_BUTTON = new BooleanSetting("revanced_hide_shorts_like_button", FALSE); @@ -253,13 +256,16 @@ public class Settings extends BaseSettings { "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_CLIENT = new BooleanSetting("revanced_spoof_client", TRUE, true, "revanced_spoof_client_user_dialog_message"); - public static final BooleanSetting SPOOF_CLIENT_USE_IOS = new BooleanSetting("revanced_spoof_client_use_ios", TRUE, true, parent(SPOOF_CLIENT)); + 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_type", ClientType.IOS, 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 /** diff --git a/app/src/main/java/app/revanced/integrations/youtube/settings/preference/ForceAVCSpoofingPreference.java b/app/src/main/java/app/revanced/integrations/youtube/settings/preference/ForceAVCSpoofingPreference.java new file mode 100644 index 00000000..8d37017e --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/settings/preference/ForceAVCSpoofingPreference.java @@ -0,0 +1,61 @@ +package app.revanced.integrations.youtube.settings.preference; + +import static app.revanced.integrations.shared.StringRef.str; +import static app.revanced.integrations.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/app/src/main/java/app/revanced/integrations/youtube/settings/preference/HtmlPreference.java b/app/src/main/java/app/revanced/integrations/youtube/settings/preference/HtmlPreference.java new file mode 100644 index 00000000..96d29645 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/settings/preference/HtmlPreference.java @@ -0,0 +1,35 @@ +package app.revanced.integrations.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/app/src/main/java/app/revanced/integrations/youtube/settings/preference/SponsorBlockPreferenceFragment.java b/app/src/main/java/app/revanced/integrations/youtube/settings/preference/SponsorBlockPreferenceFragment.java index 371ef389..2de654a2 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/settings/preference/SponsorBlockPreferenceFragment.java +++ b/app/src/main/java/app/revanced/integrations/youtube/settings/preference/SponsorBlockPreferenceFragment.java @@ -257,13 +257,19 @@ public class SponsorBlockPreferenceFragment extends PreferenceFragment { newSegmentStep.setSummary(str("revanced_sb_general_adjusting_sum")); newSegmentStep.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER); newSegmentStep.setOnPreferenceChangeListener((preference1, newValue) -> { - final int newAdjustmentValue = Integer.parseInt(newValue.toString()); - if (newAdjustmentValue == 0) { - Utils.showToastLong(str("revanced_sb_general_adjusting_invalid")); - return false; + 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); } - Settings.SB_CREATE_NEW_SEGMENT_STEP.save(newAdjustmentValue); - return true; + + Utils.showToastLong(str("revanced_sb_general_adjusting_invalid")); + updateUI(); + return false; }); category.addPreference(newSegmentStep); @@ -309,8 +315,17 @@ public class SponsorBlockPreferenceFragment extends PreferenceFragment { 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) -> { - Settings.SB_SEGMENT_MIN_DURATION.save(Float.valueOf(newValue.toString())); - return true; + 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); @@ -323,6 +338,7 @@ public class SponsorBlockPreferenceFragment extends PreferenceFragment { Utils.showToastLong(str("revanced_sb_general_uuid_invalid")); return false; } + Settings.SB_PRIVATE_USER_ID.save(newUUID); updateUI(); fetchAndDisplayStats(); @@ -503,6 +519,7 @@ public class SponsorBlockPreferenceFragment extends PreferenceFragment { 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 { diff --git a/gradle.properties b/gradle.properties index 79ce6376..d8b4047b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ org.gradle.parallel = true org.gradle.caching = true android.useAndroidX = true -version = 1.13.0 +version = 1.14.0-dev.12 diff --git a/stub/src/main/java/org/chromium/net/ExperimentalUrlRequest.java b/stub/src/main/java/org/chromium/net/ExperimentalUrlRequest.java deleted file mode 100644 index cdf2593e..00000000 --- a/stub/src/main/java/org/chromium/net/ExperimentalUrlRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.chromium.net; - -public abstract class ExperimentalUrlRequest { - public abstract class Builder { - public abstract ExperimentalUrlRequest.Builder addHeader(String name, String value); - public abstract ExperimentalUrlRequest build(); - } -} diff --git a/stub/src/main/java/org/chromium/net/UrlRequest.java b/stub/src/main/java/org/chromium/net/UrlRequest.java index 565fc222..4c02f1a4 100644 --- a/stub/src/main/java/org/chromium/net/UrlRequest.java +++ b/stub/src/main/java/org/chromium/net/UrlRequest.java @@ -1,4 +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(); + } }