availableClients = List.of(
+ ANDROID_REEL,
+ ANDROID_VR_1_43_32,
+ VISIONOS,
+ ANDROID_VR_1_61_48
+ );
+
+ app.revanced.extension.shared.spoof.SpoofVideoStreamsPatch.setClientsToUse(
+ availableClients, SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get());
+ }
+}
diff --git a/extensions/music/src/main/java/app/revanced/extension/music/patches/theme/ThemePatch.java b/extensions/music/src/main/java/app/revanced/extension/music/patches/theme/ThemePatch.java
new file mode 100644
index 0000000000..3f4e396699
--- /dev/null
+++ b/extensions/music/src/main/java/app/revanced/extension/music/patches/theme/ThemePatch.java
@@ -0,0 +1,27 @@
+package app.revanced.extension.music.patches.theme;
+
+import app.revanced.extension.shared.theme.BaseThemePatch;
+
+@SuppressWarnings("unused")
+public class ThemePatch extends BaseThemePatch {
+
+ // Color constants used in relation with litho components.
+ private static final int[] DARK_VALUES = {
+ 0xFF212121, // Comments box background.
+ 0xFF030303, // Button container background in album.
+ 0xFF000000, // Button container background in playlist.
+ };
+
+ /**
+ * Injection point.
+ *
+ * Change the color of Litho components.
+ * If the color of the component matches one of the values, return the background color.
+ *
+ * @param originalValue The original color value.
+ * @return The new or original color value.
+ */
+ public static int getValue(int originalValue) {
+ return processColorValue(originalValue, DARK_VALUES, null);
+ }
+}
diff --git a/extensions/music/src/main/java/app/revanced/extension/music/settings/MusicActivityHook.java b/extensions/music/src/main/java/app/revanced/extension/music/settings/MusicActivityHook.java
new file mode 100644
index 0000000000..c3874f655c
--- /dev/null
+++ b/extensions/music/src/main/java/app/revanced/extension/music/settings/MusicActivityHook.java
@@ -0,0 +1,148 @@
+package app.revanced.extension.music.settings;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.graphics.PorterDuff;
+import android.graphics.drawable.Drawable;
+import android.preference.PreferenceFragment;
+import android.view.View;
+import android.widget.Toolbar;
+
+import app.revanced.extension.music.VersionCheckUtils;
+import app.revanced.extension.music.settings.preference.MusicPreferenceFragment;
+import app.revanced.extension.music.settings.search.MusicSearchViewController;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.ResourceType;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.BaseActivityHook;
+
+/**
+ * Hooks GoogleApiActivity to inject a custom {@link MusicPreferenceFragment} with a toolbar and search.
+ */
+public class MusicActivityHook extends BaseActivityHook {
+
+ @SuppressLint("StaticFieldLeak")
+ public static MusicSearchViewController searchViewController;
+
+ /**
+ * How much time has passed since the first launch of the app. Simple check to prevent
+ * forcing bold icons on first launch where the settings menu is partially broken
+ * due to missing icon resources the client has not yet received.
+ *
+ * @see app.revanced.extension.youtube.settings.YouTubeActivityHook#MINIMUM_TIME_AFTER_FIRST_LAUNCH_BEFORE_ALLOWING_BOLD_ICONS
+ */
+ private static final long MINIMUM_TIME_AFTER_FIRST_LAUNCH_BEFORE_ALLOWING_BOLD_ICONS = 30 * 1000; // 30 seconds.
+
+ static {
+ final boolean useBoldIcons = VersionCheckUtils.IS_8_40_OR_GREATER
+ && !Settings.SETTINGS_DISABLE_BOLD_ICONS.get()
+ && (System.currentTimeMillis() - Settings.FIRST_TIME_APP_LAUNCHED.get())
+ > MINIMUM_TIME_AFTER_FIRST_LAUNCH_BEFORE_ALLOWING_BOLD_ICONS;
+
+ Utils.setAppIsUsingBoldIcons(useBoldIcons);
+ }
+
+ /**
+ * Injection point.
+ */
+ @SuppressWarnings("unused")
+ public static void initialize(Activity parentActivity) {
+ // Must touch the Music settings to ensure the class is loaded and
+ // the values can be found when setting the UI preferences.
+ // Logging anything under non debug ensures this is set.
+ Logger.printInfo(() -> "Permanent repeat enabled: " + Settings.PERMANENT_REPEAT.get());
+
+ // YT Music always uses dark mode.
+ Utils.setIsDarkModeEnabled(true);
+
+ BaseActivityHook.initialize(new MusicActivityHook(), parentActivity);
+ }
+
+ /**
+ * Sets the fixed theme for the activity.
+ */
+ @Override
+ protected void customizeActivityTheme(Activity activity) {
+ // Override the default YouTube Music theme to increase start padding of list items.
+ // Custom style located in resources/music/values/style.xml
+ activity.setTheme(Utils.getResourceIdentifierOrThrow(
+ ResourceType.STYLE, "Theme.ReVanced.YouTubeMusic.Settings"));
+ }
+
+ /**
+ * Returns the fixed background color for the toolbar.
+ */
+ @Override
+ protected int getToolbarBackgroundColor() {
+ return Utils.getResourceColor("ytm_color_black");
+ }
+
+ /**
+ * Returns the navigation icon with a color filter applied.
+ */
+ @Override
+ protected Drawable getNavigationIcon() {
+ Drawable navigationIcon = MusicPreferenceFragment.getBackButtonDrawable();
+ navigationIcon.setColorFilter(Utils.getAppForegroundColor(), PorterDuff.Mode.SRC_IN);
+ return navigationIcon;
+ }
+
+ /**
+ * Returns the click listener that finishes the activity when the navigation icon is clicked.
+ */
+ @Override
+ protected View.OnClickListener getNavigationClickListener(Activity activity) {
+ return view -> {
+ if (searchViewController != null && searchViewController.isSearchActive()) {
+ searchViewController.closeSearch();
+ } else {
+ activity.finish();
+ }
+ };
+ }
+
+ /**
+ * Adds search view components to the toolbar for {@link MusicPreferenceFragment}.
+ *
+ * @param activity The activity hosting the toolbar.
+ * @param toolbar The configured toolbar.
+ * @param fragment The PreferenceFragment associated with the activity.
+ */
+ @Override
+ protected void onPostToolbarSetup(Activity activity, Toolbar toolbar, PreferenceFragment fragment) {
+ if (fragment instanceof MusicPreferenceFragment) {
+ searchViewController = MusicSearchViewController.addSearchViewComponents(
+ activity, toolbar, (MusicPreferenceFragment) fragment);
+ }
+ }
+
+ /**
+ * Creates a new {@link MusicPreferenceFragment} for the activity.
+ */
+ @Override
+ protected PreferenceFragment createPreferenceFragment() {
+ return new MusicPreferenceFragment();
+ }
+
+ /**
+ * Injection point.
+ *
+ * Overrides {@link Activity#finish()} of the injection Activity.
+ *
+ * @return if the original activity finish method should be allowed to run.
+ */
+ @SuppressWarnings("unused")
+ public static boolean handleFinish() {
+ return MusicSearchViewController.handleFinish(searchViewController);
+ }
+
+ /**
+ * Injection point.
+ *
+ * Decides whether to use bold icons.
+ */
+ @SuppressWarnings("unused")
+ public static boolean useBoldIcons(boolean original) {
+ return Utils.appIsUsingBoldIcons();
+ }
+}
diff --git a/extensions/music/src/main/java/app/revanced/extension/music/settings/Settings.java b/extensions/music/src/main/java/app/revanced/extension/music/settings/Settings.java
new file mode 100644
index 0000000000..7decd29b8a
--- /dev/null
+++ b/extensions/music/src/main/java/app/revanced/extension/music/settings/Settings.java
@@ -0,0 +1,41 @@
+package app.revanced.extension.music.settings;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+import static app.revanced.extension.shared.settings.Setting.parent;
+
+import app.revanced.extension.shared.settings.YouTubeAndMusicSettings;
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.settings.EnumSetting;
+import app.revanced.extension.shared.spoof.ClientType;
+
+public class Settings extends YouTubeAndMusicSettings {
+
+ // Ads
+ public static final BooleanSetting HIDE_VIDEO_ADS = new BooleanSetting("revanced_music_hide_video_ads", TRUE, true);
+ public static final BooleanSetting HIDE_GET_PREMIUM_LABEL = new BooleanSetting("revanced_music_hide_get_premium_label", TRUE, true);
+
+ // General
+ public static final BooleanSetting HIDE_CAST_BUTTON = new BooleanSetting("revanced_music_hide_cast_button", TRUE, true);
+ public static final BooleanSetting HIDE_CATEGORY_BAR = new BooleanSetting("revanced_music_hide_category_bar", FALSE, true);
+ public static final BooleanSetting HIDE_HISTORY_BUTTON = new BooleanSetting("revanced_music_hide_history_button", FALSE, true);
+ public static final BooleanSetting HIDE_SEARCH_BUTTON = new BooleanSetting("revanced_music_hide_search_button", FALSE, true);
+ public static final BooleanSetting HIDE_NOTIFICATION_BUTTON = new BooleanSetting("revanced_music_hide_notification_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_BAR_HOME_BUTTON = new BooleanSetting("revanced_music_hide_navigation_bar_home_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_BAR_SAMPLES_BUTTON = new BooleanSetting("revanced_music_hide_navigation_bar_samples_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_BAR_EXPLORE_BUTTON = new BooleanSetting("revanced_music_hide_navigation_bar_explore_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_BAR_LIBRARY_BUTTON = new BooleanSetting("revanced_music_hide_navigation_bar_library_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_BAR_UPGRADE_BUTTON = new BooleanSetting("revanced_music_hide_navigation_bar_upgrade_button", TRUE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_BAR = new BooleanSetting("revanced_music_hide_navigation_bar", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_BAR_LABEL = new BooleanSetting("revanced_music_hide_navigation_bar_labels", FALSE, true);
+
+ // Player
+ public static final BooleanSetting CHANGE_MINIPLAYER_COLOR = new BooleanSetting("revanced_music_change_miniplayer_color", FALSE, true);
+ public static final BooleanSetting PERMANENT_REPEAT = new BooleanSetting("revanced_music_play_permanent_repeat", FALSE, true);
+
+ // Miscellaneous
+ public static final EnumSetting SPOOF_VIDEO_STREAMS_CLIENT_TYPE = new EnumSetting<>("revanced_spoof_video_streams_client_type",
+ ClientType.ANDROID_REEL, true, parent(SPOOF_VIDEO_STREAMS));
+
+ public static final BooleanSetting FORCE_ORIGINAL_AUDIO = new BooleanSetting("revanced_force_original_audio", TRUE, true);
+}
diff --git a/extensions/music/src/main/java/app/revanced/extension/music/settings/preference/MusicPreferenceFragment.java b/extensions/music/src/main/java/app/revanced/extension/music/settings/preference/MusicPreferenceFragment.java
new file mode 100644
index 0000000000..86e5173420
--- /dev/null
+++ b/extensions/music/src/main/java/app/revanced/extension/music/settings/preference/MusicPreferenceFragment.java
@@ -0,0 +1,93 @@
+package app.revanced.extension.music.settings.preference;
+
+import android.app.Dialog;
+import android.preference.PreferenceScreen;
+import android.widget.Toolbar;
+
+import app.revanced.extension.music.settings.MusicActivityHook;
+import app.revanced.extension.shared.GmsCoreSupport;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.preference.ToolbarPreferenceFragment;
+
+/**
+ * Preference fragment for ReVanced settings.
+ */
+@SuppressWarnings("deprecation")
+public class MusicPreferenceFragment extends ToolbarPreferenceFragment {
+ /**
+ * The main PreferenceScreen used to display the current set of preferences.
+ */
+ private PreferenceScreen preferenceScreen;
+
+ /**
+ * Initializes the preference fragment.
+ */
+ @Override
+ protected void initialize() {
+ super.initialize();
+
+ try {
+ preferenceScreen = getPreferenceScreen();
+ Utils.sortPreferenceGroups(preferenceScreen);
+ setPreferenceScreenToolbar(preferenceScreen);
+
+ // Clunky work around until preferences are custom classes that manage themselves.
+ // Custom branding only works with non-root install. But the preferences must be
+ // added during patched because of difficulties detecting during patching if it's
+ // a root install. So instead the non-functional preferences are removed during
+ // runtime if the app is mount (root) installation.
+ if (GmsCoreSupport.isPackageNameOriginal()) {
+ removePreferences(
+ BaseSettings.CUSTOM_BRANDING_ICON.key,
+ BaseSettings.CUSTOM_BRANDING_NAME.key);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "initialize failure", ex);
+ }
+ }
+
+ /**
+ * Called when the fragment starts.
+ */
+ @Override
+ public void onStart() {
+ super.onStart();
+ try {
+ // Initialize search controller if needed
+ if (MusicActivityHook.searchViewController != null) {
+ // Trigger search data collection after fragment is ready.
+ MusicActivityHook.searchViewController.initializeSearchData();
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onStart failure", ex);
+ }
+ }
+
+ /**
+ * Sets toolbar for all nested preference screens.
+ */
+ @Override
+ protected void customizeToolbar(Toolbar toolbar) {
+ MusicActivityHook.setToolbarLayoutParams(toolbar);
+ }
+
+ /**
+ * Perform actions after toolbar setup.
+ */
+ @Override
+ protected void onPostToolbarSetup(Toolbar toolbar, Dialog preferenceScreenDialog) {
+ if (MusicActivityHook.searchViewController != null
+ && MusicActivityHook.searchViewController.isSearchActive()) {
+ toolbar.post(() -> MusicActivityHook.searchViewController.closeSearch());
+ }
+ }
+
+ /**
+ * Returns the preference screen for external access by SearchViewController.
+ */
+ public PreferenceScreen getPreferenceScreenForSearch() {
+ return preferenceScreen;
+ }
+}
diff --git a/extensions/music/src/main/java/app/revanced/extension/music/settings/search/MusicSearchResultsAdapter.java b/extensions/music/src/main/java/app/revanced/extension/music/settings/search/MusicSearchResultsAdapter.java
new file mode 100644
index 0000000000..65ccd4ea1a
--- /dev/null
+++ b/extensions/music/src/main/java/app/revanced/extension/music/settings/search/MusicSearchResultsAdapter.java
@@ -0,0 +1,28 @@
+package app.revanced.extension.music.settings.search;
+
+import android.content.Context;
+import android.preference.PreferenceScreen;
+
+import app.revanced.extension.shared.settings.search.BaseSearchResultsAdapter;
+import app.revanced.extension.shared.settings.search.BaseSearchViewController;
+import app.revanced.extension.shared.settings.search.BaseSearchResultItem;
+
+import java.util.List;
+
+/**
+ * Music-specific search results adapter.
+ */
+@SuppressWarnings("deprecation")
+public class MusicSearchResultsAdapter extends BaseSearchResultsAdapter {
+
+ public MusicSearchResultsAdapter(Context context, List items,
+ BaseSearchViewController.BasePreferenceFragment fragment,
+ BaseSearchViewController searchViewController) {
+ super(context, items, fragment, searchViewController);
+ }
+
+ @Override
+ protected PreferenceScreen getMainPreferenceScreen() {
+ return fragment.getPreferenceScreenForSearch();
+ }
+}
diff --git a/extensions/music/src/main/java/app/revanced/extension/music/settings/search/MusicSearchViewController.java b/extensions/music/src/main/java/app/revanced/extension/music/settings/search/MusicSearchViewController.java
new file mode 100644
index 0000000000..6681a2f027
--- /dev/null
+++ b/extensions/music/src/main/java/app/revanced/extension/music/settings/search/MusicSearchViewController.java
@@ -0,0 +1,71 @@
+package app.revanced.extension.music.settings.search;
+
+import android.app.Activity;
+import android.preference.Preference;
+import android.preference.PreferenceScreen;
+import android.view.View;
+import android.widget.Toolbar;
+
+import app.revanced.extension.music.settings.preference.MusicPreferenceFragment;
+import app.revanced.extension.shared.settings.search.*;
+
+/**
+ * Music-specific search view controller implementation.
+ */
+@SuppressWarnings("deprecation")
+public class MusicSearchViewController extends BaseSearchViewController {
+
+ public static MusicSearchViewController addSearchViewComponents(Activity activity, Toolbar toolbar,
+ MusicPreferenceFragment fragment) {
+ return new MusicSearchViewController(activity, toolbar, fragment);
+ }
+
+ private MusicSearchViewController(Activity activity, Toolbar toolbar, MusicPreferenceFragment fragment) {
+ super(activity, toolbar, new PreferenceFragmentAdapter(fragment));
+ }
+
+ @Override
+ protected BaseSearchResultsAdapter createSearchResultsAdapter() {
+ return new MusicSearchResultsAdapter(activity, filteredSearchItems, fragment, this);
+ }
+
+ @Override
+ protected boolean isSpecialPreferenceGroup(Preference preference) {
+ // Music doesn't have SponsorBlock, so no special groups.
+ return false;
+ }
+
+ @Override
+ protected void setupSpecialPreferenceListeners(BaseSearchResultItem item) {
+ // Music doesn't have special preferences.
+ // This method can be empty or handle music-specific preferences if any.
+ }
+
+ // Static method for handling Activity finish
+ public static boolean handleFinish(MusicSearchViewController searchViewController) {
+ if (searchViewController != null && searchViewController.isSearchActive()) {
+ searchViewController.closeSearch();
+ return true;
+ }
+ return false;
+ }
+
+ // Adapter to wrap MusicPreferenceFragment to BasePreferenceFragment interface.
+ private record PreferenceFragmentAdapter(MusicPreferenceFragment fragment) implements BasePreferenceFragment {
+
+ @Override
+ public PreferenceScreen getPreferenceScreenForSearch() {
+ return fragment.getPreferenceScreenForSearch();
+ }
+
+ @Override
+ public View getView() {
+ return fragment.getView();
+ }
+
+ @Override
+ public Activity getActivity() {
+ return fragment.getActivity();
+ }
+ }
+}
diff --git a/extensions/nothingx/build.gradle.kts b/extensions/nothingx/build.gradle.kts
new file mode 100644
index 0000000000..ed2b78c5f6
--- /dev/null
+++ b/extensions/nothingx/build.gradle.kts
@@ -0,0 +1,10 @@
+dependencies {
+ compileOnly(project(":extensions:shared:library"))
+ compileOnly(project(":extensions:nothingx:stub"))
+}
+
+android {
+ defaultConfig {
+ minSdk = 23
+ }
+}
\ No newline at end of file
diff --git a/extensions/nothingx/src/main/AndroidManifest.xml b/extensions/nothingx/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..15e7c2ae67
--- /dev/null
+++ b/extensions/nothingx/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/extensions/nothingx/src/main/java/app/revanced/extension/nothingx/patches/ShowK1TokensPatch.java b/extensions/nothingx/src/main/java/app/revanced/extension/nothingx/patches/ShowK1TokensPatch.java
new file mode 100644
index 0000000000..c301ae2fb3
--- /dev/null
+++ b/extensions/nothingx/src/main/java/app/revanced/extension/nothingx/patches/ShowK1TokensPatch.java
@@ -0,0 +1,590 @@
+package app.revanced.extension.nothingx.patches;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Application;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.os.Build;
+import android.os.Bundle;
+import android.util.Log;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.ScrollView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileReader;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Patches to expose the K1 token for Nothing X app to enable pairing with GadgetBridge.
+ */
+@SuppressWarnings("unused")
+public class ShowK1TokensPatch {
+
+ private static final String TAG = "ReVanced";
+ private static final String PACKAGE_NAME = "com.nothing.smartcenter";
+ private static final String EMPTY_MD5 = "d41d8cd98f00b204e9800998ecf8427e";
+ private static final String PREFS_NAME = "revanced_nothingx_prefs";
+ private static final String KEY_DONT_SHOW_DIALOG = "dont_show_k1_dialog";
+
+ // Colors
+ private static final int COLOR_BG = 0xFF1E1E1E;
+ private static final int COLOR_CARD = 0xFF2D2D2D;
+ private static final int COLOR_TEXT_PRIMARY = 0xFFFFFFFF;
+ private static final int COLOR_TEXT_SECONDARY = 0xFFB0B0B0;
+ private static final int COLOR_ACCENT = 0xFFFF9500;
+ private static final int COLOR_TOKEN_BG = 0xFF3A3A3A;
+ private static final int COLOR_BUTTON_POSITIVE = 0xFFFF9500;
+ private static final int COLOR_BUTTON_NEGATIVE = 0xFFFF6B6B;
+
+ // Match standalone K1: k1:, K1:, k1>, etc.
+ private static final Pattern K1_STANDALONE_PATTERN = Pattern.compile("(?i)(?:k1\\s*[:>]\\s*)([0-9a-f]{32})");
+ // Match combined r3+k1: format (64 chars = r3(32) + k1(32))
+ private static final Pattern K1_COMBINED_PATTERN = Pattern.compile("(?i)r3\\+k1\\s*:\\s*([0-9a-f]{64})");
+
+ private static volatile boolean k1Logged = false;
+ private static volatile boolean lifecycleCallbacksRegistered = false;
+ private static Context appContext;
+
+ /**
+ * Get K1 tokens from database and log files.
+ * Call this after the app initializes.
+ *
+ * @param context Application context
+ */
+ public static void showK1Tokens(Context context) {
+ if (k1Logged) {
+ return;
+ }
+
+ appContext = context.getApplicationContext();
+
+ Set allTokens = new LinkedHashSet<>();
+
+ // First try to get from database.
+ String dbToken = getK1TokensFromDatabase();
+ if (dbToken != null) {
+ allTokens.add(dbToken);
+ }
+
+ // Then get from log files.
+ Set logTokens = getK1TokensFromLogFiles();
+ allTokens.addAll(logTokens);
+
+ if (allTokens.isEmpty()) {
+ return;
+ }
+
+ // Log all found tokens.
+ int index = 1;
+ for (String token : allTokens) {
+ Log.i(TAG, "#" + index++ + ": " + token.toUpperCase());
+ }
+
+ // Register lifecycle callbacks to show dialog when an Activity is ready.
+ registerLifecycleCallbacks(allTokens);
+
+ k1Logged = true;
+ }
+
+ /**
+ * Register ActivityLifecycleCallbacks to show dialog when first Activity resumes.
+ *
+ * @param tokens Set of K1 tokens to display
+ */
+ private static void registerLifecycleCallbacks(Set tokens) {
+ if (lifecycleCallbacksRegistered || !(appContext instanceof Application)) {
+ return;
+ }
+
+ Application application = (Application) appContext;
+ application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
+ @Override
+ public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
+ }
+
+ @Override
+ public void onActivityStarted(Activity activity) {
+ }
+
+ @Override
+ public void onActivityResumed(Activity activity) {
+ // Check if user chose not to show dialog.
+ SharedPreferences prefs = activity.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
+ if (prefs.getBoolean(KEY_DONT_SHOW_DIALOG, false)) {
+ application.unregisterActivityLifecycleCallbacks(this);
+ lifecycleCallbacksRegistered = false;
+ return;
+ }
+
+ // Show dialog on first Activity resume.
+ if (tokens != null && !tokens.isEmpty()) {
+ activity.runOnUiThread(() -> showK1TokensDialog(activity, tokens));
+ // Unregister after showing
+ application.unregisterActivityLifecycleCallbacks(this);
+ lifecycleCallbacksRegistered = false;
+ }
+ }
+
+ @Override
+ public void onActivityPaused(Activity activity) {
+ }
+
+ @Override
+ public void onActivityStopped(Activity activity) {
+ }
+
+ @Override
+ public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
+ }
+
+ @Override
+ public void onActivityDestroyed(Activity activity) {
+ }
+ });
+
+ lifecycleCallbacksRegistered = true;
+ }
+
+ /**
+ * Show dialog with K1 tokens.
+ *
+ * @param activity Activity context
+ * @param tokens Set of K1 tokens
+ */
+ private static void showK1TokensDialog(Activity activity, Set tokens) {
+ try {
+ // Create main container.
+ LinearLayout mainLayout = new LinearLayout(activity);
+ mainLayout.setOrientation(LinearLayout.VERTICAL);
+ mainLayout.setBackgroundColor(COLOR_BG);
+ mainLayout.setPadding(dpToPx(activity, 24), dpToPx(activity, 16),
+ dpToPx(activity, 24), dpToPx(activity, 16));
+
+ // Title.
+ TextView titleView = new TextView(activity);
+ titleView.setText("K1 Token(s) Found");
+ titleView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
+ titleView.setTypeface(Typeface.DEFAULT_BOLD);
+ titleView.setTextColor(COLOR_TEXT_PRIMARY);
+ titleView.setGravity(Gravity.CENTER);
+ mainLayout.addView(titleView, new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ));
+
+ // Subtitle.
+ TextView subtitleView = new TextView(activity);
+ subtitleView.setText(tokens.size() == 1 ? "1 token found • Tap to copy" : tokens.size() + " tokens found • Tap to copy");
+ subtitleView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
+ subtitleView.setTextColor(COLOR_TEXT_SECONDARY);
+ subtitleView.setGravity(Gravity.CENTER);
+ LinearLayout.LayoutParams subtitleParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ subtitleParams.topMargin = dpToPx(activity, 4);
+ subtitleParams.bottomMargin = dpToPx(activity, 16);
+ mainLayout.addView(subtitleView, subtitleParams);
+
+ // Scrollable content.
+ ScrollView scrollView = new ScrollView(activity);
+ scrollView.setVerticalScrollBarEnabled(false);
+ LinearLayout.LayoutParams scrollParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ 0,
+ 1.0f
+ );
+ scrollParams.topMargin = dpToPx(activity, 8);
+ scrollParams.bottomMargin = dpToPx(activity, 16);
+ mainLayout.addView(scrollView, scrollParams);
+
+ LinearLayout tokensContainer = new LinearLayout(activity);
+ tokensContainer.setOrientation(LinearLayout.VERTICAL);
+ scrollView.addView(tokensContainer);
+
+ // Add each token as a card.
+ boolean singleToken = tokens.size() == 1;
+ int index = 1;
+ for (String token : tokens) {
+ LinearLayout tokenCard = createTokenCard(activity, token, index++, singleToken);
+ LinearLayout.LayoutParams cardParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ cardParams.bottomMargin = dpToPx(activity, 12);
+ tokensContainer.addView(tokenCard, cardParams);
+ }
+
+ // Info text.
+ TextView infoView = new TextView(activity);
+ infoView.setText(tokens.size() == 1 ? "Tap the token to copy it" : "Tap any token to copy it");
+ infoView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12);
+ infoView.setTextColor(COLOR_TEXT_SECONDARY);
+ infoView.setGravity(Gravity.CENTER);
+ infoView.setAlpha(0.7f);
+ LinearLayout.LayoutParams infoParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ infoParams.topMargin = dpToPx(activity, 8);
+ mainLayout.addView(infoView, infoParams);
+
+ // Button row.
+ LinearLayout buttonRow = new LinearLayout(activity);
+ buttonRow.setOrientation(LinearLayout.HORIZONTAL);
+ buttonRow.setGravity(Gravity.END);
+ LinearLayout.LayoutParams buttonRowParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ buttonRowParams.topMargin = dpToPx(activity, 16);
+ mainLayout.addView(buttonRow, buttonRowParams);
+
+ // "Don't show again" button.
+ Button dontShowButton = new Button(activity);
+ dontShowButton.setText("Don't show again");
+ dontShowButton.setTextColor(Color.WHITE);
+ dontShowButton.setBackgroundColor(Color.TRANSPARENT);
+ dontShowButton.setAllCaps(false);
+ dontShowButton.setTypeface(Typeface.DEFAULT);
+ dontShowButton.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
+ dontShowButton.setPadding(dpToPx(activity, 16), dpToPx(activity, 8),
+ dpToPx(activity, 16), dpToPx(activity, 8));
+ LinearLayout.LayoutParams dontShowParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ dontShowParams.rightMargin = dpToPx(activity, 8);
+ buttonRow.addView(dontShowButton, dontShowParams);
+
+ // "OK" button.
+ Button okButton = new Button(activity);
+ okButton.setText("OK");
+ okButton.setTextColor(Color.BLACK);
+ okButton.setBackgroundColor(COLOR_BUTTON_POSITIVE);
+ okButton.setAllCaps(false);
+ okButton.setTypeface(Typeface.DEFAULT_BOLD);
+ okButton.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
+ okButton.setPadding(dpToPx(activity, 24), dpToPx(activity, 12),
+ dpToPx(activity, 24), dpToPx(activity, 12));
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ okButton.setElevation(dpToPx(activity, 4));
+ }
+ buttonRow.addView(okButton, new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ));
+
+ // Build dialog.
+ AlertDialog.Builder builder = new AlertDialog.Builder(activity);
+ builder.setView(mainLayout);
+
+ final AlertDialog dialog = builder.create();
+
+ // Style the dialog with dark background.
+ if (dialog.getWindow() != null) {
+ dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
+ }
+
+ dialog.show();
+
+ // Set button click listeners after dialog is created.
+ dontShowButton.setOnClickListener(v -> {
+ SharedPreferences prefs = activity.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
+ prefs.edit().putBoolean(KEY_DONT_SHOW_DIALOG, true).apply();
+ Toast.makeText(activity, "Dialog disabled. Clear app data to re-enable.",
+ Toast.LENGTH_SHORT).show();
+ dialog.dismiss();
+ });
+
+ okButton.setOnClickListener(v -> {
+ dialog.dismiss();
+ });
+
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to show K1 dialog", e);
+ }
+ }
+
+ /**
+ * Create a card view for a single token.
+ */
+ private static LinearLayout createTokenCard(Activity activity, String token, int index, boolean singleToken) {
+ LinearLayout card = new LinearLayout(activity);
+ card.setOrientation(LinearLayout.VERTICAL);
+ card.setBackgroundColor(COLOR_TOKEN_BG);
+ card.setPadding(dpToPx(activity, 16), dpToPx(activity, 12),
+ dpToPx(activity, 16), dpToPx(activity, 12));
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ card.setElevation(dpToPx(activity, 2));
+ }
+ card.setClickable(true);
+ card.setFocusable(true);
+
+ // Token label (only show if multiple tokens).
+ if (!singleToken) {
+ TextView labelView = new TextView(activity);
+ labelView.setText("Token #" + index);
+ labelView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12);
+ labelView.setTextColor(COLOR_ACCENT);
+ labelView.setTypeface(Typeface.DEFAULT_BOLD);
+ card.addView(labelView);
+ }
+
+ // Token value.
+ TextView tokenView = new TextView(activity);
+ tokenView.setText(token.toUpperCase());
+ tokenView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16);
+ tokenView.setTextColor(COLOR_TEXT_PRIMARY);
+ tokenView.setTypeface(Typeface.MONOSPACE);
+ tokenView.setLetterSpacing(0.05f);
+ LinearLayout.LayoutParams tokenParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ if (!singleToken) {
+ tokenParams.topMargin = dpToPx(activity, 8);
+ }
+ card.addView(tokenView, tokenParams);
+
+ // Click to copy.
+ card.setOnClickListener(v -> {
+ ClipboardManager clipboard = (ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE);
+ if (clipboard != null) {
+ clipboard.setText(token.toUpperCase());
+ Toast.makeText(activity, "Token copied!", Toast.LENGTH_SHORT).show();
+ }
+ });
+
+ return card;
+ }
+
+ /**
+ * Convert dp to pixels.
+ */
+ private static int dpToPx(Context context, float dp) {
+ return (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ dp,
+ context.getResources().getDisplayMetrics()
+ );
+ }
+
+ /**
+ * Get K1 tokens from log files.
+ * Prioritizes pairing K1 tokens over reconnect tokens.
+ */
+ private static Set getK1TokensFromLogFiles() {
+ Set pairingTokens = new LinkedHashSet<>();
+ Set reconnectTokens = new LinkedHashSet<>();
+ try {
+ File logDir = new File("/data/data/" + PACKAGE_NAME + "/files/log");
+ if (!logDir.exists() || !logDir.isDirectory()) {
+ return pairingTokens;
+ }
+
+ File[] logFiles = logDir.listFiles((dir, name) ->
+ name.endsWith(".log") || name.endsWith(".log.") || name.matches(".*\\.log\\.\\d+"));
+
+ if (logFiles == null || logFiles.length == 0) {
+ return pairingTokens;
+ }
+
+ for (File logFile : logFiles) {
+ try (BufferedReader reader = new BufferedReader(new FileReader(logFile))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ // Determine if this is a pairing or reconnect context.
+ boolean isPairingContext = line.toLowerCase().contains("watchbind");
+ boolean isReconnectContext = line.toLowerCase().contains("watchreconnect");
+
+ String k1Token = null;
+
+ // First check for combined r3+k1 format (priority).
+ Matcher combinedMatcher = K1_COMBINED_PATTERN.matcher(line);
+ if (combinedMatcher.find()) {
+ String combined = combinedMatcher.group(1);
+ if (combined.length() == 64) {
+ // Second half is the actual K1
+ k1Token = combined.substring(32).toLowerCase();
+ }
+ }
+
+ // Then check for standalone K1 format (only if not found in combined).
+ if (k1Token == null) {
+ Matcher standaloneMatcher = K1_STANDALONE_PATTERN.matcher(line);
+ if (standaloneMatcher.find()) {
+ String token = standaloneMatcher.group(1);
+ if (token != null && token.length() == 32) {
+ k1Token = token.toLowerCase();
+ }
+ }
+ }
+
+ // Add to appropriate set.
+ if (k1Token != null) {
+ if (isPairingContext && !isReconnectContext) {
+ pairingTokens.add(k1Token);
+ } else {
+ reconnectTokens.add(k1Token);
+ }
+ }
+ }
+ } catch (Exception e) {
+ // Skip unreadable files.
+ }
+ }
+ } catch (Exception ex) {
+ // Fail silently.
+ }
+
+ // Return pairing tokens first, add reconnect tokens if no pairing tokens found.
+ if (!pairingTokens.isEmpty()) {
+ Log.i(TAG, "Found " + pairingTokens.size() + " pairing K1 token(s)");
+ return pairingTokens;
+ }
+
+ if (!reconnectTokens.isEmpty()) {
+ Log.i(TAG, "Found " + reconnectTokens.size() + " reconnect K1 token(s) (may not work for initial pairing)");
+ }
+ return reconnectTokens;
+ }
+
+ /**
+ * Try to get K1 tokens from the database.
+ */
+ private static String getK1TokensFromDatabase() {
+ try {
+ File dbDir = new File("/data/data/" + PACKAGE_NAME + "/databases");
+ if (!dbDir.exists() || !dbDir.isDirectory()) {
+ return null;
+ }
+
+ File[] dbFiles = dbDir.listFiles((dir, name) ->
+ name.endsWith(".db") && !name.startsWith("google_app_measurement") && !name.contains("firebase"));
+
+ if (dbFiles == null || dbFiles.length == 0) {
+ return null;
+ }
+
+ for (File dbFile : dbFiles) {
+ String token = getK1TokensFromDatabase(dbFile);
+ if (token != null) {
+ return token;
+ }
+ }
+
+ return null;
+ } catch (Exception ex) {
+ return null;
+ }
+ }
+
+ /**
+ * Extract K1 tokens from a database file.
+ */
+ private static String getK1TokensFromDatabase(File dbFile) {
+ SQLiteDatabase db = null;
+ try {
+ db = SQLiteDatabase.openDatabase(dbFile.getPath(), null, SQLiteDatabase.OPEN_READONLY);
+
+ // Get all tables.
+ Cursor cursor = db.rawQuery(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
+ null
+ );
+
+ List tables = new ArrayList<>();
+ while (cursor.moveToNext()) {
+ tables.add(cursor.getString(0));
+ }
+ cursor.close();
+
+ // Scan all columns for 32-char hex strings.
+ for (String table : tables) {
+ Cursor schemaCursor = null;
+ try {
+ schemaCursor = db.rawQuery("PRAGMA table_info(" + table + ")", null);
+ List columns = new ArrayList<>();
+ while (schemaCursor.moveToNext()) {
+ columns.add(schemaCursor.getString(1));
+ }
+ schemaCursor.close();
+
+ for (String column : columns) {
+ Cursor dataCursor = null;
+ try {
+ dataCursor = db.query(table, new String[]{column}, null, null, null, null, null);
+ while (dataCursor.moveToNext()) {
+ String value = dataCursor.getString(0);
+ if (value != null && value.length() == 32 && value.matches("[0-9a-fA-F]{32}")) {
+ // Skip obviously fake tokens (MD5 of empty string).
+ if (!value.equalsIgnoreCase(EMPTY_MD5)) {
+ dataCursor.close();
+ db.close();
+ return value.toLowerCase();
+ }
+ }
+ }
+ } catch (Exception e) {
+ // Skip non-string columns.
+ } finally {
+ if (dataCursor != null) {
+ dataCursor.close();
+ }
+ }
+ }
+ } catch (Exception e) {
+ // Continue to next table.
+ } finally {
+ if (schemaCursor != null && !schemaCursor.isClosed()) {
+ schemaCursor.close();
+ }
+ }
+ }
+
+ return null;
+ } catch (Exception ex) {
+ return null;
+ } finally {
+ if (db != null && db.isOpen()) {
+ db.close();
+ }
+ }
+ }
+
+ /**
+ * Reset the logged flag (useful for testing or re-pairing).
+ */
+ public static void resetK1Logged() {
+ k1Logged = false;
+ lifecycleCallbacksRegistered = false;
+ }
+
+ /**
+ * Reset the "don't show again" preference.
+ */
+ public static void resetDontShowPreference() {
+ if (appContext != null) {
+ SharedPreferences prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
+ prefs.edit().putBoolean(KEY_DONT_SHOW_DIALOG, false).apply();
+ }
+ }
+}
diff --git a/extensions/nothingx/stub/build.gradle.kts b/extensions/nothingx/stub/build.gradle.kts
new file mode 100644
index 0000000000..fcadc678c4
--- /dev/null
+++ b/extensions/nothingx/stub/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "app.revanced.extension"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 26
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
\ No newline at end of file
diff --git a/extensions/nothingx/stub/src/main/AndroidManifest.xml b/extensions/nothingx/stub/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..15e7c2ae67
--- /dev/null
+++ b/extensions/nothingx/stub/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/extensions/nunl/build.gradle.kts b/extensions/nunl/build.gradle.kts
new file mode 100644
index 0000000000..ab48531bba
--- /dev/null
+++ b/extensions/nunl/build.gradle.kts
@@ -0,0 +1,10 @@
+dependencies {
+ compileOnly(project(":extensions:shared:library"))
+ compileOnly(project(":extensions:nunl:stub"))
+}
+
+android {
+ defaultConfig {
+ minSdk = 26
+ }
+}
diff --git a/extensions/nunl/src/main/AndroidManifest.xml b/extensions/nunl/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..9b65eb06cf
--- /dev/null
+++ b/extensions/nunl/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/extensions/nunl/src/main/java/app/revanced/extension/nunl/ads/HideAdsPatch.java b/extensions/nunl/src/main/java/app/revanced/extension/nunl/ads/HideAdsPatch.java
new file mode 100644
index 0000000000..2e4ab5b069
--- /dev/null
+++ b/extensions/nunl/src/main/java/app/revanced/extension/nunl/ads/HideAdsPatch.java
@@ -0,0 +1,114 @@
+package app.revanced.extension.nunl.ads;
+
+import nl.nu.performance.api.client.interfaces.Block;
+import nl.nu.performance.api.client.unions.SmallArticleLinkFlavor;
+import nl.nu.performance.api.client.objects.*;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import app.revanced.extension.shared.Logger;
+
+@SuppressWarnings("unused")
+public class HideAdsPatch {
+ private static final String[] blockedHeaderBlocks = {
+ "Aanbiedingen (Adverteerders)",
+ "Aangeboden door NUshop"
+ };
+
+ // "Rubrieken" menu links to ads.
+ private static final String[] blockedLinkBlocks = {
+ "Van onze adverteerders"
+ };
+
+ public static void filterAds(List blocks) {
+ try {
+ ArrayList cleanedList = new ArrayList<>();
+
+ boolean skipFullHeader = false;
+ boolean skipUntilDivider = false;
+
+ int index = 0;
+ while (index < blocks.size()) {
+ Block currentBlock = blocks.get(index);
+
+ // Because of pagination, we might not see the Divider in front of it.
+ // Just remove it as is and leave potential extra spacing visible on the screen.
+ if (currentBlock instanceof DpgBannerBlock) {
+ index++;
+ continue;
+ }
+
+ if (index + 1 < blocks.size()) {
+ // Filter Divider -> DpgMediaBanner -> Divider.
+ if (currentBlock instanceof DividerBlock
+ && blocks.get(index + 1) instanceof DpgBannerBlock) {
+ index += 2;
+ continue;
+ }
+
+ // Filter Divider -> LinkBlock (... -> LinkBlock -> LinkBlock-> LinkBlock -> Divider).
+ if (currentBlock instanceof DividerBlock
+ && blocks.get(index + 1) instanceof LinkBlock linkBlock) {
+ Link link = linkBlock.getLink();
+ if (link != null && link.getTitle() != null) {
+ for (String blockedLinkBlock : blockedLinkBlocks) {
+ if (blockedLinkBlock.equals(link.getTitle().getText())) {
+ skipUntilDivider = true;
+ break;
+ }
+ }
+ if (skipUntilDivider) {
+ index++;
+ continue;
+ }
+ }
+ }
+ }
+
+ // Skip LinkBlocks with a "flavor" claiming to be "isPartner" (sponsored inline ads).
+ if (currentBlock instanceof LinkBlock linkBlock
+ && linkBlock.getLink() != null
+ && linkBlock.getLink().getLinkFlavor() instanceof SmallArticleLinkFlavor smallArticleLinkFlavor
+ && smallArticleLinkFlavor.isPartner() != null
+ && smallArticleLinkFlavor.isPartner()) {
+ index++;
+ continue;
+ }
+
+ if (currentBlock instanceof DividerBlock) {
+ skipUntilDivider = false;
+ }
+
+ // Filter HeaderBlock with known ads until next HeaderBlock.
+ if (currentBlock instanceof HeaderBlock headerBlock) {
+ StyledText headerText = headerBlock.getTitle();
+ if (headerText != null) {
+ skipFullHeader = false;
+ for (String blockedHeaderBlock : blockedHeaderBlocks) {
+ if (blockedHeaderBlock.equals(headerText.getText())) {
+ skipFullHeader = true;
+ break;
+ }
+ }
+ if (skipFullHeader) {
+ index++;
+ continue;
+ }
+ }
+ }
+
+ if (!skipFullHeader && !skipUntilDivider) {
+ cleanedList.add(currentBlock);
+ }
+ index++;
+ }
+
+ // Replace list in-place to not deal with moving the result to the correct register in smali.
+ blocks.clear();
+ blocks.addAll(cleanedList);
+ } catch (Exception ex) {
+ Logger.printException(() -> "filterAds failure", ex);
+ }
+ }
+}
diff --git a/extensions/nunl/stub/build.gradle.kts b/extensions/nunl/stub/build.gradle.kts
new file mode 100644
index 0000000000..7905271b26
--- /dev/null
+++ b/extensions/nunl/stub/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "app.revanced.extension"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 26
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
diff --git a/extensions/nunl/stub/src/main/AndroidManifest.xml b/extensions/nunl/stub/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..9b65eb06cf
--- /dev/null
+++ b/extensions/nunl/stub/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/interfaces/Block.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/interfaces/Block.java
new file mode 100644
index 0000000000..3514f360cb
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/interfaces/Block.java
@@ -0,0 +1,5 @@
+package nl.nu.performance.api.client.interfaces;
+
+public class Block {
+
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DividerBlock.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DividerBlock.java
new file mode 100644
index 0000000000..0351aec049
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DividerBlock.java
@@ -0,0 +1,7 @@
+package nl.nu.performance.api.client.objects;
+
+import nl.nu.performance.api.client.interfaces.Block;
+
+public class DividerBlock extends Block {
+
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DpgBannerBlock.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DpgBannerBlock.java
new file mode 100644
index 0000000000..ac300b0539
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DpgBannerBlock.java
@@ -0,0 +1,7 @@
+package nl.nu.performance.api.client.objects;
+
+import nl.nu.performance.api.client.interfaces.Block;
+
+public class DpgBannerBlock extends Block {
+
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/HeaderBlock.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/HeaderBlock.java
new file mode 100644
index 0000000000..7b1f7ad192
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/HeaderBlock.java
@@ -0,0 +1,9 @@
+package nl.nu.performance.api.client.objects;
+
+import nl.nu.performance.api.client.interfaces.Block;
+
+public class HeaderBlock extends Block {
+ public final StyledText getTitle() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/Link.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/Link.java
new file mode 100644
index 0000000000..771d11dad1
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/Link.java
@@ -0,0 +1,13 @@
+package nl.nu.performance.api.client.objects;
+
+import nl.nu.performance.api.client.unions.LinkFlavor;
+
+public class Link {
+ public final StyledText getTitle() {
+ throw new UnsupportedOperationException("Stub");
+ }
+
+ public final LinkFlavor getLinkFlavor() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/LinkBlock.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/LinkBlock.java
new file mode 100644
index 0000000000..dea1950573
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/LinkBlock.java
@@ -0,0 +1,10 @@
+package nl.nu.performance.api.client.objects;
+
+import android.os.Parcelable;
+import nl.nu.performance.api.client.interfaces.Block;
+
+public abstract class LinkBlock extends Block implements Parcelable {
+ public final Link getLink() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/StyledText.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/StyledText.java
new file mode 100644
index 0000000000..719403eb4e
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/StyledText.java
@@ -0,0 +1,7 @@
+package nl.nu.performance.api.client.objects;
+
+public class StyledText {
+ public final String getText() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/LinkFlavor.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/LinkFlavor.java
new file mode 100644
index 0000000000..08413d3fd9
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/LinkFlavor.java
@@ -0,0 +1,4 @@
+package nl.nu.performance.api.client.unions;
+
+public interface LinkFlavor {
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/SmallArticleLinkFlavor.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/SmallArticleLinkFlavor.java
new file mode 100644
index 0000000000..4dcbf23cb9
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/SmallArticleLinkFlavor.java
@@ -0,0 +1,7 @@
+package nl.nu.performance.api.client.unions;
+
+public class SmallArticleLinkFlavor implements LinkFlavor {
+ public final Boolean isPartner() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
diff --git a/extensions/primevideo/build.gradle.kts b/extensions/primevideo/build.gradle.kts
new file mode 100644
index 0000000000..17a3c31a21
--- /dev/null
+++ b/extensions/primevideo/build.gradle.kts
@@ -0,0 +1,10 @@
+dependencies {
+ compileOnly(project(":extensions:shared:library"))
+ compileOnly(project(":extensions:primevideo:stub"))
+}
+
+android {
+ defaultConfig {
+ minSdk = 21
+ }
+}
diff --git a/extensions/primevideo/src/main/AndroidManifest.xml b/extensions/primevideo/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..9b65eb06cf
--- /dev/null
+++ b/extensions/primevideo/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/extensions/primevideo/src/main/java/app/revanced/extension/primevideo/ads/SkipAdsPatch.java b/extensions/primevideo/src/main/java/app/revanced/extension/primevideo/ads/SkipAdsPatch.java
new file mode 100644
index 0000000000..d0a97810a2
--- /dev/null
+++ b/extensions/primevideo/src/main/java/app/revanced/extension/primevideo/ads/SkipAdsPatch.java
@@ -0,0 +1,36 @@
+package app.revanced.extension.primevideo.ads;
+
+import com.amazon.avod.fsm.SimpleTrigger;
+import com.amazon.avod.media.ads.AdBreak;
+import com.amazon.avod.media.ads.internal.state.AdBreakTrigger;
+import com.amazon.avod.media.ads.internal.state.AdEnabledPlayerTriggerType;
+import com.amazon.avod.media.playback.VideoPlayer;
+import com.amazon.avod.media.ads.internal.state.ServerInsertedAdBreakState;
+
+import app.revanced.extension.shared.Logger;
+
+@SuppressWarnings("unused")
+public final class SkipAdsPatch {
+ public static void enterServerInsertedAdBreakState(ServerInsertedAdBreakState state, AdBreakTrigger trigger, VideoPlayer player) {
+ try {
+ AdBreak adBreak = trigger.getBreak();
+
+ // There are two scenarios when entering the original method:
+ // 1. Player naturally entered an ad break while watching a video.
+ // 2. User is skipped/scrubbed to a position on the timeline. If seek position is past an ad break,
+ // user is forced to watch an ad before continuing.
+ //
+ // Scenario 2 is indicated by trigger.getSeekStartPosition() != null, so skip directly to the scrubbing
+ // target. Otherwise, just calculate when the ad break should end and skip to there.
+ if (trigger.getSeekStartPosition() != null)
+ player.seekTo(trigger.getSeekTarget().getTotalMilliseconds());
+ else
+ player.seekTo(player.getCurrentPosition() + adBreak.getDurationExcludingAux().getTotalMilliseconds());
+
+ // Send "end of ads" trigger to state machine so everything doesn't get whacky.
+ state.doTrigger(new SimpleTrigger(AdEnabledPlayerTriggerType.NO_MORE_ADS_SKIP_TRANSITION));
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed skipping ads", ex);
+ }
+ }
+}
\ No newline at end of file
diff --git a/extensions/primevideo/src/main/java/app/revanced/extension/primevideo/videoplayer/PlaybackSpeedPatch.java b/extensions/primevideo/src/main/java/app/revanced/extension/primevideo/videoplayer/PlaybackSpeedPatch.java
new file mode 100644
index 0000000000..b11ec0875d
--- /dev/null
+++ b/extensions/primevideo/src/main/java/app/revanced/extension/primevideo/videoplayer/PlaybackSpeedPatch.java
@@ -0,0 +1,207 @@
+package app.revanced.extension.primevideo.videoplayer;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.graphics.RectF;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.graphics.Color;
+import android.graphics.drawable.Drawable;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.ColorFilter;
+import android.graphics.PixelFormat;
+import java.util.Arrays;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.ui.Dim;
+
+import com.amazon.video.sdk.player.Player;
+
+public class PlaybackSpeedPatch {
+ private static Player player;
+ private static final float[] SPEED_VALUES = {0.5f, 0.7f, 0.8f, 0.9f, 0.95f, 1.0f, 1.05f, 1.1f, 1.2f, 1.3f, 1.5f, 2.0f};
+ private static final String SPEED_BUTTON_TAG = "speed_overlay";
+
+ public static void setPlayer(Player playerInstance) {
+ player = playerInstance;
+ if (player != null) {
+ // Reset playback rate when switching between episodes to ensure correct display.
+ player.setPlaybackRate(1.0f);
+ }
+ }
+
+ public static void initializeSpeedOverlay(View userControlsView) {
+ try {
+ LinearLayout buttonContainer = Utils.getChildViewByResourceName(userControlsView, "ButtonContainerPlayerTop");
+
+ // If the speed overlay exists we should return early.
+ if (Utils.getChildView(buttonContainer, false, child ->
+ child instanceof ImageView && SPEED_BUTTON_TAG.equals(child.getTag())) != null) {
+ return;
+ }
+
+ ImageView speedButton = createSpeedButton(userControlsView.getContext());
+ speedButton.setOnClickListener(v -> changePlaybackSpeed(speedButton));
+ buttonContainer.addView(speedButton, 0);
+
+ } catch (IllegalArgumentException e) {
+ Logger.printException(() -> "initializeSpeedOverlay, no button container found", e);
+ } catch (Exception e) {
+ Logger.printException(() -> "initializeSpeedOverlay failure", e);
+ }
+ }
+
+ private static ImageView createSpeedButton(Context context) {
+ ImageView speedButton = new ImageView(context);
+ speedButton.setContentDescription("Playback Speed");
+ speedButton.setTag(SPEED_BUTTON_TAG);
+ speedButton.setClickable(true);
+ speedButton.setFocusable(true);
+ speedButton.setScaleType(ImageView.ScaleType.CENTER);
+
+ SpeedIconDrawable speedIcon = new SpeedIconDrawable();
+ speedButton.setImageDrawable(speedIcon);
+
+ speedButton.setMinimumWidth(Dim.dp48);
+ speedButton.setMinimumHeight(Dim.dp48);
+
+ return speedButton;
+ }
+
+ private static String[] getSpeedOptions() {
+ String[] options = new String[SPEED_VALUES.length];
+ for (int i = 0; i < SPEED_VALUES.length; i++) {
+ options[i] = SPEED_VALUES[i] + "x";
+ }
+ return options;
+ }
+
+ private static void changePlaybackSpeed(ImageView imageView) {
+ if (player == null) {
+ Logger.printException(() -> "Player not available");
+ return;
+ }
+
+ try {
+ player.pause();
+ AlertDialog dialog = createSpeedPlaybackDialog(imageView);
+ dialog.setOnDismissListener(dialogInterface -> player.play());
+ dialog.show();
+
+ } catch (Exception e) {
+ Logger.printException(() -> "changePlaybackSpeed", e);
+ }
+ }
+
+ private static AlertDialog createSpeedPlaybackDialog(ImageView imageView) {
+ Context context = imageView.getContext();
+ int currentSelection = getCurrentSpeedSelection();
+
+ return new AlertDialog.Builder(context)
+ .setTitle("Select Playback Speed")
+ .setSingleChoiceItems(getSpeedOptions(), currentSelection,
+ PlaybackSpeedPatch::handleSpeedSelection)
+ .create();
+ }
+
+ private static int getCurrentSpeedSelection() {
+ try {
+ float currentRate = player.getPlaybackRate();
+ int index = Arrays.binarySearch(SPEED_VALUES, currentRate);
+ return Math.max(index, 0); // Use slowest speed if not found.
+ } catch (Exception e) {
+ Logger.printException(() -> "getCurrentSpeedSelection error getting current playback speed", e);
+ return 0;
+ }
+ }
+
+ private static void handleSpeedSelection(android.content.DialogInterface dialog, int selectedIndex) {
+ try {
+ float selectedSpeed = SPEED_VALUES[selectedIndex];
+ player.setPlaybackRate(selectedSpeed);
+ player.play();
+ } catch (Exception e) {
+ Logger.printException(() -> "handleSpeedSelection error setting playback speed", e);
+ } finally {
+ dialog.dismiss();
+ }
+ }
+}
+
+class SpeedIconDrawable extends Drawable {
+ private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ @Override
+ public void draw(Canvas canvas) {
+ int w = getBounds().width();
+ int h = getBounds().height();
+ float centerX = w / 2f;
+ // Position gauge in lower portion.
+ float centerY = h * 0.7f;
+ float radius = Math.min(w, h) / 2f * 0.8f;
+
+ paint.setColor(Color.WHITE);
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeWidth(radius * 0.1f);
+
+ // Draw semicircle.
+ RectF oval = new RectF(centerX - radius, centerY - radius, centerX + radius, centerY + radius);
+ canvas.drawArc(oval, 180, 180, false, paint);
+
+ // Draw three tick marks.
+ paint.setStrokeWidth(radius * 0.06f);
+ for (int i = 0; i < 3; i++) {
+ float angle = 180 + (i * 45); // 180°, 225°, 270°.
+ float angleRad = (float) Math.toRadians(angle);
+
+ float startX = centerX + (radius * 0.8f) * (float) Math.cos(angleRad);
+ float startY = centerY + (radius * 0.8f) * (float) Math.sin(angleRad);
+ float endX = centerX + radius * (float) Math.cos(angleRad);
+ float endY = centerY + radius * (float) Math.sin(angleRad);
+
+ canvas.drawLine(startX, startY, endX, endY, paint);
+ }
+
+ // Draw needle.
+ paint.setStrokeWidth(radius * 0.08f);
+ float needleAngle = 200; // Slightly right of center.
+ float needleAngleRad = (float) Math.toRadians(needleAngle);
+
+ float needleEndX = centerX + (radius * 0.6f) * (float) Math.cos(needleAngleRad);
+ float needleEndY = centerY + (radius * 0.6f) * (float) Math.sin(needleAngleRad);
+
+ canvas.drawLine(centerX, centerY, needleEndX, needleEndY, paint);
+
+ // Center dot.
+ paint.setStyle(Paint.Style.FILL);
+ canvas.drawCircle(centerX, centerY, radius * 0.06f, paint);
+ }
+
+ @Override
+ public void setAlpha(int alpha) {
+ paint.setAlpha(alpha);
+ }
+
+ @Override
+ public void setColorFilter(ColorFilter colorFilter) {
+ paint.setColorFilter(colorFilter);
+ }
+
+ @Override
+ public int getOpacity() {
+ return PixelFormat.TRANSLUCENT;
+ }
+
+ @Override
+ public int getIntrinsicWidth() {
+ return Dim.dp32;
+ }
+
+ @Override
+ public int getIntrinsicHeight() {
+ return Dim.dp32;
+ }
+}
diff --git a/extensions/primevideo/stub/build.gradle.kts b/extensions/primevideo/stub/build.gradle.kts
new file mode 100644
index 0000000000..7744c0eaac
--- /dev/null
+++ b/extensions/primevideo/stub/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "app.revanced.extension"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 21
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
diff --git a/extensions/primevideo/stub/src/main/AndroidManifest.xml b/extensions/primevideo/stub/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..9b65eb06cf
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/SimpleTrigger.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/SimpleTrigger.java
new file mode 100644
index 0000000000..b537fe0402
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/SimpleTrigger.java
@@ -0,0 +1,6 @@
+package com.amazon.avod.fsm;
+
+public final class SimpleTrigger implements Trigger {
+ public SimpleTrigger(T triggerType) {
+ }
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/StateBase.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/StateBase.java
new file mode 100644
index 0000000000..95741308c3
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/StateBase.java
@@ -0,0 +1,7 @@
+package com.amazon.avod.fsm;
+
+public abstract class StateBase {
+ // This method orginally has protected access (modified in patch code).
+ public void doTrigger(Trigger trigger) {
+ }
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/Trigger.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/Trigger.java
new file mode 100644
index 0000000000..282f0f2004
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/Trigger.java
@@ -0,0 +1,4 @@
+package com.amazon.avod.fsm;
+
+public interface Trigger {
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/TimeSpan.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/TimeSpan.java
new file mode 100644
index 0000000000..cc90e43cdc
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/TimeSpan.java
@@ -0,0 +1,7 @@
+package com.amazon.avod.media;
+
+public final class TimeSpan {
+ public long getTotalMilliseconds() {
+ throw new UnsupportedOperationException();
+ }
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/AdBreak.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/AdBreak.java
new file mode 100644
index 0000000000..9a950434dc
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/AdBreak.java
@@ -0,0 +1,7 @@
+package com.amazon.avod.media.ads;
+
+import com.amazon.avod.media.TimeSpan;
+
+public interface AdBreak {
+ TimeSpan getDurationExcludingAux();
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdBreakState.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdBreakState.java
new file mode 100644
index 0000000000..f417660ed7
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdBreakState.java
@@ -0,0 +1,4 @@
+package com.amazon.avod.media.ads.internal.state;
+
+public abstract class AdBreakState extends AdEnabledPlaybackState {
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdBreakTrigger.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdBreakTrigger.java
new file mode 100644
index 0000000000..f8b3995650
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdBreakTrigger.java
@@ -0,0 +1,18 @@
+package com.amazon.avod.media.ads.internal.state;
+
+import com.amazon.avod.media.ads.AdBreak;
+import com.amazon.avod.media.TimeSpan;
+
+public class AdBreakTrigger {
+ public AdBreak getBreak() {
+ throw new UnsupportedOperationException();
+ }
+
+ public TimeSpan getSeekTarget() {
+ throw new UnsupportedOperationException();
+ }
+
+ public TimeSpan getSeekStartPosition() {
+ throw new UnsupportedOperationException();
+ }
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdEnabledPlaybackState.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdEnabledPlaybackState.java
new file mode 100644
index 0000000000..445aad580a
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdEnabledPlaybackState.java
@@ -0,0 +1,8 @@
+package com.amazon.avod.media.ads.internal.state;
+
+import com.amazon.avod.fsm.StateBase;
+import com.amazon.avod.media.playback.state.PlayerStateType;
+import com.amazon.avod.media.playback.state.trigger.PlayerTriggerType;
+
+public class AdEnabledPlaybackState extends StateBase {
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdEnabledPlayerTriggerType.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdEnabledPlayerTriggerType.java
new file mode 100644
index 0000000000..e7951e9342
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdEnabledPlayerTriggerType.java
@@ -0,0 +1,5 @@
+package com.amazon.avod.media.ads.internal.state;
+
+public enum AdEnabledPlayerTriggerType {
+ NO_MORE_ADS_SKIP_TRANSITION
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/ServerInsertedAdBreakState.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/ServerInsertedAdBreakState.java
new file mode 100644
index 0000000000..07c198013f
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/ServerInsertedAdBreakState.java
@@ -0,0 +1,4 @@
+package com.amazon.avod.media.ads.internal.state;
+
+public class ServerInsertedAdBreakState extends AdBreakState {
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/VideoPlayer.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/VideoPlayer.java
new file mode 100644
index 0000000000..4f82e98727
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/VideoPlayer.java
@@ -0,0 +1,13 @@
+package com.amazon.avod.media.playback;
+
+public interface VideoPlayer {
+ long getCurrentPosition();
+
+ void seekTo(long positionMs);
+
+ void pause();
+
+ void play();
+
+ boolean isPlaying();
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/state/PlayerStateType.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/state/PlayerStateType.java
new file mode 100644
index 0000000000..202723285e
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/state/PlayerStateType.java
@@ -0,0 +1,4 @@
+package com.amazon.avod.media.playback.state;
+
+public interface PlayerStateType {
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/state/trigger/PlayerTriggerType.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/state/trigger/PlayerTriggerType.java
new file mode 100644
index 0000000000..eac139f9bf
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/state/trigger/PlayerTriggerType.java
@@ -0,0 +1,4 @@
+package com.amazon.avod.media.playback.state.trigger;
+
+public interface PlayerTriggerType {
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/video/sdk/player/Player.java b/extensions/primevideo/stub/src/main/java/com/amazon/video/sdk/player/Player.java
new file mode 100644
index 0000000000..bd609e1964
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/video/sdk/player/Player.java
@@ -0,0 +1,11 @@
+package com.amazon.video.sdk.player;
+
+public interface Player {
+ float getPlaybackRate();
+
+ void setPlaybackRate(float rate);
+
+ void play();
+
+ void pause();
+}
\ No newline at end of file
diff --git a/extensions/proguard-rules.pro b/extensions/proguard-rules.pro
new file mode 100644
index 0000000000..8f804140d6
--- /dev/null
+++ b/extensions/proguard-rules.pro
@@ -0,0 +1,9 @@
+-dontobfuscate
+-dontoptimize
+-keepattributes *
+-keep class app.revanced.** {
+ *;
+}
+-keep class com.google.** {
+ *;
+}
diff --git a/extensions/reddit/build.gradle.kts b/extensions/reddit/build.gradle.kts
new file mode 100644
index 0000000000..75c8d7a179
--- /dev/null
+++ b/extensions/reddit/build.gradle.kts
@@ -0,0 +1,9 @@
+dependencies {
+ compileOnly(project(":extensions:reddit:stub"))
+}
+
+android {
+ defaultConfig {
+ minSdk = 28
+ }
+}
diff --git a/extensions/reddit/src/main/AndroidManifest.xml b/extensions/reddit/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..9b65eb06cf
--- /dev/null
+++ b/extensions/reddit/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/extensions/reddit/src/main/java/app/revanced/extension/reddit/patches/FilterPromotedLinksPatch.java b/extensions/reddit/src/main/java/app/revanced/extension/reddit/patches/FilterPromotedLinksPatch.java
new file mode 100644
index 0000000000..12cdc88345
--- /dev/null
+++ b/extensions/reddit/src/main/java/app/revanced/extension/reddit/patches/FilterPromotedLinksPatch.java
@@ -0,0 +1,27 @@
+package app.revanced.extension.reddit.patches;
+
+import com.reddit.domain.model.ILink;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@SuppressWarnings("unused")
+public final class FilterPromotedLinksPatch {
+
+ /**
+ * Injection point.
+ *
+ * Filters list from promoted links.
+ **/
+ public static List> filterChildren(final Iterable> links) {
+ final List filteredList = new ArrayList<>();
+
+ for (Object item : links) {
+ if (item instanceof ILink && ((ILink) item).getPromoted()) continue;
+
+ filteredList.add(item);
+ }
+
+ return filteredList;
+ }
+}
diff --git a/extensions/reddit/stub/build.gradle.kts b/extensions/reddit/stub/build.gradle.kts
new file mode 100644
index 0000000000..b4bee8809f
--- /dev/null
+++ b/extensions/reddit/stub/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "app.revanced.extension"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 24
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
diff --git a/extensions/reddit/stub/src/main/AndroidManifest.xml b/extensions/reddit/stub/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..15e7c2ae67
--- /dev/null
+++ b/extensions/reddit/stub/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/extensions/reddit/stub/src/main/java/com/reddit/domain/model/ILink.java b/extensions/reddit/stub/src/main/java/com/reddit/domain/model/ILink.java
new file mode 100644
index 0000000000..f9cbb955cb
--- /dev/null
+++ b/extensions/reddit/stub/src/main/java/com/reddit/domain/model/ILink.java
@@ -0,0 +1,7 @@
+package com.reddit.domain.model;
+
+public class ILink {
+ public boolean getPromoted() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
diff --git a/extensions/samsung/radio/build.gradle.kts b/extensions/samsung/radio/build.gradle.kts
new file mode 100644
index 0000000000..15d386efb3
--- /dev/null
+++ b/extensions/samsung/radio/build.gradle.kts
@@ -0,0 +1,10 @@
+dependencies {
+ compileOnly(project(":extensions:shared:library"))
+ compileOnly(project(":extensions:samsung:radio:stub"))
+}
+
+android {
+ defaultConfig {
+ minSdk = 26
+ }
+}
diff --git a/extensions/samsung/radio/src/main/AndroidManifest.xml b/extensions/samsung/radio/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..9b65eb06cf
--- /dev/null
+++ b/extensions/samsung/radio/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/extensions/samsung/radio/src/main/java/app/revanced/extension/samsung/radio/misc/fix/crash/FixCrashPatch.java b/extensions/samsung/radio/src/main/java/app/revanced/extension/samsung/radio/misc/fix/crash/FixCrashPatch.java
new file mode 100644
index 0000000000..72c5addc4c
--- /dev/null
+++ b/extensions/samsung/radio/src/main/java/app/revanced/extension/samsung/radio/misc/fix/crash/FixCrashPatch.java
@@ -0,0 +1,24 @@
+package app.revanced.extension.samsung.radio.misc.fix.crash;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+@SuppressWarnings("unused")
+public final class FixCrashPatch {
+ /**
+ * Injection point.
+ *
+ * Add the required permissions to the request list to avoid crashes on API 34+.
+ **/
+ public static final String[] fixPermissionRequestList(String[] perms) {
+ List permsList = new ArrayList<>(Arrays.asList(perms));
+ if (permsList.contains("android.permission.POST_NOTIFICATIONS")) {
+ permsList.addAll(Arrays.asList("android.permission.RECORD_AUDIO", "android.permission.READ_PHONE_STATE", "android.permission.FOREGROUND_SERVICE_MICROPHONE"));
+ }
+ if (permsList.contains("android.permission.RECORD_AUDIO")) {
+ permsList.add("android.permission.FOREGROUND_SERVICE_MICROPHONE");
+ }
+ return permsList.toArray(new String[0]);
+ }
+}
diff --git a/extensions/samsung/radio/src/main/java/app/revanced/extension/samsung/radio/restrictions/device/BypassDeviceChecksPatch.java b/extensions/samsung/radio/src/main/java/app/revanced/extension/samsung/radio/restrictions/device/BypassDeviceChecksPatch.java
new file mode 100644
index 0000000000..19b6c3e822
--- /dev/null
+++ b/extensions/samsung/radio/src/main/java/app/revanced/extension/samsung/radio/restrictions/device/BypassDeviceChecksPatch.java
@@ -0,0 +1,19 @@
+package app.revanced.extension.samsung.radio.restrictions.device;
+
+import android.os.SemSystemProperties;
+
+import java.util.Arrays;
+
+@SuppressWarnings("unused")
+public final class BypassDeviceChecksPatch {
+
+ /**
+ * Injection point.
+ *
+ * Check if the device has the required hardware
+ **/
+ public static final boolean checkIfDeviceIsIncompatible(String[] deviceList) {
+ String currentDevice = SemSystemProperties.getSalesCode();
+ return Arrays.asList(deviceList).contains(currentDevice);
+ }
+}
diff --git a/extensions/samsung/radio/stub/build.gradle.kts b/extensions/samsung/radio/stub/build.gradle.kts
new file mode 100644
index 0000000000..b4bee8809f
--- /dev/null
+++ b/extensions/samsung/radio/stub/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "app.revanced.extension"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 24
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
diff --git a/extensions/samsung/radio/stub/src/main/AndroidManifest.xml b/extensions/samsung/radio/stub/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..15e7c2ae67
--- /dev/null
+++ b/extensions/samsung/radio/stub/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/extensions/samsung/radio/stub/src/main/java/android/os/SemSystemProperties.java b/extensions/samsung/radio/stub/src/main/java/android/os/SemSystemProperties.java
new file mode 100644
index 0000000000..33a4b4400c
--- /dev/null
+++ b/extensions/samsung/radio/stub/src/main/java/android/os/SemSystemProperties.java
@@ -0,0 +1,7 @@
+package android.os;
+
+public class SemSystemProperties {
+ public static String getSalesCode() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/build.gradle.kts b/extensions/shared/build.gradle.kts
new file mode 100644
index 0000000000..3eb6ff48c7
--- /dev/null
+++ b/extensions/shared/build.gradle.kts
@@ -0,0 +1,10 @@
+dependencies {
+ implementation(project(":extensions:shared:library"))
+ compileOnly(libs.okhttp)
+}
+
+android {
+ defaultConfig {
+ minSdk = 23
+ }
+}
diff --git a/extensions/shared/library/build.gradle.kts b/extensions/shared/library/build.gradle.kts
new file mode 100644
index 0000000000..8215e513ad
--- /dev/null
+++ b/extensions/shared/library/build.gradle.kts
@@ -0,0 +1,24 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "app.revanced.extension"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 23
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+dependencies {
+ compileOnly(libs.annotation)
+ compileOnly(libs.okhttp)
+ compileOnly(libs.protobuf.javalite)
+ implementation(project(":extensions:shared:protobuf", configuration = "shadowRuntimeElements"))
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/ByteTrieSearch.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/ByteTrieSearch.java
new file mode 100644
index 0000000000..c91de4a7aa
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/ByteTrieSearch.java
@@ -0,0 +1,43 @@
+package app.revanced.extension.shared;
+
+import java.nio.charset.StandardCharsets;
+
+public final class ByteTrieSearch extends TrieSearch {
+
+ private static final class ByteTrieNode extends TrieNode {
+ ByteTrieNode() {
+ super();
+ }
+ ByteTrieNode(char nodeCharacterValue) {
+ super(nodeCharacterValue);
+ }
+ @Override
+ TrieNode createNode(char nodeCharacterValue) {
+ return new ByteTrieNode(nodeCharacterValue);
+ }
+ @Override
+ char getCharValue(byte[] text, int index) {
+ return (char) text[index];
+ }
+ @Override
+ int getTextLength(byte[] text) {
+ return text.length;
+ }
+ }
+
+ /**
+ * Helper method for the common usage of converting Strings to raw UTF-8 bytes.
+ */
+ public static byte[][] convertStringsToBytes(String... strings) {
+ final int length = strings.length;
+ byte[][] replacement = new byte[length][];
+ for (int i = 0; i < length; i++) {
+ replacement[i] = strings[i].getBytes(StandardCharsets.UTF_8);
+ }
+ return replacement;
+ }
+
+ public ByteTrieSearch(byte[]... patterns) {
+ super(new ByteTrieNode(), patterns);
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/GmsCoreSupport.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/GmsCoreSupport.java
new file mode 100644
index 0000000000..fb7e68963a
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/GmsCoreSupport.java
@@ -0,0 +1,412 @@
+package app.revanced.extension.shared;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.Dialog;
+import android.app.SearchManager;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.PowerManager;
+import android.provider.Settings;
+import android.util.Pair;
+import android.widget.LinearLayout;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.requests.Requester;
+import app.revanced.extension.shared.requests.Route;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.ui.CustomDialog;
+
+import org.json.JSONObject;
+
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Locale;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.requests.Route.Method.GET;
+
+@SuppressWarnings("unused")
+public class GmsCoreSupport {
+ private static GmsCore gmsCore = GmsCore.UNKNOWN;
+
+ static {
+ for (GmsCore core : GmsCore.values()) {
+ if (core.getGroupId().equals(getGmsCoreVendorGroupId())) {
+ GmsCoreSupport.gmsCore = core;
+ break;
+ }
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void checkGmsCore(Activity context) {
+ gmsCore.check(context);
+ }
+
+ private static String getOriginalPackageName() {
+ return null; // Modified during patching.
+ }
+
+ private static String getGmsCoreVendorGroupId() {
+ return "app.revanced"; // Modified during patching.
+ }
+
+
+ /**
+ * @return If the current package name is the same as the original unpatched app.
+ * If `GmsCore support` was not included during patching, this returns true;
+ */
+ public static boolean isPackageNameOriginal() {
+ String originalPackageName = getOriginalPackageName();
+ return originalPackageName == null
+ || originalPackageName.equals(Utils.getContext().getPackageName());
+ }
+
+ private enum GmsCore {
+ REVANCED("app.revanced", "https://github.com/revanced/gmscore/releases/latest", () -> {
+ try {
+ HttpURLConnection connection = Requester.getConnectionFromRoute(
+ "https://api.github.com",
+ new Route(GET, "/repos/revanced/gmscore/releases/latest")
+ );
+ connection.setConnectTimeout(5000);
+ connection.setReadTimeout(5000);
+
+ int responseCode = connection.getResponseCode();
+ if (responseCode != 200) {
+ Logger.printDebug(() -> "GitHub API returned status code: " + responseCode);
+ return null;
+ }
+
+ // Parse the response
+ JSONObject releaseData = Requester.parseJSONObject(connection);
+ String tagName = releaseData.optString("tag_name", "");
+ connection.disconnect();
+
+ if (tagName.isEmpty()) {
+ Logger.printDebug(() -> "No tag_name found in GitHub release data");
+ return null;
+ }
+
+ if (tagName.startsWith("v")) tagName = tagName.substring(1);
+
+ return tagName;
+ } catch (Exception ex) {
+ Logger.printInfo(() -> "Failed to fetch latest GmsCore version from GitHub", ex);
+ return null;
+ }
+ }),
+ UNKNOWN(getGmsCoreVendorGroupId(), getGmsCoreVendorGroupId() + "android.gms", () -> null);
+
+ private static final String DONT_KILL_MY_APP_URL
+ = "https://dontkillmyapp.com/";
+ private static final Route DONT_KILL_MY_APP_MANUFACTURER_API
+ = new Route(GET, "/api/v2/{manufacturer}.json");
+ private static final String DONT_KILL_MY_APP_NAME_PARAMETER
+ = "?app=MicroG";
+ private static final String BUILD_MANUFACTURER
+ = Build.MANUFACTURER.toLowerCase(Locale.ROOT).replace(" ", "-");
+
+ /**
+ * If a manufacturer specific page exists on DontKillMyApp.
+ */
+ @Nullable
+ private volatile Boolean dontKillMyAppManufacturerSupported;
+
+ private final String groupId;
+ private final String packageName;
+ private final String downloadQuery;
+ private final GetLatestVersion getLatestVersion;
+ private final Uri gmsCoreProvider;
+
+ GmsCore(String groupId, String downloadQuery, GetLatestVersion getLatestVersion) {
+ this.groupId = groupId;
+ this.packageName = groupId + ".android.gms";
+ this.gmsCoreProvider = Uri.parse("content://" + groupId + ".android.gsf.gservices/prefix");
+
+ this.downloadQuery = downloadQuery;
+ this.getLatestVersion = getLatestVersion;
+ }
+
+ String getGroupId() {
+ return groupId;
+ }
+
+ void check(Activity context) {
+ checkInstallation(context);
+ checkUpdates(context);
+ }
+
+ private void checkInstallation(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 (isPackageNameOriginal()) {
+ 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 relaunch
+ // with the appearance of a hung app.
+ }
+
+ // Verify GmsCore is installed.
+ try {
+ PackageManager manager = context.getPackageManager();
+ manager.getPackageInfo(packageName, PackageManager.GET_ACTIVITIES);
+ } catch (PackageManager.NameNotFoundException exception) {
+ Logger.printInfo(() -> "GmsCore was not found");
+ // Cannot show a dialog and must show a toast,
+ // because on some installations the app crashes before a dialog can be displayed.
+ Utils.showToastLong(str("revanced_gms_core_toast_not_installed_message"));
+
+ open(downloadQuery);
+ return;
+ }
+
+ // Check if GmsCore is whitelisted from battery optimizations.
+ if (isAndroidAutomotive(context)) {
+ // Ignore Android Automotive devices (Google built-in),
+ // as there is no way to disable battery optimizations.
+ Logger.printDebug(() -> "Device is Android Automotive");
+ } else if (batteryOptimizationsEnabled(context)) {
+ Logger.printInfo(() -> "GmsCore is not whitelisted from battery optimizations");
+
+ showBatteryOptimizationDialog(context,
+ "revanced_gms_core_dialog_not_whitelisted_using_battery_optimizations_message",
+ "revanced_gms_core_dialog_continue_text",
+ (dialog, id) -> openGmsCoreDisableBatteryOptimizationsIntent(context));
+ return;
+ }
+
+ // Check if GmsCore is currently running in the background.
+ var client = context.getContentResolver().acquireContentProviderClient(gmsCoreProvider);
+ //noinspection TryFinallyCanBeTryWithResources
+ try {
+ if (client == null) {
+ Logger.printInfo(() -> "GmsCore is not running in the background");
+ checkIfDontKillMyAppSupportsManufacturer();
+
+ showBatteryOptimizationDialog(context,
+ "revanced_gms_core_dialog_not_whitelisted_not_allowed_in_background_message",
+ "gmsrevanced_gms_core_log_open_website_text",
+ (dialog, id) -> openDontKillMyApp());
+ }
+ } finally {
+ if (client != null) client.close();
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "checkGmsCore failure", ex);
+ }
+ }
+
+ private void checkUpdates(Activity context) {
+ if (!BaseSettings.GMS_CORE_CHECK_UPDATES.get()) {
+ Logger.printDebug(() -> "GmsCore update check is disabled in settings");
+ return;
+ }
+
+ Utils.runOnBackgroundThread(() -> {
+ try {
+ PackageManager manager = context.getPackageManager();
+ var installedVersion = manager.getPackageInfo(packageName, 0).versionName;
+
+ // GmsCore adds suffixes for flavor builds. Remove the suffix for version comparison.
+ int suffixIndex = installedVersion.indexOf('-');
+ if (suffixIndex != -1)
+ installedVersion = installedVersion.substring(0, suffixIndex);
+ String finalInstalledVersion = installedVersion;
+
+ Logger.printDebug(() -> "Installed GmsCore version: " + finalInstalledVersion);
+
+ var latestVersion = getLatestVersion.get();
+
+ if (latestVersion == null || latestVersion.isEmpty()) {
+ Logger.printDebug(() -> "Could not get latest GmsCore version");
+ Utils.showToastLong(str("revanced_gms_core_toast_update_check_failed_message"));
+ return;
+ }
+
+ Logger.printDebug(() -> "Latest GmsCore version on GitHub: " + latestVersion);
+
+ // Compare versions
+ if (!installedVersion.equals(latestVersion)) {
+ Logger.printInfo(() -> "GmsCore update available. Installed: " + finalInstalledVersion
+ + ", Latest: " + latestVersion);
+
+ showUpdateDialog(context, installedVersion, latestVersion);
+ } else {
+ Logger.printDebug(() -> "GmsCore is up to date");
+ }
+ } catch (Exception ex) {
+ Logger.printInfo(() -> "Could not check GmsCore updates", ex);
+ Utils.showToastLong(str("revanced_gms_core_toast_update_check_failed_message"));
+ }
+ });
+ }
+
+ private void open(String queryOrLink) {
+ Logger.printInfo(() -> "Opening link: " + queryOrLink);
+
+ Intent intent;
+ try {
+ // Check if queryOrLink is a valid URL.
+ new URL(queryOrLink);
+
+ intent = new Intent(Intent.ACTION_VIEW, Uri.parse(queryOrLink));
+ } catch (MalformedURLException e) {
+ intent = new Intent(Intent.ACTION_WEB_SEARCH);
+ intent.putExtra(SearchManager.QUERY, queryOrLink);
+ }
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ Utils.getContext().startActivity(intent);
+
+ // Gracefully exit, otherwise the broken app will continue to run.
+ System.exit(0);
+ }
+
+ private void showUpdateDialog(Activity context, String installedVersion, String latestVersion) {
+ // 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(() -> {
+ try {
+ Pair dialogPair = CustomDialog.create(
+ context,
+ str("revanced_gms_core_dialog_title"),
+ String.format(str("revanced_gms_core_update_available_message"), latestVersion, installedVersion),
+ null,
+ str("revanced_gms_core_dialog_open_website_text"),
+ () -> open(downloadQuery),
+ () -> {
+ },
+ str("revanced_gms_core_dialog_cancel_text"),
+ null,
+ true
+ );
+
+ Dialog dialog = dialogPair.first;
+ dialog.setCancelable(true);
+ Utils.showDialog(context, dialog);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to show GmsCore update dialog", ex);
+ }
+ }, 100);
+ }
+
+ private static void showBatteryOptimizationDialog(Activity context,
+ String dialogMessageRef,
+ String positiveButtonTextRef,
+ 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(() -> {
+ // Create the custom dialog.
+ Pair dialogPair = CustomDialog.create(
+ context,
+ str("revanced_gms_core_dialog_title"), // Title.
+ str(dialogMessageRef), // Message.
+ null, // No EditText.
+ str(positiveButtonTextRef), // OK button text.
+ () -> onPositiveClickListener.onClick(null, 0), // Convert DialogInterface.OnClickListener to Runnable.
+ null, // No Cancel button action.
+ null, // No Neutral button text.
+ null, // No Neutral button action.
+ true // Dismiss dialog when onNeutralClick.
+ );
+
+ Dialog dialog = dialogPair.first;
+
+ // Do not set cancelable to false to allow using back button to skip the action,
+ // just in case the battery change can never be satisfied.
+ dialog.setCancelable(true);
+
+ // Show the dialog
+ Utils.showDialog(context, dialog);
+ }, 100);
+ }
+
+ @SuppressLint("BatteryLife") // Permission is part of GmsCore
+ private void openGmsCoreDisableBatteryOptimizationsIntent(Activity activity) {
+ Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
+ intent.setData(Uri.fromParts("package", packageName, null));
+ activity.startActivityForResult(intent, 0);
+ }
+
+ private void checkIfDontKillMyAppSupportsManufacturer() {
+ Utils.runOnBackgroundThread(() -> {
+ try {
+ final long start = System.currentTimeMillis();
+ HttpURLConnection connection = Requester.getConnectionFromRoute(
+ DONT_KILL_MY_APP_URL, DONT_KILL_MY_APP_MANUFACTURER_API, BUILD_MANUFACTURER);
+ connection.setConnectTimeout(5000);
+ connection.setReadTimeout(5000);
+
+ final boolean supported = connection.getResponseCode() == 200;
+ Logger.printInfo(() -> "Manufacturer is " + (supported ? "" : "NOT ")
+ + "listed on DontKillMyApp: " + BUILD_MANUFACTURER
+ + " fetch took: " + (System.currentTimeMillis() - start) + "ms");
+ dontKillMyAppManufacturerSupported = supported;
+ } catch (Exception ex) {
+ Logger.printInfo(() -> "Could not check if manufacturer is listed on DontKillMyApp: "
+ + BUILD_MANUFACTURER, ex);
+ dontKillMyAppManufacturerSupported = null;
+ }
+ });
+ }
+
+ private void openDontKillMyApp() {
+ final Boolean manufacturerSupported = dontKillMyAppManufacturerSupported;
+
+ String manufacturerPageToOpen;
+ if (manufacturerSupported == null) {
+ // Fetch has not completed yet. Only happens on extremely slow internet connections
+ // and the user spends less than 1 second reading what's on screen.
+ // Instead of waiting for the fetch (which may timeout),
+ // open the website without a vendor.
+ manufacturerPageToOpen = "";
+ } else if (manufacturerSupported) {
+ manufacturerPageToOpen = BUILD_MANUFACTURER;
+ } else {
+ // No manufacturer specific page exists. Open the general page.
+ manufacturerPageToOpen = "general";
+ }
+
+ open(DONT_KILL_MY_APP_URL + manufacturerPageToOpen + DONT_KILL_MY_APP_NAME_PARAMETER);
+ }
+
+ /**
+ * @return If GmsCore is not whitelisted from battery optimizations.
+ */
+ private boolean batteryOptimizationsEnabled(Context context) {
+ //noinspection ObsoleteSdkInt
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ // Android 5.0 does not have battery optimization settings.
+ return false;
+ }
+ var powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
+ return !powerManager.isIgnoringBatteryOptimizations(packageName);
+ }
+
+ private boolean isAndroidAutomotive(Context context) {
+ return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE);
+ }
+ }
+
+ @FunctionalInterface
+ private interface GetLatestVersion {
+ String get();
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/Logger.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/Logger.java
new file mode 100644
index 0000000000..610cd3414f
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/Logger.java
@@ -0,0 +1,214 @@
+package app.revanced.extension.shared;
+
+import static app.revanced.extension.shared.settings.BaseSettings.DEBUG;
+import static app.revanced.extension.shared.settings.BaseSettings.DEBUG_STACKTRACE;
+import static app.revanced.extension.shared.settings.BaseSettings.DEBUG_TOAST_ON_ERROR;
+
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.preference.LogBufferManager;
+
+/**
+ * ReVanced specific logger. Logging is done to standard device log (accessible through ADB),
+ * and additionally accessible through {@link LogBufferManager}.
+ *
+ * All methods are thread safe, and are safe to call even
+ * if {@link Utils#getContext()} is not available.
+ */
+public class Logger {
+
+ /**
+ * Log messages using lambdas.
+ */
+ @FunctionalInterface
+ public interface LogMessage {
+ /**
+ * @return Logger string message. This method is only called if logging is enabled.
+ */
+ @NonNull
+ String buildMessageString();
+ }
+
+ private enum LogLevel {
+ DEBUG,
+ INFO,
+ ERROR
+ }
+
+ /**
+ * Log tag prefix. Only used for system logging.
+ */
+ private static final String REVANCED_LOG_TAG_PREFIX = "revanced: ";
+
+ private static final String LOGGER_CLASS_NAME = Logger.class.getName();
+
+ /**
+ * @return For outer classes, this returns {@link Class#getSimpleName()}.
+ * For static, inner, or anonymous classes, this returns the simple name of the enclosing class.
+ *
+ * For example, each of these classes returns 'SomethingView':
+ *
+ * com.company.SomethingView
+ * com.company.SomethingView$StaticClass
+ * com.company.SomethingView$1
+ *
+ */
+ private static String getOuterClassSimpleName(Object obj) {
+ Class> logClass = obj.getClass();
+ String fullClassName = logClass.getName();
+ final int dollarSignIndex = fullClassName.indexOf('$');
+ if (dollarSignIndex < 0) {
+ return logClass.getSimpleName(); // Already an outer class.
+ }
+
+ // Class is inner, static, or anonymous.
+ // Parse the simple name full name.
+ // A class with no package returns index of -1, but incrementing gives index zero which is correct.
+ final int simpleClassNameStartIndex = fullClassName.lastIndexOf('.') + 1;
+ return fullClassName.substring(simpleClassNameStartIndex, dollarSignIndex);
+ }
+
+ /**
+ * Internal method to handle logging to Android Log and {@link LogBufferManager}.
+ * Appends the log message, stack trace (if enabled), and exception (if present) to logBuffer
+ * with class name but without 'revanced:' prefix.
+ *
+ * @param logLevel The log level.
+ * @param message Log message object.
+ * @param ex Optional exception.
+ * @param includeStackTrace If the current stack should be included.
+ * @param showToast If a toast is to be shown.
+ */
+ private static void logInternal(LogLevel logLevel, LogMessage message, @Nullable Throwable ex,
+ boolean includeStackTrace, boolean showToast) {
+ // It's very important that no Settings are used in this method,
+ // as this code is used when a context is not set and thus referencing
+ // a setting will crash the app.
+ String messageString = message.buildMessageString();
+ String className = getOuterClassSimpleName(message);
+
+ String logText = messageString;
+
+ // Append exception message if present.
+ if (ex != null) {
+ var exceptionMessage = ex.getMessage();
+ if (exceptionMessage != null) {
+ logText += "\nException: " + exceptionMessage;
+ }
+ }
+
+ if (includeStackTrace) {
+ var sw = new StringWriter();
+ new Throwable().printStackTrace(new PrintWriter(sw));
+ String stackTrace = sw.toString();
+ // Remove the stacktrace elements of this class.
+ final int loggerIndex = stackTrace.lastIndexOf(LOGGER_CLASS_NAME);
+ final int loggerBegins = stackTrace.indexOf('\n', loggerIndex);
+ logText += stackTrace.substring(loggerBegins);
+ }
+
+ // Do not include "revanced:" prefix in clipboard logs.
+ String managerToastString = className + ": " + logText;
+ LogBufferManager.appendToLogBuffer(managerToastString);
+
+ String logTag = REVANCED_LOG_TAG_PREFIX + className;
+ switch (logLevel) {
+ case DEBUG:
+ if (ex == null) Log.d(logTag, logText);
+ else Log.d(logTag, logText, ex);
+ break;
+ case INFO:
+ if (ex == null) Log.i(logTag, logText);
+ else Log.i(logTag, logText, ex);
+ break;
+ case ERROR:
+ if (ex == null) Log.e(logTag, logText);
+ else Log.e(logTag, logText, ex);
+ break;
+ }
+
+ if (showToast) {
+ Utils.showToastLong(managerToastString);
+ }
+ }
+
+ private static boolean shouldLogDebug() {
+ // If the app is still starting up and the context is not yet set,
+ // then allow debug logging regardless what the debug setting actually is.
+ return Utils.context == null || DEBUG.get();
+ }
+
+ private static boolean shouldShowErrorToast() {
+ return Utils.context != null && DEBUG_TOAST_ON_ERROR.get();
+ }
+
+ private static boolean includeStackTrace() {
+ return Utils.context != null && DEBUG_STACKTRACE.get();
+ }
+
+ /**
+ * 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(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(LogMessage message, @Nullable Exception ex) {
+ if (shouldLogDebug()) {
+ logInternal(LogLevel.DEBUG, message, ex, includeStackTrace(), false);
+ }
+ }
+
+ /**
+ * Logs information messages using the outer class name of the code calling this method.
+ */
+ public static void printInfo(LogMessage message) {
+ printInfo(message, null);
+ }
+
+ /**
+ * Logs information messages using the outer class name of the code calling this method.
+ */
+ public static void printInfo(LogMessage message, @Nullable Exception ex) {
+ logInternal(LogLevel.INFO, message, ex, includeStackTrace(), false);
+ }
+
+ /**
+ * Logs exceptions under the outer class name of the code calling this method.
+ * Appends the log message, exception (if present), and toast message (if enabled) to logBuffer.
+ */
+ public static void printException(LogMessage message) {
+ printException(message, null);
+ }
+
+ /**
+ * Logs exceptions under the outer class name of the code calling this method.
+ *
+ * If the calling code is showing its own error toast,
+ * instead use {@link #printInfo(LogMessage, Exception)}
+ *
+ * @param message log message
+ * @param ex exception (optional)
+ */
+ public static void printException(LogMessage message, @Nullable Throwable ex) {
+ logInternal(LogLevel.ERROR, message, ex, includeStackTrace(), shouldShowErrorToast());
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/ResourceType.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/ResourceType.java
new file mode 100644
index 0000000000..48032017a4
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/ResourceType.java
@@ -0,0 +1,57 @@
+package app.revanced.extension.shared;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public enum ResourceType {
+ ANIM("anim"),
+ ANIMATOR("animator"),
+ ARRAY("array"),
+ ATTR("attr"),
+ BOOL("bool"),
+ COLOR("color"),
+ DIMEN("dimen"),
+ DRAWABLE("drawable"),
+ FONT("font"),
+ FRACTION("fraction"),
+ ID("id"),
+ INTEGER("integer"),
+ INTERPOLATOR("interpolator"),
+ LAYOUT("layout"),
+ MENU("menu"),
+ MIPMAP("mipmap"),
+ NAVIGATION("navigation"),
+ PLURALS("plurals"),
+ RAW("raw"),
+ STRING("string"),
+ STYLE("style"),
+ STYLEABLE("styleable"),
+ TRANSITION("transition"),
+ VALUES("values"),
+ XML("xml");
+
+ private static final Map VALUE_MAP;
+
+ static {
+ ResourceType[] values = values();
+ VALUE_MAP = new HashMap<>(2 * values.length);
+
+ for (ResourceType type : values) {
+ VALUE_MAP.put(type.value, type);
+ }
+ }
+
+ public final String value;
+
+ public static ResourceType fromValue(String value) {
+ ResourceType type = VALUE_MAP.get(value);
+ if (type == null) {
+ throw new IllegalArgumentException("Unknown resource type: " + value);
+ }
+ return type;
+ }
+
+ ResourceType(String value) {
+ this.value = value;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/StringRef.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/StringRef.java
new file mode 100644
index 0000000000..c1c2c90d14
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/StringRef.java
@@ -0,0 +1,122 @@
+package app.revanced.extension.shared;
+
+import android.content.Context;
+import android.content.res.Resources;
+
+import androidx.annotation.NonNull;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+public class StringRef {
+ private static Resources resources;
+ private static String packageName;
+
+ // must use a thread safe map, as this class is used both on and off the main thread
+ private static final Map strings = Collections.synchronizedMap(new HashMap<>());
+
+ /**
+ * Returns a cached instance.
+ * Should be used if the same String could be loaded more than once.
+ *
+ * @param id string resource name/id
+ * @see #sf(String)
+ */
+ @NonNull
+ public static StringRef sfc(@NonNull String id) {
+ StringRef ref = strings.get(id);
+ if (ref == null) {
+ ref = new StringRef(id);
+ strings.put(id, ref);
+ }
+ return ref;
+ }
+
+ /**
+ * Creates a new instance, but does not cache the value.
+ * Should be used for Strings that are loaded exactly once.
+ *
+ * @param id string resource name/id
+ * @see #sfc(String)
+ */
+ @NonNull
+ public static StringRef sf(@NonNull String id) {
+ return new StringRef(id);
+ }
+
+ /**
+ * Gets string value by string id, shorthand for sfc(id).toString()
+ *
+ * @param id string resource name/id
+ * @return String value from string.xml
+ */
+ @NonNull
+ public static String str(@NonNull String id) {
+ return sfc(id).toString();
+ }
+
+ /**
+ * Gets string value by string id, shorthand for sfc(id).toString() and formats the string
+ * with given args.
+ *
+ * @param id string resource name/id
+ * @param args the args to format the string with
+ * @return String value from string.xml formatted with given args
+ */
+ @NonNull
+ public static String str(@NonNull String id, Object... args) {
+ return String.format(str(id), args);
+ }
+
+ /**
+ * Creates a StringRef object that'll not change its value
+ *
+ * @param value value which toString() method returns when invoked on returned object
+ * @return Unique StringRef instance, its value will never change
+ */
+ @NonNull
+ public static StringRef constant(@NonNull String value) {
+ final StringRef ref = new StringRef(value);
+ ref.resolved = true;
+ return ref;
+ }
+
+ /**
+ * Shorthand for constant("")
+ * Its value always resolves to empty string
+ */
+ @NonNull
+ public static final StringRef empty = constant("");
+
+ @NonNull
+ private String value;
+ private boolean resolved;
+
+ public StringRef(@NonNull String resName) {
+ this.value = resName;
+ }
+
+ @Override
+ @NonNull
+ public String toString() {
+ if (!resolved) {
+ if (resources == null || packageName == null) {
+ var context = Utils.getContext();
+ resources = context.getResources();
+ packageName = context.getPackageName();
+ }
+ resolved = true;
+ if (resources != null) {
+ final int identifier = resources.getIdentifier(value, "string", packageName);
+ if (identifier == 0)
+ Logger.printException(() -> "Resource not found: " + value);
+ else
+ value = resources.getString(identifier);
+ } else {
+ Logger.printException(() -> "Could not resolve resources!");
+ }
+ }
+ return value;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/StringTrieSearch.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/StringTrieSearch.java
new file mode 100644
index 0000000000..9c7b882138
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/StringTrieSearch.java
@@ -0,0 +1,32 @@
+package app.revanced.extension.shared;
+
+/**
+ * Text pattern searching using a prefix tree (trie).
+ */
+public final class StringTrieSearch extends TrieSearch {
+
+ private static final class StringTrieNode extends TrieNode {
+ StringTrieNode() {
+ super();
+ }
+ StringTrieNode(char nodeCharacterValue) {
+ super(nodeCharacterValue);
+ }
+ @Override
+ TrieNode createNode(char nodeValue) {
+ return new StringTrieNode(nodeValue);
+ }
+ @Override
+ char getCharValue(String text, int index) {
+ return text.charAt(index);
+ }
+ @Override
+ int getTextLength(String text) {
+ return text.length();
+ }
+ }
+
+ public StringTrieSearch(String... patterns) {
+ super(new StringTrieNode(), patterns);
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/TrieSearch.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/TrieSearch.java
new file mode 100644
index 0000000000..97fa4605d8
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/TrieSearch.java
@@ -0,0 +1,425 @@
+package app.revanced.extension.shared;
+
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Searches for a group of different patterns using a trie (prefix tree).
+ * Can significantly speed up searching for multiple patterns.
+ */
+public abstract class TrieSearch {
+
+ public interface TriePatternMatchedCallback {
+ /**
+ * Called when a pattern is matched.
+ *
+ * @param textSearched Text that was searched.
+ * @param matchedStartIndex Start index of the search text, where the pattern was matched.
+ * @param matchedLength Length of the match.
+ * @param callbackParameter Optional parameter passed into {@link TrieSearch#matches(Object, Object)}.
+ * @return True, if the search should stop here.
+ * If false, searching will continue to look for other matches.
+ */
+ boolean patternMatched(T textSearched, int matchedStartIndex, int matchedLength, Object callbackParameter);
+ }
+
+ /**
+ * Represents a compressed tree path for a single pattern that shares no sibling nodes.
+ *
+ * For example, if a tree contains the patterns: "foobar", "football", "feet",
+ * it would contain 3 compressed paths of: "bar", "tball", "eet".
+ *
+ * And the tree would contain children arrays only for the first level containing 'f',
+ * the second level containing 'o',
+ * and the third level containing 'o'.
+ *
+ * This is done to reduce memory usage, which can be substantial if many long patterns are used.
+ */
+ private static final class TrieCompressedPath {
+ final T pattern;
+ final int patternStartIndex;
+ final int patternLength;
+ final TriePatternMatchedCallback callback;
+
+ TrieCompressedPath(T pattern, int patternStartIndex, int patternLength, TriePatternMatchedCallback callback) {
+ this.pattern = pattern;
+ this.patternStartIndex = patternStartIndex;
+ this.patternLength = patternLength;
+ this.callback = callback;
+ }
+ boolean matches(TrieNode enclosingNode, // Used only for the get character method.
+ T searchText, int searchTextLength, int searchTextIndex, Object callbackParameter) {
+ if (searchTextLength - searchTextIndex < patternLength - patternStartIndex) {
+ return false; // Remaining search text is shorter than the remaining leaf pattern and they cannot match.
+ }
+
+ for (int i = searchTextIndex, j = patternStartIndex; j < patternLength; i++, j++) {
+ if (enclosingNode.getCharValue(searchText, i) != enclosingNode.getCharValue(pattern, j)) {
+ return false;
+ }
+ }
+
+ return callback == null || callback.patternMatched(searchText,
+ searchTextIndex - patternStartIndex, patternLength, callbackParameter);
+ }
+ }
+
+ static abstract class TrieNode {
+ /**
+ * Dummy value used for root node. Value can be anything as it's never referenced.
+ */
+ private static final char ROOT_NODE_CHARACTER_VALUE = 0; // ASCII null character.
+
+ /**
+ * How much to expand the children array when resizing.
+ */
+ private static final int CHILDREN_ARRAY_INCREASE_SIZE_INCREMENT = 2;
+
+ /**
+ * Character this node represents.
+ * This field is ignored for the root node (which does not represent any character).
+ */
+ private final char nodeValue;
+
+ /**
+ * A compressed graph path that represents the remaining pattern characters of a single child node.
+ *
+ * If present then child array is always null, although callbacks for other
+ * end of patterns can also exist on this same node.
+ */
+ @Nullable
+ private TrieCompressedPath leaf;
+
+ /**
+ * All child nodes. Only present if no compressed leaf exist.
+ *
+ * Array is dynamically increased in size as needed,
+ * and uses perfect hashing for the elements it contains.
+ *
+ * So if the array contains a given character,
+ * the character will always map to the node with index: (character % arraySize).
+ *
+ * Elements not contained can collide with elements the array does contain,
+ * so must compare the nodes character value.
+ *
+ /*
+ * Alternatively, this could be implemented as a sorted, densely packed array
+ * with lookups performed via binary search.
+ * This approach would save a small amount of memory by eliminating null
+ * child entries. However, it would result in a worst-case lookup time of
+ * O(n log m), where:
+ * - n is the number of characters in the input text, and
+ * - m is the maximum size of the sorted character arrays.
+ * In contrast, using a hash-based array guarantees O(n) lookup time.
+ * Given that the total memory usage is already very small (all Litho filters
+ * together use approximately 10KB), the hash-based implementation is preferred
+ * for its superior performance.
+ */
+ @Nullable
+ private TrieNode[] children;
+
+ /**
+ * Callbacks for all patterns that end at this node.
+ */
+ @Nullable
+ private List> endOfPatternCallback;
+
+ TrieNode() {
+ this.nodeValue = ROOT_NODE_CHARACTER_VALUE;
+ }
+ TrieNode(char nodeCharacterValue) {
+ this.nodeValue = nodeCharacterValue;
+ }
+
+ /**
+ * @param pattern Pattern to add.
+ * @param patternIndex Current recursive index of the pattern.
+ * @param patternLength Length of the pattern.
+ * @param callback Callback, where a value of NULL indicates to always accept a pattern match.
+ */
+ private void addPattern(T pattern, int patternIndex, int patternLength,
+ @Nullable TriePatternMatchedCallback callback) {
+ if (patternIndex == patternLength) { // Reached the end of the pattern.
+ if (endOfPatternCallback == null) {
+ endOfPatternCallback = new ArrayList<>(1);
+ }
+ endOfPatternCallback.add(callback);
+ return;
+ }
+
+ if (leaf != null) {
+ // Reached end of the graph and a leaf exist.
+ // Recursively call back into this method and push the existing leaf down 1 level.
+ if (children != null) throw new IllegalStateException();
+ //noinspection unchecked
+ children = new TrieNode[1];
+ TrieCompressedPath temp = leaf;
+ leaf = null;
+ addPattern(temp.pattern, temp.patternStartIndex, temp.patternLength, temp.callback);
+ // Continue onward and add the parameter pattern.
+ } else if (children == null) {
+ leaf = new TrieCompressedPath<>(pattern, patternIndex, patternLength, callback);
+ return;
+ }
+
+ final char character = getCharValue(pattern, patternIndex);
+ final int arrayIndex = hashIndexForTableSize(children.length, character);
+ TrieNode child = children[arrayIndex];
+ if (child == null) {
+ child = createNode(character);
+ children[arrayIndex] = child;
+ } else if (child.nodeValue != character) {
+ // Hash collision. Resize the table until perfect hashing is found.
+ child = createNode(character);
+ expandChildArray(child);
+ }
+ child.addPattern(pattern, patternIndex + 1, patternLength, callback);
+ }
+
+ /**
+ * Resizes the children table until all nodes hash to exactly one array index.
+ */
+ private void expandChildArray(TrieNode child) {
+ int replacementArraySize = Objects.requireNonNull(children).length;
+ while (true) {
+ replacementArraySize += CHILDREN_ARRAY_INCREASE_SIZE_INCREMENT;
+ //noinspection unchecked
+ TrieNode[] replacement = new TrieNode[replacementArraySize];
+ addNodeToArray(replacement, child);
+
+ boolean collision = false;
+ for (TrieNode existingChild : children) {
+ if (existingChild != null) {
+ if (!addNodeToArray(replacement, existingChild)) {
+ collision = true;
+ break;
+ }
+ }
+ }
+ if (collision) {
+ continue;
+ }
+
+ children = replacement;
+ return;
+ }
+ }
+
+ private static boolean addNodeToArray(TrieNode[] array, TrieNode childToAdd) {
+ final int insertIndex = hashIndexForTableSize(array.length, childToAdd.nodeValue);
+ if (array[insertIndex] != null ) {
+ return false; // Collision.
+ }
+ array[insertIndex] = childToAdd;
+ return true;
+ }
+
+ private static int hashIndexForTableSize(int arraySize, char nodeValue) {
+ return nodeValue % arraySize;
+ }
+
+ /**
+ * This method is static and uses a loop to avoid all recursion.
+ * This is done for performance since the JVM does not optimize tail recursion.
+ *
+ * @param startNode Node to start the search from.
+ * @param searchText Text to search for patterns in.
+ * @param searchTextIndex Start index, inclusive.
+ * @param searchTextEndIndex End index, exclusive.
+ * @return If any pattern matches, and it's associated callback halted the search.
+ */
+ private static boolean matches(final TrieNode startNode, final T searchText,
+ int searchTextIndex, final int searchTextEndIndex,
+ final Object callbackParameter) {
+ TrieNode node = startNode;
+ int currentMatchLength = 0;
+
+ while (true) {
+ TrieCompressedPath leaf = node.leaf;
+ if (leaf != null && leaf.matches(startNode, searchText, searchTextEndIndex, searchTextIndex, callbackParameter)) {
+ return true; // Leaf exists and it matched the search text.
+ }
+
+ List> endOfPatternCallback = node.endOfPatternCallback;
+ if (endOfPatternCallback != null) {
+ final int matchStartIndex = searchTextIndex - currentMatchLength;
+ for (@Nullable TriePatternMatchedCallback callback : endOfPatternCallback) {
+ if (callback == null) {
+ return true; // No callback and all matches are valid.
+ }
+ if (callback.patternMatched(searchText, matchStartIndex, currentMatchLength, callbackParameter)) {
+ return true; // Callback confirmed the match.
+ }
+ }
+ }
+
+ TrieNode[] children = node.children;
+ if (children == null) {
+ return false; // Reached a graph end point and there's no further patterns to search.
+ }
+ if (searchTextIndex == searchTextEndIndex) {
+ return false; // Reached end of the search text and found no matches.
+ }
+
+ // Use the start node to reduce VM method lookup, since all nodes are the same class type.
+ final char character = startNode.getCharValue(searchText, searchTextIndex);
+ final int arrayIndex = hashIndexForTableSize(children.length, character);
+ TrieNode child = children[arrayIndex];
+ if (child == null || child.nodeValue != character) {
+ return false;
+ }
+
+ node = child;
+ searchTextIndex++;
+ currentMatchLength++;
+ }
+ }
+
+ /**
+ * Gives an approximate memory usage.
+ *
+ * @return Estimated number of memory pointers used, starting from this node and including all children.
+ */
+ private int estimatedNumberOfPointersUsed() {
+ int numberOfPointers = 4; // Number of fields in this class.
+ if (leaf != null) {
+ numberOfPointers += 4; // Number of fields in leaf node.
+ }
+
+ if (endOfPatternCallback != null) {
+ numberOfPointers += endOfPatternCallback.size();
+ }
+
+ if (children != null) {
+ numberOfPointers += children.length;
+ for (TrieNode child : children) {
+ if (child != null) {
+ numberOfPointers += child.estimatedNumberOfPointersUsed();
+ }
+ }
+ }
+ return numberOfPointers;
+ }
+
+ abstract TrieNode createNode(char nodeValue);
+ abstract char getCharValue(T text, int index);
+ abstract int getTextLength(T text);
+ }
+
+ /**
+ * Root node, and it's children represent the first pattern characters.
+ */
+ private final TrieNode root;
+
+ /**
+ * Patterns to match.
+ */
+ private final List patterns = new ArrayList<>();
+
+ @SafeVarargs
+ TrieSearch(TrieNode root, T... patterns) {
+ this.root = Objects.requireNonNull(root);
+ addPatterns(patterns);
+ }
+
+ @SafeVarargs
+ public final void addPatterns(T... patterns) {
+ for (T pattern : patterns) {
+ addPattern(pattern);
+ }
+ }
+
+ /**
+ * Adds a pattern that will always return a positive match if found.
+ *
+ * @param pattern Pattern to add. Calling this with a zero length pattern does nothing.
+ */
+ public void addPattern(T pattern) {
+ addPattern(pattern, root.getTextLength(pattern), null);
+ }
+
+ /**
+ * @param pattern Pattern to add. Calling this with a zero length pattern does nothing.
+ * @param callback Callback to determine if searching should halt when a match is found.
+ */
+ public void addPattern(T pattern, TriePatternMatchedCallback callback) {
+ addPattern(pattern, root.getTextLength(pattern), Objects.requireNonNull(callback));
+ }
+
+ void addPattern(T pattern, int patternLength, @Nullable TriePatternMatchedCallback callback) {
+ if (patternLength == 0) return; // Nothing to match
+
+ patterns.add(pattern);
+ root.addPattern(pattern, 0, patternLength, callback);
+ }
+
+ public final boolean matches(T textToSearch) {
+ return matches(textToSearch, 0);
+ }
+
+ public boolean matches(T textToSearch, Object callbackParameter) {
+ return matches(textToSearch, 0, root.getTextLength(textToSearch),
+ Objects.requireNonNull(callbackParameter));
+ }
+
+ public boolean matches(T textToSearch, int startIndex) {
+ return matches(textToSearch, startIndex, root.getTextLength(textToSearch));
+ }
+
+ public final boolean matches(T textToSearch, int startIndex, int endIndex) {
+ return matches(textToSearch, startIndex, endIndex, null);
+ }
+
+ /**
+ * Searches through text, looking for any substring that matches any pattern in this tree.
+ *
+ * @param textToSearch Text to search through.
+ * @param startIndex Index to start searching, inclusive value.
+ * @param endIndex Index to stop matching, exclusive value.
+ * @param callbackParameter Optional parameter passed to the callbacks.
+ * @return If any pattern matched, and it's callback halted searching.
+ */
+ public boolean matches(T textToSearch, int startIndex, int endIndex, @Nullable Object callbackParameter) {
+ return matches(textToSearch, root.getTextLength(textToSearch), startIndex, endIndex, callbackParameter);
+ }
+
+ private boolean matches(T textToSearch, int textToSearchLength, int startIndex, int endIndex,
+ @Nullable Object callbackParameter) {
+ if (endIndex > textToSearchLength) {
+ throw new IllegalArgumentException("endIndex: " + endIndex
+ + " is greater than texToSearchLength: " + textToSearchLength);
+ }
+ if (patterns.isEmpty()) {
+ return false; // No patterns were added.
+ }
+ for (int i = startIndex; i < endIndex; i++) {
+ if (TrieNode.matches(root, textToSearch, i, endIndex, callbackParameter)) return true;
+ }
+ return false;
+ }
+
+ /**
+ * @return Estimated memory size (in kilobytes) of this instance.
+ */
+ public int getEstimatedMemorySize() {
+ if (patterns.isEmpty()) {
+ return 0;
+ }
+ // Assume the device has less than 32GB of ram (and can use pointer compression),
+ // or the device is 32-bit.
+ final int numberOfBytesPerPointer = 4;
+ return (int) Math.ceil((numberOfBytesPerPointer * root.estimatedNumberOfPointersUsed()) / 1024.0);
+ }
+
+ public int numberOfPatterns() {
+ return patterns.size();
+ }
+
+ public List getPatterns() {
+ return Collections.unmodifiableList(patterns);
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java
new file mode 100644
index 0000000000..cf65db8a4c
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java
@@ -0,0 +1,1222 @@
+package app.revanced.extension.shared;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.graphics.Color;
+import android.net.ConnectivityManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.preference.Preference;
+import android.preference.PreferenceGroup;
+import android.preference.PreferenceScreen;
+import android.util.Pair;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.view.Window;
+import android.view.WindowManager;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+import android.widget.Toast;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.text.Bidi;
+import java.text.Collator;
+import java.text.Normalizer;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+
+import app.revanced.extension.shared.settings.AppLanguage;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.settings.preference.ReVancedAboutPreference;
+import app.revanced.extension.shared.ui.Dim;
+
+public class Utils {
+
+ @SuppressLint("StaticFieldLeak")
+ static volatile Context context;
+
+ private static String versionName;
+ private static String applicationLabel;
+
+ @ColorInt
+ private static int darkColor = Color.BLACK;
+ @ColorInt
+ private static int lightColor = Color.WHITE;
+
+ @Nullable
+ private static Boolean isDarkModeEnabled;
+
+ private static boolean appIsUsingBoldIcons;
+
+ // Cached Collator instance with its locale.
+ @Nullable
+ private static Locale cachedCollatorLocale;
+ @Nullable
+ private static Collator cachedCollator;
+
+ private static final Pattern PUNCTUATION_PATTERN = Pattern.compile("\\p{P}+");
+ private static final Pattern DIACRITICS_PATTERN = Pattern.compile("\\p{M}");
+
+ private Utils() {
+ } // utility class
+
+ /**
+ * Injection point.
+ *
+ * @return The manifest 'Version' entry of the patches.jar used during patching.
+ */
+ @SuppressWarnings("SameReturnValue")
+ public static String getPatchesReleaseVersion() {
+ return ""; // Value is replaced during patching.
+ }
+
+ private static PackageInfo getPackageInfo() throws PackageManager.NameNotFoundException {
+ final var packageName = Objects.requireNonNull(getContext()).getPackageName();
+
+ PackageManager packageManager = context.getPackageManager();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ return packageManager.getPackageInfo(
+ packageName,
+ PackageManager.PackageInfoFlags.of(0)
+ );
+ }
+
+ return packageManager.getPackageInfo(
+ packageName,
+ 0
+ );
+ }
+
+ /**
+ * @return The version name of the app, such as 20.13.41
+ */
+ public static String getAppVersionName() {
+ if (versionName == null) {
+ try {
+ versionName = getPackageInfo().versionName;
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to get package info", ex);
+ versionName = "Unknown";
+ }
+ }
+
+ return versionName;
+ }
+
+ @SuppressWarnings("unused")
+ public static String getApplicationName() {
+ if (applicationLabel == null) {
+ try {
+ ApplicationInfo applicationInfo = getPackageInfo().applicationInfo;
+ applicationLabel = (String) applicationInfo.loadLabel(context.getPackageManager());
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to get application name", ex);
+ applicationLabel = "Unknown";
+ }
+ }
+
+ return applicationLabel;
+ }
+
+ /**
+ * Hide a view by setting its layout height and width to 1dp.
+ *
+ * @param setting The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static void hideViewBy0dpUnderCondition(BooleanSetting setting, View view) {
+ if (hideViewBy0dpUnderCondition(setting.get(), view)) {
+ Logger.printDebug(() -> "View hidden by setting: " + setting);
+ }
+ }
+
+ /**
+ * Hide a view by setting its layout height and width to 0dp.
+ *
+ * @param condition The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static boolean hideViewBy0dpUnderCondition(boolean condition, View view) {
+ if (condition) {
+ hideViewBy0dp(view);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Hide a view by setting its layout params to 0x0
+ * @param view The view to hide.
+ */
+ public static void hideViewBy0dp(View view) {
+ ViewGroup.LayoutParams params = view.getLayoutParams();
+ if (params == null)
+ params = new ViewGroup.LayoutParams(0, 0);
+
+ params.width = 0;
+ params.height = 0;
+ view.setLayoutParams(params);
+ }
+
+ /**
+ * Hide a view by setting its visibility as GONE.
+ *
+ * @param setting The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static void hideViewUnderCondition(BooleanSetting setting, View view) {
+ if (hideViewUnderCondition(setting.get(), view)) {
+ Logger.printDebug(() -> "View hidden by setting: " + setting);
+ }
+ }
+
+ /**
+ * Hide a view by setting its visibility as GONE.
+ *
+ * @param condition The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static boolean hideViewUnderCondition(boolean condition, View view) {
+ if (condition) {
+ view.setVisibility(View.GONE);
+ return true;
+ }
+
+ return false;
+ }
+
+ public static void hideViewByRemovingFromParentUnderCondition(BooleanSetting setting, View view) {
+ if (hideViewByRemovingFromParentUnderCondition(setting.get(), view)) {
+ Logger.printDebug(() -> "View hidden by setting: " + setting);
+ }
+ }
+
+ public static boolean hideViewByRemovingFromParentUnderCondition(boolean condition, View view) {
+ if (condition) {
+ ViewParent parent = view.getParent();
+ if (parent instanceof ViewGroup parentGroup) {
+ parentGroup.removeView(view);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * General purpose pool for network calls and other background tasks.
+ * All tasks run at max thread priority.
+ */
+ private static final ThreadPoolExecutor backgroundThreadPool = new ThreadPoolExecutor(
+ 3, // 3 threads always ready to go.
+ Integer.MAX_VALUE,
+ 10, // For any threads over the minimum, keep them alive 10 seconds after they go idle.
+ TimeUnit.SECONDS,
+ new SynchronousQueue<>(),
+ r -> { // ThreadFactory
+ Thread t = new Thread(r);
+ t.setPriority(Thread.MAX_PRIORITY); // Run at max priority.
+ return t;
+ });
+
+ public static void runOnBackgroundThread(Runnable task) {
+ backgroundThreadPool.execute(task);
+ }
+
+ public static Future submitOnBackgroundThread(Callable call) {
+ return backgroundThreadPool.submit(call);
+ }
+
+ /**
+ * Simulates a delay by doing meaningless calculations.
+ * Used for debugging to verify UI timeout logic.
+ */
+ @SuppressWarnings("UnusedReturnValue")
+ public static long doNothingForDuration(long amountOfTimeToWaste) {
+ final long timeCalculationStarted = System.currentTimeMillis();
+ Logger.printDebug(() -> "Artificially creating delay of: " + amountOfTimeToWaste + "ms");
+
+ long meaninglessValue = 0;
+ while (System.currentTimeMillis() - timeCalculationStarted < amountOfTimeToWaste) {
+ // Could do a thread sleep, but that will trigger an exception if the thread is interrupted.
+ meaninglessValue += Long.numberOfLeadingZeros((long) Math.exp(Math.random()));
+ }
+ // Return the value, otherwise the compiler or VM might optimize and remove the meaningless time-wasting work,
+ // leaving an empty loop that hammers on the System.currentTimeMillis native call.
+ return meaninglessValue;
+ }
+
+ public static boolean containsAny(String value, String... targets) {
+ return indexOfFirstFound(value, targets) >= 0;
+ }
+
+ public static int indexOfFirstFound(String value, String... targets) {
+ if (isNotEmpty(value)) {
+ for (String string : targets) {
+ if (!string.isEmpty()) {
+ final int indexOf = value.indexOf(string);
+ if (indexOf >= 0) return indexOf;
+ }
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * @return zero, if the resource is not found.
+ */
+ @SuppressLint("DiscouragedApi")
+ public static int getResourceIdentifier(Context context, @Nullable ResourceType type, String resourceIdentifierName) {
+ return context.getResources().getIdentifier(resourceIdentifierName,
+ type == null ? null : type.value, context.getPackageName());
+ }
+
+ public static int getResourceIdentifierOrThrow(Context context, @Nullable ResourceType type, String resourceIdentifierName) {
+ final int resourceId = getResourceIdentifier(context, type, resourceIdentifierName);
+ if (resourceId == 0) {
+ throw new Resources.NotFoundException("No resource id exists with name: " + resourceIdentifierName
+ + " type: " + type);
+ }
+ return resourceId;
+ }
+
+ /**
+ * @return zero, if the resource is not found.
+ * @see #getResourceIdentifierOrThrow(ResourceType, String)
+ */
+ public static int getResourceIdentifier(@Nullable ResourceType type, String resourceIdentifierName) {
+ return getResourceIdentifier(getContext(), type, resourceIdentifierName);
+ }
+
+ /**
+ * @return zero, if the resource is not found.
+ * @see #getResourceIdentifier(ResourceType, String)
+ */
+ public static int getResourceIdentifierOrThrow(@Nullable ResourceType type, String resourceIdentifierName) {
+ return getResourceIdentifierOrThrow(getContext(), type, resourceIdentifierName);
+ }
+
+ public static String getResourceString(int id) throws Resources.NotFoundException {
+ return getContext().getResources().getString(id);
+ }
+
+ public static int getResourceInteger(String resourceIdentifierName) throws Resources.NotFoundException {
+ return getContext().getResources().getInteger(getResourceIdentifierOrThrow(ResourceType.INTEGER, resourceIdentifierName));
+ }
+
+ public static Animation getResourceAnimation(String resourceIdentifierName) throws Resources.NotFoundException {
+ return AnimationUtils.loadAnimation(getContext(), getResourceIdentifierOrThrow(ResourceType.ANIM, resourceIdentifierName));
+ }
+
+ @ColorInt
+ public static int getResourceColor(String resourceIdentifierName) throws Resources.NotFoundException {
+ //noinspection deprecation
+ return getContext().getResources().getColor(getResourceIdentifierOrThrow(ResourceType.COLOR, resourceIdentifierName));
+ }
+
+ public static int getResourceDimensionPixelSize(String resourceIdentifierName) throws Resources.NotFoundException {
+ return getContext().getResources().getDimensionPixelSize(getResourceIdentifierOrThrow(ResourceType.DIMEN, resourceIdentifierName));
+ }
+
+ public static float getResourceDimension(String resourceIdentifierName) throws Resources.NotFoundException {
+ return getContext().getResources().getDimension(getResourceIdentifierOrThrow(ResourceType.DIMEN, resourceIdentifierName));
+ }
+
+ public static String[] getResourceStringArray(String resourceIdentifierName) throws Resources.NotFoundException {
+ return getContext().getResources().getStringArray(getResourceIdentifierOrThrow(ResourceType.ARRAY, resourceIdentifierName));
+ }
+
+ public interface MatchFilter {
+ boolean matches(T object);
+ }
+
+ /**
+ * Includes sub children.
+ */
+ public static R getChildViewByResourceName(View view, String str) {
+ var child = view.findViewById(Utils.getResourceIdentifierOrThrow(ResourceType.ID, str));
+ //noinspection unchecked
+ return (R) child;
+ }
+
+ /**
+ * @param searchRecursively If children ViewGroups should also be
+ * recursively searched using depth first search.
+ * @return The first child view that matches the filter.
+ */
+ @Nullable
+ public static T getChildView(ViewGroup viewGroup, boolean searchRecursively,
+ MatchFilter filter) {
+ for (int i = 0, childCount = viewGroup.getChildCount(); i < childCount; i++) {
+ View childAt = viewGroup.getChildAt(i);
+
+ if (filter.matches(childAt)) {
+ //noinspection unchecked
+ return (T) childAt;
+ }
+ // Must do recursive after filter check, in case the filter is looking for a ViewGroup.
+ if (searchRecursively && childAt instanceof ViewGroup) {
+ T match = getChildView((ViewGroup) childAt, true, filter);
+ if (match != null) return match;
+ }
+ }
+
+ return null;
+ }
+
+ @Nullable
+ public static ViewParent getParentView(View view, int nthParent) {
+ ViewParent parent = view.getParent();
+
+ int currentDepth = 0;
+ while (++currentDepth < nthParent && parent != null) {
+ parent = parent.getParent();
+ }
+
+ if (currentDepth == nthParent) {
+ return parent;
+ }
+
+ final int currentDepthLog = currentDepth;
+ Logger.printDebug(() -> "Could not find parent view of depth: " + nthParent
+ + " and instead found at: " + currentDepthLog + " view: " + view);
+ return null;
+ }
+
+ public static void restartApp(Context context) {
+ String packageName = context.getPackageName();
+ Intent intent = Objects.requireNonNull(context.getPackageManager().getLaunchIntentForPackage(packageName));
+ Intent mainIntent = Intent.makeRestartActivityTask(intent.getComponent());
+ // Required for API 34 and later
+ // Ref: https://developer.android.com/about/versions/14/behavior-changes-14#safer-intents
+ mainIntent.setPackage(packageName);
+ context.startActivity(mainIntent);
+ System.exit(0);
+ }
+
+ public static Context getContext() {
+ if (context == null) {
+ Logger.printException(() -> "Context is not set by extension hook, returning null", null);
+ }
+ return context;
+ }
+
+ public static void setContext(Context appContext) {
+ // Intentionally use logger before context is set,
+ // to expose any bugs in the 'no context available' logger code.
+ Logger.printInfo(() -> "Set context: " + appContext);
+ // Must initially set context to check the app language.
+ context = appContext;
+
+ AppLanguage language = BaseSettings.REVANCED_LANGUAGE.get();
+ if (language != AppLanguage.DEFAULT) {
+ // Create a new context with the desired language.
+ Logger.printDebug(() -> "Using app language: " + language);
+ Configuration config = new Configuration(appContext.getResources().getConfiguration());
+ config.setLocale(language.getLocale());
+ context = appContext.createConfigurationContext(config);
+ }
+
+ setThemeLightColor(getThemeColor(getThemeLightColorResourceName(), Color.WHITE));
+ setThemeDarkColor(getThemeColor(getThemeDarkColorResourceName(), Color.BLACK));
+ }
+
+ public static void setClipboard(CharSequence text) {
+ ClipboardManager clipboard = (ClipboardManager) context
+ .getSystemService(Context.CLIPBOARD_SERVICE);
+ ClipData clip = ClipData.newPlainText("ReVanced", text);
+ clipboard.setPrimaryClip(clip);
+ }
+
+ public static boolean isNotEmpty(@Nullable String str) {
+ return str != null && !str.isEmpty();
+ }
+
+ @SuppressWarnings("unused")
+ public static boolean isTablet() {
+ return context.getResources().getConfiguration().smallestScreenWidthDp >= 600;
+ }
+
+ @Nullable
+ private static Boolean isRightToLeftTextLayout;
+
+ /**
+ * @return If the device language uses right to left text layout (Hebrew, Arabic, etc.).
+ * If this should match any ReVanced language override then instead use
+ * {@link #isRightToLeftLocale(Locale)} with {@link BaseSettings#REVANCED_LANGUAGE}.
+ * This is the default locale of the device, which may differ if
+ * {@link BaseSettings#REVANCED_LANGUAGE} is set to a different language.
+ */
+ public static boolean isRightToLeftLocale() {
+ if (isRightToLeftTextLayout == null) {
+ isRightToLeftTextLayout = isRightToLeftLocale(Locale.getDefault());
+ }
+ return isRightToLeftTextLayout;
+ }
+
+ /**
+ * @return If the locale uses right to left text layout (Hebrew, Arabic, etc.).
+ */
+ public static boolean isRightToLeftLocale(Locale locale) {
+ String displayLanguage = locale.getDisplayLanguage();
+ return new Bidi(displayLanguage, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT).isRightToLeft();
+ }
+
+ /**
+ * @return A UTF8 string containing a left-to-right or right-to-left
+ * character of the device locale. If this should match any ReVanced language
+ * override then instead use {@link #getTextDirectionString(Locale)} with
+ * {@link BaseSettings#REVANCED_LANGUAGE}.
+ */
+ public static String getTextDirectionString() {
+ return getTextDirectionString(isRightToLeftLocale());
+ }
+
+ @SuppressWarnings("unused")
+ public static String getTextDirectionString(Locale locale) {
+ return getTextDirectionString(isRightToLeftLocale(locale));
+ }
+
+ private static String getTextDirectionString(boolean isRightToLeft) {
+ return isRightToLeft
+ ? "\u200F" // u200F = right to left character.
+ : "\u200E"; // u200E = left to right character.
+ }
+
+ /**
+ * @return if the text contains at least 1 number character,
+ * including any Unicode numbers such as Arabic.
+ */
+ @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+ public static boolean containsNumber(CharSequence text) {
+ for (int index = 0, length = text.length(); index < length;) {
+ final int codePoint = Character.codePointAt(text, index);
+ if (Character.isDigit(codePoint)) {
+ return true;
+ }
+ index += Character.charCount(codePoint);
+ }
+
+ return false;
+ }
+
+ /**
+ * Ignore this class. It must be public to satisfy Android requirements.
+ */
+ @SuppressWarnings("deprecation")
+ public static final class DialogFragmentWrapper extends DialogFragment {
+
+ private Dialog dialog;
+ @Nullable
+ private DialogFragmentOnStartAction onStartAction;
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ // Do not call super method to prevent state saving.
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ return dialog;
+ }
+
+ @Override
+ public void onStart() {
+ try {
+ super.onStart();
+
+ if (onStartAction != null) {
+ onStartAction.onStart(dialog);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onStart failure: " + dialog.getClass().getSimpleName(), ex);
+ }
+ }
+ }
+
+ /**
+ * Interface for {@link #showDialog(Activity, Dialog, boolean, DialogFragmentOnStartAction)}.
+ */
+ @FunctionalInterface
+ public interface DialogFragmentOnStartAction {
+ void onStart(Dialog dialog);
+ }
+
+ public static void showDialog(Activity activity, Dialog dialog) {
+ showDialog(activity, dialog, true, null);
+ }
+
+ /**
+ * Utility method to allow showing a Dialog on top of other 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 Dialog#show()} on the dialog.
+ */
+ @SuppressWarnings("deprecation")
+ public static void showDialog(Activity activity,
+ Dialog dialog,
+ boolean isCancelable,
+ @Nullable DialogFragmentOnStartAction onStartAction) {
+ verifyOnMainThread();
+
+ DialogFragmentWrapper fragment = new DialogFragmentWrapper();
+ fragment.dialog = dialog;
+ fragment.onStartAction = onStartAction;
+ fragment.setCancelable(isCancelable);
+
+ fragment.show(activity.getFragmentManager(), null);
+ }
+
+ /**
+ * Safe to call from any thread.
+ */
+ public static void showToastShort(String messageToToast) {
+ showToast(messageToToast, Toast.LENGTH_SHORT);
+ }
+
+ /**
+ * Safe to call from any thread.
+ */
+ public static void showToastLong(String messageToToast) {
+ showToast(messageToToast, Toast.LENGTH_LONG);
+ }
+
+ /**
+ * Safe to call from any thread.
+ *
+ * @param messageToToast Message to show.
+ * @param toastDuration Either {@link Toast#LENGTH_SHORT} or {@link Toast#LENGTH_LONG}.
+ */
+ public static void showToast(String messageToToast, int toastDuration) {
+ Objects.requireNonNull(messageToToast);
+ runOnMainThreadNowOrLater(() -> {
+ Context currentContext = context;
+
+ if (currentContext == null) {
+ Logger.printException(() -> "Cannot show toast (context is null): " + messageToToast);
+ } else {
+ Logger.printDebug(() -> "Showing toast: " + messageToToast);
+ Toast.makeText(currentContext, messageToToast, toastDuration).show();
+ }
+ });
+ }
+
+ /**
+ * @return The current dark mode as set by any patch.
+ * Or if none is set, then the system dark mode status is returned.
+ */
+ public static boolean isDarkModeEnabled() {
+ Boolean isDarkMode = isDarkModeEnabled;
+ if (isDarkMode != null) {
+ return isDarkMode;
+ }
+
+ Configuration config = Resources.getSystem().getConfiguration();
+ final int currentNightMode = config.uiMode & Configuration.UI_MODE_NIGHT_MASK;
+ return currentNightMode == Configuration.UI_MODE_NIGHT_YES;
+ }
+
+ /**
+ * Overrides dark mode status as returned by {@link #isDarkModeEnabled()}.
+ */
+ public static void setIsDarkModeEnabled(boolean isDarkMode) {
+ isDarkModeEnabled = isDarkMode;
+ Logger.printDebug(() -> "Dark mode status: " + isDarkMode);
+ }
+
+ public static boolean isLandscapeOrientation() {
+ final int orientation = Resources.getSystem().getConfiguration().orientation;
+ return orientation == Configuration.ORIENTATION_LANDSCAPE;
+ }
+
+ /**
+ * Automatically logs any exceptions the runnable throws.
+ *
+ * @see #runOnMainThreadNowOrLater(Runnable)
+ */
+ public static void runOnMainThread(Runnable runnable) {
+ runOnMainThreadDelayed(runnable, 0);
+ }
+
+ /**
+ * Automatically logs any exceptions the runnable throws.
+ */
+ public static void runOnMainThreadDelayed(Runnable runnable, long delayMillis) {
+ Runnable loggingRunnable = () -> {
+ try {
+ runnable.run();
+ } catch (Exception ex) {
+ Logger.printException(() -> runnable.getClass().getSimpleName() + ": " + ex.getMessage(), ex);
+ }
+ };
+ new Handler(Looper.getMainLooper()).postDelayed(loggingRunnable, delayMillis);
+ }
+
+ /**
+ * If called from the main thread, the code is run immediately.
+ * If called off the main thread, this is the same as {@link #runOnMainThread(Runnable)}.
+ */
+ public static void runOnMainThreadNowOrLater(Runnable runnable) {
+ if (isCurrentlyOnMainThread()) {
+ runnable.run();
+ } else {
+ runOnMainThread(runnable);
+ }
+ }
+
+ /**
+ * @return if the calling thread is on the main thread.
+ */
+ public static boolean isCurrentlyOnMainThread() {
+ return Looper.getMainLooper().isCurrentThread();
+ }
+
+ /**
+ * @throws IllegalStateException if the calling thread is _off_ the main thread.
+ */
+ public static void verifyOnMainThread() throws IllegalStateException {
+ if (!isCurrentlyOnMainThread()) {
+ throw new IllegalStateException("Must call _on_ the main thread");
+ }
+ }
+
+ /**
+ * @throws IllegalStateException if the calling thread is _on_ the main thread.
+ */
+ public static void verifyOffMainThread() throws IllegalStateException {
+ if (isCurrentlyOnMainThread()) {
+ throw new IllegalStateException("Must call _off_ the main thread");
+ }
+ }
+
+ public static void openLink(String url) {
+ try {
+ Intent intent = new Intent("android.intent.action.VIEW", Uri.parse(url));
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+ Logger.printInfo(() -> "Opening link with external browser: " + intent);
+ getContext().startActivity(intent);
+ } catch (Exception ex) {
+ Logger.printException(() -> "openLink failure", ex);
+ }
+ }
+
+ public enum NetworkType {
+ NONE,
+ MOBILE,
+ OTHER,
+ }
+
+ /**
+ * Calling extension code must ensure the un-patched app has the permission
+ * android.permission.ACCESS_NETWORK_STATE,
+ * otherwise the app will crash if this method is used.
+ */
+ public static boolean isNetworkConnected() {
+ NetworkType networkType = getNetworkType();
+ return networkType == NetworkType.MOBILE
+ || networkType == NetworkType.OTHER;
+ }
+
+ /**
+ * Calling extension code must ensure the un-patched app has the permission
+ * android.permission.ACCESS_NETWORK_STATE,
+ * otherwise the app will crash if this method is used.
+ */
+ @SuppressWarnings({"MissingPermission", "deprecation"})
+ public static NetworkType getNetworkType() {
+ Context networkContext = getContext();
+ if (networkContext == null) {
+ return NetworkType.NONE;
+ }
+ ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ var networkInfo = cm.getActiveNetworkInfo();
+
+ if (networkInfo == null || !networkInfo.isConnected()) {
+ return NetworkType.NONE;
+ }
+ var type = networkInfo.getType();
+ return (type == ConnectivityManager.TYPE_MOBILE)
+ || (type == ConnectivityManager.TYPE_BLUETOOTH) ? NetworkType.MOBILE : NetworkType.OTHER;
+ }
+
+ /**
+ * Hides a view by setting its layout width and height to 0dp.
+ * Handles null layout params safely.
+ *
+ * @param view The view to hide. If null, does nothing.
+ */
+ public static void hideViewByLayoutParams(@Nullable View view) {
+ if (view == null) return;
+
+ ViewGroup.LayoutParams params = view.getLayoutParams();
+
+ if (params == null) {
+ // Create generic 0x0 layout params accepted by all ViewGroups.
+ params = new ViewGroup.LayoutParams(0, 0);
+ } else {
+ params.width = 0;
+ params.height = 0;
+ }
+
+ view.setLayoutParams(params);
+ }
+
+ /**
+ * Configures the parameters of a dialog window, including its width, gravity, vertical offset and background dimming.
+ * The width is calculated as a percentage of the screen's portrait width and the vertical offset is specified in DIP.
+ * The default dialog background is removed to allow for custom styling.
+ *
+ * @param window The {@link Window} object to configure.
+ * @param gravity The gravity for positioning the dialog (e.g., {@link Gravity#BOTTOM}).
+ * @param yOffsetDip The vertical offset from the gravity position in DIP.
+ * @param widthPercentage The width of the dialog as a percentage of the screen's portrait width (0-100).
+ * @param dimAmount If true, sets the background dim amount to 0 (no dimming); if false, leaves the default dim amount.
+ */
+ public static void setDialogWindowParameters(Window window, int gravity, int yOffsetDip, int widthPercentage, boolean dimAmount) {
+ WindowManager.LayoutParams params = window.getAttributes();
+
+ params.width = Dim.pctPortraitWidth(widthPercentage);
+ params.height = WindowManager.LayoutParams.WRAP_CONTENT;
+ params.gravity = gravity;
+ params.y = yOffsetDip > 0 ? Dim.dp(yOffsetDip) : 0;
+ if (dimAmount) {
+ params.dimAmount = 0f;
+ }
+
+ window.setAttributes(params); // Apply window attributes.
+ window.setBackgroundDrawable(null); // Remove default dialog background
+ }
+
+ /**
+ * @return If the unpatched app is currently using bold icons.
+ */
+ public static boolean appIsUsingBoldIcons() {
+ return appIsUsingBoldIcons;
+ }
+
+ /**
+ * Controls if ReVanced bold icons are shown in various places.
+ * @param boldIcons If the app is currently using bold icons.
+ */
+ public static void setAppIsUsingBoldIcons(boolean boldIcons) {
+ appIsUsingBoldIcons = boldIcons;
+ }
+
+ /**
+ * Sets the theme light color used by the app.
+ */
+ public static void setThemeLightColor(@ColorInt int color) {
+ Logger.printDebug(() -> "Setting theme light color: " + getColorHexString(color));
+ lightColor = color;
+ }
+
+ /**
+ * Sets the theme dark used by the app.
+ */
+ public static void setThemeDarkColor(@ColorInt int color) {
+ Logger.printDebug(() -> "Setting theme dark color: " + getColorHexString(color));
+ darkColor = color;
+ }
+
+ /**
+ * Returns the themed light color, or {@link Color#WHITE} if no theme was set using
+ * {@link #setThemeLightColor(int).
+ */
+ @ColorInt
+ public static int getThemeLightColor() {
+ return lightColor;
+ }
+
+ /**
+ * Returns the themed dark color, or {@link Color#BLACK} if no theme was set using
+ * {@link #setThemeDarkColor(int)}.
+ */
+ @ColorInt
+ public static int getThemeDarkColor() {
+ return darkColor;
+ }
+
+ /**
+ * Injection point.
+ */
+ @SuppressWarnings("SameReturnValue")
+ private static String getThemeLightColorResourceName() {
+ // Value is changed by Settings patch.
+ return "#FFFFFFFF";
+ }
+
+ /**
+ * Injection point.
+ */
+ @SuppressWarnings("SameReturnValue")
+ private static String getThemeDarkColorResourceName() {
+ // Value is changed by Settings patch.
+ return "#FF000000";
+ }
+
+ @ColorInt
+ private static int getThemeColor(String resourceName, int defaultColor) {
+ try {
+ return getColorFromString(resourceName);
+ } catch (Exception ex) {
+ // This code can never be reached since a bad custom color will
+ // fail during resource compilation. So no localized strings are needed here.
+ Logger.printException(() -> "Invalid custom theme color: " + resourceName, ex);
+ return defaultColor;
+ }
+ }
+
+
+ @ColorInt
+ public static int getDialogBackgroundColor() {
+ if (isDarkModeEnabled()) {
+ final int darkColor = getThemeDarkColor();
+ return darkColor == Color.BLACK
+ // Lighten the background a little if using AMOLED dark theme
+ // as the dialogs are almost invisible.
+ ? 0xFF080808 // 3%
+ : darkColor;
+ }
+ return getThemeLightColor();
+ }
+
+ /**
+ * @return The current app background color.
+ */
+ @ColorInt
+ public static int getAppBackgroundColor() {
+ return isDarkModeEnabled() ? getThemeDarkColor() : getThemeLightColor();
+ }
+
+ /**
+ * @return The current app foreground color.
+ */
+ @ColorInt
+ public static int getAppForegroundColor() {
+ return isDarkModeEnabled()
+ ? getThemeLightColor()
+ : getThemeDarkColor();
+ }
+
+ @ColorInt
+ public static int getOkButtonBackgroundColor() {
+ return isDarkModeEnabled()
+ // Must be inverted color.
+ ? Color.WHITE
+ : Color.BLACK;
+ }
+
+ @ColorInt
+ public static int getCancelOrNeutralButtonBackgroundColor() {
+ return isDarkModeEnabled()
+ ? adjustColorBrightness(getDialogBackgroundColor(), 1.10f)
+ : adjustColorBrightness(getThemeLightColor(), 0.95f);
+ }
+
+ @ColorInt
+ public static int getEditTextBackground() {
+ return isDarkModeEnabled()
+ ? adjustColorBrightness(getDialogBackgroundColor(), 1.05f)
+ : adjustColorBrightness(getThemeLightColor(), 0.97f);
+ }
+
+ public static String getColorHexString(@ColorInt int color) {
+ return String.format("#%06X", (0x00FFFFFF & color));
+ }
+
+ /**
+ * {@link PreferenceScreen} and {@link PreferenceGroup} sorting styles.
+ */
+ private enum Sort {
+ /**
+ * Sort by the localized preference title.
+ */
+ BY_TITLE("_sort_by_title"),
+
+ /**
+ * Sort by the preference keys.
+ */
+ BY_KEY("_sort_by_key"),
+
+ /**
+ * Unspecified sorting.
+ */
+ UNSORTED("_sort_by_unsorted");
+
+ final String keySuffix;
+
+ Sort(String keySuffix) {
+ this.keySuffix = keySuffix;
+ }
+
+ static Sort fromKey(@Nullable String key, Sort defaultSort) {
+ if (key != null) {
+ for (Sort sort : values()) {
+ if (key.endsWith(sort.keySuffix)) {
+ return sort;
+ }
+ }
+ }
+ return defaultSort;
+ }
+ }
+
+ /**
+ * Removes punctuation and converts text to lowercase. Returns an empty string if input is null.
+ */
+ public static String removePunctuationToLowercase(@Nullable CharSequence original) {
+ if (original == null) return "";
+ return PUNCTUATION_PATTERN.matcher(original).replaceAll("")
+ .toLowerCase(BaseSettings.REVANCED_LANGUAGE.get().getLocale());
+ }
+
+ /**
+ * Normalizes text for search: applies NFD, removes diacritics, and lowercases (locale-neutral).
+ * Returns an empty string if input is null.
+ */
+ public static String normalizeTextToLowercase(@Nullable CharSequence original) {
+ if (original == null) return "";
+ return DIACRITICS_PATTERN.matcher(Normalizer.normalize(original, Normalizer.Form.NFD))
+ .replaceAll("").toLowerCase(Locale.ROOT);
+ }
+
+ /**
+ * Returns a cached Collator for the current locale, or creates a new one if locale changed.
+ */
+ private static Collator getCollator() {
+ Locale currentLocale = BaseSettings.REVANCED_LANGUAGE.get().getLocale();
+
+ if (cachedCollator == null || !currentLocale.equals(cachedCollatorLocale)) {
+ cachedCollatorLocale = currentLocale;
+ cachedCollator = Collator.getInstance(currentLocale);
+ cachedCollator.setStrength(Collator.SECONDARY); // Case-insensitive, diacritic-insensitive.
+ }
+
+ return cachedCollator;
+ }
+
+ /**
+ * Sorts a {@link PreferenceGroup} and all nested subgroups by title or key.
+ *
+ * The sort order is controlled by the {@link Sort} suffix present in the preference key.
+ * Preferences without a key or without a {@link Sort} suffix remain in their original order.
+ *
+ * Sorting is performed using {@link Collator} with the current user locale,
+ * ensuring correct alphabetical ordering for all supported languages
+ * (e.g., Ukrainian "і", German "ß", French accented characters, etc.).
+ *
+ * @param group the {@link PreferenceGroup} to sort
+ */
+ @SuppressWarnings("deprecation")
+ public static void sortPreferenceGroups(PreferenceGroup group) {
+ Sort groupSort = Sort.fromKey(group.getKey(), Sort.UNSORTED);
+ List> preferences = new ArrayList<>();
+
+ // Get cached Collator for locale-aware string comparison.
+ Collator collator = getCollator();
+
+ for (int i = 0, prefCount = group.getPreferenceCount(); i < prefCount; i++) {
+ Preference preference = group.getPreference(i);
+
+ final Sort preferenceSort;
+ if (preference instanceof PreferenceGroup subGroup) {
+ sortPreferenceGroups(subGroup);
+ preferenceSort = groupSort; // Sort value for groups is for it's content, not itself.
+ } else {
+ // Allow individual preferences to set a key sorting.
+ // Used to force a preference to the top or bottom of a group.
+ preferenceSort = Sort.fromKey(preference.getKey(), groupSort);
+ }
+
+ final String sortValue;
+ switch (preferenceSort) {
+ case BY_TITLE:
+ sortValue = removePunctuationToLowercase(preference.getTitle());
+ break;
+ case BY_KEY:
+ sortValue = preference.getKey();
+ break;
+ case UNSORTED:
+ continue; // Keep original sorting.
+ default:
+ throw new IllegalStateException();
+ }
+
+ preferences.add(new Pair<>(sortValue, preference));
+ }
+
+ // Sort the list using locale-specific collation rules.
+ Collections.sort(preferences, (pair1, pair2)
+ -> collator.compare(pair1.first, pair2.first));
+
+ // Reassign order values to reflect the new sorted sequence
+ int index = 0;
+ for (Pair pair : preferences) {
+ int order = index++;
+ Preference pref = pair.second;
+
+ // Move any screens, intents, and the one off About preference to the top.
+ if (pref instanceof PreferenceScreen || pref instanceof ReVancedAboutPreference
+ || pref.getIntent() != null) {
+ // Any arbitrary large number.
+ order -= 1000;
+ }
+
+ pref.setOrder(order);
+ }
+ }
+
+ /**
+ * Set all preferences to multiline titles if the device is not using an English variant.
+ * The English strings are heavily scrutinized and all titles fit on screen
+ * except 2 or 3 preference strings and those do not affect readability.
+ *
+ * Allowing multiline for those 2 or 3 English preferences looks weird and out of place,
+ * and visually it looks better to clip the text and keep all titles 1 line.
+ */
+ @SuppressWarnings("deprecation")
+ public static void setPreferenceTitlesToMultiLineIfNeeded(PreferenceGroup group) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ return;
+ }
+
+ String revancedLocale = Utils.getContext().getResources().getConfiguration().locale.getLanguage();
+ if (revancedLocale.equals(Locale.ENGLISH.getLanguage())) {
+ return;
+ }
+
+ for (int i = 0, prefCount = group.getPreferenceCount(); i < prefCount; i++) {
+ Preference pref = group.getPreference(i);
+ pref.setSingleLineTitle(false);
+
+ if (pref instanceof PreferenceGroup subGroup) {
+ setPreferenceTitlesToMultiLineIfNeeded(subGroup);
+ }
+ }
+ }
+
+ /**
+ * Parse a color resource or hex code to an int representation of the color.
+ */
+ @ColorInt
+ public static int getColorFromString(String colorString) throws IllegalArgumentException, Resources.NotFoundException {
+ if (colorString.startsWith("#")) {
+ return Color.parseColor(colorString);
+ }
+ return getResourceColor(colorString);
+ }
+
+ /**
+ * Uses {@link #adjustColorBrightness(int, float)} depending on if light or dark mode is active.
+ */
+ @ColorInt
+ public static int adjustColorBrightness(@ColorInt int baseColor, float lightThemeFactor, float darkThemeFactor) {
+ return isDarkModeEnabled()
+ ? adjustColorBrightness(baseColor, darkThemeFactor)
+ : adjustColorBrightness(baseColor, lightThemeFactor);
+ }
+
+ /**
+ * Adjusts the brightness of a color by lightening or darkening it based on the given factor.
+ *
+ * If the factor is greater than 1, the color is lightened by interpolating toward white (#FFFFFF).
+ * If the factor is less than or equal to 1, the color is darkened by scaling its RGB components toward black (#000000).
+ * The alpha channel remains unchanged.
+ *
+ * @param color The input color to adjust, in ARGB format.
+ * @param factor The adjustment factor. Use values > 1.0f to lighten (e.g., 1.11f for slight lightening)
+ * or values <= 1.0f to darken (e.g., 0.95f for slight darkening).
+ * @return The adjusted color in ARGB format.
+ */
+ @ColorInt
+ public static int adjustColorBrightness(@ColorInt int color, float factor) {
+ final int alpha = Color.alpha(color);
+ int red = Color.red(color);
+ int green = Color.green(color);
+ int blue = Color.blue(color);
+
+ if (factor > 1.0f) {
+ // Lighten: Interpolate toward white (255).
+ final float t = 1.0f - (1.0f / factor); // Interpolation parameter.
+ red = Math.round(red + (255 - red) * t);
+ green = Math.round(green + (255 - green) * t);
+ blue = Math.round(blue + (255 - blue) * t);
+ } else {
+ // Darken or no change: Scale toward black.
+ red = Math.round(red * factor);
+ green = Math.round(green * factor);
+ blue = Math.round(blue * factor);
+ }
+
+ // Ensure values are within [0, 255].
+ red = clamp(red, 0, 255);
+ green = clamp(green, 0, 255);
+ blue = clamp(blue, 0, 255);
+
+ return Color.argb(alpha, red, green, blue);
+ }
+
+ public static int clamp(int value, int lower, int upper) {
+ return Math.max(lower, Math.min(value, upper));
+ }
+
+ public static float clamp(float value, float lower, float upper) {
+ return Math.max(lower, Math.min(value, upper));
+ }
+
+ /**
+ * @param maxSize The maximum number of elements to keep in the map.
+ * @return A {@link LinkedHashMap} that automatically evicts the oldest entry
+ * when the size exceeds {@code maxSize}.
+ */
+ public static Map createSizeRestrictedMap(int maxSize) {
+ return new LinkedHashMap<>(2 * maxSize) {
+ @Override
+ protected boolean removeEldestEntry(Entry eldest) {
+ return size() > maxSize;
+ }
+ };
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/checks/Check.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/checks/Check.java
new file mode 100644
index 0000000000..bde66a043c
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/checks/Check.java
@@ -0,0 +1,215 @@
+package app.revanced.extension.shared.checks;
+
+import static android.text.Html.FROM_HTML_MODE_COMPACT;
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.Utils.DialogFragmentOnStartAction;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.Intent;
+import android.graphics.PorterDuff;
+import android.net.Uri;
+import android.os.Build;
+import android.text.Html;
+import android.util.Pair;
+import android.view.Gravity;
+import android.view.View;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+
+import java.util.Collection;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.ResourceType;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.ui.CustomDialog;
+
+@RequiresApi(api = Build.VERSION_CODES.N)
+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 BaseSettings#CHECK_ENVIRONMENT_WARNINGS_ISSUED}
+ * set to -1.
+ */
+ static boolean debugAlwaysShowWarning() {
+ final boolean alwaysShowWarning = BaseSettings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get() < 0;
+ if (alwaysShowWarning) {
+ Logger.printInfo(() -> "Debug forcing environment check warning to show");
+ }
+
+ return alwaysShowWarning;
+ }
+
+ static boolean shouldRun() {
+ return BaseSettings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get()
+ < NUMBER_OF_TIMES_TO_IGNORE_WARNING_BEFORE_DISABLING;
+ }
+
+ static void disableForever() {
+ Logger.printInfo(() -> "Environment checks disabled forever");
+
+ BaseSettings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.save(Integer.MAX_VALUE);
+ }
+
+ static void issueWarning(Activity activity, Collection failedChecks) {
+ final var reasons = new StringBuilder();
+
+ reasons.append("");
+ for (var check : failedChecks) {
+ // Add a non breaking space to fix bullet points spacing issue.
+ reasons.append(" ").append(check.failureReason());
+ }
+ reasons.append(" ");
+
+ var message = Html.fromHtml(
+ str("revanced_check_environment_failed_message", reasons.toString()),
+ FROM_HTML_MODE_COMPACT
+ );
+
+ Utils.runOnMainThreadDelayed(() -> {
+ // Create the custom dialog.
+ Pair dialogPair = CustomDialog.create(
+ activity,
+ str("revanced_check_environment_failed_title"), // Title.
+ message, // Message.
+ null, // No EditText.
+ str("revanced_check_environment_dialog_open_official_source_button"), // OK button text.
+ () -> {
+ // Action for the OK (website) button.
+ 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);
+ },
+ null, // No cancel button.
+ str("revanced_check_environment_dialog_ignore_button"), // Neutral button text.
+ () -> {
+ // Neutral button action.
+ // Cleanup data if the user incorrectly imported a huge negative number.
+ final int current = Math.max(0, BaseSettings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get());
+ BaseSettings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.save(current + 1);
+ },
+ true // Dismiss dialog when onNeutralClick.
+ );
+
+ // Get the dialog and main layout.
+ Dialog dialog = dialogPair.first;
+ LinearLayout mainLayout = dialogPair.second;
+
+ // Add icon to the dialog.
+ ImageView iconView = new ImageView(activity);
+ iconView.setImageResource(Utils.getResourceIdentifierOrThrow(
+ ResourceType.DRAWABLE, "revanced_ic_dialog_alert"));
+ iconView.setColorFilter(Utils.getAppForegroundColor(), PorterDuff.Mode.SRC_IN);
+ iconView.setPadding(0, 0, 0, 0);
+ LinearLayout.LayoutParams iconParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ iconParams.gravity = Gravity.CENTER;
+ mainLayout.addView(iconView, 0); // Add icon at the top.
+
+ dialog.setCancelable(false);
+
+ // Show the dialog.
+ Utils.showDialog(activity, dialog, false, new DialogFragmentOnStartAction() {
+ boolean hasRun;
+ @Override
+ public void onStart(Dialog 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;
+
+ // Get the button container to access buttons.
+ LinearLayout buttonContainer = (LinearLayout) mainLayout.getChildAt(mainLayout.getChildCount() - 1);
+
+ Button openWebsiteButton;
+ Button ignoreButton;
+
+ // Check if buttons are in a single-row layout (buttonContainer has one child: rowContainer).
+ if (buttonContainer.getChildCount() == 1
+ && buttonContainer.getChildAt(0) instanceof LinearLayout rowContainer) {
+ // Neutral button is the first child (index 0).
+ ignoreButton = (Button) rowContainer.getChildAt(0);
+ // OK button is the last child.
+ openWebsiteButton = (Button) rowContainer.getChildAt(rowContainer.getChildCount() - 1);
+ } else {
+ // Multi-row layout: buttons are in separate containers, ordered OK, Cancel, Neutral.
+ LinearLayout okContainer =
+ (LinearLayout) buttonContainer.getChildAt(0); // OK is first.
+ openWebsiteButton = (Button) okContainer.getChildAt(0);
+ LinearLayout neutralContainer =
+ (LinearLayout)buttonContainer.getChildAt(buttonContainer.getChildCount() - 1); // Neutral is last.
+ ignoreButton = (Button) neutralContainer.getChildAt(0);
+ }
+
+ // Initially set buttons to INVISIBLE and disabled.
+ openWebsiteButton.setVisibility(View.INVISIBLE);
+ openWebsiteButton.setEnabled(false);
+ ignoreButton.setVisibility(View.INVISIBLE);
+ ignoreButton.setEnabled(false);
+
+ // Start the countdown for showing and enabling buttons.
+ getCountdownRunnable(ignoreButton, openWebsiteButton).run();
+ }
+ });
+ }, 1000); // Use a delay, so this dialog is shown on top of any other startup dialogs.
+ }
+
+ private static Runnable getCountdownRunnable(Button ignoreButton, 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.setVisibility(View.VISIBLE);
+ openWebsiteButton.setEnabled(true);
+ }
+ secondsRemaining--;
+ Utils.runOnMainThreadDelayed(this, 1000);
+ } else {
+ ignoreButton.setVisibility(View.VISIBLE);
+ ignoreButton.setEnabled(true);
+ }
+ }
+ };
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/checks/CheckEnvironmentPatch.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/checks/CheckEnvironmentPatch.java
new file mode 100644
index 0000000000..e54ab27f74
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/checks/CheckEnvironmentPatch.java
@@ -0,0 +1,347 @@
+package app.revanced.extension.shared.checks;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.util.Base64;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.*;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.checks.Check.debugAlwaysShowWarning;
+import static app.revanced.extension.shared.checks.PatchInfo.Build.*;
+
+/**
+ * This class is used to check if the app was patched by the user
+ * and not downloaded pre-patched, because pre-patched apps are difficult to trust.
+ *
+ * Various indicators help to detect if the app was patched by the user.
+ */
+@RequiresApi(api = Build.VERSION_CODES.N)
+@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.flutter.debug",
+ "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("HardwareIds")
+ @Override
+ protected Boolean check() {
+ if (PATCH_BOARD.isEmpty()) {
+ // Did not patch with Manager, and cannot conclusively say where this was from.
+ Logger.printInfo(() -> "APK does not contain a hardware signature and cannot compare to current device");
+ return null;
+ }
+
+ //noinspection deprecation
+ final var passed = buildFieldEqualsHash("BOARD", Build.BOARD, PATCH_BOARD) &
+ buildFieldEqualsHash("BOOTLOADER", Build.BOOTLOADER, PATCH_BOOTLOADER) &
+ buildFieldEqualsHash("BRAND", Build.BRAND, PATCH_BRAND) &
+ buildFieldEqualsHash("CPU_ABI", Build.CPU_ABI, PATCH_CPU_ABI) &
+ buildFieldEqualsHash("CPU_ABI2", Build.CPU_ABI2, PATCH_CPU_ABI2) &
+ buildFieldEqualsHash("DEVICE", Build.DEVICE, PATCH_DEVICE) &
+ buildFieldEqualsHash("DISPLAY", Build.DISPLAY, PATCH_DISPLAY) &
+ buildFieldEqualsHash("FINGERPRINT", Build.FINGERPRINT, PATCH_FINGERPRINT) &
+ buildFieldEqualsHash("HARDWARE", Build.HARDWARE, PATCH_HARDWARE) &
+ buildFieldEqualsHash("HOST", Build.HOST, PATCH_HOST) &
+ buildFieldEqualsHash("ID", Build.ID, PATCH_ID) &
+ buildFieldEqualsHash("MANUFACTURER", Build.MANUFACTURER, PATCH_MANUFACTURER) &
+ buildFieldEqualsHash("MODEL", Build.MODEL, PATCH_MODEL) &
+ buildFieldEqualsHash("PRODUCT", Build.PRODUCT, PATCH_PRODUCT) &
+ buildFieldEqualsHash("RADIO", Build.RADIO, PATCH_RADIO) &
+ buildFieldEqualsHash("TAGS", Build.TAGS, PATCH_TAGS) &
+ buildFieldEqualsHash("TYPE", Build.TYPE, PATCH_TYPE) &
+ buildFieldEqualsHash("USER", Build.USER, PATCH_USER);
+
+ Logger.printInfo(() -> passed
+ ? "Device hardware signature matches current device"
+ : "Device hardware signature does not match current device");
+
+ return passed;
+ }
+
+ @Override
+ protected String failureReason() {
+ return str("revanced_check_environment_not_same_patching_device");
+ }
+
+ @Override
+ public int uiSortingValue() {
+ return 0; // Show in the middle.
+ }
+ }
+
+ /**
+ * Check if the app was installed within the last 30 minutes after being patched.
+ *
+ * If the app was installed within the last 30 minutes, it is likely, the app was patched by the user.
+ *
+ * If the app was installed much later than the patch time, it is likely the app was
+ * downloaded pre-patched or the user waited too long to install the app.
+ */
+ private static class CheckIsNearPatchTime extends Check {
+ /**
+ * How soon after patching the app must be installed to pass.
+ */
+ static final int INSTALL_AFTER_PATCHING_DURATION_THRESHOLD = 30 * 60 * 1000; // 30 minutes.
+
+ /**
+ * Milliseconds between the time the app was patched, and when it was installed/updated.
+ */
+ long durationBetweenPatchingAndInstallation;
+
+ @NonNull
+ @Override
+ protected Boolean check() {
+ try {
+ Context context = Utils.getContext();
+ PackageManager packageManager = context.getPackageManager();
+ PackageInfo packageInfo = packageManager.getPackageInfo(context.getPackageName(), 0);
+
+ // Duration since initial install or last update, whichever 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 installation source is not Manager or ADB.
+ Check.disableForever();
+ return;
+ } else {
+ failedChecks.add(nearPatchTime);
+ }
+
+ if (DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
+ // Show all failures for debugging layout.
+ failedChecks = Arrays.asList(
+ sameHardware,
+ nearPatchTime,
+ installerCheck
+ );
+ }
+
+ //noinspection ComparatorCombinators
+ Collections.sort(failedChecks, (o1, o2) -> o1.uiSortingValue() - o2.uiSortingValue());
+
+ Check.issueWarning(
+ context,
+ failedChecks
+ );
+ } catch (Exception ex) {
+ Logger.printException(() -> "check failure", ex);
+ }
+ });
+ }
+
+ private static boolean buildFieldEqualsHash(String buildFieldName, String buildFieldValue, @Nullable String hash) {
+ try {
+ final var sha1 = MessageDigest.getInstance("SHA-1")
+ .digest(buildFieldValue.getBytes(StandardCharsets.UTF_8));
+
+ // Must be careful to use same base64 encoding Kotlin uses.
+ String runtimeHash = new String(Base64.encode(sha1, Base64.NO_WRAP), StandardCharsets.ISO_8859_1);
+ final boolean equals = runtimeHash.equals(hash);
+ if (!equals) {
+ Logger.printInfo(() -> "Hashes do not match. " + buildFieldName + ": '" + buildFieldValue
+ + "' runtimeHash: '" + runtimeHash + "' patchTimeHash: '" + hash + "'");
+ }
+
+ return equals;
+ } catch (NoSuchAlgorithmException ex) {
+ Logger.printException(() -> "buildFieldEqualsHash failure", ex); // Will never happen.
+
+ return false;
+ }
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/checks/PatchInfo.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/checks/PatchInfo.java
new file mode 100644
index 0000000000..cceb34f779
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/checks/PatchInfo.java
@@ -0,0 +1,32 @@
+package app.revanced.extension.shared.checks;
+
+/**
+ * Fields are set by the patch. Do not modify.
+ * Fields are not final, because the compiler is inlining them.
+ *
+ * @noinspection CanBeFinal
+ */
+final class PatchInfo {
+ static long PATCH_TIME = 0L;
+
+ final static class Build {
+ static String PATCH_BOARD = "";
+ static String PATCH_BOOTLOADER = "";
+ static String PATCH_BRAND = "";
+ static String PATCH_CPU_ABI = "";
+ static String PATCH_CPU_ABI2 = "";
+ static String PATCH_DEVICE = "";
+ static String PATCH_DISPLAY = "";
+ static String PATCH_FINGERPRINT = "";
+ static String PATCH_HARDWARE = "";
+ static String PATCH_HOST = "";
+ static String PATCH_ID = "";
+ static String PATCH_MANUFACTURER = "";
+ static String PATCH_MODEL = "";
+ static String PATCH_PRODUCT = "";
+ static String PATCH_RADIO = "";
+ static String PATCH_TAGS = "";
+ static String PATCH_TYPE = "";
+ static String PATCH_USER = "";
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/fixes/redgifs/BaseFixRedgifsApiPatch.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/fixes/redgifs/BaseFixRedgifsApiPatch.java
new file mode 100644
index 0000000000..00ee6def3b
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/fixes/redgifs/BaseFixRedgifsApiPatch.java
@@ -0,0 +1,70 @@
+package app.revanced.extension.shared.fixes.redgifs;
+
+import androidx.annotation.NonNull;
+
+import org.json.JSONException;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+
+import app.revanced.extension.shared.Logger;
+import okhttp3.Interceptor;
+import okhttp3.MediaType;
+import okhttp3.Protocol;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+
+public abstract class BaseFixRedgifsApiPatch implements Interceptor {
+ protected static BaseFixRedgifsApiPatch INSTANCE;
+ public abstract String getDefaultUserAgent();
+
+ @NonNull
+ @Override
+ public Response intercept(@NonNull Chain chain) throws IOException {
+ Request request = chain.request();
+ if (!request.url().host().equals("api.redgifs.com")) {
+ return chain.proceed(request);
+ }
+
+ String userAgent = getDefaultUserAgent();
+
+ if (request.header("Authorization") != null) {
+ Response response = chain.proceed(request.newBuilder().header("User-Agent", userAgent).build());
+ if (response.isSuccessful()) {
+ return response;
+ }
+ // It's possible that the user agent is being overwritten later down in the interceptor
+ // chain, so make sure we grab the new user agent from the request headers.
+ userAgent = response.request().header("User-Agent");
+ response.close();
+ }
+
+ try {
+ RedgifsTokenManager.RedgifsToken token = RedgifsTokenManager.refreshToken(userAgent);
+
+ // Emulate response for old OAuth endpoint
+ if (request.url().encodedPath().equals("/v2/oauth/client")) {
+ String responseBody = RedgifsTokenManager.getEmulatedOAuthResponseBody(token);
+ return new Response.Builder()
+ .message("OK")
+ .code(HttpURLConnection.HTTP_OK)
+ .protocol(Protocol.HTTP_1_1)
+ .request(request)
+ .header("Content-Type", "application/json")
+ .body(ResponseBody.create(
+ responseBody, MediaType.get("application/json")))
+ .build();
+ }
+
+ Request modifiedRequest = request.newBuilder()
+ .header("Authorization", "Bearer " + token.getAccessToken())
+ .header("User-Agent", userAgent)
+ .build();
+ return chain.proceed(modifiedRequest);
+ } catch (JSONException ex) {
+ Logger.printException(() -> "Could not parse Redgifs response", ex);
+ throw new IOException(ex);
+ }
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/fixes/redgifs/RedgifsTokenManager.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/fixes/redgifs/RedgifsTokenManager.java
new file mode 100644
index 0000000000..792465a89f
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/fixes/redgifs/RedgifsTokenManager.java
@@ -0,0 +1,94 @@
+package app.revanced.extension.shared.fixes.redgifs;
+
+import static app.revanced.extension.shared.requests.Route.Method.GET;
+
+import androidx.annotation.GuardedBy;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import app.revanced.extension.shared.requests.Requester;
+
+
+/**
+ * Manages Redgifs token lifecycle.
+ */
+public class RedgifsTokenManager {
+ public static class RedgifsToken {
+ // Expire after 23 hours to provide some breathing room
+ private static final long EXPIRY_SECONDS = 23 * 60 * 60;
+
+ private final String accessToken;
+ private final long refreshTimeInSeconds;
+
+ public RedgifsToken(String accessToken, long refreshTime) {
+ this.accessToken = accessToken;
+ this.refreshTimeInSeconds = refreshTime;
+ }
+
+ public String getAccessToken() {
+ return accessToken;
+ }
+
+ public long getExpiryTimeInSeconds() {
+ return refreshTimeInSeconds + EXPIRY_SECONDS;
+ }
+
+ public boolean isValid() {
+ if (accessToken == null) return false;
+ return getExpiryTimeInSeconds() >= System.currentTimeMillis() / 1000;
+ }
+ }
+ public static final String REDGIFS_API_HOST = "https://api.redgifs.com";
+ private static final String GET_TEMPORARY_TOKEN = REDGIFS_API_HOST + "/v2/auth/temporary";
+ @GuardedBy("itself")
+ private static final Map tokenMap = new HashMap<>();
+
+ private static String getToken(String userAgent) throws IOException, JSONException {
+ HttpURLConnection connection = (HttpURLConnection) new URL(GET_TEMPORARY_TOKEN).openConnection();
+ connection.setFixedLengthStreamingMode(0);
+ connection.setRequestMethod(GET.name());
+ connection.setRequestProperty("User-Agent", userAgent);
+ connection.setRequestProperty("Content-Type", "application/json");
+ connection.setRequestProperty("Accept", "application/json");
+ connection.setUseCaches(false);
+
+ JSONObject responseObject = Requester.parseJSONObject(connection);
+ return responseObject.getString("token");
+ }
+
+ public static RedgifsToken refreshToken(String userAgent) throws IOException, JSONException {
+ synchronized(tokenMap) {
+ // Reference: https://github.com/JeffreyCA/Apollo-ImprovedCustomApi/pull/67
+ RedgifsToken token = tokenMap.get(userAgent);
+ if (token != null && token.isValid()) {
+ return token;
+ }
+
+ // Copy user agent from original request if present because Redgifs verifies
+ // that the user agent in subsequent requests matches the one in the OAuth token.
+ String accessToken = getToken(userAgent);
+ long refreshTime = System.currentTimeMillis() / 1000;
+ token = new RedgifsToken(accessToken, refreshTime);
+ tokenMap.put(userAgent, token);
+ return token;
+ }
+ }
+
+ public static String getEmulatedOAuthResponseBody(RedgifsToken token) throws JSONException {
+ // Reference: https://github.com/JeffreyCA/Apollo-ImprovedCustomApi/pull/67
+ JSONObject responseObject = new JSONObject();
+ responseObject.put("access_token", token.accessToken);
+ responseObject.put("expiry_time", token.getExpiryTimeInSeconds() - (System.currentTimeMillis() / 1000));
+ responseObject.put("scope", "read");
+ responseObject.put("token_type", "Bearer");
+ return responseObject.toString();
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/fixes/slink/BaseFixSLinksPatch.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/fixes/slink/BaseFixSLinksPatch.java
new file mode 100644
index 0000000000..a8a6bf504e
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/fixes/slink/BaseFixSLinksPatch.java
@@ -0,0 +1,208 @@
+package app.revanced.extension.shared.fixes.slink;
+
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import androidx.annotation.NonNull;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.SocketTimeoutException;
+import java.net.URL;
+import java.util.Objects;
+
+import static app.revanced.extension.shared.Utils.getContext;
+
+
+/**
+ * Base class to implement /s/ link resolution in 3rd party Reddit apps.
+ *
+ *
+ * Usage:
+ *
+ *
+ * An implementation of this class must have two static methods that are called by the app:
+ *
+ * public static boolean patchResolveSLink(String link)
+ * public static void patchSetAccessToken(String accessToken)
+ *
+ * The static methods must call the instance methods of the base class.
+ *
+ * The singleton pattern can be used to access the instance of the class:
+ *
+ * {@code
+ * {
+ * INSTANCE = new FixSLinksPatch();
+ * }
+ * }
+ *
+ * Set the app's web view activity class as a fallback to open /s/ links if the resolution fails:
+ *
+ * {@code
+ * private FixSLinksPatch() {
+ * webViewActivityClass = WebViewActivity.class;
+ * }
+ * }
+ *
+ * Hook the app's navigation handler to call this method before doing any of its own resolution:
+ *
+ * {@code
+ * public static boolean patchResolveSLink(Context context, String link) {
+ * return INSTANCE.resolveSLink(context, link);
+ * }
+ * }
+ *
+ * If this method returns true, the app should early return and not do any of its own resolution.
+ *
+ *
+ * Hook the app's access token so that this class can use it to resolve /s/ links:
+ *
+ * {@code
+ * public static void patchSetAccessToken(String accessToken) {
+ * INSTANCE.setAccessToken(access_token);
+ * }
+ * }
+ *
+ */
+public abstract class BaseFixSLinksPatch {
+ /**
+ * The class of the activity used to open links in a web view if resolving them fails.
+ */
+ protected Class extends Activity> webViewActivityClass;
+
+ /**
+ * The access token used to resolve the /s/ link.
+ */
+ protected String accessToken;
+
+ /**
+ * The URL that was trying to be resolved before the access token was set.
+ * If this is not null, the URL will be resolved right after the access token is set.
+ */
+ protected String pendingUrl;
+
+ /**
+ * The singleton instance of the class.
+ */
+ protected static BaseFixSLinksPatch INSTANCE;
+
+ public boolean resolveSLink(String link) {
+ switch (resolveLink(link)) {
+ case ACCESS_TOKEN_START: {
+ pendingUrl = link;
+ return true;
+ }
+ case DO_NOTHING:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private ResolveResult resolveLink(String link) {
+ Context context = getContext();
+ if (link.matches(".*reddit\\.com/r/[^/]+/s/[^/]+")) {
+ // A link ends with #bypass if it failed to resolve below.
+ // resolveLink is called with the same link again but this time with #bypass
+ // so that the link is opened in the app browser instead of trying to resolve it again.
+ if (link.endsWith("#bypass")) {
+ openInAppBrowser(context, link);
+
+ return ResolveResult.DO_NOTHING;
+ }
+
+ Logger.printDebug(() -> "Resolving " + link);
+
+ if (accessToken == null) {
+ // This is not optimal.
+ // However, an accessToken is necessary to make an authenticated request to Reddit.
+ // in case Reddit has banned the IP - e.g. VPN.
+ Intent startIntent = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
+ context.startActivity(startIntent);
+
+ return ResolveResult.ACCESS_TOKEN_START;
+ }
+
+
+ Utils.runOnBackgroundThread(() -> {
+ String bypassLink = link + "#bypass";
+
+ String finalLocation = bypassLink;
+ try {
+ HttpURLConnection connection = getHttpURLConnection(link, accessToken);
+ connection.connect();
+ String location = connection.getHeaderField("location");
+ connection.disconnect();
+
+ Objects.requireNonNull(location, "Location is null");
+
+ finalLocation = location;
+ Logger.printDebug(() -> "Resolved " + link + " to " + location);
+ } catch (SocketTimeoutException e) {
+ Logger.printException(() -> "Timeout when trying to resolve " + link, e);
+ finalLocation = bypassLink;
+ } catch (Exception e) {
+ Logger.printException(() -> "Failed to resolve " + link, e);
+ finalLocation = bypassLink;
+ } finally {
+ Intent startIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(finalLocation));
+ startIntent.setPackage(context.getPackageName());
+ startIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(startIntent);
+ }
+ });
+
+ return ResolveResult.DO_NOTHING;
+ }
+
+ return ResolveResult.CONTINUE;
+ }
+
+ public void setAccessToken(String accessToken) {
+ Logger.printDebug(() -> "Setting access token");
+
+ this.accessToken = accessToken;
+
+ // In case a link was trying to be resolved before access token was set.
+ // The link is resolved now, after the access token is set.
+ if (pendingUrl != null) {
+ String link = pendingUrl;
+ pendingUrl = null;
+
+ Logger.printDebug(() -> "Opening pending URL");
+
+ resolveLink(link);
+ }
+ }
+
+ private void openInAppBrowser(Context context, String link) {
+ Intent intent = new Intent(context, webViewActivityClass);
+ intent.putExtra("url", link);
+ context.startActivity(intent);
+ }
+
+ @NonNull
+ private HttpURLConnection getHttpURLConnection(String link, String accessToken) throws IOException {
+ URL url = new URL(link);
+
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ connection.setInstanceFollowRedirects(false);
+ connection.setRequestMethod("HEAD");
+ connection.setConnectTimeout(2000);
+ connection.setReadTimeout(2000);
+
+ if (accessToken != null) {
+ Logger.printDebug(() -> "Setting access token to make /s/ request");
+
+ connection.setRequestProperty("Authorization", "Bearer " + accessToken);
+ } else {
+ Logger.printDebug(() -> "Not setting access token to make /s/ request, because it is null");
+ }
+
+ return connection;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/fixes/slink/ResolveResult.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/fixes/slink/ResolveResult.java
new file mode 100644
index 0000000000..8026c20585
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/fixes/slink/ResolveResult.java
@@ -0,0 +1,10 @@
+package app.revanced.extension.shared.fixes.slink;
+
+public enum ResolveResult {
+ // Let app handle rest of stuff
+ CONTINUE,
+ // Start app, to make it cache its access_token
+ ACCESS_TOKEN_START,
+ // Don't do anything - we started resolving
+ DO_NOTHING
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/CheckWatchHistoryDomainNameResolutionPatch.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/CheckWatchHistoryDomainNameResolutionPatch.java
new file mode 100644
index 0000000000..d12eabc0bd
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/CheckWatchHistoryDomainNameResolutionPatch.java
@@ -0,0 +1,94 @@
+package app.revanced.extension.shared.patches;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.text.Html;
+import android.util.Pair;
+import android.widget.LinearLayout;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.ui.CustomDialog;
+
+@SuppressWarnings("unused")
+public class CheckWatchHistoryDomainNameResolutionPatch {
+
+ private static final String HISTORY_TRACKING_ENDPOINT = "s.youtube.com";
+
+ private static final String SINKHOLE_IPV4 = "0.0.0.0";
+ private static final String SINKHOLE_IPV6 = "::";
+
+ private static boolean domainResolvesToValidIP(String host) {
+ try {
+ InetAddress address = InetAddress.getByName(host);
+ String hostAddress = address.getHostAddress();
+
+ if (address.isLoopbackAddress()) {
+ Logger.printDebug(() -> host + " resolves to localhost");
+ } else if (SINKHOLE_IPV4.equals(hostAddress) || SINKHOLE_IPV6.equals(hostAddress)) {
+ Logger.printDebug(() -> host + " resolves to sinkhole ip");
+ } else {
+ return true; // Domain is not blocked.
+ }
+ } catch (UnknownHostException e) {
+ Logger.printDebug(() -> host + " failed to resolve");
+ }
+
+ return false;
+ }
+
+ /**
+ * Injection point.
+ *
+ * Checks if YouTube watch history endpoint cannot be reached.
+ */
+ public static void checkDnsResolver(Activity context) {
+ if (!Utils.isNetworkConnected() || !BaseSettings.CHECK_WATCH_HISTORY_DOMAIN_NAME.get()) return;
+
+ Utils.runOnBackgroundThread(() -> {
+ try {
+ // If the user has a flaky DNS server, or they just lost internet connectivity
+ // and the isNetworkConnected() check has not detected it yet (it can take a few
+ // seconds after losing connection), then the history tracking endpoint will
+ // show a resolving error but it's actually an internet connection problem.
+ //
+ // Prevent this false positive by verify youtube.com resolves.
+ // If youtube.com does not resolve, then it's not a watch history domain resolving error
+ // because the entire app will not work since no domains are resolving.
+ String domainYouTube = "youtube.com";
+ if (!domainResolvesToValidIP(domainYouTube)
+ || domainResolvesToValidIP(HISTORY_TRACKING_ENDPOINT)
+ // Check multiple times, so a false positive from a flaky connection is almost impossible.
+ || !domainResolvesToValidIP(domainYouTube)
+ || domainResolvesToValidIP(HISTORY_TRACKING_ENDPOINT)) {
+ return;
+ }
+
+ Utils.runOnMainThread(() -> {
+ Pair dialogPair = CustomDialog.create(
+ context,
+ str("revanced_check_watch_history_domain_name_dialog_title"), // Title.
+ Html.fromHtml(str("revanced_check_watch_history_domain_name_dialog_message")), // Message (HTML).
+ null, // No EditText.
+ null, // OK button text.
+ () -> {}, // OK button action (just dismiss).
+ null, // No cancel button.
+ str("revanced_check_watch_history_domain_name_dialog_ignore"), // Neutral button text.
+ () -> BaseSettings.CHECK_WATCH_HISTORY_DOMAIN_NAME.save(false), // Neutral button action (Ignore).
+ true // Dismiss dialog on Neutral button click.
+ );
+
+ Utils.showDialog(context, dialogPair.first, false, null);
+ });
+ } catch (Exception ex) {
+ Logger.printException(() -> "checkDnsResolver failure", ex);
+ }
+ });
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/CustomBrandingPatch.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/CustomBrandingPatch.java
new file mode 100644
index 0000000000..d13513e2df
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/CustomBrandingPatch.java
@@ -0,0 +1,227 @@
+package app.revanced.extension.shared.patches;
+
+import android.app.Notification;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.graphics.Color;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import app.revanced.extension.shared.GmsCoreSupport;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.ResourceType;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.BaseSettings;
+
+/**
+ * Patch shared by YouTube and YT Music.
+ */
+@SuppressWarnings("unused")
+public class CustomBrandingPatch {
+
+ // Important: In the future, additional branding themes can be added but all existing and prior
+ // themes cannot be removed or renamed.
+ //
+ // This is because if a user has a branding theme selected, then only that launch alias is enabled.
+ // If a future update removes or renames that alias, then after updating the app is effectively
+ // broken and it cannot be opened and not even clearing the app data will fix it.
+ // In that situation the only fix is to completely uninstall and reinstall again.
+ //
+ // The most that can be done is to hide a theme from the UI and keep the alias with dummy data.
+ public enum BrandingTheme {
+ /**
+ * Original unpatched icon.
+ */
+ ORIGINAL,
+ ROUNDED,
+ MINIMAL,
+ SCALED,
+ /**
+ * User provided custom icon.
+ */
+ CUSTOM;
+
+ private String packageAndNameIndexToClassAlias(String packageName, int appIndex) {
+ if (appIndex <= 0) {
+ throw new IllegalArgumentException("App index starts at index 1");
+ }
+ return packageName + ".revanced_" + name().toLowerCase(Locale.US) + '_' + appIndex;
+ }
+ }
+
+ @Nullable
+ private static Integer notificationSmallIcon;
+
+ private static int getNotificationSmallIcon() {
+ // Cannot use static initialization block otherwise cyclic references exist
+ // between Settings initialization and this class.
+ if (notificationSmallIcon == null) {
+ if (GmsCoreSupport.isPackageNameOriginal()) {
+ Logger.printDebug(() -> "App is root mounted. Not overriding small notification icon");
+ return notificationSmallIcon = 0;
+ }
+
+ BrandingTheme branding = BaseSettings.CUSTOM_BRANDING_ICON.get();
+ if (branding == BrandingTheme.ORIGINAL) {
+ notificationSmallIcon = 0;
+ } else {
+ // Original icon is quantum_ic_video_youtube_white_24
+ String iconName = "revanced_notification_icon";
+ if (branding == BrandingTheme.CUSTOM) {
+ iconName += "_custom";
+ }
+
+ notificationSmallIcon = Utils.getResourceIdentifier(ResourceType.DRAWABLE, iconName);
+ if (notificationSmallIcon == 0) {
+ Logger.printException(() -> "Could not load notification small icon");
+ }
+ }
+ }
+ return notificationSmallIcon;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static View getLottieViewOrNull(View lottieStartupView) {
+ if (BaseSettings.CUSTOM_BRANDING_ICON.get() == BrandingTheme.ORIGINAL) {
+ return lottieStartupView;
+ }
+
+ return null;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void setNotificationIcon(Notification.Builder builder) {
+ try {
+ final int smallIcon = getNotificationSmallIcon();
+ if (smallIcon != 0) {
+ builder.setSmallIcon(smallIcon)
+ .setColor(Color.TRANSPARENT); // Remove YT red tint.
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "setNotificationIcon failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ *
+ * The total number of app name aliases, including dummy aliases.
+ */
+ private static int numberOfPresetAppNames() {
+ // Modified during patching, but requires a default if custom branding is excluded.
+ return 1;
+ }
+
+
+ /**
+ * Injection point.
+ *
+ * If a custom icon was provided during patching.
+ */
+ private static boolean userProvidedCustomIcon() {
+ // Modified during patching, but requires a default if custom branding is excluded.
+ return false;
+ }
+
+ /**
+ * Injection point.
+ *
+ * If a custom name was provided during patching.
+ */
+ private static boolean userProvidedCustomName() {
+ // Modified during patching, but requires a default if custom branding is excluded..
+ return false;
+ }
+
+ public static int getDefaultAppNameIndex() {
+ return userProvidedCustomName()
+ ? numberOfPresetAppNames()
+ : 2;
+ }
+
+ public static BrandingTheme getDefaultIconStyle() {
+ return userProvidedCustomIcon()
+ ? BrandingTheme.CUSTOM
+ : BrandingTheme.ROUNDED;
+ }
+
+ /**
+ * Injection point.
+ */
+ @SuppressWarnings("ConstantConditions")
+ public static void setBranding() {
+ try {
+ if (GmsCoreSupport.isPackageNameOriginal()) {
+ Logger.printInfo(() -> "App is root mounted. Cannot dynamically change app icon");
+ return;
+ }
+
+ Context context = Utils.getContext();
+ PackageManager pm = context.getPackageManager();
+ String packageName = context.getPackageName();
+
+ BrandingTheme selectedBranding = BaseSettings.CUSTOM_BRANDING_ICON.get();
+ final int selectedNameIndex = BaseSettings.CUSTOM_BRANDING_NAME.get();
+ ComponentName componentToEnable = null;
+ ComponentName defaultComponent = null;
+ List componentsToDisable = new ArrayList<>();
+
+ for (BrandingTheme theme : BrandingTheme.values()) {
+ // Must always update all aliases including custom alias (last index).
+ final int numberOfPresetAppNames = numberOfPresetAppNames();
+
+ // App name indices starts at 1.
+ for (int index = 1; index <= numberOfPresetAppNames; index++) {
+ String aliasClass = theme.packageAndNameIndexToClassAlias(packageName, index);
+ ComponentName component = new ComponentName(packageName, aliasClass);
+ if (defaultComponent == null) {
+ // Default is always the first alias.
+ defaultComponent = component;
+ }
+
+ if (index == selectedNameIndex && theme == selectedBranding) {
+ componentToEnable = component;
+ } else {
+ componentsToDisable.add(component);
+ }
+ }
+ }
+
+ if (componentToEnable == null) {
+ // User imported a bad app name index value. Either the imported data
+ // was corrupted, or they previously had custom name enabled and the app
+ // no longer has a custom name specified.
+ Utils.showToastLong("Custom branding reset");
+ BaseSettings.CUSTOM_BRANDING_ICON.resetToDefault();
+ BaseSettings.CUSTOM_BRANDING_NAME.resetToDefault();
+
+ componentToEnable = defaultComponent;
+ componentsToDisable.remove(defaultComponent);
+ }
+
+ for (ComponentName disable : componentsToDisable) {
+ pm.setComponentEnabledSetting(disable,
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
+ }
+
+ // Use info logging because if the alias status become corrupt the app cannot launch.
+ ComponentName componentToEnableFinal = componentToEnable;
+ Logger.printInfo(() -> "Enabling: " + componentToEnableFinal.getClassName());
+
+ pm.setComponentEnabledSetting(componentToEnable,
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED, 0);
+ } catch (Exception ex) {
+ Logger.printException(() -> "setBranding failure", ex);
+ }
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/EnableDebuggingPatch.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/EnableDebuggingPatch.java
new file mode 100644
index 0000000000..b63f2c6049
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/EnableDebuggingPatch.java
@@ -0,0 +1,138 @@
+package app.revanced.extension.shared.patches;
+
+import static java.lang.Boolean.TRUE;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.settings.BaseSettings;
+
+@SuppressWarnings("unused")
+public final class EnableDebuggingPatch {
+
+ /**
+ * Only log if debugging is enabled on startup.
+ * This prevents enabling debugging
+ * while the app is running then failing to restart
+ * resulting in an incomplete log.
+ */
+ private static final boolean LOG_FEATURE_FLAGS = BaseSettings.DEBUG.get();
+
+ private static final ConcurrentMap featureFlags = LOG_FEATURE_FLAGS
+ ? new ConcurrentHashMap<>(800, 0.5f, 1)
+ : null;
+
+ private static final Set DISABLED_FEATURE_FLAGS = parseFlags(BaseSettings.DISABLED_FEATURE_FLAGS.get());
+
+ // Log all disabled flags on app startup.
+ static {
+ if (LOG_FEATURE_FLAGS && !DISABLED_FEATURE_FLAGS.isEmpty()) {
+ StringBuilder sb = new StringBuilder("Disabled feature flags:\n");
+ for (Long flag : DISABLED_FEATURE_FLAGS) {
+ sb.append(" ").append(flag).append('\n');
+ }
+ Logger.printDebug(sb::toString);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean isBooleanFeatureFlagEnabled(boolean value, long flag) {
+ if (LOG_FEATURE_FLAGS && value) {
+ Long flagObj = flag;
+ if (DISABLED_FEATURE_FLAGS.contains(flagObj)) {
+ return false;
+ }
+ if (featureFlags.putIfAbsent(flagObj, TRUE) == null) {
+ Logger.printDebug(() -> "boolean feature is enabled: " + flag);
+ }
+ }
+
+ return value;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static double isDoubleFeatureFlagEnabled(double value, long flag, double defaultValue) {
+ if (LOG_FEATURE_FLAGS && defaultValue != value) {
+ if (DISABLED_FEATURE_FLAGS.contains(flag)) return defaultValue;
+
+ if (featureFlags.putIfAbsent(flag, true) == null) {
+ // Align the log outputs to make post processing easier.
+ Logger.printDebug(() -> " double feature is enabled: " + flag
+ + " value: " + value + (defaultValue == 0 ? "" : " default: " + defaultValue));
+ }
+ }
+
+ return value;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static long isLongFeatureFlagEnabled(long value, long flag, long defaultValue) {
+ if (LOG_FEATURE_FLAGS && defaultValue != value) {
+ if (DISABLED_FEATURE_FLAGS.contains(flag)) return defaultValue;
+
+ if (featureFlags.putIfAbsent(flag, true) == null) {
+ Logger.printDebug(() -> " long feature is enabled: " + flag
+ + " value: " + value + (defaultValue == 0 ? "" : " default: " + defaultValue));
+ }
+ }
+
+ return value;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static String isStringFeatureFlagEnabled(String value, long flag, String defaultValue) {
+ if (LOG_FEATURE_FLAGS && !defaultValue.equals(value)) {
+ if (featureFlags.putIfAbsent(flag, true) == null) {
+ Logger.printDebug(() -> " string feature is enabled: " + flag
+ + " value: " + value + (defaultValue.isEmpty() ? "" : " default: " + defaultValue));
+ }
+ }
+
+ return value;
+ }
+
+ /**
+ * Get all logged feature flags.
+ * @return Set of all known flags
+ */
+ public static Set getAllLoggedFlags() {
+ if (featureFlags != null) {
+ return new HashSet<>(featureFlags.keySet());
+ }
+
+ return new HashSet<>();
+ }
+
+ /**
+ * Public method for parsing flags.
+ * @param flags String containing newline-separated flag IDs
+ * @return Set of parsed flag IDs
+ */
+ public static Set parseFlags(String flags) {
+ Set parsedFlags = new HashSet<>();
+ if (!flags.isBlank()) {
+ for (String flag : flags.split("\n")) {
+ String trimmedFlag = flag.trim();
+ if (trimmedFlag.isEmpty()) continue; // Skip empty lines.
+ try {
+ parsedFlags.add(Long.parseLong(trimmedFlag));
+ } catch (NumberFormatException e) {
+ Logger.printException(() -> "Invalid flag ID: " + flag);
+ }
+ }
+ }
+
+ return parsedFlags;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/ForceOriginalAudioPatch.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/ForceOriginalAudioPatch.java
new file mode 100644
index 0000000000..8ae454e69a
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/ForceOriginalAudioPatch.java
@@ -0,0 +1,71 @@
+package app.revanced.extension.shared.patches;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.settings.AppLanguage;
+import app.revanced.extension.shared.spoof.ClientType;
+import app.revanced.extension.shared.spoof.SpoofVideoStreamsPatch;
+
+@SuppressWarnings("unused")
+public class ForceOriginalAudioPatch {
+
+ private static final String DEFAULT_AUDIO_TRACKS_SUFFIX = ".4";
+
+ private static volatile boolean enabled;
+
+ public static void setEnabled(boolean isEnabled, ClientType client) {
+ enabled = isEnabled;
+
+ if (isEnabled && !client.useAuth && !client.supportsMultiAudioTracks) {
+ // If client spoofing does not use authentication and lacks multi-audio streams,
+ // then can use any language code for the request and if that requested language is
+ // not available YT uses the original audio language. Authenticated requests ignore
+ // the language code and always use the account language. Use a language that is
+ // not auto-dubbed by YouTube: https://support.google.com/youtube/answer/15569972
+ // but the language is also supported natively by the Meta Quest device that
+ // Android VR is spoofing.
+ AppLanguage override = AppLanguage.NB; // Norwegian Bokmal.
+ Logger.printDebug(() -> "Setting language override: " + override);
+ SpoofVideoStreamsPatch.setLanguageOverride(override);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean ignoreDefaultAudioStream(boolean original) {
+ if (enabled) {
+ return false;
+ }
+ return original;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean isDefaultAudioStream(boolean isDefault, String audioTrackId, String audioTrackDisplayName) {
+ try {
+ if (!enabled) {
+ return isDefault;
+ }
+
+ if (audioTrackId.isEmpty()) {
+ // Older app targets can have empty audio tracks and these might be placeholders.
+ // The real audio tracks are called after these.
+ return isDefault;
+ }
+
+ Logger.printDebug(() -> "default: " + String.format("%-5s", isDefault) + " id: "
+ + String.format("%-8s", audioTrackId) + " name:" + audioTrackDisplayName);
+
+ final boolean isOriginal = audioTrackId.endsWith(DEFAULT_AUDIO_TRACKS_SUFFIX);
+ if (isOriginal) {
+ Logger.printDebug(() -> "Using audio: " + audioTrackId);
+ }
+
+ return isOriginal;
+ } catch (Exception ex) {
+ Logger.printException(() -> "isDefaultAudioStream failure", ex);
+ return isDefault;
+ }
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/SanitizeSharingLinksPatch.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/SanitizeSharingLinksPatch.java
new file mode 100644
index 0000000000..b0bcbc6f04
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/SanitizeSharingLinksPatch.java
@@ -0,0 +1,31 @@
+package app.revanced.extension.shared.patches;
+
+import app.revanced.extension.shared.privacy.LinkSanitizer;
+import app.revanced.extension.shared.settings.BaseSettings;
+
+/**
+ * YouTube and YouTube Music.
+ */
+@SuppressWarnings("unused")
+public final class SanitizeSharingLinksPatch {
+
+ private static final LinkSanitizer sanitizer = new LinkSanitizer(
+ "si",
+ "feature" // Old tracking parameter name, and may be obsolete.
+ );
+
+ /**
+ * Injection point.
+ */
+ public static String sanitize(String url) {
+ if (BaseSettings.SANITIZE_SHARING_LINKS.get()) {
+ url = sanitizer.sanitizeURLString(url);
+ }
+
+ if (BaseSettings.REPLACE_MUSIC_LINKS_WITH_YOUTUBE.get()) {
+ url = url.replace("music.youtube.com", "youtube.com");
+ }
+
+ return url;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/litho/CustomFilter.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/litho/CustomFilter.java
new file mode 100644
index 0000000000..beb623a799
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/litho/CustomFilter.java
@@ -0,0 +1,203 @@
+package app.revanced.extension.shared.patches.litho;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.StringTrieSearch;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.ByteTrieSearch;
+import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
+import app.revanced.extension.shared.settings.YouTubeAndMusicSettings;
+
+/**
+ * Allows custom filtering using a path and optionally a proto buffer string.
+ */
+@SuppressWarnings("unused")
+public final class CustomFilter extends Filter {
+
+ private static void showInvalidSyntaxToast(String expression) {
+ Utils.showToastLong(str("revanced_custom_filter_toast_invalid_syntax", expression));
+ }
+
+ private static class CustomFilterGroup extends StringFilterGroup {
+ /**
+ * Optional character for the path that indicates the custom filter path must match the start.
+ * Must be the first character of the expression.
+ */
+ public static final String SYNTAX_STARTS_WITH = "^";
+
+ /**
+ * Optional character that separates the path from an accessibility string pattern.
+ */
+ public static final String SYNTAX_ACCESSIBILITY_SYMBOL = "#";
+
+ /**
+ * Optional character that separates the path/accessibility from a proto buffer string pattern.
+ */
+ public static final String SYNTAX_BUFFER_SYMBOL = "$";
+
+ /**
+ * @return the parsed objects
+ */
+ @NonNull
+ @SuppressWarnings("ConstantConditions")
+ static Collection parseCustomFilterGroups() {
+ String rawCustomFilterText = YouTubeAndMusicSettings.CUSTOM_FILTER_STRINGS.get();
+ if (rawCustomFilterText.isBlank()) {
+ return Collections.emptyList();
+ }
+
+ // Map key is the full path including optional special characters (^, #, $),
+ // and any accessibility pattern, but does not contain any buffer patterns.
+ Map result = new HashMap<>();
+
+ Pattern pattern = Pattern.compile(
+ "(" // Map key group.
+ // Optional starts with.
+ + "(\\Q" + SYNTAX_STARTS_WITH + "\\E?)"
+ // Path string.
+ + "([^\\Q" + SYNTAX_ACCESSIBILITY_SYMBOL + SYNTAX_BUFFER_SYMBOL + "\\E]*)"
+ // Optional accessibility string.
+ + "(?:\\Q" + SYNTAX_ACCESSIBILITY_SYMBOL + "\\E([^\\Q" + SYNTAX_BUFFER_SYMBOL + "\\E]*))?"
+ // Optional buffer string.
+ + "(?:\\Q" + SYNTAX_BUFFER_SYMBOL + "\\E(.*))?"
+ + ")"); // end map key group
+
+ for (String expression : rawCustomFilterText.split("\n")) {
+ if (expression.isBlank()) continue;
+
+ Matcher matcher = pattern.matcher(expression);
+ if (!matcher.find()) {
+ showInvalidSyntaxToast(expression);
+ continue;
+ }
+
+ final String mapKey = matcher.group(1);
+ final boolean pathStartsWith = !matcher.group(2).isEmpty();
+ final String path = matcher.group(3);
+ final String accessibility = matcher.group(4); // null if not present
+ final String buffer = matcher.group(5); // null if not present
+
+ if (path.isBlank()
+ || (accessibility != null && accessibility.isEmpty())
+ || (buffer != null && buffer.isEmpty())) {
+ showInvalidSyntaxToast(expression);
+ continue;
+ }
+
+ // Use one group object for all expressions with the same path.
+ // This ensures the buffer is searched exactly once
+ // when multiple paths are used with different buffer strings.
+ CustomFilterGroup group = result.get(mapKey);
+ if (group == null) {
+ group = new CustomFilterGroup(pathStartsWith, path);
+ result.put(mapKey, group);
+ }
+
+ if (accessibility != null) {
+ group.addAccessibilityString(accessibility);
+ }
+
+ if (buffer != null) {
+ group.addBufferString(buffer);
+ }
+ }
+
+ return result.values();
+ }
+
+ final boolean startsWith;
+ StringTrieSearch accessibilitySearch;
+ ByteTrieSearch bufferSearch;
+
+ CustomFilterGroup(boolean startsWith, String path) {
+ super(YouTubeAndMusicSettings.CUSTOM_FILTER, path);
+ this.startsWith = startsWith;
+ }
+
+ void addAccessibilityString(String accessibilityString) {
+ if (accessibilitySearch == null) {
+ accessibilitySearch = new StringTrieSearch();
+ }
+ accessibilitySearch.addPattern(accessibilityString);
+ }
+
+ void addBufferString(String bufferString) {
+ if (bufferSearch == null) {
+ bufferSearch = new ByteTrieSearch();
+ }
+ bufferSearch.addPattern(bufferString.getBytes());
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("CustomFilterGroup{");
+ if (accessibilitySearch != null) {
+ builder.append(", accessibility=");
+ builder.append(accessibilitySearch.getPatterns());
+ }
+
+ builder.append("path=");
+ if (startsWith) builder.append(SYNTAX_STARTS_WITH);
+ builder.append(filters[0]);
+
+ if (bufferSearch != null) {
+ String delimitingCharacter = "❙";
+ builder.append(", bufferStrings=");
+ builder.append(delimitingCharacter);
+ for (byte[] bufferString : bufferSearch.getPatterns()) {
+ builder.append(new String(bufferString));
+ builder.append(delimitingCharacter);
+ }
+ }
+ builder.append("}");
+ return builder.toString();
+ }
+ }
+
+ public CustomFilter() {
+ Collection groups = CustomFilterGroup.parseCustomFilterGroups();
+
+ if (!groups.isEmpty()) {
+ CustomFilterGroup[] groupsArray = groups.toArray(new CustomFilterGroup[0]);
+ Logger.printDebug(()-> "Using Custom filters: " + Arrays.toString(groupsArray));
+ addPathCallbacks(groupsArray);
+ }
+ }
+
+ @Override
+ public boolean isFiltered(String identifier, String accessibility, String path, byte[] buffer,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ // All callbacks are custom filter groups.
+ CustomFilterGroup custom = (CustomFilterGroup) matchedGroup;
+
+ // Check path start requirement.
+ if (custom.startsWith && contentIndex != 0) {
+ return false;
+ }
+
+ // Check accessibility string if specified.
+ if (custom.accessibilitySearch != null && !custom.accessibilitySearch.matches(accessibility)) {
+ return false;
+ }
+
+ // Check buffer if specified.
+ if (custom.bufferSearch != null && !custom.bufferSearch.matches(buffer)) {
+ return false;
+ }
+
+ return true; // All custom filter conditions passed.
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/litho/Filter.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/litho/Filter.java
new file mode 100644
index 0000000000..b34ca9bdd7
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/litho/Filter.java
@@ -0,0 +1,80 @@
+package app.revanced.extension.shared.patches.litho;
+
+import app.revanced.extension.shared.patches.litho.FilterGroup.ByteArrayFilterGroup;
+import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Filters litho based components.
+ *
+ * Callbacks to filter content are added using {@link #addIdentifierCallbacks(StringFilterGroup...)}
+ * and {@link #addPathCallbacks(StringFilterGroup...)}.
+ *
+ * To filter {@link FilterContentType#PROTOBUFFER} or {@link FilterContentType#ACCESSIBILITY}, first add a callback to
+ * either an identifier or a path.
+ * Then inside {@link #isFiltered(String, String, String, byte[], StringFilterGroup, FilterContentType, int)}
+ * search for the buffer content using either a {@link ByteArrayFilterGroup} (if searching for 1 pattern)
+ * or a {@link FilterGroupList.ByteArrayFilterGroupList} (if searching for more than 1 pattern).
+ *
+ * All callbacks must be registered before the constructor completes.
+ */
+public abstract class Filter {
+
+ public enum FilterContentType {
+ IDENTIFIER,
+ PATH,
+ ACCESSIBILITY,
+ PROTOBUFFER
+ }
+
+ /**
+ * Identifier callbacks. Do not add to this instance,
+ * and instead use {@link #addIdentifierCallbacks(StringFilterGroup...)}.
+ */
+ public final List identifierCallbacks = new ArrayList<>();
+ /**
+ * Path callbacks. Do not add to this instance,
+ * and instead use {@link #addPathCallbacks(StringFilterGroup...)}.
+ */
+ public final List pathCallbacks = new ArrayList<>();
+
+ /**
+ * Adds callbacks to {@link #isFiltered(String, String, String, byte[], StringFilterGroup, FilterContentType, int)}
+ * if any of the groups are found.
+ */
+ protected final void addIdentifierCallbacks(StringFilterGroup... groups) {
+ identifierCallbacks.addAll(Arrays.asList(groups));
+ }
+
+ /**
+ * Adds callbacks to {@link #isFiltered(String, String, String, byte[], StringFilterGroup, FilterContentType, int)}
+ * if any of the groups are found.
+ */
+ protected final void addPathCallbacks(StringFilterGroup... groups) {
+ pathCallbacks.addAll(Arrays.asList(groups));
+ }
+
+ /**
+ * Called after an enabled filter has been matched.
+ * Default implementation is to always filter the matched component and log the action.
+ * Subclasses can perform additional or different checks if needed.
+ *
+ * Method is called off the main thread.
+ *
+ * @param identifier Litho identifier.
+ * @param accessibility Accessibility string, or an empty string if not present for the component.
+ * @param buffer Protocol buffer.
+ * @param matchedGroup The actual filter that matched.
+ * @param contentType The type of content matched.
+ * @param contentIndex Matched index of the identifier or path.
+ * @return True if the litho component should be filtered out.
+ */
+ public boolean isFiltered(String identifier, String accessibility, String path, byte[] buffer,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ return true;
+ }
+}
+
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/litho/FilterGroup.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/litho/FilterGroup.java
new file mode 100644
index 0000000000..212787f305
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/litho/FilterGroup.java
@@ -0,0 +1,213 @@
+package app.revanced.extension.shared.patches.litho;
+
+import androidx.annotation.NonNull;
+import app.revanced.extension.shared.ByteTrieSearch;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.settings.BooleanSetting;
+
+public abstract class FilterGroup {
+ public final static class FilterGroupResult {
+ private BooleanSetting setting;
+ private int matchedIndex;
+ private int matchedLength;
+ // In the future it might be useful to include which pattern matched,
+ // but for now that is not needed.
+
+ FilterGroupResult() {
+ this(null, -1, 0);
+ }
+
+ FilterGroupResult(BooleanSetting setting, int matchedIndex, int matchedLength) {
+ setValues(setting, matchedIndex, matchedLength);
+ }
+
+ public void setValues(BooleanSetting setting, int matchedIndex, int matchedLength) {
+ this.setting = setting;
+ this.matchedIndex = matchedIndex;
+ this.matchedLength = matchedLength;
+ }
+
+ /**
+ * A null value if the group has no setting,
+ * or if no match is returned from {@link FilterGroupList#check(Object)}.
+ */
+ public BooleanSetting getSetting() {
+ return setting;
+ }
+
+ public boolean isFiltered() {
+ return matchedIndex >= 0;
+ }
+
+ /**
+ * Matched index of first pattern that matched, or -1 if nothing matched.
+ */
+ public int getMatchedIndex() {
+ return matchedIndex;
+ }
+
+ /**
+ * Length of the matched filter pattern.
+ */
+ public int getMatchedLength() {
+ return matchedLength;
+ }
+ }
+
+ protected final BooleanSetting setting;
+ public final T[] filters;
+
+ /**
+ * Initialize a new filter group.
+ *
+ * @param setting The associated setting.
+ * @param filters The filters.
+ */
+ @SafeVarargs
+ public FilterGroup(final BooleanSetting setting, final T... filters) {
+ this.setting = setting;
+ this.filters = filters;
+ if (filters.length == 0) {
+ throw new IllegalArgumentException("Must use one or more filter patterns (zero specified)");
+ }
+ }
+
+ public boolean isEnabled() {
+ return setting == null || setting.get();
+ }
+
+ /**
+ * @return If {@link FilterGroupList} should include this group when searching.
+ * By default, all filters are included except non enabled settings that require reboot.
+ */
+ @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+ public boolean includeInSearch() {
+ return isEnabled() || !setting.rebootApp;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + ": " + (setting == null ? "(null setting)" : setting);
+ }
+
+ public abstract FilterGroupResult check(final T stack);
+
+
+ public static class StringFilterGroup extends FilterGroup {
+
+ public StringFilterGroup(final BooleanSetting setting, final String... filters) {
+ super(setting, filters);
+ }
+
+ @Override
+ public FilterGroupResult check(final String string) {
+ int matchedIndex = -1;
+ int matchedLength = 0;
+ if (isEnabled()) {
+ for (String pattern : filters) {
+ if (!string.isEmpty()) {
+ final int indexOf = string.indexOf(pattern);
+ if (indexOf >= 0) {
+ matchedIndex = indexOf;
+ matchedLength = pattern.length();
+ break;
+ }
+ }
+ }
+ }
+ return new FilterGroupResult(setting, matchedIndex, matchedLength);
+ }
+ }
+
+ /**
+ * If you have more than 1 filter patterns, then all instances of
+ * this class should be filtered using {@link FilterGroupList.ByteArrayFilterGroupList#check(byte[])},
+ * which uses a prefix tree to give better performance.
+ */
+ public static class ByteArrayFilterGroup extends FilterGroup {
+
+ private volatile int[][] failurePatterns;
+
+ // Modified implementation from https://stackoverflow.com/a/1507813
+ private static int indexOf(final byte[] data, final byte[] pattern, final int[] failure) {
+ // Finds the first occurrence of the pattern in the byte array using
+ // KMP matching algorithm.
+ int patternLength = pattern.length;
+ for (int i = 0, j = 0, dataLength = data.length; i < dataLength; i++) {
+ while (j > 0 && pattern[j] != data[i]) {
+ j = failure[j - 1];
+ }
+ if (pattern[j] == data[i]) {
+ j++;
+ }
+ if (j == patternLength) {
+ return i - patternLength + 1;
+ }
+ }
+ return -1;
+ }
+
+ private static int[] createFailurePattern(byte[] pattern) {
+ // Computes the failure function using a bootstrapping process,
+ // where the pattern is matched against itself.
+ final int patternLength = pattern.length;
+ final int[] failure = new int[patternLength];
+
+ for (int i = 1, j = 0; i < patternLength; i++) {
+ while (j > 0 && pattern[j] != pattern[i]) {
+ j = failure[j - 1];
+ }
+ if (pattern[j] == pattern[i]) {
+ j++;
+ }
+ failure[i] = j;
+ }
+ return failure;
+ }
+
+ public ByteArrayFilterGroup(BooleanSetting setting, byte[]... filters) {
+ super(setting, filters);
+ }
+
+ /**
+ * Converts the Strings into byte arrays. Used to search for text in binary data.
+ */
+ public ByteArrayFilterGroup(BooleanSetting setting, String... filters) {
+ super(setting, ByteTrieSearch.convertStringsToBytes(filters));
+ }
+
+ private synchronized void buildFailurePatterns() {
+ if (failurePatterns != null) return; // Thread race and another thread already initialized the search.
+ Logger.printDebug(() -> "Building failure array for: " + this);
+ int[][] failurePatterns = new int[filters.length][];
+ int i = 0;
+ for (byte[] pattern : filters) {
+ failurePatterns[i++] = createFailurePattern(pattern);
+ }
+ this.failurePatterns = failurePatterns; // Must set after initialization finishes.
+ }
+
+ @Override
+ public FilterGroupResult check(final byte[] bytes) {
+ int matchedLength = 0;
+ int matchedIndex = -1;
+ if (isEnabled()) {
+ int[][] failures = failurePatterns;
+ if (failures == null) {
+ buildFailurePatterns(); // Lazy load.
+ failures = failurePatterns;
+ }
+ for (int i = 0, length = filters.length; i < length; i++) {
+ byte[] filter = filters[i];
+ matchedIndex = indexOf(bytes, filter, failures[i]);
+ if (matchedIndex >= 0) {
+ matchedLength = filter.length;
+ break;
+ }
+ }
+ }
+ return new FilterGroupResult(setting, matchedIndex, matchedLength);
+ }
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/litho/FilterGroupList.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/litho/FilterGroupList.java
new file mode 100644
index 0000000000..da22ca9ff7
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/litho/FilterGroupList.java
@@ -0,0 +1,72 @@
+package app.revanced.extension.shared.patches.litho;
+
+import androidx.annotation.NonNull;
+import app.revanced.extension.shared.ByteTrieSearch;
+import app.revanced.extension.shared.StringTrieSearch;
+import app.revanced.extension.shared.TrieSearch;
+import app.revanced.extension.shared.patches.litho.FilterGroup.ByteArrayFilterGroup;
+import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+
+public abstract class FilterGroupList> implements Iterable {
+
+ private final List filterGroups = new ArrayList<>();
+ private final TrieSearch search = createSearchGraph();
+
+ @SafeVarargs
+ public final void addAll(final T... groups) {
+ filterGroups.addAll(Arrays.asList(groups));
+
+ for (T group : groups) {
+ if (!group.includeInSearch()) {
+ continue;
+ }
+ for (V pattern : group.filters) {
+ search.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> {
+ if (group.isEnabled()) {
+ FilterGroup.FilterGroupResult result = (FilterGroup.FilterGroupResult) callbackParameter;
+ result.setValues(group.setting, matchedStartIndex, matchedLength);
+ return true;
+ }
+ return false;
+ });
+ }
+ }
+ }
+
+ @NonNull
+ @Override
+ public Iterator iterator() {
+ return filterGroups.iterator();
+ }
+
+ public FilterGroup.FilterGroupResult check(V stack) {
+ FilterGroup.FilterGroupResult result = new FilterGroup.FilterGroupResult();
+ search.matches(stack, result);
+ return result;
+
+ }
+
+ protected abstract TrieSearch createSearchGraph();
+
+ public static final class StringFilterGroupList extends FilterGroupList {
+ protected StringTrieSearch createSearchGraph() {
+ return new StringTrieSearch();
+ }
+ }
+
+ /**
+ * If searching for a single byte pattern, then it is slightly better to use
+ * {@link ByteArrayFilterGroup#check(byte[])} as it uses KMP which is faster
+ * than a prefix tree to search for only 1 pattern.
+ */
+ public static final class ByteArrayFilterGroupList extends FilterGroupList {
+ protected ByteTrieSearch createSearchGraph() {
+ return new ByteTrieSearch();
+ }
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/litho/LithoFilterPatch.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/litho/LithoFilterPatch.java
new file mode 100644
index 0000000000..e1b329ee54
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/patches/litho/LithoFilterPatch.java
@@ -0,0 +1,439 @@
+package app.revanced.extension.shared.patches.litho;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.StringTrieSearch;
+import app.revanced.extension.shared.settings.YouTubeAndMusicSettings;
+
+@SuppressWarnings("unused")
+public final class LithoFilterPatch {
+ /**
+ * Simple wrapper to pass the litho parameters through the prefix search.
+ */
+ private static final class LithoFilterParameters {
+ final String identifier;
+ final String path;
+ final String accessibility;
+ final byte[] buffer;
+
+ LithoFilterParameters(String lithoIdentifier, String lithoPath,
+ String accessibility, byte[] buffer) {
+ this.identifier = lithoIdentifier;
+ this.path = lithoPath;
+ this.accessibility = accessibility;
+ this.buffer = buffer;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ // Estimate the percentage of the buffer that are Strings.
+ StringBuilder builder = new StringBuilder(Math.max(100, buffer.length / 2));
+ builder.append("ID: ");
+ builder.append(identifier);
+ if (!accessibility.isEmpty()) {
+ // AccessibilityId and AccessibilityText are pieces of BufferStrings.
+ builder.append(" Accessibility: ");
+ builder.append(accessibility);
+ }
+ builder.append(" Path: ");
+ builder.append(path);
+ if (YouTubeAndMusicSettings.DEBUG_PROTOCOLBUFFER.get()) {
+ builder.append(" BufferStrings: ");
+ findAsciiStrings(builder, buffer);
+ }
+
+ return builder.toString();
+ }
+
+ /**
+ * Search through a byte array for all ASCII strings.
+ */
+ static void findAsciiStrings(StringBuilder builder, byte[] buffer) {
+ // Valid ASCII values (ignore control characters).
+ final int minimumAscii = 32; // 32 = space character
+ final int maximumAscii = 126; // 127 = delete character
+ final int minimumAsciiStringLength = 4; // Minimum length of an ASCII string to include.
+ String delimitingCharacter = "❙"; // Non ascii character, to allow easier log filtering.
+
+ final int length = buffer.length;
+ int start = 0;
+ int end = 0;
+ while (end < length) {
+ int value = buffer[end];
+ if (value < minimumAscii || value > maximumAscii || end == length - 1) {
+ if (end - start >= minimumAsciiStringLength) {
+ for (int i = start; i < end; i++) {
+ builder.append((char) buffer[i]);
+ }
+ builder.append(delimitingCharacter);
+ }
+ start = end + 1;
+ }
+ end++;
+ }
+ }
+ }
+
+ /**
+ * Placeholder for actual filters.
+ */
+ private static final class DummyFilter extends Filter {
+ }
+
+ private static final Filter[] filters = new Filter[]{
+ new DummyFilter() // Replaced during patching, do not touch.
+ };
+
+ /**
+ * Litho layout fixed thread pool size override.
+ *
+ * Unpatched YouTube uses a layout fixed thread pool between 1 and 3 threads:
+ *
+ * 1 thread - > Device has less than 6 cores
+ * 2 threads -> Device has over 6 cores and less than 6GB of memory
+ * 3 threads -> Device has over 6 cores and more than 6GB of memory
+ *
+ *
+ * Using more than 1 thread causes layout issues such as the You tab watch/playlist shelf
+ * that is sometimes incorrectly hidden (ReVanced is not hiding it), and seems to
+ * fix a race issue if using the active navigation tab status with litho filtering.
+ */
+ private static final int LITHO_LAYOUT_THREAD_POOL_SIZE = 1;
+
+ /**
+ * For YouTube 20.22+, this is set to true by a patch,
+ * because it cannot use the thread buffer due to the buffer frequently not being correct,
+ * especially for components that are recreated such as dragging off-screen then back on screen.
+ * Instead, parse the identifier found near the start of the buffer and use that to
+ * identify the correct buffer to use when filtering.
+ *
+ * This is set during patching, do not change manually.
+ */
+ private static final boolean EXTRACT_IDENTIFIER_FROM_BUFFER = false;
+
+ /**
+ * Turns on additional logging, used for development purposes only.
+ */
+ public static final boolean DEBUG_EXTRACT_IDENTIFIER_FROM_BUFFER = false;
+
+ /**
+ * String suffix for components.
+ * Can be any of: ".eml", ".eml-fe", ".e-b", ".eml-js", "e-js-b"
+ */
+ private static final byte[] LITHO_COMPONENT_EXTENSION_BYTES = ".e".getBytes(StandardCharsets.US_ASCII);
+
+ /**
+ * Used as placeholder for litho id/path filters that do not use a buffer
+ */
+ private static final byte[] EMPTY_BYTE_ARRAY = new byte[0];
+
+ /**
+ * Because litho filtering is multithreaded and the buffer is passed in from a different injection point,
+ * the buffer is saved to a ThreadLocal so each calling thread does not interfere with other threads.
+ * Used for 20.21 and lower.
+ */
+ private static final ThreadLocal bufferThreadLocal = new ThreadLocal<>();
+
+ /**
+ * Identifier to protocol buffer mapping. Only used for 20.22+.
+ * Thread local is needed because filtering is multithreaded and each thread can load
+ * a different component with the same identifier.
+ */
+ private static final ThreadLocal> identifierToBufferThread = new ThreadLocal<>();
+
+ /**
+ * Global shared buffer. Used only if the buffer is not found in the ThreadLocal.
+ */
+ private static final Map identifierToBufferGlobal
+ = Collections.synchronizedMap(createIdentifierToBufferMap());
+
+ private static final StringTrieSearch pathSearchTree = new StringTrieSearch();
+ private static final StringTrieSearch identifierSearchTree = new StringTrieSearch();
+
+ static {
+
+ for (Filter filter : filters) {
+ filterUsingCallbacks(identifierSearchTree, filter,
+ filter.identifierCallbacks, Filter.FilterContentType.IDENTIFIER);
+ filterUsingCallbacks(pathSearchTree, filter,
+ filter.pathCallbacks, Filter.FilterContentType.PATH);
+ }
+
+ Logger.printDebug(() -> "Using: "
+ + identifierSearchTree.numberOfPatterns() + " identifier filters"
+ + " (" + identifierSearchTree.getEstimatedMemorySize() + " KB), "
+ + pathSearchTree.numberOfPatterns() + " path filters"
+ + " (" + pathSearchTree.getEstimatedMemorySize() + " KB)");
+ }
+
+ private static void filterUsingCallbacks(StringTrieSearch pathSearchTree,
+ Filter filter, List groups,
+ Filter.FilterContentType type) {
+ String filterSimpleName = filter.getClass().getSimpleName();
+
+ for (StringFilterGroup group : groups) {
+ if (!group.includeInSearch()) {
+ continue;
+ }
+
+ for (String pattern : group.filters) {
+ pathSearchTree.addPattern(pattern, (textSearched, matchedStartIndex,
+ matchedLength, callbackParameter) -> {
+ if (!group.isEnabled()) return false;
+
+ LithoFilterParameters parameters = (LithoFilterParameters) callbackParameter;
+ final boolean isFiltered = filter.isFiltered(parameters.identifier,
+ parameters.accessibility, parameters.path, parameters.buffer,
+ group, type, matchedStartIndex);
+
+ if (isFiltered && BaseSettings.DEBUG.get()) {
+ Logger.printDebug(() -> type == Filter.FilterContentType.IDENTIFIER
+ ? filterSimpleName + " filtered identifier: " + parameters.identifier
+ : filterSimpleName + " filtered path: " + parameters.path);
+ }
+
+ return isFiltered;
+ }
+ );
+ }
+ }
+ }
+
+ private static Map createIdentifierToBufferMap() {
+ // It's unclear how many items should be cached. This is a guess.
+ return Utils.createSizeRestrictedMap(100);
+ }
+
+ /**
+ * Helper function that differs from {@link Character#isDigit(char)}
+ * as this only matches ascii and not Unicode numbers.
+ */
+ private static boolean isAsciiNumber(byte character) {
+ return '0' <= character && character <= '9';
+ }
+
+ private static boolean isAsciiLowerCaseLetter(byte character) {
+ return 'a' <= character && character <= 'z';
+ }
+
+ /**
+ * Injection point. Called off the main thread.
+ * Targets 20.22+
+ */
+ public static void setProtoBuffer(byte[] buffer) {
+ if (DEBUG_EXTRACT_IDENTIFIER_FROM_BUFFER) {
+ StringBuilder builder = new StringBuilder();
+ LithoFilterParameters.findAsciiStrings(builder, buffer);
+ Logger.printDebug(() -> "New buffer: " + builder);
+ }
+
+ // The identifier always seems to start very close to the buffer start.
+ // Highest identifier start index ever observed is 50, with most around 30 to 40.
+ // The buffer can be very large with up to 200kb has been observed,
+ // so the search is restricted to only the start.
+ final int maxBufferStartIndex = 500; // 10x expected upper bound.
+
+ // Could use Boyer-Moore-Horspool since the string is ASCII and has a limited number of
+ // unique characters, but it seems to be slower since the extra overhead of checking the
+ // bad character array negates any performance gain of skipping a few extra subsearches.
+ int emlIndex = -1;
+ final int emlStringLength = LITHO_COMPONENT_EXTENSION_BYTES.length;
+ final int lastBufferIndexToCheckFrom = Math.min(maxBufferStartIndex, buffer.length - emlStringLength);
+ for (int i = 0; i < lastBufferIndexToCheckFrom; i++) {
+ boolean match = true;
+ for (int j = 0; j < emlStringLength; j++) {
+ if (buffer[i + j] != LITHO_COMPONENT_EXTENSION_BYTES[j]) {
+ match = false;
+ break;
+ }
+ }
+ if (match) {
+ emlIndex = i;
+ break;
+ }
+ }
+
+ if (emlIndex < 0) {
+ // Buffer is not used for creating a new litho component.
+ if (DEBUG_EXTRACT_IDENTIFIER_FROM_BUFFER) {
+ Logger.printDebug(() -> "Could not find eml index");
+ }
+ return;
+ }
+
+ int startIndex = emlIndex - 1;
+ while (startIndex > 0) {
+ final byte character = buffer[startIndex];
+ int startIndexFinal = startIndex;
+ if (isAsciiLowerCaseLetter(character) || isAsciiNumber(character) || character == '_') {
+ // Valid character for the first path element.
+ startIndex--;
+ } else {
+ startIndex++;
+ break;
+ }
+ }
+
+ // Strip away any numbers on the start of the identifier, which can
+ // be from random data in the buffer before the identifier starts.
+ while (true) {
+ final byte character = buffer[startIndex];
+ if (isAsciiNumber(character)) {
+ startIndex++;
+ } else {
+ break;
+ }
+ }
+
+ // Find the pipe character after the identifier.
+ int endIndex = -1;
+ for (int i = emlIndex, length = buffer.length; i < length; i++) {
+ if (buffer[i] == '|') {
+ endIndex = i;
+ break;
+ }
+ }
+ if (endIndex < 0) {
+ if (BaseSettings.DEBUG.get()) {
+ Logger.printException(() -> "Debug: Could not find buffer identifier");
+ }
+ return;
+ }
+
+ String identifier = new String(buffer, startIndex, endIndex - startIndex, StandardCharsets.US_ASCII);
+ if (DEBUG_EXTRACT_IDENTIFIER_FROM_BUFFER) {
+ Logger.printDebug(() -> "Found buffer for identifier: " + identifier);
+ }
+ identifierToBufferGlobal.put(identifier, buffer);
+
+ Map map = identifierToBufferThread.get();
+ if (map == null) {
+ map = createIdentifierToBufferMap();
+ identifierToBufferThread.set(map);
+ }
+ map.put(identifier, buffer);
+ }
+
+ /**
+ * Injection point. Called off the main thread.
+ * Targets 20.21 and lower.
+ */
+ public static void setProtoBuffer(@Nullable ByteBuffer buffer) {
+ if (buffer == null || !buffer.hasArray()) {
+ // It appears the buffer can be cleared out just before the call to #filter()
+ // Ignore this null value and retain the last buffer that was set.
+ Logger.printDebug(() -> "Ignoring null or empty buffer: " + buffer);
+ } else {
+ // Set the buffer to a thread local. The buffer will remain in memory, even after the call to #filter completes.
+ // This is intentional, as it appears the buffer can be set once and then filtered multiple times.
+ // The buffer will be cleared from memory after a new buffer is set by the same thread,
+ // or when the calling thread eventually dies.
+ bufferThreadLocal.set(buffer.array());
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean isFiltered(String identifier, @Nullable String accessibilityId,
+ @Nullable String accessibilityText, StringBuilder pathBuilder) {
+ try {
+ if (identifier.isEmpty() || pathBuilder.length() == 0) {
+ return false;
+ }
+
+ byte[] buffer = null;
+ if (EXTRACT_IDENTIFIER_FROM_BUFFER) {
+ final int pipeIndex = identifier.indexOf('|');
+ if (pipeIndex >= 0) {
+ // If the identifier contains no pipe, then it's not an ".eml" identifier
+ // and the buffer is not uniquely identified. Typically, this only happens
+ // for subcomponents where buffer filtering is not used.
+ String identifierKey = identifier.substring(0, pipeIndex);
+
+ var map = identifierToBufferThread.get();
+ if (map != null) {
+ buffer = map.get(identifierKey);
+ }
+
+ if (buffer == null) {
+ // Buffer for thread local not found. Use the last buffer found from any thread.
+ buffer = identifierToBufferGlobal.get(identifierKey);
+
+ if (DEBUG_EXTRACT_IDENTIFIER_FROM_BUFFER && buffer == null) {
+ // No buffer is found for some components, such as
+ // shorts_lockup_cell.eml on channel profiles.
+ // For now, just ignore this and filter without a buffer.
+ if (BaseSettings.DEBUG.get()) {
+ Logger.printException(() -> "Debug: Could not find buffer for identifier: " + identifier);
+ }
+ }
+ }
+ }
+ } else {
+ buffer = bufferThreadLocal.get();
+ }
+
+ // Potentially the buffer may have been null or never set up until now.
+ // Use an empty buffer so the litho id/path filters that do not use a buffer still work.
+ if (buffer == null) {
+ buffer = EMPTY_BYTE_ARRAY;
+ }
+
+ String path = pathBuilder.toString();
+
+ String accessibility = "";
+ if (accessibilityId != null && !accessibilityId.isBlank()) {
+ accessibility = accessibilityId;
+ }
+ if (accessibilityText != null && !accessibilityText.isBlank()) {
+ accessibility = accessibilityId + '|' + accessibilityText;
+ }
+ LithoFilterParameters parameter = new LithoFilterParameters(identifier, path, accessibility, buffer);
+ Logger.printDebug(() -> "Searching " + parameter);
+
+ return identifierSearchTree.matches(identifier, parameter)
+ || pathSearchTree.matches(path, parameter);
+ } catch (Exception ex) {
+ Logger.printException(() -> "isFiltered failure", ex);
+ }
+
+ return false;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static int getExecutorCorePoolSize(int originalCorePoolSize) {
+ if (originalCorePoolSize != LITHO_LAYOUT_THREAD_POOL_SIZE) {
+ Logger.printDebug(() -> "Overriding core thread pool size from: " + originalCorePoolSize
+ + " to: " + LITHO_LAYOUT_THREAD_POOL_SIZE);
+ }
+
+ return LITHO_LAYOUT_THREAD_POOL_SIZE;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static int getExecutorMaxThreads(int originalMaxThreads) {
+ if (originalMaxThreads != LITHO_LAYOUT_THREAD_POOL_SIZE) {
+ Logger.printDebug(() -> "Overriding max thread pool size from: " + originalMaxThreads
+ + " to: " + LITHO_LAYOUT_THREAD_POOL_SIZE);
+ }
+
+ return LITHO_LAYOUT_THREAD_POOL_SIZE;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/privacy/LinkSanitizer.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/privacy/LinkSanitizer.java
new file mode 100644
index 0000000000..421761f7da
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/privacy/LinkSanitizer.java
@@ -0,0 +1,68 @@
+package app.revanced.extension.shared.privacy;
+
+import android.net.Uri;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+
+import app.revanced.extension.shared.Logger;
+
+/**
+ * Strips away specific parameters from URLs.
+ */
+public class LinkSanitizer {
+
+ private final Collection parametersToRemove;
+
+ public LinkSanitizer(String ... parametersToRemove) {
+ final int parameterCount = parametersToRemove.length;
+
+ // List is faster if only checking a few parameters.
+ this.parametersToRemove = parameterCount > 4
+ ? Set.of(parametersToRemove)
+ : List.of(parametersToRemove);
+ }
+
+ public String sanitizeURLString(String url) {
+ try {
+ return sanitizeURI(Uri.parse(url)).toString();
+ } catch (Exception ex) {
+ Logger.printException(() -> "sanitizeURLString failure: " + url, ex);
+ return url;
+ }
+ }
+
+ public Uri sanitizeURI(Uri uri) {
+ try {
+ String scheme = uri.getScheme();
+ if (scheme == null || !(scheme.equals("http") || scheme.equals("https"))) {
+ // Opening YouTube share sheet 'other' option passes the video title as a URI.
+ // Checking !uri.isHierarchical() works for all cases, except if the
+ // video title starts with / and then it's hierarchical but still an invalid URI.
+ Logger.printDebug(() -> "Ignoring URI: " + uri);
+ return uri;
+ }
+
+ Uri.Builder builder = uri.buildUpon().clearQuery();
+
+ if (!parametersToRemove.isEmpty()) {
+ for (String paramName : uri.getQueryParameterNames()) {
+ if (!parametersToRemove.contains(paramName)) {
+ for (String value : uri.getQueryParameters(paramName)) {
+ builder.appendQueryParameter(paramName, value);
+ }
+ }
+ }
+ }
+
+ Uri sanitizedURL = builder.build();
+ Logger.printInfo(() -> "Sanitized URL: " + uri + " to: " + sanitizedURL);
+
+ return sanitizedURL;
+ } catch (Exception ex) {
+ Logger.printException(() -> "sanitizeURI failure: " + uri, ex);
+ return uri;
+ }
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/requests/Requester.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/requests/Requester.java
new file mode 100644
index 0000000000..2e5c457f7b
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/requests/Requester.java
@@ -0,0 +1,145 @@
+package app.revanced.extension.shared.requests;
+
+import app.revanced.extension.shared.Utils;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+
+public class Requester {
+ private Requester() {
+ }
+
+ public static HttpURLConnection getConnectionFromRoute(String apiUrl, Route route, String... params) throws IOException {
+ return getConnectionFromCompiledRoute(apiUrl, route.compile(params));
+ }
+
+ public static HttpURLConnection getConnectionFromCompiledRoute(String apiUrl, Route.CompiledRoute route) throws IOException {
+ String url = apiUrl + route.getCompiledRoute();
+ HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
+ // This request sends data via URL query parameters. No request body is included.
+ // If a request body is added, the caller must set the appropriate Content-Length header.
+ connection.setFixedLengthStreamingMode(0);
+ connection.setRequestMethod(route.getMethod().name());
+ String agentString = System.getProperty("http.agent")
+ + "; ReVanced/" + Utils.getAppVersionName()
+ + " (" + Utils.getPatchesReleaseVersion() + ")";
+ connection.setRequestProperty("User-Agent", agentString);
+
+ return connection;
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection}, and closes the underlying InputStream.
+ */
+ private static String parseInputStreamAndClose(InputStream inputStream) throws IOException {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
+ StringBuilder jsonBuilder = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ jsonBuilder.append(line);
+ jsonBuilder.append('\n');
+ }
+ return jsonBuilder.toString();
+ }
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} response as a String.
+ * This does not close the url connection. If further requests to this host are unlikely
+ * in the near future, then instead use {@link #parseStringAndDisconnect(HttpURLConnection)}.
+ */
+ public static String parseString(HttpURLConnection connection) throws IOException {
+ return parseInputStreamAndClose(connection.getInputStream());
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} response as a String, and disconnect.
+ *
+ * Should only be used if other requests to the server in the near future are unlikely
+ *
+ * @see #parseString(HttpURLConnection)
+ */
+ public static String parseStringAndDisconnect(HttpURLConnection connection) throws IOException {
+ String result = parseString(connection);
+ connection.disconnect();
+ return result;
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} error stream as a String.
+ * If the server sent no error response data, this returns an empty string.
+ */
+ public static String parseErrorString(HttpURLConnection connection) throws IOException {
+ InputStream errorStream = connection.getErrorStream();
+ if (errorStream == null) {
+ return "";
+ }
+ return parseInputStreamAndClose(errorStream);
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} error stream as a String, and disconnect.
+ * If the server sent no error response data, this returns an empty string.
+ *
+ * Should only be used if other requests to the server are unlikely in the near future.
+ *
+ * @see #parseErrorString(HttpURLConnection)
+ */
+ public static String parseErrorStringAndDisconnect(HttpURLConnection connection) throws IOException {
+ String result = parseErrorString(connection);
+ connection.disconnect();
+ return result;
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} response into a JSONObject.
+ * This does not close the url connection. If further requests to this host are unlikely
+ * in the near future, then instead use {@link #parseJSONObjectAndDisconnect(HttpURLConnection)}.
+ */
+ public static JSONObject parseJSONObject(HttpURLConnection connection) throws JSONException, IOException {
+ return new JSONObject(parseString(connection));
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection}, close the underlying InputStream, and disconnect.
+ *
+ * Should only be used if other requests to the server in the near future are unlikely
+ *
+ * @see #parseJSONObject(HttpURLConnection)
+ */
+ public static JSONObject parseJSONObjectAndDisconnect(HttpURLConnection connection) throws JSONException, IOException {
+ JSONObject object = parseJSONObject(connection);
+ connection.disconnect();
+ return object;
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection}, and closes the underlying InputStream.
+ * This does not close the url connection. If further requests to this host are unlikely
+ * in the near future, then instead use {@link #parseJSONArrayAndDisconnect(HttpURLConnection)}.
+ */
+ public static JSONArray parseJSONArray(HttpURLConnection connection) throws JSONException, IOException {
+ return new JSONArray(parseString(connection));
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection}, close the underlying InputStream, and disconnect.
+ *
+ * Should only be used if other requests to the server in the near future are unlikely
+ *
+ * @see #parseJSONArray(HttpURLConnection)
+ */
+ public static JSONArray parseJSONArrayAndDisconnect(HttpURLConnection connection) throws JSONException, IOException {
+ JSONArray array = parseJSONArray(connection);
+ connection.disconnect();
+ return array;
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/requests/Route.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/requests/Route.java
new file mode 100644
index 0000000000..74428224a7
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/requests/Route.java
@@ -0,0 +1,66 @@
+package app.revanced.extension.shared.requests;
+
+public class Route {
+ private final String route;
+ private final Method method;
+ private final int paramCount;
+
+ public Route(Method method, String route) {
+ this.method = method;
+ this.route = route;
+ this.paramCount = countMatches(route, '{');
+
+ if (paramCount != countMatches(route, '}'))
+ throw new IllegalArgumentException("Not enough parameters");
+ }
+
+ public Method getMethod() {
+ return method;
+ }
+
+ public CompiledRoute compile(String... params) {
+ if (params.length != paramCount)
+ throw new IllegalArgumentException("Error compiling route [" + route + "], incorrect amount of parameters provided. " +
+ "Expected: " + paramCount + ", provided: " + params.length);
+
+ StringBuilder compiledRoute = new StringBuilder(route);
+ for (int i = 0; i < paramCount; i++) {
+ int paramStart = compiledRoute.indexOf("{");
+ int paramEnd = compiledRoute.indexOf("}");
+ compiledRoute.replace(paramStart, paramEnd + 1, params[i]);
+ }
+ return new CompiledRoute(this, compiledRoute.toString());
+ }
+
+ public static class CompiledRoute {
+ private final Route baseRoute;
+ private final String compiledRoute;
+
+ private CompiledRoute(Route baseRoute, String compiledRoute) {
+ this.baseRoute = baseRoute;
+ this.compiledRoute = compiledRoute;
+ }
+
+ public String getCompiledRoute() {
+ return compiledRoute;
+ }
+
+ public Method getMethod() {
+ return baseRoute.method;
+ }
+ }
+
+ private int countMatches(CharSequence seq, char c) {
+ int count = 0;
+ for (int i = 0, length = seq.length(); i < length; i++) {
+ if (seq.charAt(i) == c)
+ count++;
+ }
+ return count;
+ }
+
+ public enum Method {
+ GET,
+ POST
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/AppLanguage.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/AppLanguage.java
new file mode 100644
index 0000000000..fbc734a51d
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/AppLanguage.java
@@ -0,0 +1,119 @@
+package app.revanced.extension.shared.settings;
+
+import java.util.Locale;
+
+public enum AppLanguage {
+ /**
+ * The current app language.
+ */
+ DEFAULT,
+
+ // Languages codes not included with YouTube, but are translated on Crowdin
+ GA,
+
+ // Language codes found in locale_config.xml
+ // All region specific variants have been removed.
+ AF,
+ AM,
+ AR,
+ AS,
+ AZ,
+ BE,
+ BG,
+ BN,
+ BS,
+ CA,
+ CS,
+ DA,
+ DE,
+ EL,
+ EN,
+ ES,
+ ET,
+ EU,
+ FA,
+ FI,
+ FR,
+ GL,
+ GU,
+ HE, // App uses obsolete 'IW' and not the modern 'HE' ISO code.
+ HI,
+ HR,
+ HU,
+ HY,
+ ID,
+ IS,
+ IT,
+ JA,
+ KA,
+ KK,
+ KM,
+ KN,
+ KO,
+ KY,
+ LO,
+ LT,
+ LV,
+ MK,
+ ML,
+ MN,
+ MR,
+ MS,
+ MY,
+ NB,
+ NE,
+ NL,
+ OR,
+ PA,
+ PL,
+ PT,
+ RO,
+ RU,
+ SI,
+ SK,
+ SL,
+ SQ,
+ SR,
+ SV,
+ SW,
+ TA,
+ TE,
+ TH,
+ TL,
+ TR,
+ UK,
+ UR,
+ UZ,
+ VI,
+ ZH,
+ ZU;
+
+ private final String language;
+ private final Locale locale;
+
+ AppLanguage() {
+ language = name().toLowerCase(Locale.US);
+ locale = Locale.forLanguageTag(language);
+ }
+
+ /**
+ * @return The 2 letter ISO 639_1 language code.
+ */
+ public String getLanguage() {
+ // Changing the app language does not force the app to completely restart,
+ // so the default needs to be the current language and not a static field.
+ if (this == DEFAULT) {
+ return Locale.getDefault().getLanguage();
+ }
+
+ return language;
+ }
+
+ public Locale getLocale() {
+ if (this == DEFAULT) {
+ return Locale.getDefault();
+ }
+
+ return locale;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/BaseActivityHook.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/BaseActivityHook.java
new file mode 100644
index 0000000000..1a2bfe9a2b
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/BaseActivityHook.java
@@ -0,0 +1,173 @@
+package app.revanced.extension.shared.settings;
+
+import static app.revanced.extension.shared.Utils.getResourceIdentifierOrThrow;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.preference.PreferenceFragment;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import android.widget.Toolbar;
+
+import androidx.annotation.RequiresApi;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.ResourceType;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.preference.ToolbarPreferenceFragment;
+import app.revanced.extension.shared.ui.Dim;
+
+/**
+ * Base class for hooking activities to inject a custom PreferenceFragment with a toolbar.
+ * Provides common logic for initializing the activity and setting up the toolbar.
+ */
+@SuppressWarnings("deprecation")
+@RequiresApi(api = Build.VERSION_CODES.O)
+public abstract class BaseActivityHook extends Activity {
+
+ private static final int ID_REVANCED_SETTINGS_FRAGMENTS =
+ getResourceIdentifierOrThrow(ResourceType.ID, "revanced_settings_fragments");
+ private static final int ID_REVANCED_TOOLBAR_PARENT =
+ getResourceIdentifierOrThrow(ResourceType.ID, "revanced_toolbar_parent");
+ public static final int LAYOUT_REVANCED_SETTINGS_WITH_TOOLBAR =
+ getResourceIdentifierOrThrow(ResourceType.LAYOUT, "revanced_settings_with_toolbar");
+ private static final int STRING_REVANCED_SETTINGS_TITLE =
+ getResourceIdentifierOrThrow(ResourceType.STRING, "revanced_settings_title");
+
+ /**
+ * Layout parameters for the toolbar, extracted from the dummy toolbar.
+ */
+ protected static ViewGroup.LayoutParams toolbarLayoutParams;
+
+ /**
+ * Sets the layout parameters for the toolbar.
+ */
+ public static void setToolbarLayoutParams(Toolbar toolbar) {
+ if (toolbarLayoutParams != null) {
+ toolbar.setLayoutParams(toolbarLayoutParams);
+ }
+ }
+
+ /**
+ * Initializes the activity by setting the theme, content view and injecting a PreferenceFragment.
+ */
+ public static void initialize(BaseActivityHook hook, Activity activity) {
+ try {
+ hook.customizeActivityTheme(activity);
+ activity.setContentView(hook.getContentViewResourceId());
+
+ // Sanity check.
+ String dataString = activity.getIntent().getDataString();
+ if (!"revanced_settings_intent".equals(dataString)) {
+ Logger.printException(() -> "Unknown intent: " + dataString);
+ return;
+ }
+
+ PreferenceFragment fragment = hook.createPreferenceFragment();
+ hook.createToolbar(activity, fragment);
+
+ activity.getFragmentManager()
+ .beginTransaction()
+ .replace(ID_REVANCED_SETTINGS_FRAGMENTS, fragment)
+ .commit();
+ } catch (Exception ex) {
+ Logger.printException(() -> "initialize failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ * Overrides the ReVanced settings language.
+ */
+ @SuppressWarnings("unused")
+ public static Context getAttachBaseContext(Context original) {
+ AppLanguage language = BaseSettings.REVANCED_LANGUAGE.get();
+ if (language == AppLanguage.DEFAULT) {
+ return original;
+ }
+
+ return Utils.getContext();
+ }
+
+ /**
+ * Creates and configures a toolbar for the activity, replacing a dummy placeholder.
+ */
+ @SuppressLint("UseCompatLoadingForDrawables")
+ protected void createToolbar(Activity activity, PreferenceFragment fragment) {
+ // Replace dummy placeholder toolbar.
+ // This is required to fix submenu title alignment issue with Android ASOP 15+
+ ViewGroup toolbarParent = activity.findViewById(ID_REVANCED_TOOLBAR_PARENT);
+ ViewGroup dummyToolbar = Utils.getChildViewByResourceName(toolbarParent, "revanced_toolbar");
+ toolbarLayoutParams = dummyToolbar.getLayoutParams();
+ toolbarParent.removeView(dummyToolbar);
+
+ // Sets appropriate system navigation bar color for the activity.
+ ToolbarPreferenceFragment.setNavigationBarColor(activity.getWindow());
+
+ Toolbar toolbar = new Toolbar(toolbarParent.getContext());
+ toolbar.setBackgroundColor(getToolbarBackgroundColor());
+ toolbar.setNavigationIcon(getNavigationIcon());
+ toolbar.setNavigationOnClickListener(getNavigationClickListener(activity));
+ toolbar.setTitle(STRING_REVANCED_SETTINGS_TITLE);
+
+ toolbar.setTitleMarginStart(Dim.dp16);
+ toolbar.setTitleMarginEnd(Dim.dp16);
+ TextView toolbarTextView = Utils.getChildView(toolbar, false, view -> view instanceof TextView);
+ if (toolbarTextView != null) {
+ toolbarTextView.setTextColor(Utils.getAppForegroundColor());
+ toolbarTextView.setTextSize(20);
+ }
+ setToolbarLayoutParams(toolbar);
+
+ onPostToolbarSetup(activity, toolbar, fragment);
+
+ toolbarParent.addView(toolbar, 0);
+ }
+
+ /**
+ * Returns the resource ID for the content view layout.
+ */
+ protected int getContentViewResourceId() {
+ return LAYOUT_REVANCED_SETTINGS_WITH_TOOLBAR;
+ }
+
+ /**
+ * Customizes the activity's theme.
+ */
+ protected abstract void customizeActivityTheme(Activity activity);
+
+ /**
+ * Returns the background color for the toolbar.
+ */
+ protected abstract int getToolbarBackgroundColor();
+
+ /**
+ * Returns the navigation icon drawable for the toolbar.
+ */
+ protected abstract Drawable getNavigationIcon();
+
+ /**
+ * Returns the click listener for the toolbar's navigation icon.
+ */
+ protected abstract View.OnClickListener getNavigationClickListener(Activity activity);
+
+ /**
+ * Creates the PreferenceFragment to be injected into the activity.
+ */
+ protected PreferenceFragment createPreferenceFragment() {
+ return new ToolbarPreferenceFragment();
+ }
+
+ /**
+ * Performs additional setup after the toolbar is configured.
+ *
+ * @param activity The activity hosting the toolbar.
+ * @param toolbar The configured toolbar.
+ * @param fragment The PreferenceFragment associated with the activity.
+ */
+ protected void onPostToolbarSetup(Activity activity, Toolbar toolbar, PreferenceFragment fragment) {}
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/BaseSettings.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/BaseSettings.java
new file mode 100644
index 0000000000..5fc4418366
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/BaseSettings.java
@@ -0,0 +1,70 @@
+package app.revanced.extension.shared.settings;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+import static app.revanced.extension.shared.patches.CustomBrandingPatch.BrandingTheme;
+import static app.revanced.extension.shared.settings.Setting.parent;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.patches.CustomBrandingPatch;
+
+/**
+ * Settings shared across multiple apps.
+ *
+ * To ensure this class is loaded when the UI is created, app specific setting bundles should extend
+ * or reference this class.
+ */
+public class BaseSettings {
+ public static final BooleanSetting DEBUG = new BooleanSetting("revanced_debug", FALSE);
+ public static final BooleanSetting DEBUG_STACKTRACE = new BooleanSetting("revanced_debug_stacktrace", FALSE, parent(DEBUG));
+ public static final BooleanSetting DEBUG_TOAST_ON_ERROR = new BooleanSetting("revanced_debug_toast_on_error", TRUE, "revanced_debug_toast_on_error_user_dialog_message");
+
+ public static final IntegerSetting CHECK_ENVIRONMENT_WARNINGS_ISSUED = new IntegerSetting("revanced_check_environment_warnings_issued", 0, true, false);
+
+ public static final EnumSetting REVANCED_LANGUAGE = new EnumSetting<>("revanced_language", AppLanguage.DEFAULT, true, "revanced_language_user_dialog_message");
+
+ /**
+ * Use the icons declared in the preferences created during patching. If no icons or styles are declared then this setting does nothing.
+ */
+ public static final BooleanSetting SHOW_MENU_ICONS = new BooleanSetting("revanced_show_menu_icons", TRUE, true);
+ /**
+ * Do not use this setting directly. Instead use {@link app.revanced.extension.shared.Utils#appIsUsingBoldIcons()}
+ */
+ public static final BooleanSetting SETTINGS_DISABLE_BOLD_ICONS = new BooleanSetting("revanced_settings_disable_bold_icons", FALSE, true);
+
+ public static final BooleanSetting SETTINGS_SEARCH_HISTORY = new BooleanSetting("revanced_settings_search_history", TRUE, true);
+ public static final StringSetting SETTINGS_SEARCH_ENTRIES = new StringSetting("revanced_settings_search_entries", "");
+
+ /**
+ * The first time the app was launched with no previous app data (either a clean install, or after wiping app data).
+ */
+ public static final LongSetting FIRST_TIME_APP_LAUNCHED = new LongSetting("revanced_last_time_app_was_launched", -1L, false, false);
+
+ public static final BooleanSetting GMS_CORE_CHECK_UPDATES = new BooleanSetting("revanced_gms_core_check_updates", true, true);
+
+ //
+ // Settings shared by YouTube and YouTube Music.
+ //
+
+ 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_STREAMING_DATA_STATS_FOR_NERDS = new BooleanSetting("revanced_spoof_video_streams_stats_for_nerds", TRUE, parent(SPOOF_VIDEO_STREAMS));
+
+ public static final BooleanSetting SANITIZE_SHARING_LINKS = new BooleanSetting("revanced_sanitize_sharing_links", TRUE);
+ public static final BooleanSetting REPLACE_MUSIC_LINKS_WITH_YOUTUBE = new BooleanSetting("revanced_replace_music_with_youtube", FALSE);
+
+ public static final BooleanSetting CHECK_WATCH_HISTORY_DOMAIN_NAME = new BooleanSetting("revanced_check_watch_history_domain_name", TRUE, false, false);
+
+ public static final EnumSetting CUSTOM_BRANDING_ICON = new EnumSetting<>("revanced_custom_branding_icon", CustomBrandingPatch.getDefaultIconStyle(), true);
+ public static final IntegerSetting CUSTOM_BRANDING_NAME = new IntegerSetting("revanced_custom_branding_name", CustomBrandingPatch.getDefaultAppNameIndex(), true);
+
+ public static final StringSetting DISABLED_FEATURE_FLAGS = new StringSetting("revanced_disabled_feature_flags", "", true, parent(DEBUG));
+
+ static {
+ final long now = System.currentTimeMillis();
+
+ if (FIRST_TIME_APP_LAUNCHED.get() < 0) {
+ Logger.printInfo(() -> "First launch of installation with no prior app data");
+ FIRST_TIME_APP_LAUNCHED.save(now);
+ }
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/BooleanSetting.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/BooleanSetting.java
new file mode 100644
index 0000000000..c67ebabf96
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/BooleanSetting.java
@@ -0,0 +1,81 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Objects;
+
+@SuppressWarnings("unused")
+public class BooleanSetting extends Setting {
+ public BooleanSetting(String key, Boolean defaultValue) {
+ super(key, defaultValue);
+ }
+ public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+ public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+ public BooleanSetting(String key, Boolean defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+ public BooleanSetting(String key, Boolean defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+ public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+ public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+ public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+ public BooleanSetting(@NonNull String key, @NonNull Boolean defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ /**
+ * Sets, but does _not_ persistently save the value.
+ * This method is only to be used by the Settings preference code.
+ *
+ * This intentionally is a static method to deter
+ * accidental usage when {@link #save(Boolean)} was intended.
+ */
+ public static void privateSetValue(@NonNull BooleanSetting setting, @NonNull Boolean newValue) {
+ setting.value = Objects.requireNonNull(newValue);
+
+ if (setting.isSetToDefault()) {
+ setting.removeFromPreferences();
+ }
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getBoolean(key, defaultValue);
+ }
+
+ @Override
+ protected Boolean readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ return json.getBoolean(importExportKey);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = Boolean.valueOf(Objects.requireNonNull(newValue));
+ }
+
+ @Override
+ public void saveToPreferences() {
+ preferences.saveBoolean(key, value);
+ }
+
+ @NonNull
+ @Override
+ public Boolean get() {
+ return value;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/EnumSetting.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/EnumSetting.java
new file mode 100644
index 0000000000..2c2cb6a3a8
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/EnumSetting.java
@@ -0,0 +1,122 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Locale;
+import java.util.Objects;
+
+import app.revanced.extension.shared.Logger;
+
+/**
+ * If an Enum value is removed or changed, any saved or imported data using the
+ * non-existent value will be reverted to the default value
+ * (the event is logged, but no user error is displayed).
+ *
+ * All saved JSON text is converted to lowercase to keep the output less obnoxious.
+ */
+@SuppressWarnings("unused")
+public class EnumSetting> extends Setting {
+ public EnumSetting(String key, T defaultValue) {
+ super(key, defaultValue);
+ }
+ public EnumSetting(String key, T defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+ public EnumSetting(String key, T defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+ public EnumSetting(String key, T defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+ public EnumSetting(String key, T defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+ public EnumSetting(String key, T defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+ public EnumSetting(String key, T defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+ public EnumSetting(String key, T defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+ public EnumSetting(@NonNull String key, @NonNull T defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getEnum(key, defaultValue);
+ }
+
+ @Override
+ protected T readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ String enumName = json.getString(importExportKey);
+ try {
+ return getEnumFromString(enumName);
+ } catch (IllegalArgumentException ex) {
+ // Info level to allow removing enum values in the future without showing any user errors.
+ Logger.printInfo(() -> "Using default, and ignoring unknown enum value: " + enumName, ex);
+ return defaultValue;
+ }
+ }
+
+ @Override
+ protected void writeToJSON(JSONObject json, String importExportKey) throws JSONException {
+ // Use lowercase to keep the output less ugly.
+ json.put(importExportKey, value.name().toLowerCase(Locale.ENGLISH));
+ }
+
+ /**
+ * @param enumName Enum name. Casing does not matter.
+ * @return Enum of this type with the same declared name.
+ * @throws IllegalArgumentException if the name is not a valid enum of this type.
+ */
+ protected T getEnumFromString(String enumName) {
+ //noinspection ConstantConditions
+ for (Enum> value : defaultValue.getClass().getEnumConstants()) {
+ if (value.name().equalsIgnoreCase(enumName)) {
+ //noinspection unchecked
+ return (T) value;
+ }
+ }
+
+ throw new IllegalArgumentException("Unknown enum value: " + enumName);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = getEnumFromString(Objects.requireNonNull(newValue));
+ }
+
+ @Override
+ public void saveToPreferences() {
+ preferences.saveEnumAsString(key, value);
+ }
+
+ @NonNull
+ @Override
+ public T get() {
+ return value;
+ }
+
+ /**
+ * Availability based on if this setting is currently set to any of the provided types.
+ */
+ @SafeVarargs
+ public final Setting.Availability availability(T... types) {
+ Objects.requireNonNull(types);
+
+ return () -> {
+ T currentEnumType = get();
+ for (T enumType : types) {
+ if (currentEnumType == enumType) return true;
+ }
+ return false;
+ };
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/FloatSetting.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/FloatSetting.java
new file mode 100644
index 0000000000..59846e037f
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/FloatSetting.java
@@ -0,0 +1,67 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Objects;
+
+@SuppressWarnings("unused")
+public class FloatSetting extends Setting {
+
+ public FloatSetting(String key, Float defaultValue) {
+ super(key, defaultValue);
+ }
+ public FloatSetting(String key, Float defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+ public FloatSetting(String key, Float defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+ public FloatSetting(String key, Float defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+ public FloatSetting(String key, Float defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+ public FloatSetting(String key, Float defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+ public FloatSetting(String key, Float defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+ public FloatSetting(String key, Float defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+ public FloatSetting(@NonNull String key, @NonNull Float defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getFloatString(key, defaultValue);
+ }
+
+ @Override
+ protected Float readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ return (float) json.getDouble(importExportKey);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = Float.valueOf(Objects.requireNonNull(newValue));
+ }
+
+ @Override
+ public void saveToPreferences() {
+ preferences.saveFloatString(key, value);
+ }
+
+ @NonNull
+ @Override
+ public Float get() {
+ return value;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/IntegerSetting.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/IntegerSetting.java
new file mode 100644
index 0000000000..ccf128dfdd
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/IntegerSetting.java
@@ -0,0 +1,67 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Objects;
+
+@SuppressWarnings("unused")
+public class IntegerSetting extends Setting {
+
+ public IntegerSetting(String key, Integer defaultValue) {
+ super(key, defaultValue);
+ }
+ public IntegerSetting(String key, Integer defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+ public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+ public IntegerSetting(String key, Integer defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+ public IntegerSetting(String key, Integer defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+ public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+ public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+ public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+ public IntegerSetting(@NonNull String key, @NonNull Integer defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getIntegerString(key, defaultValue);
+ }
+
+ @Override
+ protected Integer readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ return json.getInt(importExportKey);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = Integer.valueOf(Objects.requireNonNull(newValue));
+ }
+
+ @Override
+ public void saveToPreferences() {
+ preferences.saveIntegerString(key, value);
+ }
+
+ @NonNull
+ @Override
+ public Integer get() {
+ return value;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/LongSetting.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/LongSetting.java
new file mode 100644
index 0000000000..ea3adcebac
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/LongSetting.java
@@ -0,0 +1,67 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Objects;
+
+@SuppressWarnings("unused")
+public class LongSetting extends Setting {
+
+ public LongSetting(String key, Long defaultValue) {
+ super(key, defaultValue);
+ }
+ public LongSetting(String key, Long defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+ public LongSetting(String key, Long defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+ public LongSetting(String key, Long defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+ public LongSetting(String key, Long defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+ public LongSetting(String key, Long defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+ public LongSetting(String key, Long defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+ public LongSetting(String key, Long defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+ public LongSetting(@NonNull String key, @NonNull Long defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getLongString(key, defaultValue);
+ }
+
+ @Override
+ protected Long readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ return json.getLong(importExportKey);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = Long.valueOf(Objects.requireNonNull(newValue));
+ }
+
+ @Override
+ public void saveToPreferences() {
+ preferences.saveLongString(key, value);
+ }
+
+ @NonNull
+ @Override
+ public Long get() {
+ return value;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/Setting.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/Setting.java
new file mode 100644
index 0000000000..53a980e3c2
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/Setting.java
@@ -0,0 +1,504 @@
+package app.revanced.extension.shared.settings;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.StringRef;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.preference.SharedPrefCategory;
+
+public abstract class Setting {
+
+ /**
+ * Indicates if a {@link Setting} is available to edit and use.
+ * Typically this is dependent upon other BooleanSetting(s) set to 'true',
+ * but this can be used to call into extension code and check other conditions.
+ */
+ public interface Availability {
+ boolean isAvailable();
+
+ /**
+ * @return parent settings (dependencies) of this availability.
+ */
+ default List> getParentSettings() {
+ return Collections.emptyList();
+ }
+ }
+
+ /**
+ * Availability based on a single parent setting being enabled.
+ */
+ public static Availability parent(BooleanSetting parent) {
+ return new Availability() {
+ @Override
+ public boolean isAvailable() {
+ return parent.get();
+ }
+
+ @Override
+ public List> getParentSettings() {
+ return Collections.singletonList(parent);
+ }
+ };
+ }
+
+ /**
+ * Availability based on a single parent setting being disabled.
+ */
+ public static Availability parentNot(BooleanSetting parent) {
+ return new Availability() {
+ @Override
+ public boolean isAvailable() {
+ return !parent.get();
+ }
+
+ @Override
+ public List> getParentSettings() {
+ return Collections.singletonList(parent);
+ }
+ };
+ }
+
+ /**
+ * Availability based on all parents being enabled.
+ */
+ public static Availability parentsAll(BooleanSetting... parents) {
+ return new Availability() {
+ @Override
+ public boolean isAvailable() {
+ for (BooleanSetting parent : parents) {
+ if (!parent.get()) return false;
+ }
+ return true;
+ }
+
+ @Override
+ public List> getParentSettings() {
+ return Collections.unmodifiableList(Arrays.asList(parents));
+ }
+ };
+ }
+
+ /**
+ * Availability based on any parent being enabled.
+ */
+ public static Availability parentsAny(BooleanSetting... parents) {
+ return new Availability() {
+ @Override
+ public boolean isAvailable() {
+ for (BooleanSetting parent : parents) {
+ if (parent.get()) return true;
+ }
+ return false;
+ }
+
+ @Override
+ public List> getParentSettings() {
+ return Collections.unmodifiableList(Arrays.asList(parents));
+ }
+ };
+ }
+
+ /**
+ * Callback for importing/exporting settings.
+ */
+ public interface ImportExportCallback {
+ /**
+ * Called after all settings have been imported.
+ */
+ void settingsImported(@Nullable Context context);
+
+ /**
+ * Called after all settings have been exported.
+ */
+ void settingsExported(@Nullable Context context);
+ }
+
+ private static final List importExportCallbacks = new ArrayList<>();
+
+ /**
+ * Adds a callback for {@link #importFromJSON(Context, String)} and {@link #exportToJson(Context)}.
+ */
+ public static void addImportExportCallback(ImportExportCallback callback) {
+ importExportCallbacks.add(Objects.requireNonNull(callback));
+ }
+
+ /**
+ * All settings that were instantiated.
+ * When a new setting is created, it is automatically added to this list.
+ */
+ private static final List> SETTINGS = new ArrayList<>();
+
+ /**
+ * Map of setting path to setting object.
+ */
+ private static final Map> PATH_TO_SETTINGS = new HashMap<>();
+
+ /**
+ * Preference all instances are saved to.
+ */
+ public static final SharedPrefCategory preferences = new SharedPrefCategory("revanced_prefs");
+
+ @Nullable
+ public static Setting> getSettingFromPath(String str) {
+ return PATH_TO_SETTINGS.get(str);
+ }
+
+ /**
+ * @return All settings that have been created.
+ */
+ public static List> allLoadedSettings() {
+ return Collections.unmodifiableList(SETTINGS);
+ }
+
+ /**
+ * @return All settings that have been created, sorted by keys.
+ */
+ private static List> allLoadedSettingsSorted() {
+ //noinspection ComparatorCombinators
+ Collections.sort(SETTINGS, (Setting> o1, Setting> o2) -> o1.key.compareTo(o2.key));
+ return allLoadedSettings();
+ }
+
+ /**
+ * The key used to store the value in the shared preferences.
+ */
+ public final String key;
+
+ /**
+ * The default value of the setting.
+ */
+ public final T defaultValue;
+
+ /**
+ * If the app should be rebooted, if this setting is changed
+ */
+ public final boolean rebootApp;
+
+ /**
+ * If this setting should be included when importing/exporting settings.
+ */
+ public final boolean includeWithImportExport;
+
+ /**
+ * If this setting is available to edit and use.
+ * Not to be confused with it's status returned from {@link #get()}.
+ */
+ @Nullable
+ private final Availability availability;
+
+ /**
+ * Confirmation message to display, if the user tries to change the setting from the default value.
+ */
+ @Nullable
+ public final StringRef userDialogMessage;
+
+ // Must be volatile, as some settings are read/write from different threads.
+ // Of note, the object value is persistently stored using SharedPreferences (which is thread safe).
+ /**
+ * The value of the setting.
+ */
+ protected volatile T value;
+
+ public Setting(String key, T defaultValue) {
+ this(key, defaultValue, false, true, null, null);
+ }
+ public Setting(String key, T defaultValue, boolean rebootApp) {
+ this(key, defaultValue, rebootApp, true, null, null);
+ }
+ public Setting(String key, T defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ this(key, defaultValue, rebootApp, includeWithImportExport, null, null);
+ }
+ public Setting(String key, T defaultValue, String userDialogMessage) {
+ this(key, defaultValue, false, true, userDialogMessage, null);
+ }
+ public Setting(String key, T defaultValue, Availability availability) {
+ this(key, defaultValue, false, true, null, availability);
+ }
+ public Setting(String key, T defaultValue, boolean rebootApp, String userDialogMessage) {
+ this(key, defaultValue, rebootApp, true, userDialogMessage, null);
+ }
+ public Setting(String key, T defaultValue, boolean rebootApp, Availability availability) {
+ this(key, defaultValue, rebootApp, true, null, availability);
+ }
+ public Setting(String key, T defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ this(key, defaultValue, rebootApp, true, userDialogMessage, availability);
+ }
+
+ /**
+ * A setting backed by a shared preference.
+ *
+ * @param key The key used to store the value in the shared preferences.
+ * @param defaultValue The default value of the setting.
+ * @param rebootApp If the app should be rebooted, if this setting is changed.
+ * @param includeWithImportExport If this setting should be shown in the import/export dialog.
+ * @param userDialogMessage Confirmation message to display, if the user tries to change the setting from the default value.
+ * @param availability Condition that must be true, for this setting to be available to configure.
+ */
+ public Setting(String key,
+ T defaultValue,
+ boolean rebootApp,
+ boolean includeWithImportExport,
+ @Nullable String userDialogMessage,
+ @Nullable Availability availability
+ ) {
+ this.key = Objects.requireNonNull(key);
+ this.value = this.defaultValue = Objects.requireNonNull(defaultValue);
+ this.rebootApp = rebootApp;
+ this.includeWithImportExport = includeWithImportExport;
+ this.userDialogMessage = (userDialogMessage == null) ? null : new StringRef(userDialogMessage);
+ this.availability = availability;
+
+ SETTINGS.add(this);
+ if (PATH_TO_SETTINGS.put(key, this) != null) {
+ Logger.printException(() -> this.getClass().getSimpleName()
+ + " error: Duplicate Setting key found: " + key);
+ }
+
+ load();
+ }
+
+ /**
+ * Sets, but does _not_ persistently save the value.
+ * This method is only to be used by the Settings preference code.
+ *
+ * This intentionally is a static method to deter
+ * accidental usage when {@link #save(Object)} was intended.
+ */
+ public static void privateSetValueFromString(Setting> setting, String newValue) {
+ setting.setValueFromString(newValue);
+
+ // Clear the preference value since default is used, to allow changing
+ // the changing the default for a future release. Without this after upgrading
+ // the saved value will be whatever was the default when the app was first installed.
+ if (setting.isSetToDefault()) {
+ setting.removeFromPreferences();
+ }
+ }
+
+ /**
+ * Sets the value of {@link #value}, but do not save to {@link #preferences}.
+ */
+ protected abstract void setValueFromString(String newValue);
+
+ /**
+ * Load and set the value of {@link #value}.
+ */
+ protected abstract void load();
+
+ /**
+ * Persistently saves the value.
+ */
+ public final void save(T newValue) {
+ if (value.equals(newValue)) {
+ return;
+ }
+
+ // Must set before saving to preferences (otherwise importing fails to update UI correctly).
+ value = Objects.requireNonNull(newValue);
+
+ if (defaultValue.equals(newValue)) {
+ removeFromPreferences();
+ } else {
+ saveToPreferences();
+ }
+ }
+
+ /**
+ * Save {@link #value} to {@link #preferences}.
+ */
+ protected abstract void saveToPreferences();
+
+ /**
+ * Remove {@link #value} from {@link #preferences}.
+ */
+ protected final void removeFromPreferences() {
+ Logger.printDebug(() -> "Clearing stored preference value (reset to default): " + key);
+ preferences.removeKey(key);
+ }
+
+ @NonNull
+ public abstract T get();
+
+ /**
+ * Identical to calling {@link #save(Object)} using {@link #defaultValue}.
+ *
+ * @return The newly saved default value.
+ */
+ public T resetToDefault() {
+ save(defaultValue);
+ return defaultValue;
+ }
+
+ /**
+ * @return if this setting can be configured and used.
+ */
+ public boolean isAvailable() {
+ return availability == null || availability.isAvailable();
+ }
+
+ /**
+ * Get the parent Settings that this setting depends on.
+ * @return List of parent Settings, or empty list if no dependencies exist.
+ * Defensive: handles null availability or missing getParentSettings() override.
+ */
+ public List> getParentSettings() {
+ return availability == null
+ ? Collections.emptyList()
+ : Objects.requireNonNullElse(availability.getParentSettings(), Collections.emptyList());
+ }
+
+ /**
+ * @return if the currently set value is the same as {@link #defaultValue}.
+ */
+ public boolean isSetToDefault() {
+ return value.equals(defaultValue);
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return key + "=" + get();
+ }
+
+ // region Import / export
+
+ /**
+ * If a setting path has this prefix, then remove it before importing/exporting.
+ */
+ private static final String OPTIONAL_REVANCED_SETTINGS_PREFIX = "revanced_";
+
+ /**
+ * The path, minus any 'revanced' prefix to keep json concise.
+ */
+ private String getImportExportKey() {
+ if (key.startsWith(OPTIONAL_REVANCED_SETTINGS_PREFIX)) {
+ return key.substring(OPTIONAL_REVANCED_SETTINGS_PREFIX.length());
+ }
+ return key;
+ }
+
+ /**
+ * @param importExportKey The JSON key. The JSONObject parameter will contain data for this key.
+ * @return the value stored using the import/export key. Do not set any values in this method.
+ */
+ protected abstract T readFromJSON(JSONObject json, String importExportKey) throws JSONException;
+
+ /**
+ * Saves this instance to JSON.
+ *
+ * To keep the JSON simple and readable,
+ * subclasses should not write out any embedded types (such as JSON Array or Dictionaries).
+ *
+ * If this instance is not a type supported natively by JSON (ie: it's not a String/Integer/Float/Long),
+ * then subclasses can override this method and write out a String value representing the value.
+ */
+ protected void writeToJSON(JSONObject json, String importExportKey) throws JSONException {
+ json.put(importExportKey, value);
+ }
+
+ public static String exportToJson(@Nullable Context alertDialogContext) {
+ try {
+ JSONObject json = new JSONObject();
+ for (Setting> setting : allLoadedSettingsSorted()) {
+ String importExportKey = setting.getImportExportKey();
+ if (json.has(importExportKey)) {
+ throw new IllegalArgumentException("duplicate key found: " + importExportKey);
+ }
+
+ final boolean exportDefaultValues = false; // Enable to see what all settings looks like in the UI.
+ //noinspection ConstantValue
+ if (setting.includeWithImportExport && (!setting.isSetToDefault() || exportDefaultValues)) {
+ setting.writeToJSON(json, importExportKey);
+ }
+ }
+
+ for (ImportExportCallback callback : importExportCallbacks) {
+ callback.settingsExported(alertDialogContext);
+ }
+
+ if (json.length() == 0) {
+ return "";
+ }
+
+ String export = json.toString(0);
+
+ // Remove the outer JSON braces to make the output more compact,
+ // and leave less chance of the user forgetting to copy it
+ return export.substring(2, export.length() - 2);
+ } catch (JSONException e) {
+ Logger.printException(() -> "Export failure", e); // should never happen
+ return "";
+ }
+ }
+
+ /**
+ * @return if any settings that require a reboot were changed.
+ */
+ public static boolean importFromJSON(Context alertDialogContext, String settingsJsonString) {
+ try {
+ if (!settingsJsonString.matches("[\\s\\S]*\\{")) {
+ settingsJsonString = '{' + settingsJsonString + '}'; // Restore outer JSON braces
+ }
+ JSONObject json = new JSONObject(settingsJsonString);
+
+ boolean rebootSettingChanged = false;
+ int numberOfSettingsImported = 0;
+ //noinspection rawtypes
+ for (Setting setting : SETTINGS) {
+ String key = setting.getImportExportKey();
+ if (json.has(key)) {
+ Object value = setting.readFromJSON(json, key);
+ if (!setting.get().equals(value)) {
+ rebootSettingChanged |= setting.rebootApp;
+ //noinspection unchecked
+ setting.save(value);
+ }
+ numberOfSettingsImported++;
+ } else if (setting.includeWithImportExport && !setting.isSetToDefault()) {
+ Logger.printDebug(() -> "Resetting to default: " + setting);
+ rebootSettingChanged |= setting.rebootApp;
+ setting.resetToDefault();
+ }
+ }
+
+ for (ImportExportCallback callback : importExportCallbacks) {
+ callback.settingsImported(alertDialogContext);
+ }
+
+ // Use a delay, otherwise the toast can move about on screen from the dismissing dialog.
+ final int numberOfSettingsImportedFinal = numberOfSettingsImported;
+ Utils.runOnMainThreadDelayed(() -> Utils.showToastLong(numberOfSettingsImportedFinal == 0
+ ? str("revanced_settings_import_reset")
+ : str("revanced_settings_import_success", numberOfSettingsImportedFinal)),
+ 150);
+
+ return rebootSettingChanged;
+ } catch (JSONException | IllegalArgumentException ex) {
+ Utils.showToastLong(str("revanced_settings_import_failure_parse", ex.getMessage()));
+ Logger.printInfo(() -> "", ex);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Import failure: " + ex.getMessage(), ex); // should never happen
+ }
+ return false;
+ }
+
+ // End import / export
+
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/StringSetting.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/StringSetting.java
new file mode 100644
index 0000000000..adb9beaa18
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/StringSetting.java
@@ -0,0 +1,67 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Objects;
+
+@SuppressWarnings("unused")
+public class StringSetting extends Setting {
+
+ public StringSetting(String key, String defaultValue) {
+ super(key, defaultValue);
+ }
+ public StringSetting(String key, String defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+ public StringSetting(String key, String defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+ public StringSetting(String key, String defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+ public StringSetting(String key, String defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+ public StringSetting(String key, String defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+ public StringSetting(String key, String defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+ public StringSetting(String key, String defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+ public StringSetting(@NonNull String key, @NonNull String defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getString(key, defaultValue);
+ }
+
+ @Override
+ protected String readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ return json.getString(importExportKey);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = Objects.requireNonNull(newValue);
+ }
+
+ @Override
+ public void saveToPreferences() {
+ preferences.saveString(key, value);
+ }
+
+ @NonNull
+ @Override
+ public String get() {
+ return value;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/YouTubeAndMusicSettings.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/YouTubeAndMusicSettings.java
new file mode 100644
index 0000000000..221ce00456
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/YouTubeAndMusicSettings.java
@@ -0,0 +1,14 @@
+package app.revanced.extension.shared.settings;
+
+import static app.revanced.extension.shared.settings.Setting.parent;
+import static java.lang.Boolean.FALSE;
+
+public class YouTubeAndMusicSettings extends BaseSettings {
+ // Custom filter
+ public static final BooleanSetting CUSTOM_FILTER = new BooleanSetting("revanced_custom_filter", FALSE);
+ public static final StringSetting CUSTOM_FILTER_STRINGS = new StringSetting("revanced_custom_filter_strings", "", true, parent(CUSTOM_FILTER));
+
+ // Miscellaneous
+ public static final BooleanSetting DEBUG_PROTOCOLBUFFER = new BooleanSetting("revanced_debug_protocolbuffer", FALSE, false,
+ "revanced_debug_protocolbuffer_user_dialog_message", parent(BaseSettings.DEBUG));
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java
new file mode 100644
index 0000000000..a515471a00
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java
@@ -0,0 +1,360 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.annotation.SuppressLint;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.preference.Preference;
+import android.preference.PreferenceFragment;
+import android.preference.PreferenceGroup;
+import android.preference.PreferenceManager;
+import android.preference.PreferenceScreen;
+import android.preference.SwitchPreference;
+import android.preference.EditTextPreference;
+import android.preference.ListPreference;
+import android.util.Pair;
+import android.widget.LinearLayout;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Objects;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.ResourceType;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.ui.CustomDialog;
+
+@SuppressWarnings("deprecation")
+public abstract class AbstractPreferenceFragment extends PreferenceFragment {
+
+ /**
+ * Indicates that if a preference changes,
+ * to apply the change from the Setting to the UI component.
+ */
+ public static boolean settingImportInProgress;
+
+ /**
+ * Prevents recursive calls during preference <-> UI syncing from showing extra dialogs.
+ */
+ private static boolean updatingPreference;
+
+ /**
+ * Used to prevent showing reboot dialog, if user cancels a setting user dialog.
+ */
+ private static boolean showingUserDialogMessage;
+
+ /**
+ * Confirm and restart dialog button text and title.
+ * Set by subclasses if Strings cannot be added as a resource.
+ */
+ @Nullable
+ protected static CharSequence restartDialogTitle, restartDialogMessage, restartDialogButtonText, confirmDialogTitle;
+
+ private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> {
+ try {
+ if (updatingPreference) {
+ Logger.printDebug(() -> "Ignoring preference change as sync is in progress");
+ return;
+ }
+
+ Setting> setting = Setting.getSettingFromPath(Objects.requireNonNull(str));
+ if (setting == null) {
+ return;
+ }
+ Preference pref = findPreference(str);
+ if (pref == null) {
+ return;
+ }
+ Logger.printDebug(() -> "Preference changed: " + setting.key);
+
+ if (!settingImportInProgress && !showingUserDialogMessage) {
+ if (setting.userDialogMessage != null && !prefIsSetToDefault(pref, setting)) {
+ // Do not change the setting yet, to allow preserving whatever
+ // list/text value was previously set if it needs to be reverted.
+ showSettingUserDialogConfirmation(pref, setting);
+ return;
+ } else if (setting.rebootApp) {
+ showRestartDialog(getContext());
+ }
+ }
+
+ updatingPreference = true;
+ // Apply 'Setting <- Preference', unless during importing when it needs to be 'Setting -> Preference'.
+ // Updating here can cause a recursive call back into this same method.
+ updatePreference(pref, setting, true, settingImportInProgress);
+ // Update any other preference availability that may now be different.
+ updateUIAvailability();
+ updatingPreference = false;
+ } catch (Exception ex) {
+ Logger.printException(() -> "OnSharedPreferenceChangeListener failure", ex);
+ }
+ };
+
+ /**
+ * Initialize this instance, and do any custom behavior.
+ *
+ * To ensure all {@link Setting} instances are correctly synced to the UI,
+ * it is important that subclasses make a call or otherwise reference their Settings class bundle
+ * so all app specific {@link Setting} instances are loaded before this method returns.
+ */
+ protected void initialize() {
+ String preferenceResourceName;
+ if (BaseSettings.SHOW_MENU_ICONS.get()) {
+ preferenceResourceName = Utils.appIsUsingBoldIcons()
+ ? "revanced_prefs_icons_bold"
+ : "revanced_prefs_icons";
+ } else {
+ preferenceResourceName = "revanced_prefs";
+ }
+
+ final var identifier = Utils.getResourceIdentifier(ResourceType.XML, preferenceResourceName);
+ if (identifier == 0) return;
+ addPreferencesFromResource(identifier);
+
+ PreferenceScreen screen = getPreferenceScreen();
+ Utils.sortPreferenceGroups(screen);
+ Utils.setPreferenceTitlesToMultiLineIfNeeded(screen);
+ }
+
+ private void showSettingUserDialogConfirmation(Preference pref, Setting> setting) {
+ Utils.verifyOnMainThread();
+
+ final var context = getContext();
+ if (confirmDialogTitle == null) {
+ confirmDialogTitle = str("revanced_settings_confirm_user_dialog_title");
+ }
+
+ showingUserDialogMessage = true;
+
+ CharSequence message = BulletPointPreference.formatIntoBulletPoints(
+ Objects.requireNonNull(setting.userDialogMessage).toString());
+
+ Pair dialogPair = CustomDialog.create(
+ context,
+ confirmDialogTitle, // Title.
+ message,
+ null, // No EditText.
+ null, // OK button text.
+ () -> {
+ // OK button action. User confirmed, save to the Setting.
+ updatePreference(pref, setting, true, false);
+
+ // Update availability of other preferences that may be changed.
+ updateUIAvailability();
+
+ if (setting.rebootApp) {
+ showRestartDialog(context);
+ }
+ },
+ () -> {
+ // Cancel button action. Restore whatever the setting was before the change.
+ updatePreference(pref, setting, true, true);
+ },
+ null, // No Neutral button.
+ null, // No Neutral button action.
+ true // Dismiss dialog when onNeutralClick.
+ );
+
+ dialogPair.first.setOnDismissListener(d -> showingUserDialogMessage = false);
+ dialogPair.first.setCancelable(false);
+
+ // Show the dialog.
+ dialogPair.first.show();
+ }
+
+ /**
+ * Updates all Preferences values and their availability using the current values in {@link Setting}.
+ */
+ protected void updateUIToSettingValues() {
+ updatePreferenceScreen(getPreferenceScreen(), true, true);
+ }
+
+ /**
+ * Updates Preferences availability only using the status of {@link Setting}.
+ */
+ protected void updateUIAvailability() {
+ updatePreferenceScreen(getPreferenceScreen(), false, false);
+ }
+
+ /**
+ * @return If the preference is currently set to the default value of the Setting.
+ */
+ protected boolean prefIsSetToDefault(Preference pref, Setting> setting) {
+ Object defaultValue = setting.defaultValue;
+ if (pref instanceof SwitchPreference switchPref) {
+ return switchPref.isChecked() == (Boolean) defaultValue;
+ }
+ String defaultValueString = defaultValue.toString();
+ if (pref instanceof EditTextPreference editPreference) {
+ return editPreference.getText().equals(defaultValueString);
+ }
+ if (pref instanceof ListPreference listPref) {
+ return listPref.getValue().equals(defaultValueString);
+ }
+
+ throw new IllegalStateException("Must override method to handle "
+ + "preference type: " + pref.getClass());
+ }
+
+ /**
+ * Syncs all UI Preferences to any {@link Setting} they represent.
+ */
+ private void updatePreferenceScreen(@NonNull PreferenceGroup group,
+ boolean syncSettingValue,
+ boolean applySettingToPreference) {
+ // Alternatively this could iterate through all Settings and check for any matching Preferences,
+ // but there are many more Settings than UI preferences so it's more efficient to only check
+ // the Preferences.
+ for (int i = 0, prefCount = group.getPreferenceCount(); i < prefCount; i++) {
+ Preference pref = group.getPreference(i);
+ if (pref instanceof PreferenceGroup subGroup) {
+ updatePreferenceScreen(subGroup, syncSettingValue, applySettingToPreference);
+ } else if (pref.hasKey()) {
+ String key = pref.getKey();
+ Setting> setting = Setting.getSettingFromPath(key);
+
+ if (setting != null) {
+ updatePreference(pref, setting, syncSettingValue, applySettingToPreference);
+ } else if (BaseSettings.DEBUG.get() && (pref instanceof SwitchPreference
+ || pref instanceof EditTextPreference || pref instanceof ListPreference)) {
+ // Probably a typo in the patches preference declaration.
+ Logger.printException(() -> "Preference key has no setting: " + key);
+ }
+ }
+ }
+ }
+
+ /**
+ * Handles syncing a UI Preference with the {@link Setting} that backs it.
+ * If needed, subclasses can override this to handle additional UI Preference types.
+ *
+ * @param applySettingToPreference If true, then apply {@link Setting} -> Preference.
+ * If false, then apply {@link Setting} <- Preference.
+ */
+ protected void syncSettingWithPreference(@NonNull Preference pref,
+ @NonNull Setting> setting,
+ boolean applySettingToPreference) {
+ if (pref instanceof SwitchPreference switchPref) {
+ BooleanSetting boolSetting = (BooleanSetting) setting;
+ if (applySettingToPreference) {
+ switchPref.setChecked(boolSetting.get());
+ } else {
+ BooleanSetting.privateSetValue(boolSetting, switchPref.isChecked());
+ }
+ } else if (pref instanceof EditTextPreference editPreference) {
+ if (applySettingToPreference) {
+ editPreference.setText(setting.get().toString());
+ } else {
+ Setting.privateSetValueFromString(setting, editPreference.getText());
+ }
+ } else if (pref instanceof ListPreference listPref) {
+ if (applySettingToPreference) {
+ listPref.setValue(setting.get().toString());
+ } else {
+ Setting.privateSetValueFromString(setting, listPref.getValue());
+ }
+ updateListPreferenceSummary(listPref, setting);
+ } else if (!pref.getClass().equals(Preference.class)) {
+ // Ignore root preference class because there is no data to sync.
+ Logger.printException(() -> "Setting cannot be handled: " + pref.getClass() + ": " + pref);
+ }
+ }
+
+ /**
+ * Updates a UI Preference with the {@link Setting} that backs it.
+ *
+ * @param syncSetting If the UI should be synced {@link Setting} <-> Preference
+ * @param applySettingToPreference If true, then apply {@link Setting} -> Preference.
+ * If false, then apply {@link Setting} <- Preference.
+ */
+ private void updatePreference(@NonNull Preference pref, @NonNull Setting> setting,
+ boolean syncSetting, boolean applySettingToPreference) {
+ if (!syncSetting && applySettingToPreference) {
+ throw new IllegalArgumentException();
+ }
+
+ if (syncSetting) {
+ syncSettingWithPreference(pref, setting, applySettingToPreference);
+ }
+
+ updatePreferenceAvailability(pref, setting);
+ }
+
+ protected void updatePreferenceAvailability(@NonNull Preference pref, @NonNull Setting> setting) {
+ pref.setEnabled(setting.isAvailable());
+ }
+
+ protected void updateListPreferenceSummary(ListPreference listPreference, Setting> setting) {
+ String objectStringValue = setting.get().toString();
+ final int entryIndex = listPreference.findIndexOfValue(objectStringValue);
+ if (entryIndex >= 0) {
+ listPreference.setSummary(listPreference.getEntries()[entryIndex]);
+ } else {
+ // Value is not an available option.
+ // User manually edited import data, or options changed and current selection is no longer available.
+ // Still show the value in the summary, so it's clear that something is selected.
+ listPreference.setSummary(objectStringValue);
+ }
+ }
+
+ public static void showRestartDialog(Context context) {
+ Utils.verifyOnMainThread();
+ if (restartDialogTitle == null) {
+ restartDialogTitle = str("revanced_settings_restart_title");
+ }
+ if (restartDialogMessage == null) {
+ restartDialogMessage = str("revanced_settings_restart_dialog_message");
+ }
+ if (restartDialogButtonText == null) {
+ restartDialogButtonText = str("revanced_settings_restart");
+ }
+
+ Pair dialogPair = CustomDialog.create(
+ context,
+ restartDialogTitle, // Title.
+ restartDialogMessage, // Message.
+ null, // No EditText.
+ restartDialogButtonText, // OK button text.
+ () -> Utils.restartApp(context), // OK button action.
+ () -> {}, // Cancel button action (dismiss only).
+ null, // No Neutral button text.
+ null, // No Neutral button action.
+ true // Dismiss dialog when onNeutralClick.
+ );
+
+ // Show the dialog.
+ dialogPair.first.show();
+ }
+
+ @SuppressLint("ResourceType")
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ try {
+ PreferenceManager preferenceManager = getPreferenceManager();
+ preferenceManager.setSharedPreferencesName(Setting.preferences.name);
+
+ // Must initialize before adding change listener,
+ // otherwise the syncing of Setting -> UI
+ // causes a callback to the listener even though nothing changed.
+ initialize();
+ updateUIToSettingValues();
+
+ preferenceManager.getSharedPreferences().registerOnSharedPreferenceChangeListener(listener);
+ } catch (Exception ex) {
+ Logger.printException(() -> "onCreate() failure", ex);
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ getPreferenceManager().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(listener);
+ super.onDestroy();
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/BulletPointPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/BulletPointPreference.java
new file mode 100644
index 0000000000..ee3f02fc8c
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/BulletPointPreference.java
@@ -0,0 +1,86 @@
+package app.revanced.extension.shared.settings.preference;
+
+import android.content.Context;
+import android.preference.Preference;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.SpannedString;
+import android.text.TextUtils;
+import android.text.style.BulletSpan;
+import android.util.AttributeSet;
+
+/**
+ * Formats the summary text bullet points into Spanned text for better presentation.
+ */
+@SuppressWarnings({"unused", "deprecation"})
+public class BulletPointPreference extends Preference {
+
+ /**
+ * Replaces bullet points with styled spans.
+ */
+ public static CharSequence formatIntoBulletPoints(CharSequence source) {
+ final char bulletPoint = '•';
+ if (TextUtils.indexOf(source, bulletPoint) < 0) {
+ return source; // Nothing to do.
+ }
+
+ SpannableStringBuilder builder = new SpannableStringBuilder(source);
+
+ int lineStart = 0;
+ int length = builder.length();
+
+ while (lineStart < length) {
+ int lineEnd = TextUtils.indexOf(builder, '\n', lineStart);
+ if (lineEnd < 0) lineEnd = length;
+
+ // Apply BulletSpan only if the line starts with the '•' character.
+ if (lineEnd > lineStart && builder.charAt(lineStart) == bulletPoint) {
+ int deleteEnd = lineStart + 1; // remove the bullet itself
+
+ // If there's a single space right after the bullet, remove that too.
+ if (deleteEnd < builder.length() && builder.charAt(deleteEnd) == ' ') {
+ deleteEnd++;
+ }
+
+ builder.delete(lineStart, deleteEnd);
+
+ // Apply the BulletSpan to the remainder of that line.
+ builder.setSpan(new BulletSpan(20),
+ lineStart,
+ lineEnd - (deleteEnd - lineStart), // adjust for deleted chars.
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ );
+
+ // Update total length and lineEnd after deletion.
+ length = builder.length();
+ final int removed = deleteEnd - lineStart;
+ lineEnd -= removed;
+ }
+
+ lineStart = lineEnd + 1;
+ }
+
+ return new SpannedString(builder);
+ }
+
+ public BulletPointPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public BulletPointPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public BulletPointPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public BulletPointPreference(Context context) {
+ super(context);
+ }
+
+ @Override
+ public void setSummary(CharSequence summary) {
+ super.setSummary(formatIntoBulletPoints(summary));
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/BulletPointSwitchPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/BulletPointSwitchPreference.java
new file mode 100644
index 0000000000..ccbbf1eef9
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/BulletPointSwitchPreference.java
@@ -0,0 +1,45 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.settings.preference.BulletPointPreference.formatIntoBulletPoints;
+
+import android.content.Context;
+import android.preference.SwitchPreference;
+import android.util.AttributeSet;
+
+/**
+ * Formats the summary text bullet points into Spanned text for better presentation.
+ */
+@SuppressWarnings({"unused", "deprecation"})
+public class BulletPointSwitchPreference extends SwitchPreference {
+
+ public BulletPointSwitchPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public BulletPointSwitchPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public BulletPointSwitchPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public BulletPointSwitchPreference(Context context) {
+ super(context);
+ }
+
+ @Override
+ public void setSummary(CharSequence summary) {
+ super.setSummary(formatIntoBulletPoints(summary));
+ }
+
+ @Override
+ public void setSummaryOn(CharSequence summaryOn) {
+ super.setSummaryOn(formatIntoBulletPoints(summaryOn));
+ }
+
+ @Override
+ public void setSummaryOff(CharSequence summaryOff) {
+ super.setSummaryOff(formatIntoBulletPoints(summaryOff));
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ClearLogBufferPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ClearLogBufferPreference.java
new file mode 100644
index 0000000000..7dbf0dd387
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ClearLogBufferPreference.java
@@ -0,0 +1,33 @@
+package app.revanced.extension.shared.settings.preference;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.preference.Preference;
+
+/**
+ * A custom preference that clears the ReVanced debug log buffer when clicked.
+ * Invokes the {@link LogBufferManager#clearLogBuffer} method.
+ */
+@SuppressWarnings("unused")
+public class ClearLogBufferPreference extends Preference {
+
+ {
+ setOnPreferenceClickListener(pref -> {
+ LogBufferManager.clearLogBuffer();
+ return true;
+ });
+ }
+
+ public ClearLogBufferPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+ public ClearLogBufferPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+ public ClearLogBufferPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public ClearLogBufferPreference(Context context) {
+ super(context);
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerPreference.java
new file mode 100644
index 0000000000..c9fc7b6da9
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerPreference.java
@@ -0,0 +1,476 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.Utils.getResourceIdentifierOrThrow;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.os.Build;
+import android.os.Bundle;
+import android.preference.EditTextPreference;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.TextWatcher;
+import android.util.AttributeSet;
+import android.util.Pair;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.ScrollView;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.Nullable;
+
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.ResourceType;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.settings.StringSetting;
+import app.revanced.extension.shared.ui.ColorDot;
+import app.revanced.extension.shared.ui.CustomDialog;
+import app.revanced.extension.shared.ui.Dim;
+
+/**
+ * A custom preference for selecting a color via a hexadecimal code or a color picker dialog.
+ * Extends {@link EditTextPreference} to display a colored dot in the widget area,
+ * reflecting the currently selected color. The dot is dimmed when the preference is disabled.
+ */
+@SuppressWarnings({"unused", "deprecation"})
+public class ColorPickerPreference extends EditTextPreference {
+ /** Length of a valid color string of format #RRGGBB (without alpha) or #AARRGGBB (with alpha). */
+ public static final int COLOR_STRING_LENGTH_WITHOUT_ALPHA = 7;
+ public static final int COLOR_STRING_LENGTH_WITH_ALPHA = 9;
+
+ /** Matches everything that is not a hex number/letter. */
+ private static final Pattern PATTERN_NOT_HEX = Pattern.compile("[^0-9A-Fa-f]");
+
+ /** Alpha for dimming when the preference is disabled. */
+ public static final float DISABLED_ALPHA = 0.5f; // 50%
+
+ /** View displaying a colored dot in the widget area. */
+ private View widgetColorDot;
+
+ /** Dialog View displaying a colored dot for the selected color preview in the dialog. */
+ private View dialogColorDot;
+
+ /** Current color, including alpha channel if opacity slider is enabled. */
+ @ColorInt
+ private int currentColor;
+
+ /** Associated setting for storing the color value. */
+ private StringSetting colorSetting;
+
+ /** Dialog TextWatcher for the EditText to monitor color input changes. */
+ private TextWatcher colorTextWatcher;
+
+ /** Dialog color picker view. */
+ protected ColorPickerView dialogColorPickerView;
+
+ /** Listener for color changes. */
+ protected OnColorChangeListener colorChangeListener;
+
+ /** Whether the opacity slider is enabled. */
+ private boolean opacitySliderEnabled = false;
+
+ public static final int ID_REVANCED_COLOR_PICKER_VIEW =
+ getResourceIdentifierOrThrow(ResourceType.ID, "revanced_color_picker_view");
+ public static final int ID_PREFERENCE_COLOR_DOT =
+ getResourceIdentifierOrThrow(ResourceType.ID, "preference_color_dot");
+ public static final int LAYOUT_REVANCED_COLOR_DOT_WIDGET =
+ getResourceIdentifierOrThrow(ResourceType.LAYOUT, "revanced_color_dot_widget");
+ public static final int LAYOUT_REVANCED_COLOR_PICKER =
+ getResourceIdentifierOrThrow(ResourceType.LAYOUT, "revanced_color_picker");
+
+ /**
+ * Removes non valid hex characters, converts to all uppercase,
+ * and adds # character to the start if not present.
+ */
+ public static String cleanupColorCodeString(String colorString, boolean includeAlpha) {
+ String result = "#" + PATTERN_NOT_HEX.matcher(colorString)
+ .replaceAll("").toUpperCase(Locale.ROOT);
+
+ int maxLength = includeAlpha ? COLOR_STRING_LENGTH_WITH_ALPHA : COLOR_STRING_LENGTH_WITHOUT_ALPHA;
+ if (result.length() < maxLength) {
+ return result;
+ }
+
+ return result.substring(0, maxLength);
+ }
+
+ /**
+ * @param color Color, with or without alpha channel.
+ * @param includeAlpha Whether to include the alpha channel in the output string.
+ * @return #RRGGBB or #AARRGGBB hex color string
+ */
+ public static String getColorString(@ColorInt int color, boolean includeAlpha) {
+ if (includeAlpha) {
+ return String.format("#%08X", color);
+ }
+ color = color & 0x00FFFFFF; // Mask to strip alpha.
+ return String.format("#%06X", color);
+ }
+
+ /**
+ * Interface for notifying color changes.
+ */
+ public interface OnColorChangeListener {
+ void onColorChanged(String key, int newColor);
+ }
+
+ /**
+ * Sets the listener for color changes.
+ */
+ public void setOnColorChangeListener(OnColorChangeListener listener) {
+ this.colorChangeListener = listener;
+ }
+
+ /**
+ * Enables or disables the opacity slider in the color picker dialog.
+ */
+ public void setOpacitySliderEnabled(boolean enabled) {
+ this.opacitySliderEnabled = enabled;
+ }
+
+ public ColorPickerPreference(Context context) {
+ super(context);
+ init();
+ }
+
+ public ColorPickerPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ /**
+ * Initializes the preference by setting up the EditText, loading the color, and set the widget layout.
+ */
+ private void init() {
+ if (getKey() != null) {
+ colorSetting = (StringSetting) Setting.getSettingFromPath(getKey());
+ if (colorSetting == null) {
+ Logger.printException(() -> "Could not find color setting for: " + getKey());
+ }
+ } else {
+ Logger.printDebug(() -> "initialized without key, settings will be loaded later");
+ }
+
+ EditText editText = getEditText();
+ editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
+ | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ editText.setAutofillHints((String) null);
+ }
+
+ // Set the widget layout to a custom layout containing the colored dot.
+ setWidgetLayoutResource(LAYOUT_REVANCED_COLOR_DOT_WIDGET);
+ }
+
+ /**
+ * Sets the selected color and updates the UI and settings.
+ */
+ @Override
+ public void setText(String colorString) {
+ try {
+ Logger.printDebug(() -> "setText: " + colorString);
+ super.setText(colorString);
+
+ currentColor = Color.parseColor(colorString);
+ if (colorSetting != null) {
+ colorSetting.save(getColorString(currentColor, opacitySliderEnabled));
+ }
+ updateDialogColorDot();
+ updateWidgetColorDot();
+
+ // Notify the listener about the color change.
+ if (colorChangeListener != null) {
+ colorChangeListener.onColorChanged(getKey(), currentColor);
+ }
+ } catch (IllegalArgumentException ex) {
+ // This code is reached if the user pastes settings json with an invalid color
+ // since this preference is updated with the new setting text.
+ Logger.printDebug(() -> "Parse color error: " + colorString, ex);
+ Utils.showToastShort(str("revanced_settings_color_invalid"));
+ setText(colorSetting.resetToDefault());
+ } catch (Exception ex) {
+ Logger.printException(() -> "setText failure: " + colorString, ex);
+ }
+ }
+
+ /**
+ * Creates a TextWatcher to monitor changes in the EditText for color input.
+ */
+ private TextWatcher createColorTextWatcher(ColorPickerView colorPickerView) {
+ return new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+
+ @Override
+ public void afterTextChanged(Editable edit) {
+ try {
+ String colorString = edit.toString();
+ String sanitizedColorString = cleanupColorCodeString(colorString, opacitySliderEnabled);
+ if (!sanitizedColorString.equals(colorString)) {
+ edit.replace(0, colorString.length(), sanitizedColorString);
+ return;
+ }
+
+ int expectedLength = opacitySliderEnabled
+ ? COLOR_STRING_LENGTH_WITH_ALPHA
+ : COLOR_STRING_LENGTH_WITHOUT_ALPHA;
+ if (sanitizedColorString.length() != expectedLength) {
+ return;
+ }
+
+ final int newColor = Color.parseColor(colorString);
+ if (currentColor != newColor) {
+ Logger.printDebug(() -> "afterTextChanged: " + sanitizedColorString);
+ currentColor = newColor;
+ updateDialogColorDot();
+ updateWidgetColorDot();
+ colorPickerView.setColor(newColor);
+ }
+ } catch (Exception ex) {
+ // Should never be reached since input is validated before using.
+ Logger.printException(() -> "afterTextChanged failure", ex);
+ }
+ }
+ };
+ }
+
+ /**
+ * Hook for subclasses to add a custom view to the top of the dialog.
+ */
+ @Nullable
+ protected View createExtraDialogContentView(Context context) {
+ return null; // Default implementation returns no extra view.
+ }
+
+ /**
+ * Hook for subclasses to handle the OK button click.
+ */
+ protected void onDialogOkClicked() {
+ // Default implementation does nothing.
+ }
+
+ /**
+ * Hook for subclasses to handle the Neutral button click.
+ */
+ protected void onDialogNeutralClicked() {
+ // Default implementation.
+ try {
+ final int defaultColor = Color.parseColor(colorSetting.defaultValue);
+ dialogColorPickerView.setColor(defaultColor);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Reset button failure", ex);
+ }
+ }
+
+ @Override
+ protected void showDialog(Bundle state) {
+ Context context = getContext();
+
+ // Create content container for all dialog views.
+ LinearLayout contentContainer = new LinearLayout(context);
+ contentContainer.setOrientation(LinearLayout.VERTICAL);
+
+ // Add extra view from subclass if it exists.
+ View extraView = createExtraDialogContentView(context);
+ if (extraView != null) {
+ contentContainer.addView(extraView);
+ }
+
+ // Inflate color picker view.
+ View colorPicker = LayoutInflater.from(context).inflate(LAYOUT_REVANCED_COLOR_PICKER, null);
+ dialogColorPickerView = colorPicker.findViewById(ID_REVANCED_COLOR_PICKER_VIEW);
+ dialogColorPickerView.setOpacitySliderEnabled(opacitySliderEnabled);
+ dialogColorPickerView.setColor(currentColor);
+ contentContainer.addView(colorPicker);
+
+ // Horizontal layout for preview and EditText.
+ LinearLayout inputLayout = new LinearLayout(context);
+ inputLayout.setOrientation(LinearLayout.HORIZONTAL);
+ inputLayout.setGravity(Gravity.CENTER_VERTICAL);
+
+ dialogColorDot = new View(context);
+ LinearLayout.LayoutParams previewParams = new LinearLayout.LayoutParams(Dim.dp20,Dim.dp20);
+ previewParams.setMargins(Dim.dp16, 0, Dim.dp10, 0);
+ dialogColorDot.setLayoutParams(previewParams);
+ inputLayout.addView(dialogColorDot);
+ updateDialogColorDot();
+
+ EditText editText = getEditText();
+ ViewParent parent = editText.getParent();
+ if (parent instanceof ViewGroup parentViewGroup) {
+ parentViewGroup.removeView(editText);
+ }
+ editText.setLayoutParams(new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ));
+ String currentColorString = getColorString(currentColor, opacitySliderEnabled);
+ editText.setText(currentColorString);
+ editText.setSelection(currentColorString.length());
+ editText.setTypeface(Typeface.MONOSPACE);
+ colorTextWatcher = createColorTextWatcher(dialogColorPickerView);
+ editText.addTextChangedListener(colorTextWatcher);
+ inputLayout.addView(editText);
+
+ // Add a dummy view to take up remaining horizontal space,
+ // otherwise it will show an oversize underlined text view.
+ View paddingView = new View(context);
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
+ 0,
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ 1f
+ );
+ paddingView.setLayoutParams(params);
+ inputLayout.addView(paddingView);
+
+ contentContainer.addView(inputLayout);
+
+ // Create ScrollView to wrap the content container.
+ ScrollView contentScrollView = new ScrollView(context);
+ contentScrollView.setVerticalScrollBarEnabled(false);
+ contentScrollView.setOverScrollMode(View.OVER_SCROLL_NEVER);
+ LinearLayout.LayoutParams scrollViewParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ 0,
+ 1.0f
+ );
+ contentScrollView.setLayoutParams(scrollViewParams);
+ contentScrollView.addView(contentContainer);
+
+ final int originalColor = currentColor;
+ Pair dialogPair = CustomDialog.create(
+ context,
+ getTitle() != null ? getTitle().toString() : str("revanced_settings_color_picker_title"),
+ null,
+ null,
+ null,
+ () -> { // OK button action.
+ try {
+ String colorString = editText.getText().toString();
+ int expectedLength = opacitySliderEnabled
+ ? COLOR_STRING_LENGTH_WITH_ALPHA
+ : COLOR_STRING_LENGTH_WITHOUT_ALPHA;
+ if (colorString.length() != expectedLength) {
+ Utils.showToastShort(str("revanced_settings_color_invalid"));
+ setText(getColorString(originalColor, opacitySliderEnabled));
+ return;
+ }
+ setText(colorString);
+
+ onDialogOkClicked();
+ } catch (Exception ex) {
+ // Should never happen due to a bad color string,
+ // since the text is validated and fixed while the user types.
+ Logger.printException(() -> "OK button failure", ex);
+ }
+ },
+ () -> { // Cancel button action.
+ try {
+ setText(getColorString(originalColor, opacitySliderEnabled));
+ } catch (Exception ex) {
+ Logger.printException(() -> "Cancel button failure", ex);
+ }
+ },
+ str("revanced_settings_reset_color"), // Neutral button text.
+ this::onDialogNeutralClicked, // Neutral button action.
+ false // Do not dismiss dialog.
+ );
+
+ // Add the ScrollView to the dialog's main layout.
+ LinearLayout dialogMainLayout = dialogPair.second;
+ dialogMainLayout.addView(contentScrollView, dialogMainLayout.getChildCount() - 1);
+
+ // Set up color picker listener with debouncing.
+ // Add listener last to prevent callbacks from set calls above.
+ dialogColorPickerView.setOnColorChangedListener(color -> {
+ // Check if it actually changed, since this callback
+ // can be caused by updates in afterTextChanged().
+ if (currentColor == color) {
+ return;
+ }
+
+ String updatedColorString = getColorString(color, opacitySliderEnabled);
+ Logger.printDebug(() -> "onColorChanged: " + updatedColorString);
+ currentColor = color;
+ editText.setText(updatedColorString);
+ editText.setSelection(updatedColorString.length());
+
+ updateDialogColorDot();
+ updateWidgetColorDot();
+ });
+
+ // Configure and show the dialog.
+ Dialog dialog = dialogPair.first;
+ dialog.setCanceledOnTouchOutside(false);
+ dialog.show();
+ }
+
+ @Override
+ protected void onDialogClosed(boolean positiveResult) {
+ super.onDialogClosed(positiveResult);
+
+ if (colorTextWatcher != null) {
+ getEditText().removeTextChangedListener(colorTextWatcher);
+ colorTextWatcher = null;
+ }
+
+ dialogColorDot = null;
+ dialogColorPickerView = null;
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+ updateWidgetColorDot();
+ }
+
+ @Override
+ protected void onBindView(View view) {
+ super.onBindView(view);
+
+ widgetColorDot = view.findViewById(ID_PREFERENCE_COLOR_DOT);
+ updateWidgetColorDot();
+ }
+
+ private void updateWidgetColorDot() {
+ if (widgetColorDot == null) return;
+
+ ColorDot.applyColorDot(
+ widgetColorDot,
+ currentColor,
+ widgetColorDot.isEnabled()
+ );
+ }
+
+ private void updateDialogColorDot() {
+ if (dialogColorDot == null) return;
+
+ ColorDot.applyColorDot(
+ dialogColorDot,
+ currentColor,
+ true
+ );
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerView.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerView.java
new file mode 100644
index 0000000000..b8c9577112
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerView.java
@@ -0,0 +1,639 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.settings.preference.ColorPickerPreference.getColorString;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.ComposeShader;
+import android.graphics.LinearGradient;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.graphics.RectF;
+import android.graphics.Shader;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+
+import androidx.annotation.ColorInt;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.ui.Dim;
+
+/**
+ * A custom color picker view that allows the user to select a color using a hue slider, a saturation-value selector
+ * and an optional opacity slider.
+ * This implementation is density-independent and responsive across different screen sizes and DPIs.
+ *
+ * This view displays three main components for color selection:
+ *
+ * Hue Bar: A horizontal bar at the bottom that allows the user to select the hue component of the color.
+ * Saturation-Value Selector: A rectangular area above the hue bar that allows the user to select the
+ * saturation and value (brightness) components of the color based on the selected hue.
+ * Opacity Slider: An optional horizontal bar below the hue bar that allows the user to adjust
+ * the opacity (alpha channel) of the color.
+ *
+ *
+ * The view uses {@link LinearGradient} and {@link ComposeShader} to create the color gradients for the hue bar,
+ * opacity slider, and the saturation-value selector. It also uses {@link Paint} to draw the selectors (draggable handles).
+ *
+ * The selected color can be retrieved using {@link #getColor()} and can be set using {@link #setColor(int)}.
+ * An {@link OnColorChangedListener} can be registered to receive notifications when the selected color changes.
+ */
+public class ColorPickerView extends View {
+ /**
+ * Interface definition for a callback to be invoked when the selected color changes.
+ */
+ public interface OnColorChangedListener {
+ /**
+ * Called when the selected color has changed.
+ */
+ void onColorChanged(@ColorInt int color);
+ }
+
+ /** Expanded touch area for the hue and opacity bars to increase the touch-sensitive area. */
+ public static final float TOUCH_EXPANSION = Dim.dp20;
+
+ /** Margin between different areas of the view (saturation-value selector, hue bar, and opacity slider). */
+ private static final float MARGIN_BETWEEN_AREAS = Dim.dp24;
+
+ /** Padding around the view. */
+ private static final float VIEW_PADDING = Dim.dp16;
+
+ /** Height of the hue bar. */
+ private static final float HUE_BAR_HEIGHT = Dim.dp12;
+
+ /** Height of the opacity slider. */
+ private static final float OPACITY_BAR_HEIGHT = Dim.dp12;
+
+ /** Corner radius for the hue bar. */
+ private static final float HUE_CORNER_RADIUS = Dim.dp6;
+
+ /** Corner radius for the opacity slider. */
+ private static final float OPACITY_CORNER_RADIUS = Dim.dp6;
+
+ /** Radius of the selector handles. */
+ private static final float SELECTOR_RADIUS = Dim.dp12;
+
+ /** Stroke width for the selector handle outlines. */
+ private static final float SELECTOR_STROKE_WIDTH = 8;
+
+ /**
+ * Hue and opacity fill radius. Use slightly smaller radius for the selector handle fill,
+ * otherwise the anti-aliasing causes the fill color to bleed past the selector outline.
+ */
+ private static final float SELECTOR_FILL_RADIUS = SELECTOR_RADIUS - SELECTOR_STROKE_WIDTH / 2;
+
+ /** Thin dark outline stroke width for the selector rings. */
+ private static final float SELECTOR_EDGE_STROKE_WIDTH = 1;
+
+ /** Radius for the outer edge of the selector rings, including stroke width. */
+ public static final float SELECTOR_EDGE_RADIUS =
+ SELECTOR_RADIUS + SELECTOR_STROKE_WIDTH / 2 + SELECTOR_EDGE_STROKE_WIDTH / 2;
+
+ /** Selector outline inner color. */
+ @ColorInt
+ private static final int SELECTOR_OUTLINE_COLOR = Color.WHITE;
+
+ /** Dark edge color for the selector rings. */
+ @ColorInt
+ private static final int SELECTOR_EDGE_COLOR = Color.parseColor("#CFCFCF");
+
+ /** Precomputed array of hue colors for the hue bar (0-360 degrees). */
+ private static final int[] HUE_COLORS = new int[361];
+ static {
+ for (int i = 0; i < 361; i++) {
+ HUE_COLORS[i] = Color.HSVToColor(new float[]{i, 1, 1});
+ }
+ }
+
+ /** Paint for the hue bar. */
+ private final Paint huePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ /** Paint for the opacity slider. */
+ private final Paint opacityPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ /** Paint for the saturation-value selector. */
+ private final Paint saturationValuePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ /** Paint for the draggable selector handles. */
+ private final Paint selectorPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ {
+ selectorPaint.setStrokeWidth(SELECTOR_STROKE_WIDTH);
+ }
+
+ /** Bounds of the hue bar. */
+ private final RectF hueRect = new RectF();
+
+ /** Bounds of the opacity slider. */
+ private final RectF opacityRect = new RectF();
+
+ /** Bounds of the saturation-value selector. */
+ private final RectF saturationValueRect = new RectF();
+
+ /** HSV color calculations to avoid allocations during drawing. */
+ private final float[] hsvArray = {1, 1, 1};
+
+ /** Current hue value (0-360). */
+ private float hue = 0f;
+
+ /** Current saturation value (0-1). */
+ private float saturation = 1f;
+
+ /** Current value (brightness) value (0-1). */
+ private float value = 1f;
+
+ /** Current opacity value (0-1). */
+ private float opacity = 1f;
+
+ /** The currently selected color, including alpha channel if opacity slider is enabled. */
+ @ColorInt
+ private int selectedColor;
+
+ /** Listener for color change events. */
+ private OnColorChangedListener colorChangedListener;
+
+ /** Tracks if the hue selector is being dragged. */
+ private boolean isDraggingHue;
+
+ /** Tracks if the saturation-value selector is being dragged. */
+ private boolean isDraggingSaturation;
+
+ /** Tracks if the opacity selector is being dragged. */
+ private boolean isDraggingOpacity;
+
+ /** Flag to enable/disable the opacity slider. */
+ private boolean opacitySliderEnabled = false;
+
+ public ColorPickerView(Context context) {
+ super(context);
+ }
+
+ public ColorPickerView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public ColorPickerView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ /**
+ * Enables or disables the opacity slider.
+ */
+ public void setOpacitySliderEnabled(boolean enabled) {
+ if (opacitySliderEnabled != enabled) {
+ opacitySliderEnabled = enabled;
+ if (!enabled) {
+ opacity = 1f; // Reset to fully opaque when disabled.
+ updateSelectedColor();
+ }
+ updateOpacityShader();
+ requestLayout(); // Trigger re-measure to account for opacity slider.
+ invalidate();
+ }
+ }
+
+ /**
+ * Measures the view, ensuring a consistent aspect ratio and minimum dimensions.
+ */
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ final float DESIRED_ASPECT_RATIO = 0.8f; // height = width * 0.8
+
+ final int minWidth = Dim.dp(250);
+ final int minHeight = (int) (minWidth * DESIRED_ASPECT_RATIO) + (int) (HUE_BAR_HEIGHT + MARGIN_BETWEEN_AREAS)
+ + (opacitySliderEnabled ? (int) (OPACITY_BAR_HEIGHT + MARGIN_BETWEEN_AREAS) : 0);
+
+ int width = resolveSize(minWidth, widthMeasureSpec);
+ int height = resolveSize(minHeight, heightMeasureSpec);
+
+ // Ensure minimum dimensions for usability.
+ width = Math.max(width, minWidth);
+ height = Math.max(height, minHeight);
+
+ // Adjust height to maintain desired aspect ratio if possible.
+ final int desiredHeight = (int) (width * DESIRED_ASPECT_RATIO) + (int) (HUE_BAR_HEIGHT + MARGIN_BETWEEN_AREAS)
+ + (opacitySliderEnabled ? (int) (OPACITY_BAR_HEIGHT + MARGIN_BETWEEN_AREAS) : 0);
+ if (MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY) {
+ height = desiredHeight;
+ }
+
+ setMeasuredDimension(width, height);
+ }
+
+ /**
+ * Updates the view's layout when its size changes, recalculating bounds and shaders.
+ */
+ @Override
+ protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
+ super.onSizeChanged(width, height, oldWidth, oldHeight);
+
+ // Calculate bounds with hue bar and optional opacity bar at the bottom.
+ final float effectiveWidth = width - (2 * VIEW_PADDING);
+ final float effectiveHeight = height - (2 * VIEW_PADDING) - HUE_BAR_HEIGHT - MARGIN_BETWEEN_AREAS
+ - (opacitySliderEnabled ? OPACITY_BAR_HEIGHT + MARGIN_BETWEEN_AREAS : 0);
+
+ // Adjust rectangles to account for padding and density-independent dimensions.
+ saturationValueRect.set(
+ VIEW_PADDING,
+ VIEW_PADDING,
+ VIEW_PADDING + effectiveWidth,
+ VIEW_PADDING + effectiveHeight
+ );
+
+ hueRect.set(
+ VIEW_PADDING,
+ height - VIEW_PADDING - HUE_BAR_HEIGHT - (opacitySliderEnabled ? OPACITY_BAR_HEIGHT + MARGIN_BETWEEN_AREAS : 0),
+ VIEW_PADDING + effectiveWidth,
+ height - VIEW_PADDING - (opacitySliderEnabled ? OPACITY_BAR_HEIGHT + MARGIN_BETWEEN_AREAS : 0)
+ );
+
+ if (opacitySliderEnabled) {
+ opacityRect.set(
+ VIEW_PADDING,
+ height - VIEW_PADDING - OPACITY_BAR_HEIGHT,
+ VIEW_PADDING + effectiveWidth,
+ height - VIEW_PADDING
+ );
+ }
+
+ // Update the shaders.
+ updateHueShader();
+ updateSaturationValueShader();
+ updateOpacityShader();
+ }
+
+ /**
+ * Updates the shader for the hue bar to reflect the color gradient.
+ */
+ private void updateHueShader() {
+ LinearGradient hueShader = new LinearGradient(
+ hueRect.left, hueRect.top,
+ hueRect.right, hueRect.top,
+ HUE_COLORS,
+ null,
+ Shader.TileMode.CLAMP
+ );
+
+ huePaint.setShader(hueShader);
+ }
+
+ /**
+ * Updates the shader for the opacity slider to reflect the current RGB color with varying opacity.
+ */
+ private void updateOpacityShader() {
+ if (!opacitySliderEnabled) {
+ opacityPaint.setShader(null);
+ return;
+ }
+
+ // Create a linear gradient for opacity from transparent to opaque, using the current RGB color.
+ int rgbColor = Color.HSVToColor(0, new float[]{hue, saturation, value});
+ LinearGradient opacityShader = new LinearGradient(
+ opacityRect.left, opacityRect.top,
+ opacityRect.right, opacityRect.top,
+ rgbColor & 0x00FFFFFF, // Fully transparent
+ rgbColor | 0xFF000000, // Fully opaque
+ Shader.TileMode.CLAMP
+ );
+
+ opacityPaint.setShader(opacityShader);
+ }
+
+ /**
+ * Updates the shader for the saturation-value selector to reflect the current hue.
+ */
+ private void updateSaturationValueShader() {
+ // Create a saturation-value gradient based on the current hue.
+ // Calculate the start color (white with the selected hue) for the saturation gradient.
+ final int startColor = Color.HSVToColor(new float[]{hue, 0f, 1f});
+
+ // Calculate the middle color (fully saturated color with the selected hue) for the saturation gradient.
+ final int midColor = Color.HSVToColor(new float[]{hue, 1f, 1f});
+
+ // Create a linear gradient for the saturation from startColor to midColor (horizontal).
+ LinearGradient satShader = new LinearGradient(
+ saturationValueRect.left, saturationValueRect.top,
+ saturationValueRect.right, saturationValueRect.top,
+ startColor,
+ midColor,
+ Shader.TileMode.CLAMP
+ );
+
+ // Create a linear gradient for the value (brightness) from white to black (vertical).
+ LinearGradient valShader = new LinearGradient(
+ saturationValueRect.left, saturationValueRect.top,
+ saturationValueRect.left, saturationValueRect.bottom,
+ Color.WHITE,
+ Color.BLACK,
+ Shader.TileMode.CLAMP
+ );
+
+ // Combine the saturation and value shaders using PorterDuff.Mode.MULTIPLY to create the final color.
+ ComposeShader combinedShader = new ComposeShader(satShader, valShader, PorterDuff.Mode.MULTIPLY);
+
+ // Set the combined shader for the saturation-value paint.
+ saturationValuePaint.setShader(combinedShader);
+ }
+
+ /**
+ * Draws the color picker components, including the saturation-value selector, hue bar, opacity slider, and their respective handles.
+ */
+ @Override
+ protected void onDraw(Canvas canvas) {
+ // Draw the saturation-value selector rectangle.
+ canvas.drawRect(saturationValueRect, saturationValuePaint);
+
+ // Draw the hue bar.
+ canvas.drawRoundRect(hueRect, HUE_CORNER_RADIUS, HUE_CORNER_RADIUS, huePaint);
+
+ // Draw the opacity bar if enabled.
+ if (opacitySliderEnabled) {
+ canvas.drawRoundRect(opacityRect, OPACITY_CORNER_RADIUS, OPACITY_CORNER_RADIUS, opacityPaint);
+ }
+
+ final float hueSelectorX = hueRect.left + (hue / 360f) * hueRect.width();
+ final float hueSelectorY = hueRect.centerY();
+
+ final float satSelectorX = saturationValueRect.left + saturation * saturationValueRect.width();
+ final float satSelectorY = saturationValueRect.top + (1 - value) * saturationValueRect.height();
+
+ // Draw the saturation and hue selector handles filled with their respective colors (fully opaque).
+ hsvArray[0] = hue;
+ final int hueHandleColor = Color.HSVToColor(0xFF, hsvArray); // Force opaque for hue handle.
+ final int satHandleColor = Color.HSVToColor(0xFF, new float[]{hue, saturation, value}); // Force opaque for sat-val handle.
+ selectorPaint.setStyle(Paint.Style.FILL_AND_STROKE);
+
+ selectorPaint.setColor(hueHandleColor);
+ canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_FILL_RADIUS, selectorPaint);
+
+ selectorPaint.setColor(satHandleColor);
+ canvas.drawCircle(satSelectorX, satSelectorY, SELECTOR_FILL_RADIUS, selectorPaint);
+
+ if (opacitySliderEnabled) {
+ final float opacitySelectorX = opacityRect.left + opacity * opacityRect.width();
+ final float opacitySelectorY = opacityRect.centerY();
+ selectorPaint.setColor(selectedColor); // Use full ARGB color to show opacity.
+ canvas.drawCircle(opacitySelectorX, opacitySelectorY, SELECTOR_FILL_RADIUS, selectorPaint);
+ }
+
+ // Draw white outlines for the handles.
+ selectorPaint.setColor(SELECTOR_OUTLINE_COLOR);
+ selectorPaint.setStyle(Paint.Style.STROKE);
+ selectorPaint.setStrokeWidth(SELECTOR_STROKE_WIDTH);
+ canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_RADIUS, selectorPaint);
+ canvas.drawCircle(satSelectorX, satSelectorY, SELECTOR_RADIUS, selectorPaint);
+ if (opacitySliderEnabled) {
+ final float opacitySelectorX = opacityRect.left + opacity * opacityRect.width();
+ final float opacitySelectorY = opacityRect.centerY();
+ canvas.drawCircle(opacitySelectorX, opacitySelectorY, SELECTOR_RADIUS, selectorPaint);
+ }
+
+ // Draw thin dark outlines for the handles at the outer edge of the white outline.
+ selectorPaint.setColor(SELECTOR_EDGE_COLOR);
+ selectorPaint.setStrokeWidth(SELECTOR_EDGE_STROKE_WIDTH);
+ canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_EDGE_RADIUS, selectorPaint);
+ canvas.drawCircle(satSelectorX, satSelectorY, SELECTOR_EDGE_RADIUS, selectorPaint);
+ if (opacitySliderEnabled) {
+ final float opacitySelectorX = opacityRect.left + opacity * opacityRect.width();
+ final float opacitySelectorY = opacityRect.centerY();
+ canvas.drawCircle(opacitySelectorX, opacitySelectorY, SELECTOR_EDGE_RADIUS, selectorPaint);
+ }
+ }
+
+ /**
+ * Handles touch events to allow dragging of the hue, saturation-value, and opacity selectors.
+ *
+ * @param event The motion event.
+ * @return True if the event was handled, false otherwise.
+ */
+ @SuppressLint("ClickableViewAccessibility")
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ try {
+ final float x = event.getX();
+ final float y = event.getY();
+ final int action = event.getAction();
+ Logger.printDebug(() -> "onTouchEvent action: " + action + " x: " + x + " y: " + y);
+
+ // Define touch expansion for the hue and opacity bars.
+ RectF expandedHueRect = new RectF(
+ hueRect.left,
+ hueRect.top - TOUCH_EXPANSION,
+ hueRect.right,
+ hueRect.bottom + TOUCH_EXPANSION
+ );
+ RectF expandedOpacityRect = opacitySliderEnabled ? new RectF(
+ opacityRect.left,
+ opacityRect.top - TOUCH_EXPANSION,
+ opacityRect.right,
+ opacityRect.bottom + TOUCH_EXPANSION
+ ) : new RectF();
+
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ // Calculate current handle positions.
+ final float hueSelectorX = hueRect.left + (hue / 360f) * hueRect.width();
+ final float hueSelectorY = hueRect.centerY();
+
+ final float satSelectorX = saturationValueRect.left + saturation * saturationValueRect.width();
+ final float valSelectorY = saturationValueRect.top + (1 - value) * saturationValueRect.height();
+
+ final float opacitySelectorX = opacitySliderEnabled ? opacityRect.left + opacity * opacityRect.width() : 0;
+ final float opacitySelectorY = opacitySliderEnabled ? opacityRect.centerY() : 0;
+
+ // Create hit areas for all handles.
+ RectF hueHitRect = new RectF(
+ hueSelectorX - SELECTOR_RADIUS,
+ hueSelectorY - SELECTOR_RADIUS,
+ hueSelectorX + SELECTOR_RADIUS,
+ hueSelectorY + SELECTOR_RADIUS
+ );
+ RectF satValHitRect = new RectF(
+ satSelectorX - SELECTOR_RADIUS,
+ valSelectorY - SELECTOR_RADIUS,
+ satSelectorX + SELECTOR_RADIUS,
+ valSelectorY + SELECTOR_RADIUS
+ );
+ RectF opacityHitRect = opacitySliderEnabled ? new RectF(
+ opacitySelectorX - SELECTOR_RADIUS,
+ opacitySelectorY - SELECTOR_RADIUS,
+ opacitySelectorX + SELECTOR_RADIUS,
+ opacitySelectorY + SELECTOR_RADIUS
+ ) : new RectF();
+
+ // Check if the touch started on a handle or within the expanded bar areas.
+ if (hueHitRect.contains(x, y)) {
+ isDraggingHue = true;
+ updateHueFromTouch(x);
+ } else if (satValHitRect.contains(x, y)) {
+ isDraggingSaturation = true;
+ updateSaturationValueFromTouch(x, y);
+ } else if (opacitySliderEnabled && opacityHitRect.contains(x, y)) {
+ isDraggingOpacity = true;
+ updateOpacityFromTouch(x);
+ } else if (expandedHueRect.contains(x, y)) {
+ // Handle touch within the expanded hue bar area.
+ isDraggingHue = true;
+ updateHueFromTouch(x);
+ } else if (saturationValueRect.contains(x, y)) {
+ isDraggingSaturation = true;
+ updateSaturationValueFromTouch(x, y);
+ } else if (opacitySliderEnabled && expandedOpacityRect.contains(x, y)) {
+ isDraggingOpacity = true;
+ updateOpacityFromTouch(x);
+ }
+ break;
+
+ case MotionEvent.ACTION_MOVE:
+ // Continue updating values even if touch moves outside the view.
+ if (isDraggingHue) {
+ updateHueFromTouch(x);
+ } else if (isDraggingSaturation) {
+ updateSaturationValueFromTouch(x, y);
+ } else if (isDraggingOpacity) {
+ updateOpacityFromTouch(x);
+ }
+ break;
+
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ isDraggingHue = false;
+ isDraggingSaturation = false;
+ isDraggingOpacity = false;
+ break;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onTouchEvent failure", ex);
+ }
+
+ return true;
+ }
+
+ /**
+ * Updates the hue value based on a touch event.
+ */
+ private void updateHueFromTouch(float x) {
+ // Clamp x to the hue rectangle bounds.
+ final float clampedX = Utils.clamp(x, hueRect.left, hueRect.right);
+ final float updatedHue = ((clampedX - hueRect.left) / hueRect.width()) * 360f;
+ if (hue == updatedHue) {
+ return;
+ }
+
+ hue = updatedHue;
+ updateSaturationValueShader();
+ updateOpacityShader();
+ updateSelectedColor();
+ }
+
+ /**
+ * Updates the saturation and value based on a touch event.
+ */
+ private void updateSaturationValueFromTouch(float x, float y) {
+ // Clamp x and y to the saturation-value rectangle bounds.
+ final float clampedX = Utils.clamp(x, saturationValueRect.left, saturationValueRect.right);
+ final float clampedY = Utils.clamp(y, saturationValueRect.top, saturationValueRect.bottom);
+
+ final float updatedSaturation = (clampedX - saturationValueRect.left) / saturationValueRect.width();
+ final float updatedValue = 1 - ((clampedY - saturationValueRect.top) / saturationValueRect.height());
+
+ if (saturation == updatedSaturation && value == updatedValue) {
+ return;
+ }
+ saturation = updatedSaturation;
+ value = updatedValue;
+ updateOpacityShader();
+ updateSelectedColor();
+ }
+
+ /**
+ * Updates the opacity value based on a touch event.
+ */
+ private void updateOpacityFromTouch(float x) {
+ if (!opacitySliderEnabled) {
+ return;
+ }
+ final float clampedX = Utils.clamp(x, opacityRect.left, opacityRect.right);
+ final float updatedOpacity = (clampedX - opacityRect.left) / opacityRect.width();
+ if (opacity == updatedOpacity) {
+ return;
+ }
+ opacity = updatedOpacity;
+ updateSelectedColor();
+ }
+
+ /**
+ * Updates the selected color based on the current hue, saturation, value, and opacity.
+ */
+ private void updateSelectedColor() {
+ final int rgbColor = Color.HSVToColor(0, new float[]{hue, saturation, value});
+ final int updatedColor = opacitySliderEnabled
+ ? (rgbColor & 0x00FFFFFF) | (((int) (opacity * 255)) << 24)
+ : (rgbColor & 0x00FFFFFF) | 0xFF000000;
+
+ if (selectedColor != updatedColor) {
+ selectedColor = updatedColor;
+
+ if (colorChangedListener != null) {
+ colorChangedListener.onColorChanged(updatedColor);
+ }
+ }
+
+ // Must always redraw, otherwise if saturation is pure grey or black
+ // then the hue slider cannot be changed.
+ invalidate();
+ }
+
+ /**
+ * Sets the selected color, updating the hue, saturation, value and opacity sliders accordingly.
+ */
+ public void setColor(@ColorInt int color) {
+ if (selectedColor == color) {
+ return;
+ }
+
+ // Update the selected color.
+ selectedColor = color;
+ Logger.printDebug(() -> "setColor: " + getColorString(selectedColor, opacitySliderEnabled));
+
+ // Convert the ARGB color to HSV values.
+ float[] hsv = new float[3];
+ Color.colorToHSV(color, hsv);
+
+ // Update the hue, saturation, and value.
+ hue = hsv[0];
+ saturation = hsv[1];
+ value = hsv[2];
+ opacity = opacitySliderEnabled ? ((color >> 24) & 0xFF) / 255f : 1f;
+
+ // Update the saturation-value shader based on the new hue.
+ updateSaturationValueShader();
+ updateOpacityShader();
+
+ // Notify the listener if it's set.
+ if (colorChangedListener != null) {
+ colorChangedListener.onColorChanged(selectedColor);
+ }
+
+ // Invalidate the view to trigger a redraw.
+ invalidate();
+ }
+
+ /**
+ * Gets the currently selected color.
+ */
+ @ColorInt
+ public int getColor() {
+ return selectedColor;
+ }
+
+ /**
+ * Sets a listener to be notified when the selected color changes.
+ */
+ public void setOnColorChangedListener(OnColorChangedListener listener) {
+ colorChangedListener = listener;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerWithOpacitySliderPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerWithOpacitySliderPreference.java
new file mode 100644
index 0000000000..5e24f7bf36
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerWithOpacitySliderPreference.java
@@ -0,0 +1,34 @@
+package app.revanced.extension.shared.settings.preference;
+
+import android.content.Context;
+import android.util.AttributeSet;
+
+/**
+ * Extended ColorPickerPreference that enables the opacity slider for color selection.
+ */
+@SuppressWarnings("unused")
+public class ColorPickerWithOpacitySliderPreference extends ColorPickerPreference {
+
+ public ColorPickerWithOpacitySliderPreference(Context context) {
+ super(context);
+ init();
+ }
+
+ public ColorPickerWithOpacitySliderPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public ColorPickerWithOpacitySliderPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ /**
+ * Initialize the preference with opacity slider enabled.
+ */
+ private void init() {
+ // Enable the opacity slider for alpha channel support.
+ setOpacitySliderEnabled(true);
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/CustomDialogListPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/CustomDialogListPreference.java
new file mode 100644
index 0000000000..48c50c1f33
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/CustomDialogListPreference.java
@@ -0,0 +1,267 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.Utils.getResourceIdentifierOrThrow;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.preference.ListPreference;
+import android.util.AttributeSet;
+import android.util.Pair;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.ResourceType;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.ui.CustomDialog;
+
+/**
+ * A custom ListPreference that uses a styled custom dialog with a custom checkmark indicator,
+ * supports a static summary and highlighted entries for search functionality.
+ */
+@SuppressWarnings({"unused", "deprecation"})
+public class CustomDialogListPreference extends ListPreference {
+
+ public static final int ID_REVANCED_CHECK_ICON = getResourceIdentifierOrThrow(
+ ResourceType.ID, "revanced_check_icon");
+ public static final int ID_REVANCED_CHECK_ICON_PLACEHOLDER = getResourceIdentifierOrThrow(
+ ResourceType.ID, "revanced_check_icon_placeholder");
+ public static final int ID_REVANCED_ITEM_TEXT = getResourceIdentifierOrThrow(
+ ResourceType.ID, "revanced_item_text");
+ public static final int LAYOUT_REVANCED_CUSTOM_LIST_ITEM_CHECKED = getResourceIdentifierOrThrow(
+ ResourceType.LAYOUT, "revanced_custom_list_item_checked");
+ public static final int DRAWABLE_CHECKMARK = getResourceIdentifierOrThrow(
+ ResourceType.DRAWABLE, "revanced_settings_custom_checkmark");
+ public static final int DRAWABLE_CHECKMARK_BOLD = getResourceIdentifierOrThrow(
+ ResourceType.DRAWABLE, "revanced_settings_custom_checkmark_bold");
+
+ private String staticSummary = null;
+ private CharSequence[] highlightedEntriesForDialog = null;
+
+ /**
+ * Set a static summary that will not be overwritten by value changes.
+ */
+ public void setStaticSummary(String summary) {
+ this.staticSummary = summary;
+ }
+
+ /**
+ * Returns the static summary if set, otherwise null.
+ */
+ @Nullable
+ public String getStaticSummary() {
+ return staticSummary;
+ }
+
+ /**
+ * Always return static summary if set.
+ */
+ @Override
+ public CharSequence getSummary() {
+ if (staticSummary != null) {
+ return staticSummary;
+ }
+ return super.getSummary();
+ }
+
+ /**
+ * Sets highlighted entries for display in the dialog.
+ * These entries are used only for the current dialog and are automatically cleared.
+ */
+ public void setHighlightedEntriesForDialog(CharSequence[] highlightedEntries) {
+ this.highlightedEntriesForDialog = highlightedEntries;
+ }
+
+ /**
+ * Clears highlighted entries after the dialog is closed.
+ */
+ public void clearHighlightedEntriesForDialog() {
+ this.highlightedEntriesForDialog = null;
+ }
+
+ /**
+ * Returns entries for display in the dialog.
+ * If highlighted entries exist, they are used; otherwise, the original entries are returned.
+ */
+ private CharSequence[] getEntriesForDialog() {
+ return highlightedEntriesForDialog != null ? highlightedEntriesForDialog : getEntries();
+ }
+
+ /**
+ * Custom ArrayAdapter to handle checkmark visibility.
+ */
+ public static class ListPreferenceArrayAdapter extends ArrayAdapter {
+ private static class SubViewDataContainer {
+ ImageView checkIcon;
+ View placeholder;
+ TextView itemText;
+ }
+
+ final int layoutResourceId;
+ final CharSequence[] entryValues;
+ String selectedValue;
+
+ public ListPreferenceArrayAdapter(Context context, int resource,
+ CharSequence[] entries,
+ CharSequence[] entryValues,
+ String selectedValue) {
+ super(context, resource, entries);
+ this.layoutResourceId = resource;
+ this.entryValues = entryValues;
+ this.selectedValue = selectedValue;
+ }
+
+ @NonNull
+ @Override
+ public View getView(int position, View convertView, @NonNull ViewGroup parent) {
+ View view = convertView;
+ SubViewDataContainer holder;
+
+ if (view == null) {
+ LayoutInflater inflater = LayoutInflater.from(getContext());
+ view = inflater.inflate(layoutResourceId, parent, false);
+ holder = new SubViewDataContainer();
+ holder.placeholder = view.findViewById(ID_REVANCED_CHECK_ICON_PLACEHOLDER);
+ holder.itemText = view.findViewById(ID_REVANCED_ITEM_TEXT);
+ holder.checkIcon = view.findViewById(ID_REVANCED_CHECK_ICON);
+ holder.checkIcon.setImageResource(Utils.appIsUsingBoldIcons()
+ ? DRAWABLE_CHECKMARK_BOLD
+ : DRAWABLE_CHECKMARK
+ );
+ view.setTag(holder);
+ } else {
+ holder = (SubViewDataContainer) view.getTag();
+ }
+
+ CharSequence itemText = getItem(position);
+ holder.itemText.setText(itemText);
+ holder.itemText.setTextColor(Utils.getAppForegroundColor());
+
+ // Show or hide checkmark and placeholder.
+ String currentValue = entryValues[position].toString();
+ boolean isSelected = currentValue.equals(selectedValue);
+ holder.checkIcon.setVisibility(isSelected ? View.VISIBLE : View.GONE);
+ holder.checkIcon.setColorFilter(Utils.getAppForegroundColor());
+ holder.placeholder.setVisibility(isSelected ? View.GONE : View.VISIBLE);
+
+ return view;
+ }
+
+ public void setSelectedValue(String value) {
+ this.selectedValue = value;
+ }
+ }
+
+ public CustomDialogListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public CustomDialogListPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public CustomDialogListPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public CustomDialogListPreference(Context context) {
+ super(context);
+ }
+
+ @Override
+ protected void showDialog(Bundle state) {
+ Context context = getContext();
+
+ CharSequence[] entriesToShow = getEntriesForDialog();
+ CharSequence[] entryValues = getEntryValues();
+
+ // Create ListView.
+ ListView listView = new ListView(context);
+ listView.setId(android.R.id.list);
+ listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
+
+ // Create custom adapter for the ListView.
+ ListPreferenceArrayAdapter adapter = new ListPreferenceArrayAdapter(
+ context,
+ LAYOUT_REVANCED_CUSTOM_LIST_ITEM_CHECKED,
+ entriesToShow,
+ entryValues,
+ getValue()
+ );
+ listView.setAdapter(adapter);
+
+ // Set checked item.
+ String currentValue = getValue();
+ if (currentValue != null) {
+ for (int i = 0, length = entryValues.length; i < length; i++) {
+ if (currentValue.equals(entryValues[i].toString())) {
+ listView.setItemChecked(i, true);
+ listView.setSelection(i);
+ break;
+ }
+ }
+ }
+
+ // Create the custom dialog without OK button.
+ Pair dialogPair = CustomDialog.create(
+ context,
+ getTitle() != null ? getTitle().toString() : "",
+ null,
+ null,
+ null,
+ null,
+ this::clearHighlightedEntriesForDialog, // Cancel button action.
+ null,
+ null,
+ true
+ );
+
+ Dialog dialog = dialogPair.first;
+ // Add a listener to clear when the dialog is closed in any way.
+ dialog.setOnDismissListener(dialogInterface -> clearHighlightedEntriesForDialog());
+
+ // Add the ListView to the main layout.
+ LinearLayout mainLayout = dialogPair.second;
+ LinearLayout.LayoutParams listViewParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ 0,
+ 1.0f
+ );
+ mainLayout.addView(listView, mainLayout.getChildCount() - 1, listViewParams);
+
+ // Handle item click to select value and dismiss dialog.
+ listView.setOnItemClickListener((parent, view, position, id) -> {
+ String selectedValue = entryValues[position].toString();
+ if (callChangeListener(selectedValue)) {
+ setValue(selectedValue);
+
+ // Update summaries from the original entries (without highlighting).
+ if (staticSummary == null) {
+ CharSequence[] originalEntries = getEntries();
+ if (originalEntries != null && position < originalEntries.length) {
+ setSummary(originalEntries[position]);
+ }
+ }
+
+ adapter.setSelectedValue(selectedValue);
+ adapter.notifyDataSetChanged();
+ }
+
+ // Clear highlighted entries before closing.
+ clearHighlightedEntriesForDialog();
+ dialog.dismiss();
+ });
+
+ // Show the dialog.
+ dialog.show();
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ExportLogToClipboardPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ExportLogToClipboardPreference.java
new file mode 100644
index 0000000000..57fb128232
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ExportLogToClipboardPreference.java
@@ -0,0 +1,33 @@
+package app.revanced.extension.shared.settings.preference;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.preference.Preference;
+
+/**
+ * A custom preference that triggers exporting ReVanced debug logs to the clipboard when clicked.
+ * Invokes the {@link LogBufferManager#exportToClipboard} method.
+ */
+@SuppressWarnings({"deprecation", "unused"})
+public class ExportLogToClipboardPreference extends Preference {
+
+ {
+ setOnPreferenceClickListener(pref -> {
+ LogBufferManager.exportToClipboard();
+ return true;
+ });
+ }
+
+ public ExportLogToClipboardPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+ public ExportLogToClipboardPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+ public ExportLogToClipboardPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public ExportLogToClipboardPreference(Context context) {
+ super(context);
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/FeatureFlagsManagerPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/FeatureFlagsManagerPreference.java
new file mode 100644
index 0000000000..4e6d2e5cdd
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/FeatureFlagsManagerPreference.java
@@ -0,0 +1,626 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.Utils.getResourceIdentifierOrThrow;
+
+import android.annotation.SuppressLint;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.ShapeDrawable;
+import android.graphics.drawable.shapes.RoundRectShape;
+import android.preference.Preference;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.util.AttributeSet;
+import android.util.Pair;
+import android.util.SparseBooleanArray;
+import android.view.Gravity;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.widget.ArrayAdapter;
+import android.widget.EditText;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import android.widget.Space;
+import android.widget.TextView;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.ResourceType;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.patches.EnableDebuggingPatch;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.ui.CustomDialog;
+import app.revanced.extension.shared.ui.Dim;
+
+/**
+ * A custom preference that opens a dialog for managing feature flags.
+ * Allows moving boolean flags between active and blocked states with advanced selection.
+ */
+@SuppressWarnings({"deprecation", "unused"})
+public class FeatureFlagsManagerPreference extends Preference {
+
+ private static final int DRAWABLE_REVANCED_SETTINGS_SELECT_ALL =
+ getResourceIdentifierOrThrow(ResourceType.DRAWABLE, "revanced_settings_select_all");
+ private static final int DRAWABLE_REVANCED_SETTINGS_DESELECT_ALL =
+ getResourceIdentifierOrThrow(ResourceType.DRAWABLE, "revanced_settings_deselect_all");
+ private static final int DRAWABLE_REVANCED_SETTINGS_COPY_ALL =
+ getResourceIdentifierOrThrow(ResourceType.DRAWABLE, "revanced_settings_copy_all");
+ private static final int DRAWABLE_REVANCED_SETTINGS_ARROW_RIGHT_ONE =
+ getResourceIdentifierOrThrow(ResourceType.DRAWABLE, "revanced_settings_arrow_right_one");
+ private static final int DRAWABLE_REVANCED_SETTINGS_ARROW_RIGHT_DOUBLE =
+ getResourceIdentifierOrThrow(ResourceType.DRAWABLE, "revanced_settings_arrow_right_double");
+ private static final int DRAWABLE_REVANCED_SETTINGS_ARROW_LEFT_ONE =
+ getResourceIdentifierOrThrow(ResourceType.DRAWABLE, "revanced_settings_arrow_left_one");
+ private static final int DRAWABLE_REVANCED_SETTINGS_ARROW_LEFT_DOUBLE =
+ getResourceIdentifierOrThrow(ResourceType.DRAWABLE, "revanced_settings_arrow_left_double");
+
+ /**
+ * Flags to hide from the UI.
+ */
+ private static final Set FLAGS_TO_IGNORE = Set.of(
+ 45386834L, // 'You' tab settings icon.
+ 45532100L // Cairo flag. Turning this off with all other flags causes the settings menu to be a mix of old/new.
+ );
+
+ /**
+ * Tracks state for range selection in ListView.
+ */
+ private static class ListViewSelectionState {
+ int lastClickedPosition = -1; // Position of the last clicked item.
+ boolean isRangeSelecting = false; // True while a range is being selected.
+ }
+
+ /**
+ * Helper class to pass ListView and Adapter together.
+ */
+ private record ColumnViews(ListView listView, FlagAdapter adapter) {}
+
+ {
+ setOnPreferenceClickListener(pref -> {
+ showFlagsManagerDialog();
+ return true;
+ });
+ }
+
+ public FeatureFlagsManagerPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public FeatureFlagsManagerPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public FeatureFlagsManagerPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public FeatureFlagsManagerPreference(Context context) {
+ super(context);
+ }
+
+ /**
+ * Shows the main dialog for managing feature flags.
+ */
+ private void showFlagsManagerDialog() {
+ if (!BaseSettings.DEBUG.get()) {
+ Utils.showToastShort(str("revanced_debug_logs_disabled"));
+ return;
+ }
+
+ Context context = getContext();
+
+ // Load all known and disabled flags.
+ TreeSet allKnownFlags = new TreeSet<>(EnableDebuggingPatch.getAllLoggedFlags());
+ allKnownFlags.removeAll(FLAGS_TO_IGNORE);
+
+ TreeSet disabledFlags = new TreeSet<>(EnableDebuggingPatch.parseFlags(
+ BaseSettings.DISABLED_FEATURE_FLAGS.get()));
+ disabledFlags.removeAll(FLAGS_TO_IGNORE);
+
+ if (allKnownFlags.isEmpty() && disabledFlags.isEmpty()) {
+ // It's impossible to reach the settings menu without reaching at least one flag.
+ // So if theres no flags, then that means the user has just enabled debugging
+ // but has not restarted the app yet.
+ Utils.showToastShort(str("revanced_debug_feature_flags_manager_toast_no_flags"));
+ return;
+ }
+
+ TreeSet availableFlags = new TreeSet<>(allKnownFlags);
+ availableFlags.removeAll(disabledFlags);
+ TreeSet blockedFlags = new TreeSet<>(disabledFlags);
+
+ Pair dialogPair = CustomDialog.create(
+ context,
+ getTitle() != null ? getTitle().toString() : "",
+ null,
+ null,
+ str("revanced_settings_save"),
+ () -> saveFlags(blockedFlags),
+ () -> {},
+ str("revanced_settings_reset"),
+ this::resetFlags,
+ true
+ );
+
+ LinearLayout mainLayout = dialogPair.second;
+ LinearLayout.LayoutParams contentParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT, 0, 1.0f);
+
+ // Insert content before the dialog button row.
+ View contentView = createContentView(context, availableFlags, blockedFlags);
+ mainLayout.addView(contentView, mainLayout.getChildCount() - 1, contentParams);
+
+ Dialog dialog = dialogPair.first;
+ dialog.show();
+
+ Window window = dialog.getWindow();
+ if (window != null) {
+ Utils.setDialogWindowParameters(window, Gravity.CENTER, 0, 100, false);
+ }
+ }
+
+ /**
+ * Creates the main content view with two columns.
+ */
+ private View createContentView(Context context, TreeSet availableFlags, TreeSet blockedFlags) {
+ LinearLayout contentLayout = new LinearLayout(context);
+ contentLayout.setOrientation(LinearLayout.VERTICAL);
+
+ // Headers.
+ TextView availableHeader = createHeader(context, "revanced_debug_feature_flags_manager_active_header");
+ TextView blockedHeader = createHeader(context, "revanced_debug_feature_flags_manager_blocked_header");
+
+ LinearLayout headersLayout = new LinearLayout(context);
+ headersLayout.setOrientation(LinearLayout.HORIZONTAL);
+ headersLayout.addView(availableHeader, new LinearLayout.LayoutParams(
+ 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
+ headersLayout.addView(blockedHeader, new LinearLayout.LayoutParams(
+ 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
+
+ // Columns.
+ View leftColumn = createColumn(context, availableFlags, availableHeader);
+ View rightColumn = createColumn(context, blockedFlags, blockedHeader);
+
+ ColumnViews leftViews = (ColumnViews) leftColumn.getTag();
+ ColumnViews rightViews = (ColumnViews) rightColumn.getTag();
+
+ updateHeaderCount(availableHeader, leftViews.adapter);
+ updateHeaderCount(blockedHeader, rightViews.adapter);
+
+ // Main columns layout.
+ LinearLayout columnsLayout = new LinearLayout(context);
+ columnsLayout.setOrientation(LinearLayout.HORIZONTAL);
+ columnsLayout.setLayoutParams(new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
+ columnsLayout.addView(leftColumn, new LinearLayout.LayoutParams(
+ 0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
+
+ Space spaceBetweenColumns = new Space(context);
+ spaceBetweenColumns.setLayoutParams(new LinearLayout.LayoutParams(Dim.dp8, ViewGroup.LayoutParams.MATCH_PARENT));
+ columnsLayout.addView(spaceBetweenColumns);
+
+ columnsLayout.addView(rightColumn, new LinearLayout.LayoutParams(
+ 0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
+
+ // Move buttons below columns.
+ Pair moveButtons = createMoveButtons(context,
+ leftViews.listView, rightViews.listView,
+ availableFlags, blockedFlags, availableHeader, blockedHeader);
+
+ // Layout for buttons row.
+ LinearLayout buttonsRow = new LinearLayout(context);
+ buttonsRow.setOrientation(LinearLayout.HORIZONTAL);
+ buttonsRow.setLayoutParams(new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+
+ buttonsRow.addView(moveButtons.first, new LinearLayout.LayoutParams(
+ 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
+
+ Space spaceBetweenButtons = new Space(context);
+ spaceBetweenButtons.setLayoutParams(new LinearLayout.LayoutParams(Dim.dp8, ViewGroup.LayoutParams.WRAP_CONTENT));
+ buttonsRow.addView(spaceBetweenButtons);
+
+ buttonsRow.addView(moveButtons.second, new LinearLayout.LayoutParams(
+ 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
+
+ contentLayout.addView(headersLayout);
+ contentLayout.addView(columnsLayout);
+ contentLayout.addView(buttonsRow);
+
+ return contentLayout;
+ }
+
+ /**
+ * Creates a header TextView.
+ */
+ private TextView createHeader(Context context, String tag) {
+ TextView textview = new TextView(context);
+ textview.setTag(tag);
+ textview.setTextSize(16);
+ textview.setTextColor(Utils.getAppForegroundColor());
+ textview.setGravity(Gravity.CENTER);
+
+ return textview;
+ }
+
+ /**
+ * Creates a single column (search + buttons + list).
+ */
+ private View createColumn(Context context, TreeSet flags, TextView countText) {
+ LinearLayout wrapper = new LinearLayout(context);
+ wrapper.setOrientation(LinearLayout.VERTICAL);
+
+ Pair pair = createListView(context, flags, countText);
+ ListView listView = pair.first;
+ FlagAdapter adapter = pair.second;
+
+ EditText search = createSearchBox(context, adapter, listView, countText);
+ LinearLayout buttons = createActionButtons(context, listView, adapter);
+
+ listView.setLayoutParams(new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
+ ShapeDrawable background = new ShapeDrawable(new RoundRectShape(
+ Dim.roundedCorners(10), null, null));
+ background.getPaint().setColor(Utils.getEditTextBackground());
+ listView.setPadding(0, Dim.dp4, 0, Dim.dp4);
+ listView.setBackground(background);
+ listView.setOverScrollMode(View.OVER_SCROLL_NEVER);
+
+ wrapper.addView(search);
+ wrapper.addView(buttons);
+ wrapper.addView(listView);
+
+ // Save references for move buttons.
+ wrapper.setTag(new ColumnViews(listView, adapter));
+
+ return wrapper;
+ }
+
+ /**
+ * Updates the header text with the current count.
+ */
+ private void updateHeaderCount(TextView header, FlagAdapter adapter) {
+ header.setText(str((String) header.getTag(), adapter.getCount()));
+ }
+
+ /**
+ * Creates a search box that filters the list.
+ */
+ @SuppressLint("ClickableViewAccessibility")
+ private EditText createSearchBox(Context context, FlagAdapter adapter, ListView listView, TextView countText) {
+ EditText search = new EditText(context);
+ search.setInputType(InputType.TYPE_CLASS_NUMBER);
+ search.setTextSize(16);
+ search.setHint(str("revanced_debug_feature_flags_manager_search_hint"));
+ search.setHapticFeedbackEnabled(false);
+ search.setLayoutParams(new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
+
+ search.addTextChangedListener(new TextWatcher() {
+ @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+ @Override public void onTextChanged(CharSequence s, int start, int before, int count) {
+ adapter.setSearchQuery(s.toString());
+ listView.clearChoices();
+ updateHeaderCount(countText, adapter);
+ Drawable clearIcon = context.getResources().getDrawable(android.R.drawable.ic_menu_close_clear_cancel);
+ clearIcon.setBounds(0, 0, Dim.dp20, Dim.dp20);
+ search.setCompoundDrawables(null, null, TextUtils.isEmpty(s) ? null : clearIcon, null);
+ }
+ @Override public void afterTextChanged(Editable s) {}
+ });
+
+ search.setOnTouchListener((v, event) -> {
+ if (event.getAction() == MotionEvent.ACTION_UP) {
+ Drawable[] compoundDrawables = search.getCompoundDrawables();
+ if (compoundDrawables[2] != null &&
+ event.getRawX() >= (search.getRight() - compoundDrawables[2].getBounds().width())) {
+ search.setText("");
+ return true;
+ }
+ }
+ return false;
+ });
+
+ return search;
+ }
+
+ /**
+ * Creates action buttons.
+ */
+ private LinearLayout createActionButtons(Context context, ListView listView, FlagAdapter adapter) {
+ LinearLayout row = new LinearLayout(context);
+ row.setOrientation(LinearLayout.HORIZONTAL);
+ row.setGravity(Gravity.CENTER);
+ row.setLayoutParams(new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
+
+ ImageButton selectAll = createButton(context, DRAWABLE_REVANCED_SETTINGS_SELECT_ALL,
+ () -> {
+ for (int i = 0, count = adapter.getCount(); i < count; i++) {
+ listView.setItemChecked(i, true);
+ }
+ });
+
+ ImageButton clearAll = createButton(context, DRAWABLE_REVANCED_SETTINGS_DESELECT_ALL,
+ () -> {
+ listView.clearChoices();
+ adapter.notifyDataSetChanged();
+ });
+
+ ImageButton copy = createButton(context, DRAWABLE_REVANCED_SETTINGS_COPY_ALL,
+ () -> {
+ List items = new ArrayList<>();
+ SparseBooleanArray checked = listView.getCheckedItemPositions();
+
+ if (checked.size() > 0) {
+ for (int i = 0, count = adapter.getCount(); i < count; i++) {
+ if (checked.get(i)) {
+ items.add(adapter.getItem(i));
+ }
+ }
+ } else {
+ for (Long flag : adapter.getFullFlags()) {
+ items.add(String.valueOf(flag));
+ }
+ }
+
+ Utils.setClipboard(TextUtils.join("\n", items));
+
+ Utils.showToastShort(str("revanced_debug_feature_flags_manager_toast_copied"));
+ });
+
+ row.addView(selectAll);
+ row.addView(clearAll);
+ row.addView(copy);
+
+ return row;
+ }
+
+ /**
+ * Creates the move buttons (left and right groups).
+ */
+ private Pair createMoveButtons(Context context,
+ ListView availableListView, ListView blockedListView,
+ TreeSet availableFlags, TreeSet blockedFlags,
+ TextView availableCountText, TextView blockedCountText) {
+ // Left group: >> >
+ LinearLayout leftButtons = new LinearLayout(context);
+ leftButtons.setOrientation(LinearLayout.HORIZONTAL);
+ leftButtons.setGravity(Gravity.CENTER);
+
+ ImageButton moveAllRight = createButton(context, DRAWABLE_REVANCED_SETTINGS_ARROW_RIGHT_DOUBLE,
+ () -> moveFlags(availableListView, blockedListView, availableFlags, blockedFlags,
+ availableCountText, blockedCountText, true));
+
+ ImageButton moveOneRight = createButton(context, DRAWABLE_REVANCED_SETTINGS_ARROW_RIGHT_ONE,
+ () -> moveFlags(availableListView, blockedListView, availableFlags, blockedFlags,
+ availableCountText, blockedCountText, false));
+
+ leftButtons.addView(moveAllRight);
+ leftButtons.addView(moveOneRight);
+
+ // Right group: < <<
+ LinearLayout rightButtons = new LinearLayout(context);
+ rightButtons.setOrientation(LinearLayout.HORIZONTAL);
+ rightButtons.setGravity(Gravity.CENTER);
+
+ ImageButton moveOneLeft = createButton(context, DRAWABLE_REVANCED_SETTINGS_ARROW_LEFT_ONE,
+ () -> moveFlags(blockedListView, availableListView, blockedFlags, availableFlags,
+ blockedCountText, availableCountText, false));
+
+ ImageButton moveAllLeft = createButton(context, DRAWABLE_REVANCED_SETTINGS_ARROW_LEFT_DOUBLE,
+ () -> moveFlags(blockedListView, availableListView, blockedFlags, availableFlags,
+ blockedCountText, availableCountText, true));
+
+ rightButtons.addView(moveOneLeft);
+ rightButtons.addView(moveAllLeft);
+
+ return new Pair<>(leftButtons, rightButtons);
+ }
+
+ /**
+ * Creates a styled ImageButton.
+ */
+ @SuppressLint("ResourceType")
+ private ImageButton createButton(Context context, int drawableResId, Runnable action) {
+ ImageButton button = new ImageButton(context);
+
+ button.setImageResource(drawableResId);
+ button.setScaleType(ImageView.ScaleType.CENTER);
+ int[] attrs = {android.R.attr.selectableItemBackgroundBorderless};
+ //noinspection Recycle
+ TypedArray ripple = context.obtainStyledAttributes(attrs);
+ button.setBackgroundDrawable(ripple.getDrawable(0));
+ ripple.close();
+
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(Dim.dp32, Dim.dp32);
+ params.setMargins(Dim.dp8, Dim.dp8, Dim.dp8, Dim.dp8);
+ button.setLayoutParams(params);
+
+ button.setOnClickListener(v -> action.run());
+
+ return button;
+ }
+
+ /**
+ * Custom adapter with search filtering.
+ */
+ private static class FlagAdapter extends ArrayAdapter {
+ private final TreeSet fullFlags;
+ private String searchQuery = "";
+
+ public FlagAdapter(Context context, TreeSet fullFlags) {
+ super(context, android.R.layout.simple_list_item_multiple_choice, new ArrayList<>());
+ this.fullFlags = fullFlags;
+ updateFiltered();
+ }
+
+ public void setSearchQuery(String query) {
+ searchQuery = query == null ? "" : query.trim();
+ updateFiltered();
+ }
+
+ private void updateFiltered() {
+ clear();
+ for (Long flag : fullFlags) {
+ String flagString = String.valueOf(flag);
+ if (searchQuery.isEmpty() || flagString.contains(searchQuery)) {
+ add(flagString);
+ }
+ }
+ notifyDataSetChanged();
+ }
+
+ public void refresh() {
+ updateFiltered();
+ }
+
+ public List getFullFlags() {
+ return new ArrayList<>(fullFlags);
+ }
+ }
+
+ /**
+ * Creates a ListView with filtering, multi-select, and range selection.
+ */
+ @SuppressLint("ClickableViewAccessibility")
+ private Pair createListView(Context context,
+ TreeSet flags, TextView countText) {
+ ListView listView = new ListView(context);
+ listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
+ listView.setDividerHeight(0);
+
+ FlagAdapter adapter = new FlagAdapter(context, flags);
+ listView.setAdapter(adapter);
+
+ final ListViewSelectionState state = new ListViewSelectionState();
+
+ listView.setOnItemClickListener((parent, view, position, id) -> {
+ if (!state.isRangeSelecting) {
+ state.lastClickedPosition = position;
+ } else {
+ state.isRangeSelecting = false;
+ }
+ });
+
+ listView.setOnItemLongClickListener((parent, view, position, id) -> {
+ if (state.lastClickedPosition == -1) {
+ listView.setItemChecked(position, true);
+ state.lastClickedPosition = position;
+ } else {
+ int start = Math.min(state.lastClickedPosition, position);
+ int end = Math.max(state.lastClickedPosition, position);
+ for (int i = start; i <= end; i++) {
+ listView.setItemChecked(i, true);
+ }
+ state.isRangeSelecting = true;
+ }
+ return true;
+ });
+
+ listView.setOnTouchListener((view, event) -> {
+ if (event.getAction() == MotionEvent.ACTION_UP && state.isRangeSelecting) {
+ state.isRangeSelecting = false;
+ }
+ return false;
+ });
+
+ return new Pair<>(listView, adapter);
+ }
+
+ /**
+ * Moves selected or all flags from one list to another.
+ *
+ * @param fromListView Source ListView.
+ * @param toListView Destination ListView.
+ * @param fromFlags Source flag set.
+ * @param toFlags Destination flag set.
+ * @param fromCountText Header showing count of source items.
+ * @param toCountText Header showing count of destination items.
+ * @param moveAll If true, move all items; if false, move only selected.
+ */
+ private void moveFlags(ListView fromListView, ListView toListView,
+ TreeSet fromFlags, TreeSet toFlags,
+ TextView fromCountText, TextView toCountText,
+ boolean moveAll) {
+ if (fromListView == null || toListView == null) return;
+
+ List flagsToMove = new ArrayList<>();
+ FlagAdapter fromAdapter = (FlagAdapter) fromListView.getAdapter();
+
+ if (moveAll) {
+ flagsToMove.addAll(fromFlags);
+ } else {
+ SparseBooleanArray checked = fromListView.getCheckedItemPositions();
+ for (int i = 0, count = fromAdapter.getCount(); i < count; i++) {
+ if (checked.get(i)) {
+ String item = fromAdapter.getItem(i);
+ if (item != null) {
+ flagsToMove.add(Long.parseLong(item));
+ }
+ }
+ }
+ }
+
+ if (flagsToMove.isEmpty()) return;
+
+ for (Long flag : flagsToMove) {
+ fromFlags.remove(flag);
+ toFlags.add(flag);
+ }
+
+ // Clear selections before refreshing.
+ fromListView.clearChoices();
+ toListView.clearChoices();
+
+ // Refresh both adapters.
+ fromAdapter.refresh();
+ ((FlagAdapter) toListView.getAdapter()).refresh();
+
+ // Update headers.
+ updateHeaderCount(fromCountText, fromAdapter);
+ updateHeaderCount(toCountText, (FlagAdapter) toListView.getAdapter());
+ }
+
+ /**
+ * Saves blocked flags to settings.
+ */
+ private void saveFlags(TreeSet blockedFlags) {
+ StringBuilder flagsString = new StringBuilder();
+ for (Long flag : blockedFlags) {
+ if (flagsString.length() > 0) {
+ flagsString.append("\n");
+ }
+ flagsString.append(flag);
+ }
+
+ BaseSettings.DISABLED_FEATURE_FLAGS.save(flagsString.toString());
+ Utils.showToastShort(str("revanced_debug_feature_flags_manager_toast_saved"));
+ Logger.printDebug(() -> "Feature flags saved. Blocked: " + blockedFlags.size());
+
+ AbstractPreferenceFragment.showRestartDialog(getContext());
+ }
+
+ /**
+ * Resets all blocked flags.
+ */
+ private void resetFlags() {
+ BaseSettings.DISABLED_FEATURE_FLAGS.save("");
+ Utils.showToastShort(str("revanced_debug_feature_flags_manager_toast_reset"));
+
+ AbstractPreferenceFragment.showRestartDialog(getContext());
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ForceOriginalAudioSwitchPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ForceOriginalAudioSwitchPreference.java
new file mode 100644
index 0000000000..fdcde3668d
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ForceOriginalAudioSwitchPreference.java
@@ -0,0 +1,63 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.content.Context;
+import android.preference.SwitchPreference;
+import android.util.AttributeSet;
+
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.spoof.ClientType;
+import app.revanced.extension.shared.spoof.SpoofVideoStreamsPatch;
+
+@SuppressWarnings({"deprecation", "unused"})
+public class ForceOriginalAudioSwitchPreference extends SwitchPreference {
+
+ // Spoof stream patch is not included, or is not currently spoofing to Android Studio.
+ private static final boolean available = !SpoofVideoStreamsPatch.isPatchIncluded()
+ || !(BaseSettings.SPOOF_VIDEO_STREAMS.get()
+ && SpoofVideoStreamsPatch.getPreferredClient() == ClientType.ANDROID_CREATOR);
+
+ {
+ if (!available) {
+ // Show why force audio is not available.
+ String summary = str("revanced_force_original_audio_not_available");
+ super.setSummary(summary);
+ super.setSummaryOn(summary);
+ super.setSummaryOff(summary);
+ super.setEnabled(false);
+ }
+ }
+
+ public ForceOriginalAudioSwitchPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+ public ForceOriginalAudioSwitchPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+ public ForceOriginalAudioSwitchPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public ForceOriginalAudioSwitchPreference(Context context) {
+ super(context);
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ if (!available) {
+ return;
+ }
+
+ super.setEnabled(enabled);
+ }
+
+ @Override
+ public void setSummary(CharSequence summary) {
+ if (!available) {
+ return;
+ }
+
+ super.setSummary(summary);
+ }
+}
+
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java
new file mode 100644
index 0000000000..1044ba424e
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java
@@ -0,0 +1,133 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Build;
+import android.os.Bundle;
+import android.preference.EditTextPreference;
+import android.preference.Preference;
+import android.text.InputType;
+import android.util.AttributeSet;
+import android.util.Pair;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.ui.CustomDialog;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class ImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener {
+
+ private String existingSettings;
+
+ private void init() {
+ setSelectable(true);
+
+ EditText editText = getEditText();
+ editText.setTextIsSelectable(true);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ editText.setAutofillHints((String) null);
+ }
+ editText.setInputType(editText.getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ editText.setTextSize(14);
+
+ setOnPreferenceClickListener(this);
+ }
+
+ public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init();
+ }
+ public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+ public ImportExportPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+ public ImportExportPreference(Context context) {
+ super(context);
+ init();
+ }
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ try {
+ // Must set text before showing dialog,
+ // otherwise text is non-selectable if this preference is later reopened.
+ existingSettings = Setting.exportToJson(getContext());
+ getEditText().setText(existingSettings);
+ } catch (Exception ex) {
+ Logger.printException(() -> "showDialog failure", ex);
+ }
+ return true;
+ }
+
+ @Override
+ protected void showDialog(Bundle state) {
+ try {
+ Context context = getContext();
+ EditText editText = getEditText();
+
+ // Create a custom dialog with the EditText.
+ Pair dialogPair = CustomDialog.create(
+ context,
+ str("revanced_pref_import_export_title"), // Title.
+ null, // No message (EditText replaces it).
+ editText, // Pass the EditText.
+ str("revanced_settings_import"), // OK button text.
+ () -> importSettings(context, editText.getText().toString()), // OK button action.
+ () -> {}, // Cancel button action (dismiss only).
+ str("revanced_settings_import_copy"), // Neutral button (Copy) text.
+ () -> {
+ // Neutral button (Copy) action. Show the user the settings in JSON format.
+ Utils.setClipboard(editText.getText());
+ },
+ true // Dismiss dialog when onNeutralClick.
+ );
+
+ // If there are no settings yet, then show the on screen keyboard and bring focus to
+ // the edit text. This makes it easier to paste saved settings after a reinstall.
+ dialogPair.first.setOnShowListener(dialogInterface -> {
+ if (existingSettings.isEmpty()) {
+ editText.postDelayed(() -> {
+ editText.requestFocus();
+
+ InputMethodManager inputMethodManager = (InputMethodManager)
+ editText.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+ inputMethodManager.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT);
+ }, 100);
+ }
+ });
+
+ // Show the dialog.
+ dialogPair.first.show();
+ } catch (Exception ex) {
+ Logger.printException(() -> "showDialog failure", ex);
+ }
+ }
+
+ private void importSettings(Context context, String replacementSettings) {
+ try {
+ if (replacementSettings.equals(existingSettings)) {
+ return;
+ }
+ AbstractPreferenceFragment.settingImportInProgress = true;
+
+ final boolean rebootNeeded = Setting.importFromJSON(context, replacementSettings);
+ if (rebootNeeded) {
+ AbstractPreferenceFragment.showRestartDialog(context);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "importSettings failure", ex);
+ } finally {
+ AbstractPreferenceFragment.settingImportInProgress = false;
+ }
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/LogBufferManager.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/LogBufferManager.java
new file mode 100644
index 0000000000..4bd54c65be
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/LogBufferManager.java
@@ -0,0 +1,113 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import java.util.Deque;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentLinkedDeque;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.BaseSettings;
+
+/**
+ * Manages a buffer for storing debug logs from {@link Logger}.
+ * Stores just under 1MB of the most recent log data.
+ *
+ * All methods are thread-safe.
+ */
+public final class LogBufferManager {
+ /** Maximum byte size of all buffer entries. Must be less than Android's 1 MB Binder transaction limit. */
+ private static final int BUFFER_MAX_BYTES = 900_000;
+ /** Limit number of log lines. */
+ private static final int BUFFER_MAX_SIZE = 10_000;
+
+ private static final Deque logBuffer = new ConcurrentLinkedDeque<>();
+ private static final AtomicInteger logBufferByteSize = new AtomicInteger();
+
+ /**
+ * Appends a log message to the internal buffer if debugging is enabled.
+ * The buffer is limited to approximately {@link #BUFFER_MAX_BYTES} or {@link #BUFFER_MAX_SIZE}
+ * to prevent excessive memory usage.
+ *
+ * @param message The log message to append.
+ */
+ public static void appendToLogBuffer(String message) {
+ Objects.requireNonNull(message);
+
+ // It's very important that no Settings are used in this method,
+ // as this code is used when a context is not set and thus referencing
+ // a setting will crash the app.
+ logBuffer.addLast(message);
+ int newSize = logBufferByteSize.addAndGet(message.length());
+
+ // Remove oldest entries if over the log size limits.
+ while (newSize > BUFFER_MAX_BYTES || logBuffer.size() > BUFFER_MAX_SIZE) {
+ String removed = logBuffer.pollFirst();
+ if (removed == null) {
+ // Thread race of two different calls to this method, and the other thread won.
+ return;
+ }
+
+ newSize = logBufferByteSize.addAndGet(-removed.length());
+ }
+ }
+
+ /**
+ * Exports all logs from the internal buffer to the clipboard.
+ * Displays a toast with the result.
+ */
+ public static void exportToClipboard() {
+ try {
+ if (!BaseSettings.DEBUG.get()) {
+ Utils.showToastShort(str("revanced_debug_logs_disabled"));
+ return;
+ }
+
+ if (logBuffer.isEmpty()) {
+ Utils.showToastShort(str("revanced_debug_logs_none_found"));
+ clearLogBufferData(); // Clear toast log entry that was just created.
+ return;
+ }
+
+ // Most (but not all) Android 13+ devices always show a "copied to clipboard" toast
+ // and there is no way to programmatically detect if a toast will show or not.
+ // Show a toast even if using Android 13+, but show ReVanced toast first (before copying to clipboard).
+ Utils.showToastShort(str("revanced_debug_logs_copied_to_clipboard"));
+
+ Utils.setClipboard(String.join("\n", logBuffer));
+ } catch (Exception ex) {
+ // Handle security exception if clipboard access is denied.
+ String errorMessage = String.format(str("revanced_debug_logs_failed_to_export"), ex.getMessage());
+ Utils.showToastLong(errorMessage);
+ Logger.printDebug(() -> errorMessage, ex);
+ }
+ }
+
+ private static void clearLogBufferData() {
+ // Cannot simply clear the log buffer because there is no
+ // write lock for both the deque and the atomic int.
+ // Instead pop off log entries and decrement the size one by one.
+ while (!logBuffer.isEmpty()) {
+ String removed = logBuffer.pollFirst();
+ if (removed != null) {
+ logBufferByteSize.addAndGet(-removed.length());
+ }
+ }
+ }
+
+ /**
+ * Clears the internal log buffer and displays a toast with the result.
+ */
+ public static void clearLogBuffer() {
+ if (!BaseSettings.DEBUG.get()) {
+ Utils.showToastShort(str("revanced_debug_logs_disabled"));
+ return;
+ }
+
+ // Show toast before clearing, otherwise toast log will still remain.
+ Utils.showToastShort(str("revanced_debug_logs_clear_toast"));
+ clearLogBufferData();
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/NoTitlePreferenceCategory.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/NoTitlePreferenceCategory.java
new file mode 100644
index 0000000000..d6b895f22a
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/NoTitlePreferenceCategory.java
@@ -0,0 +1,58 @@
+package app.revanced.extension.shared.settings.preference;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.preference.PreferenceCategory;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup;
+
+/**
+ * Empty preference category with no title, used to organize and group related preferences together.
+ */
+@SuppressWarnings({"unused", "deprecation"})
+public class NoTitlePreferenceCategory extends PreferenceCategory {
+
+ public NoTitlePreferenceCategory(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public NoTitlePreferenceCategory(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public NoTitlePreferenceCategory(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public NoTitlePreferenceCategory(Context context) {
+ super(context);
+ }
+
+ @Override
+ @SuppressLint("MissingSuperCall")
+ protected View onCreateView(ViewGroup parent) {
+ // Return an zero-height view to eliminate empty title space.
+ return new View(getContext());
+ }
+
+ @Override
+ public CharSequence getTitle() {
+ // Title can be used for sorting. Return the first sub preference title.
+ if (getPreferenceCount() > 0) {
+ return getPreference(0).getTitle();
+ }
+
+ return super.getTitle();
+ }
+
+ @Override
+ public int getTitleRes() {
+ if (getPreferenceCount() > 0) {
+ return getPreference(0).getTitleRes();
+ }
+
+ return super.getTitleRes();
+ }
+}
+
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ReVancedAboutPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ReVancedAboutPreference.java
new file mode 100644
index 0000000000..0d4003b913
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ReVancedAboutPreference.java
@@ -0,0 +1,377 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.requests.Route.Method.GET;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.Dialog;
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.drawable.ShapeDrawable;
+import android.graphics.drawable.shapes.RoundRectShape;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.preference.Preference;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.view.View;
+import android.view.Window;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.LinearLayout;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.net.HttpURLConnection;
+import java.net.SocketTimeoutException;
+import java.util.ArrayList;
+import java.util.List;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.requests.Requester;
+import app.revanced.extension.shared.requests.Route;
+import app.revanced.extension.shared.ui.Dim;
+
+/**
+ * Opens a dialog showing official links.
+ */
+@SuppressWarnings({"unused", "deprecation"})
+public class ReVancedAboutPreference extends Preference {
+
+ private static String useNonBreakingHyphens(String text) {
+ // Replace any dashes with non breaking dashes, so the English text 'pre-release'
+ // and the dev release number does not break and cover two lines.
+ return text.replace("-", "‑"); // #8209 = non breaking hyphen.
+ }
+
+ /**
+ * Apps that do not support bundling resources must override this.
+ *
+ * @return A localized string to display for the key.
+ */
+ protected String getString(String key, Object ... args) {
+ return str(key, args);
+ }
+
+ private String createDialogHtml(WebLink[] aboutLinks) {
+ final boolean isNetworkConnected = Utils.isNetworkConnected();
+
+ StringBuilder builder = new StringBuilder();
+ builder.append("");
+ builder.append("");
+
+ String foregroundColorHex = Utils.getColorHexString(Utils.getAppForegroundColor());
+ String backgroundColorHex = Utils.getColorHexString(Utils.getDialogBackgroundColor());
+ // Apply light/dark mode colors.
+ builder.append(String.format(
+ "",
+ backgroundColorHex, foregroundColorHex, foregroundColorHex));
+
+ if (isNetworkConnected) {
+ builder.append(" ");
+ }
+
+ String patchesVersion = Utils.getPatchesReleaseVersion();
+
+ // Add the title.
+ builder.append("")
+ .append("ReVanced")
+ .append(" ");
+
+ builder.append("")
+ // Replace hyphens with non breaking dashes so the version number does not break lines.
+ .append(useNonBreakingHyphens(getString("revanced_settings_about_links_body", patchesVersion)))
+ .append("
");
+
+ // Add a disclaimer if using a dev release.
+ if (patchesVersion.contains("dev")) {
+ builder.append("")
+ // English text 'Pre-release' can break lines.
+ .append(useNonBreakingHyphens(getString("revanced_settings_about_links_dev_header")))
+ .append(" ");
+
+ builder.append("")
+ .append(getString("revanced_settings_about_links_dev_body"))
+ .append("
");
+ }
+
+ builder.append("")
+ .append(getString("revanced_settings_about_links_header"))
+ .append(" ");
+
+ builder.append("");
+ for (WebLink link : aboutLinks) {
+ builder.append("
");
+ builder.append(String.format("
%s ", link.url, link.name));
+ builder.append("
");
+ }
+ builder.append("
");
+
+ builder.append("");
+ return builder.toString();
+ }
+
+ {
+ setOnPreferenceClickListener(pref -> {
+ Context context = pref.getContext();
+
+ // Show a progress spinner if the social links are not fetched yet.
+ if (!AboutLinksRoutes.hasFetchedLinks() && Utils.isNetworkConnected()) {
+ // Show a progress spinner, but only if the api fetch takes more than a half a second.
+ final long delayToShowProgressSpinner = 500;
+ ProgressDialog progress = new ProgressDialog(getContext());
+ progress.setProgressStyle(ProgressDialog.STYLE_SPINNER);
+
+ Handler handler = new Handler(Looper.getMainLooper());
+ Runnable showDialogRunnable = progress::show;
+ handler.postDelayed(showDialogRunnable, delayToShowProgressSpinner);
+
+ Utils.runOnBackgroundThread(() ->
+ fetchLinksAndShowDialog(context, handler, showDialogRunnable, progress));
+ } else {
+ // No network call required and can run now.
+ fetchLinksAndShowDialog(context, null, null, null);
+ }
+
+ return false;
+ });
+ }
+
+ private void fetchLinksAndShowDialog(Context context,
+ @Nullable Handler handler,
+ Runnable showDialogRunnable,
+ @Nullable ProgressDialog progress) {
+ WebLink[] links = AboutLinksRoutes.fetchAboutLinks();
+ String htmlDialog = createDialogHtml(links);
+
+ // Enable to randomly force a delay to debug the spinner logic.
+ final boolean debugSpinnerDelayLogic = false;
+ //noinspection ConstantConditions
+ if (debugSpinnerDelayLogic && handler != null && Math.random() < 0.5f) {
+ Utils.doNothingForDuration((long) (Math.random() * 4000));
+ }
+
+ Utils.runOnMainThreadNowOrLater(() -> {
+ if (handler != null) {
+ handler.removeCallbacks(showDialogRunnable);
+ }
+
+ // Don't continue if the activity is done. To test this tap the
+ // about dialog and immediately press back before the dialog can show.
+ if (context instanceof Activity activity) {
+ if (activity.isFinishing() || activity.isDestroyed()) {
+ Logger.printDebug(() -> "Not showing about dialog, activity is closed");
+ return;
+ }
+ }
+
+ if (progress != null && progress.isShowing()) {
+ progress.dismiss();
+ }
+ new WebViewDialog(getContext(), htmlDialog).show();
+ });
+ }
+
+ public ReVancedAboutPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+ public ReVancedAboutPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+ public ReVancedAboutPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public ReVancedAboutPreference(Context context) {
+ super(context);
+ }
+}
+
+/**
+ * Displays html content as a dialog. Any links a user taps on are opened in an external browser.
+ */
+class WebViewDialog extends Dialog {
+
+ private final String htmlContent;
+
+ public WebViewDialog(@NonNull Context context, @NonNull String htmlContent) {
+ super(context);
+ this.htmlContent = htmlContent;
+ }
+
+ // JS required to hide any broken images. No remote javascript is ever loaded.
+ @SuppressLint("SetJavaScriptEnabled")
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE); // Remove default title bar.
+
+ // Create main layout.
+ LinearLayout mainLayout = new LinearLayout(getContext());
+ mainLayout.setOrientation(LinearLayout.VERTICAL);
+
+ mainLayout.setPadding(Dim.dp10, Dim.dp10, Dim.dp10, Dim.dp10);
+ // Set rounded rectangle background.
+ ShapeDrawable mainBackground = new ShapeDrawable(new RoundRectShape(
+ Dim.roundedCorners(28), null, null));
+ mainBackground.getPaint().setColor(Utils.getDialogBackgroundColor());
+ mainLayout.setBackground(mainBackground);
+
+ // Create WebView.
+ WebView webView = new WebView(getContext());
+ webView.setVerticalScrollBarEnabled(false); // Disable the vertical scrollbar.
+ webView.setOverScrollMode(View.OVER_SCROLL_NEVER);
+ webView.getSettings().setJavaScriptEnabled(true);
+ webView.setWebViewClient(new OpenLinksExternallyWebClient());
+ webView.loadDataWithBaseURL(null, htmlContent, "text/html", "utf-8", null);
+
+ // Add WebView to layout.
+ mainLayout.addView(webView);
+
+ setContentView(mainLayout);
+
+ // Set dialog window attributes.
+ Window window = getWindow();
+ if (window != null) {
+ Utils.setDialogWindowParameters(window, Gravity.CENTER, 0, 90, false);
+ }
+ }
+
+ private class OpenLinksExternallyWebClient extends WebViewClient {
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, String url) {
+ try {
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
+ getContext().startActivity(intent);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Open link failure", ex);
+ }
+ // Dismiss the about dialog using a delay,
+ // otherwise without a delay the UI looks hectic with the dialog dismissing
+ // to show the settings while simultaneously a web browser is opening.
+ Utils.runOnMainThreadDelayed(WebViewDialog.this::dismiss, 500);
+ return true;
+ }
+ }
+}
+
+class WebLink {
+ final boolean preferred;
+ String name;
+ final String url;
+
+ WebLink(JSONObject json) throws JSONException {
+ this(json.getBoolean("preferred"),
+ json.getString("name"),
+ json.getString("url")
+ );
+ }
+
+ WebLink(boolean preferred, String name, String url) {
+ this.preferred = preferred;
+ this.name = name;
+ this.url = url;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "WebLink{" +
+ "preferred=" + preferred +
+ ", name='" + name + '\'' +
+ ", url='" + url + '\'' +
+ '}';
+ }
+}
+
+class AboutLinksRoutes {
+ /**
+ * Backup icon url if the API call fails.
+ */
+ public static volatile String aboutLogoUrl = "https://revanced.app/favicon.ico";
+
+ /**
+ * Links to use if fetch links api call fails.
+ */
+ private static final WebLink[] NO_CONNECTION_STATIC_LINKS = {
+ new WebLink(true, "ReVanced.app", "https://revanced.app")
+ };
+
+ private static final String SOCIAL_LINKS_PROVIDER = "https://api.revanced.app/v4";
+ private static final Route.CompiledRoute GET_SOCIAL = new Route(GET, "/about").compile();
+
+ @Nullable
+ private static volatile WebLink[] fetchedLinks;
+
+ static boolean hasFetchedLinks() {
+ return fetchedLinks != null;
+ }
+
+ static WebLink[] fetchAboutLinks() {
+ try {
+ if (hasFetchedLinks()) return fetchedLinks;
+
+ // Check if there is no internet connection.
+ if (!Utils.isNetworkConnected()) return NO_CONNECTION_STATIC_LINKS;
+
+ HttpURLConnection connection = Requester.getConnectionFromCompiledRoute(SOCIAL_LINKS_PROVIDER, GET_SOCIAL);
+ connection.setConnectTimeout(5000);
+ connection.setReadTimeout(5000);
+ Logger.printDebug(() -> "Fetching social links from: " + connection.getURL());
+
+ // Do not show an exception toast if the server is down
+ final int responseCode = connection.getResponseCode();
+ if (responseCode != 200) {
+ Logger.printDebug(() -> "Failed to get social links. Response code: " + responseCode);
+ return NO_CONNECTION_STATIC_LINKS;
+ }
+
+ JSONObject json = Requester.parseJSONObjectAndDisconnect(connection);
+ aboutLogoUrl = json.getJSONObject("branding").getString("logo");
+
+ List links = new ArrayList<>();
+
+ JSONArray donations = json.getJSONObject("donations").getJSONArray("links");
+ for (int i = 0, length = donations.length(); i < length; i++) {
+ WebLink link = new WebLink(donations.getJSONObject(i));
+ if (link.preferred) {
+ // This could be localized, but TikTok does not support localized resources.
+ // All link names returned by the api are also non localized.
+ link.name = "Donate";
+ links.add(link);
+ }
+ }
+
+ JSONArray socials = json.getJSONArray("socials");
+ for (int i = 0, length = socials.length(); i < length; i++) {
+ WebLink link = new WebLink(socials.getJSONObject(i));
+ links.add(link);
+ }
+
+ Logger.printDebug(() -> "links: " + links);
+
+ return fetchedLinks = links.toArray(new WebLink[0]);
+
+ } catch (SocketTimeoutException ex) {
+ Logger.printInfo(() -> "Could not fetch social links", ex); // No toast.
+ } catch (JSONException ex) {
+ Logger.printException(() -> "Could not parse about information", ex);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to get about information", ex);
+ }
+
+ return NO_CONNECTION_STATIC_LINKS;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ResettableEditTextPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ResettableEditTextPreference.java
new file mode 100644
index 0000000000..c6f323ceb4
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ResettableEditTextPreference.java
@@ -0,0 +1,105 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.preference.EditTextPreference;
+import android.util.AttributeSet;
+import android.util.Pair;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+
+import androidx.annotation.Nullable;
+
+import java.util.Objects;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.ui.CustomDialog;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class ResettableEditTextPreference extends EditTextPreference {
+
+ /**
+ * Setting to reset.
+ */
+ @Nullable
+ private Setting> setting;
+
+ public ResettableEditTextPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+ public ResettableEditTextPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+ public ResettableEditTextPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public ResettableEditTextPreference(Context context) {
+ super(context);
+ }
+
+ public void setSetting(@Nullable Setting> setting) {
+ this.setting = setting;
+ }
+
+ @Override
+ protected void showDialog(Bundle state) {
+ try {
+ Context context = getContext();
+ EditText editText = getEditText();
+
+ // Resolve setting if not already set.
+ if (setting == null) {
+ String key = getKey();
+ if (key != null) {
+ setting = Setting.getSettingFromPath(key);
+ }
+ }
+
+ // Set initial EditText value to the current persisted value or empty string.
+ String initialValue = getText() != null ? getText() : "";
+ editText.setText(initialValue);
+ editText.setSelection(initialValue.length()); // Move cursor to end.
+
+ // Create custom dialog.
+ String neutralButtonText = (setting != null) ? str("revanced_settings_reset") : null;
+ Pair dialogPair = CustomDialog.create(
+ context,
+ getTitle() != null ? getTitle().toString() : "", // Title.
+ null, // Message is replaced by EditText.
+ editText, // Pass the EditText.
+ null, // OK button text.
+ () -> {
+ // OK button action. Persist the EditText value when OK is clicked.
+ String newValue = editText.getText().toString();
+ if (callChangeListener(newValue)) {
+ setText(newValue);
+ }
+ },
+ () -> {}, // Cancel button action (dismiss only).
+ neutralButtonText, // Neutral button text (Reset).
+ () -> {
+ // Neutral button action.
+ if (setting != null) {
+ try {
+ String defaultStringValue = Objects.requireNonNull(setting).defaultValue.toString();
+ editText.setText(defaultStringValue);
+ editText.setSelection(defaultStringValue.length()); // Move cursor to end of text.
+ } catch (Exception ex) {
+ Logger.printException(() -> "reset failure", ex);
+ }
+ }
+ },
+ false // Do not dismiss dialog when onNeutralClick.
+ );
+
+ // Show the dialog.
+ dialogPair.first.show();
+ } catch (Exception ex) {
+ Logger.printException(() -> "showDialog failure", ex);
+ }
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/SharedPrefCategory.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/SharedPrefCategory.java
new file mode 100644
index 0000000000..ed5db6b235
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/SharedPrefCategory.java
@@ -0,0 +1,190 @@
+package app.revanced.extension.shared.settings.preference;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.preference.PreferenceFragment;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+
+import java.util.Objects;
+
+/**
+ * Shared categories, and helper methods.
+ *
+ * The various save methods store numbers as Strings,
+ * which is required if using {@link PreferenceFragment}.
+ *
+ * If saved numbers will not be used with a preference fragment,
+ * then store the primitive numbers using the {@link #preferences} itself.
+ */
+public class SharedPrefCategory {
+ @NonNull
+ public final String name;
+ @NonNull
+ public final SharedPreferences preferences;
+
+ public SharedPrefCategory(@NonNull String name) {
+ this.name = Objects.requireNonNull(name);
+ preferences = Objects.requireNonNull(Utils.getContext()).getSharedPreferences(name, Context.MODE_PRIVATE);
+ }
+
+ private void removeConflictingPreferenceKeyValue(@NonNull String key) {
+ Logger.printException(() -> "Found conflicting preference: " + key);
+ removeKey(key);
+ }
+
+ private void saveObjectAsString(@NonNull String key, @Nullable Object value) {
+ preferences.edit().putString(key, (value == null ? null : value.toString())).commit();
+ }
+
+ /**
+ * Removes any preference data type that has the specified key.
+ */
+ public void removeKey(@NonNull String key) {
+ preferences.edit().remove(Objects.requireNonNull(key)).commit();
+ }
+
+ public void saveBoolean(@NonNull String key, boolean value) {
+ preferences.edit().putBoolean(key, value).commit();
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveEnumAsString(@NonNull String key, @Nullable Enum> value) {
+ saveObjectAsString(key, value);
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveIntegerString(@NonNull String key, @Nullable Integer value) {
+ saveObjectAsString(key, value);
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveLongString(@NonNull String key, @Nullable Long value) {
+ saveObjectAsString(key, value);
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveFloatString(@NonNull String key, @Nullable Float value) {
+ saveObjectAsString(key, value);
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveString(@NonNull String key, @Nullable String value) {
+ saveObjectAsString(key, value);
+ }
+
+ @NonNull
+ public String getString(@NonNull String key, @NonNull String _default) {
+ Objects.requireNonNull(_default);
+ try {
+ return preferences.getString(key, _default);
+ } catch (ClassCastException ex) {
+ // Value stored is a completely different type (should never happen).
+ removeConflictingPreferenceKeyValue(key);
+ return _default;
+ }
+ }
+
+ @NonNull
+ public > T getEnum(@NonNull String key, @NonNull T _default) {
+ Objects.requireNonNull(_default);
+ try {
+ String enumName = preferences.getString(key, null);
+ if (enumName != null) {
+ try {
+ // noinspection unchecked
+ return (T) Enum.valueOf(_default.getClass(), enumName);
+ } catch (IllegalArgumentException ex) {
+ // Info level to allow removing enum values in the future without showing any user errors.
+ Logger.printInfo(() -> "Using default, and ignoring unknown enum value: " + enumName);
+ removeKey(key);
+ }
+ }
+ } catch (ClassCastException ex) {
+ // Value stored is a completely different type (should never happen).
+ removeConflictingPreferenceKeyValue(key);
+ }
+ return _default;
+ }
+
+ public boolean getBoolean(@NonNull String key, boolean _default) {
+ try {
+ return preferences.getBoolean(key, _default);
+ } catch (ClassCastException ex) {
+ // Value stored is a completely different type (should never happen).
+ removeConflictingPreferenceKeyValue(key);
+ return _default;
+ }
+ }
+
+ @NonNull
+ public Integer getIntegerString(@NonNull String key, @NonNull Integer _default) {
+ try {
+ String value = preferences.getString(key, null);
+ if (value != null) {
+ return Integer.valueOf(value);
+ }
+ } catch (ClassCastException | NumberFormatException ex) {
+ try {
+ // Old data previously stored as primitive.
+ return preferences.getInt(key, _default);
+ } catch (ClassCastException ex2) {
+ // Value stored is a completely different type (should never happen).
+ removeConflictingPreferenceKeyValue(key);
+ }
+ }
+ return _default;
+ }
+
+ @NonNull
+ public Long getLongString(@NonNull String key, @NonNull Long _default) {
+ try {
+ String value = preferences.getString(key, null);
+ if (value != null) {
+ return Long.valueOf(value);
+ }
+ } catch (ClassCastException | NumberFormatException ex) {
+ try {
+ return preferences.getLong(key, _default);
+ } catch (ClassCastException ex2) {
+ removeConflictingPreferenceKeyValue(key);
+ }
+ }
+ return _default;
+ }
+
+ @NonNull
+ public Float getFloatString(@NonNull String key, @NonNull Float _default) {
+ try {
+ String value = preferences.getString(key, null);
+ if (value != null) {
+ return Float.valueOf(value);
+ }
+ } catch (ClassCastException | NumberFormatException ex) {
+ try {
+ return preferences.getFloat(key, _default);
+ } catch (ClassCastException ex2) {
+ removeConflictingPreferenceKeyValue(key);
+ }
+ }
+ return _default;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return name;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/SortedListPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/SortedListPreference.java
new file mode 100644
index 0000000000..fb32e7bc07
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/SortedListPreference.java
@@ -0,0 +1,124 @@
+package app.revanced.extension.shared.settings.preference;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.util.Pair;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import app.revanced.extension.shared.Utils;
+
+/**
+ * PreferenceList that sorts itself.
+ * By default the first entry is preserved in its original position,
+ * and all other entries are sorted alphabetically.
+ *
+ * Ideally the 'keep first entries to preserve' is an xml parameter,
+ * but currently that's not so simple since Extensions code cannot use
+ * generated code from the Patches repo (which is required for custom xml parameters).
+ *
+ * If any class wants to use a different getFirstEntriesToPreserve value,
+ * it needs to subclass this preference and override {@link #getFirstEntriesToPreserve}.
+ */
+@SuppressWarnings({"unused", "deprecation"})
+public class SortedListPreference extends CustomDialogListPreference {
+
+ /**
+ * Sorts the current list entries.
+ *
+ * @param firstEntriesToPreserve The number of entries to preserve in their original position,
+ * or a negative value to not sort and leave entries
+ * as they current are.
+ */
+ public void sortEntryAndValues(int firstEntriesToPreserve) {
+ CharSequence[] entries = getEntries();
+ CharSequence[] entryValues = getEntryValues();
+ if (entries == null || entryValues == null) {
+ return;
+ }
+
+ final int entrySize = entries.length;
+ if (entrySize != entryValues.length) {
+ // Xml array declaration has a missing/extra entry.
+ throw new IllegalStateException();
+ }
+
+ if (firstEntriesToPreserve < 0) {
+ return; // Nothing to do.
+ }
+
+ List> firstEntries = new ArrayList<>(firstEntriesToPreserve);
+
+ // Android does not have a triple class like Kotlin, So instead use a nested pair.
+ // Cannot easily use a SortedMap, because if two entries incorrectly have
+ // identical names then the duplicates entries are not preserved.
+ List>> lastEntries = new ArrayList<>();
+
+ for (int i = 0; i < entrySize; i++) {
+ Pair pair = new Pair<>(entries[i], entryValues[i]);
+ if (i < firstEntriesToPreserve) {
+ firstEntries.add(pair);
+ } else {
+ lastEntries.add(new Pair<>(Utils.removePunctuationToLowercase(pair.first), pair));
+ }
+ }
+
+ //noinspection ComparatorCombinators
+ Collections.sort(lastEntries, (pair1, pair2)
+ -> pair1.first.compareTo(pair2.first));
+
+ CharSequence[] sortedEntries = new CharSequence[entrySize];
+ CharSequence[] sortedEntryValues = new CharSequence[entrySize];
+
+ int i = 0;
+ for (Pair pair : firstEntries) {
+ sortedEntries[i] = pair.first;
+ sortedEntryValues[i] = pair.second;
+ i++;
+ }
+
+ for (Pair> outer : lastEntries) {
+ Pair inner = outer.second;
+ sortedEntries[i] = inner.first;
+ sortedEntryValues[i] = inner.second;
+ i++;
+ }
+
+ super.setEntries(sortedEntries);
+ super.setEntryValues(sortedEntryValues);
+ }
+
+ public SortedListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+
+ sortEntryAndValues(getFirstEntriesToPreserve());
+ }
+
+ public SortedListPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+
+ sortEntryAndValues(getFirstEntriesToPreserve());
+ }
+
+ public SortedListPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ sortEntryAndValues(getFirstEntriesToPreserve());
+ }
+
+ public SortedListPreference(Context context) {
+ super(context);
+
+ sortEntryAndValues(getFirstEntriesToPreserve());
+ }
+
+ /**
+ * @return The number of first entries to leave exactly where they are, and do not sort them.
+ * A negative value indicates do not sort any entries.
+ */
+ protected int getFirstEntriesToPreserve() {
+ return 1;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ToolbarPreferenceFragment.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ToolbarPreferenceFragment.java
new file mode 100644
index 0000000000..8b1d8b882d
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ToolbarPreferenceFragment.java
@@ -0,0 +1,173 @@
+package app.revanced.extension.shared.settings.preference;
+
+import android.annotation.SuppressLint;
+import android.app.Dialog;
+import android.graphics.Insets;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.preference.Preference;
+import android.preference.PreferenceGroup;
+import android.preference.PreferenceScreen;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowInsets;
+import android.widget.TextView;
+import android.widget.Toolbar;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.ResourceType;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.BaseActivityHook;
+import app.revanced.extension.shared.ui.Dim;
+
+@SuppressWarnings("deprecation")
+@RequiresApi(api = Build.VERSION_CODES.O)
+public class ToolbarPreferenceFragment extends AbstractPreferenceFragment {
+
+ /**
+ * Removes the list of preferences from this fragment, if they exist.
+ * @param keys Preference keys.
+ */
+ protected void removePreferences(String ... keys) {
+ for (String key : keys) {
+ Preference pref = findPreference(key);
+ if (pref != null) {
+ PreferenceGroup parent = pref.getParent();
+ if (parent != null) {
+ Logger.printDebug(() -> "Removing preference: " + key);
+ parent.removePreference(pref);
+ }
+ }
+ }
+ }
+
+ /**
+ * Sets toolbar for all nested preference screens.
+ */
+ protected void setPreferenceScreenToolbar(PreferenceScreen parentScreen) {
+ for (int i = 0, count = parentScreen.getPreferenceCount(); i < count; i++) {
+ Preference childPreference = parentScreen.getPreference(i);
+ if (childPreference instanceof PreferenceScreen) {
+ // Recursively set sub preferences.
+ setPreferenceScreenToolbar((PreferenceScreen) childPreference);
+
+ childPreference.setOnPreferenceClickListener(
+ childScreen -> {
+ Dialog preferenceScreenDialog = ((PreferenceScreen) childScreen).getDialog();
+ ViewGroup rootView = (ViewGroup) preferenceScreenDialog
+ .findViewById(android.R.id.content)
+ .getParent();
+
+ // Allow package-specific background customization.
+ customizeDialogBackground(rootView);
+
+ // Fix the system navigation bar color for submenus.
+ setNavigationBarColor(preferenceScreenDialog.getWindow());
+
+ // Fix edge-to-edge screen with Android 15 and YT 19.45+
+ // https://developer.android.com/develop/ui/views/layout/edge-to-edge#system-bars-insets
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ rootView.setOnApplyWindowInsetsListener((v, insets) -> {
+ Insets statusInsets = insets.getInsets(WindowInsets.Type.statusBars());
+ Insets navInsets = insets.getInsets(WindowInsets.Type.navigationBars());
+ Insets cutoutInsets = insets.getInsets(WindowInsets.Type.displayCutout());
+
+ // Apply padding for display cutout in landscape.
+ int leftPadding = cutoutInsets.left;
+ int rightPadding = cutoutInsets.right;
+ int topPadding = statusInsets.top;
+ int bottomPadding = navInsets.bottom;
+
+ v.setPadding(leftPadding, topPadding, rightPadding, bottomPadding);
+ return insets;
+ });
+ }
+
+ Toolbar toolbar = new Toolbar(childScreen.getContext());
+ toolbar.setTitle(childScreen.getTitle());
+ toolbar.setNavigationIcon(getBackButtonDrawable());
+ toolbar.setNavigationOnClickListener(view -> preferenceScreenDialog.dismiss());
+
+ toolbar.setTitleMargin(Dim.dp16, 0, Dim.dp16, 0);
+
+ TextView toolbarTextView = Utils.getChildView(toolbar,
+ true, TextView.class::isInstance);
+ if (toolbarTextView != null) {
+ toolbarTextView.setTextColor(Utils.getAppForegroundColor());
+ toolbarTextView.setTextSize(20);
+ }
+
+ // Allow package-specific toolbar customization.
+ customizeToolbar(toolbar);
+
+ // Allow package-specific post-toolbar setup.
+ onPostToolbarSetup(toolbar, preferenceScreenDialog);
+
+ rootView.addView(toolbar, 0);
+ return false;
+ }
+ );
+ }
+ }
+ }
+
+ /**
+ * Sets the system navigation bar color for the activity.
+ * Applies the background color obtained from {@link Utils#getAppBackgroundColor()} to the navigation bar.
+ * For Android 10 (API 29) and above, enforces navigation bar contrast to ensure visibility.
+ */
+ public static void setNavigationBarColor(@Nullable Window window) {
+ if (window == null) {
+ Logger.printDebug(() -> "Cannot set navigation bar color, window is null");
+ return;
+ }
+
+ window.setNavigationBarColor(Utils.getAppBackgroundColor());
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ window.setNavigationBarContrastEnforced(true);
+ }
+ }
+
+ /**
+ * Returns the drawable for the back button.
+ */
+ @SuppressLint("UseCompatLoadingForDrawables")
+ public static Drawable getBackButtonDrawable() {
+ final int backButtonResource = Utils.getResourceIdentifierOrThrow(ResourceType.DRAWABLE,
+ Utils.appIsUsingBoldIcons()
+ ? "revanced_settings_toolbar_arrow_left_bold"
+ : "revanced_settings_toolbar_arrow_left");
+ Drawable drawable = Utils.getContext().getResources().getDrawable(backButtonResource);
+ customizeBackButtonDrawable(drawable);
+ return drawable;
+ }
+
+ /**
+ * Customizes the back button drawable.
+ */
+ protected static void customizeBackButtonDrawable(Drawable drawable) {
+ drawable.setTint(Utils.getAppForegroundColor());
+ }
+
+ /**
+ * Allows subclasses to customize the dialog's root view background.
+ */
+ protected void customizeDialogBackground(ViewGroup rootView) {
+ rootView.setBackgroundColor(Utils.getAppBackgroundColor());
+ }
+
+ /**
+ * Allows subclasses to customize the toolbar.
+ */
+ protected void customizeToolbar(Toolbar toolbar) {
+ BaseActivityHook.setToolbarLayoutParams(toolbar);
+ }
+
+ /**
+ * Allows subclasses to perform actions after toolbar setup.
+ */
+ protected void onPostToolbarSetup(Toolbar toolbar, Dialog preferenceScreenDialog) {}
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/URLLinkPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/URLLinkPreference.java
new file mode 100644
index 0000000000..59f3077ceb
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/URLLinkPreference.java
@@ -0,0 +1,44 @@
+package app.revanced.extension.shared.settings.preference;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.preference.Preference;
+import android.util.AttributeSet;
+
+import app.revanced.extension.shared.Logger;
+
+/**
+ * Simple preference that opens a URL when clicked.
+ */
+@SuppressWarnings("deprecation")
+public class URLLinkPreference extends Preference {
+
+ protected String externalURL;
+
+ {
+ setOnPreferenceClickListener(pref -> {
+ if (externalURL == null) {
+ Logger.printException(() -> "URL not set " + getClass().getSimpleName());
+ return false;
+ }
+ Intent i = new Intent(Intent.ACTION_VIEW);
+ i.setData(Uri.parse(externalURL));
+ pref.getContext().startActivity(i);
+ return true;
+ });
+ }
+
+ public URLLinkPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+ public URLLinkPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+ public URLLinkPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public URLLinkPreference(Context context) {
+ super(context);
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/BaseSearchResultItem.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/BaseSearchResultItem.java
new file mode 100644
index 0000000000..95731418d2
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/BaseSearchResultItem.java
@@ -0,0 +1,373 @@
+package app.revanced.extension.shared.settings.search;
+
+import android.graphics.Color;
+import android.preference.ListPreference;
+import android.preference.Preference;
+import android.preference.SwitchPreference;
+import android.text.SpannableStringBuilder;
+import android.text.TextUtils;
+import android.text.style.BackgroundColorSpan;
+
+import androidx.annotation.ColorInt;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import app.revanced.extension.shared.ResourceType;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.preference.ColorPickerPreference;
+import app.revanced.extension.shared.settings.preference.CustomDialogListPreference;
+import app.revanced.extension.shared.settings.preference.URLLinkPreference;
+
+/**
+ * Abstract base class for search result items, defining common fields and behavior.
+ */
+public abstract class BaseSearchResultItem {
+ // Enum to represent view types.
+ public enum ViewType {
+ REGULAR,
+ SWITCH,
+ LIST,
+ COLOR_PICKER,
+ GROUP_HEADER,
+ NO_RESULTS,
+ URL_LINK;
+
+ // Get the corresponding layout resource ID.
+ public int getLayoutResourceId() {
+ return switch (this) {
+ case REGULAR, URL_LINK -> getResourceIdentifier("revanced_preference_search_result_regular");
+ case SWITCH -> getResourceIdentifier("revanced_preference_search_result_switch");
+ case LIST -> getResourceIdentifier("revanced_preference_search_result_list");
+ case COLOR_PICKER -> getResourceIdentifier("revanced_preference_search_result_color");
+ case GROUP_HEADER -> getResourceIdentifier("revanced_preference_search_result_group_header");
+ case NO_RESULTS -> getResourceIdentifier("revanced_preference_search_no_result");
+ };
+ }
+
+ private static int getResourceIdentifier(String name) {
+ // Placeholder for actual resource identifier retrieval.
+ return Utils.getResourceIdentifierOrThrow(ResourceType.LAYOUT, name);
+ }
+ }
+
+ final String navigationPath;
+ final List navigationKeys;
+ final ViewType preferenceType;
+ CharSequence highlightedTitle;
+ CharSequence highlightedSummary;
+ boolean highlightingApplied;
+
+ BaseSearchResultItem(String navPath, List navKeys, ViewType type) {
+ this.navigationPath = navPath;
+ this.navigationKeys = new ArrayList<>(navKeys != null ? navKeys : Collections.emptyList());
+ this.preferenceType = type;
+ this.highlightedTitle = "";
+ this.highlightedSummary = "";
+ this.highlightingApplied = false;
+ }
+
+ abstract boolean matchesQuery(String query);
+ abstract void applyHighlighting(Pattern queryPattern);
+ abstract void clearHighlighting();
+
+ // Shared method for highlighting text with search query.
+ protected static CharSequence highlightSearchQuery(CharSequence text, Pattern queryPattern) {
+ if (TextUtils.isEmpty(text) || queryPattern == null) return text;
+
+ final int adjustedColor = Utils.adjustColorBrightness(
+ Utils.getAppBackgroundColor(), 0.95f, 1.20f);
+ BackgroundColorSpan highlightSpan = new BackgroundColorSpan(adjustedColor);
+ SpannableStringBuilder spannable = new SpannableStringBuilder(text);
+
+ Matcher matcher = queryPattern.matcher(text);
+ while (matcher.find()) {
+ int start = matcher.start();
+ int end = matcher.end();
+ if (start == end) continue; // Skip zero matches.
+ spannable.setSpan(highlightSpan, start, end,
+ SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+
+ return spannable;
+ }
+
+ /**
+ * Search result item for group headers (navigation path only).
+ */
+ public static class GroupHeaderItem extends BaseSearchResultItem {
+ GroupHeaderItem(String navPath, List navKeys) {
+ super(navPath, navKeys, ViewType.GROUP_HEADER);
+ this.highlightedTitle = navPath;
+ }
+
+ @Override
+ boolean matchesQuery(String query) {
+ return false; // Headers are not directly searchable.
+ }
+
+ @Override
+ void applyHighlighting(Pattern queryPattern) {}
+
+ @Override
+ void clearHighlighting() {}
+ }
+
+ /**
+ * Search result item for preferences, handling type-specific data and search text.
+ */
+ @SuppressWarnings("deprecation")
+ public static class PreferenceSearchItem extends BaseSearchResultItem {
+ public final Preference preference;
+ final String searchableText;
+ final CharSequence originalTitle;
+ final CharSequence originalSummary;
+ final CharSequence originalSummaryOn;
+ final CharSequence originalSummaryOff;
+ final CharSequence[] originalEntries;
+ private CharSequence[] highlightedEntries;
+ private boolean entriesHighlightingApplied;
+
+ @ColorInt
+ private int color;
+
+ // Store last applied highlighting pattern to reapply when needed.
+ Pattern lastQueryPattern;
+
+ PreferenceSearchItem(Preference pref, String navPath, List navKeys) {
+ super(navPath, navKeys, determineType(pref));
+ this.preference = pref;
+ this.originalTitle = pref.getTitle() != null ? pref.getTitle() : "";
+ this.originalSummary = pref.getSummary();
+ this.highlightedTitle = this.originalTitle;
+ this.highlightedSummary = this.originalSummary != null ? this.originalSummary : "";
+ this.color = 0;
+ this.lastQueryPattern = null;
+
+ // Initialize type-specific fields.
+ FieldInitializationResult result = initTypeSpecificFields(pref);
+ this.originalSummaryOn = result.summaryOn;
+ this.originalSummaryOff = result.summaryOff;
+ this.originalEntries = result.entries;
+
+ // Build searchable text.
+ this.searchableText = buildSearchableText(pref);
+ }
+
+ private static class FieldInitializationResult {
+ CharSequence summaryOn = null;
+ CharSequence summaryOff = null;
+ CharSequence[] entries = null;
+ }
+
+ private static ViewType determineType(Preference pref) {
+ if (pref instanceof SwitchPreference) return ViewType.SWITCH;
+ if (pref instanceof ListPreference) return ViewType.LIST;
+ if (pref instanceof ColorPickerPreference) return ViewType.COLOR_PICKER;
+ if (pref instanceof URLLinkPreference) return ViewType.URL_LINK;
+ if ("no_results_placeholder".equals(pref.getKey())) return ViewType.NO_RESULTS;
+ return ViewType.REGULAR;
+ }
+
+ private FieldInitializationResult initTypeSpecificFields(Preference pref) {
+ FieldInitializationResult result = new FieldInitializationResult();
+
+ if (pref instanceof SwitchPreference switchPref) {
+ result.summaryOn = switchPref.getSummaryOn();
+ result.summaryOff = switchPref.getSummaryOff();
+ } else if (pref instanceof ColorPickerPreference colorPref) {
+ String colorString = colorPref.getText();
+ this.color = TextUtils.isEmpty(colorString) ? 0 : Color.parseColor(colorString);
+ } else if (pref instanceof ListPreference listPref) {
+ result.entries = listPref.getEntries();
+ if (result.entries != null) {
+ this.highlightedEntries = new CharSequence[result.entries.length];
+ System.arraycopy(result.entries, 0, this.highlightedEntries, 0, result.entries.length);
+ }
+ }
+
+ this.entriesHighlightingApplied = false;
+ return result;
+ }
+
+ private String buildSearchableText(Preference pref) {
+ StringBuilder searchBuilder = new StringBuilder();
+ String key = pref.getKey();
+ String normalizedKey = "";
+ if (key != null) {
+ // Normalize preference key by removing the common "revanced_" prefix
+ // so that users can search by the meaningful part only.
+ normalizedKey = key.startsWith("revanced_")
+ ? key.substring("revanced_".length())
+ : key;
+ }
+ appendText(searchBuilder, normalizedKey);
+ appendText(searchBuilder, originalTitle);
+ appendText(searchBuilder, originalSummary);
+
+ // Add type-specific searchable content.
+ if (pref instanceof ListPreference) {
+ if (originalEntries != null) {
+ for (CharSequence entry : originalEntries) {
+ appendText(searchBuilder, entry);
+ }
+ }
+ } else if (pref instanceof SwitchPreference) {
+ appendText(searchBuilder, originalSummaryOn);
+ appendText(searchBuilder, originalSummaryOff);
+ } else if (pref instanceof ColorPickerPreference) {
+ appendText(searchBuilder, ColorPickerPreference.getColorString(color, false));
+ }
+
+ // Include navigation path in searchable text.
+ appendText(searchBuilder, navigationPath);
+
+ return searchBuilder.toString();
+ }
+
+ /**
+ * Appends normalized searchable text to the builder.
+ * Uses full Unicode normalization for accurate search across all languages.
+ */
+ private void appendText(StringBuilder builder, CharSequence text) {
+ if (!TextUtils.isEmpty(text)) {
+ if (builder.length() > 0) builder.append(" ");
+ builder.append(Utils.normalizeTextToLowercase(text));
+ }
+ }
+
+ /**
+ * Gets the current effective summary for this preference, considering state-dependent summaries.
+ */
+ public CharSequence getCurrentEffectiveSummary() {
+ if (preference instanceof CustomDialogListPreference customPref) {
+ String staticSum = customPref.getStaticSummary();
+ if (staticSum != null) {
+ return staticSum;
+ }
+ }
+ if (preference instanceof SwitchPreference switchPref) {
+ boolean currentState = switchPref.isChecked();
+ return currentState
+ ? (originalSummaryOn != null ? originalSummaryOn :
+ originalSummary != null ? originalSummary : "")
+ : (originalSummaryOff != null ? originalSummaryOff :
+ originalSummary != null ? originalSummary : "");
+ } else if (preference instanceof ListPreference listPref) {
+ String value = listPref.getValue();
+ CharSequence[] entries = listPref.getEntries();
+ CharSequence[] entryValues = listPref.getEntryValues();
+ if (value != null && entries != null && entryValues != null) {
+ for (int i = 0, length = entries.length; i < length; i++) {
+ if (value.equals(entryValues[i].toString())) {
+ return originalEntries != null && i < originalEntries.length && originalEntries[i] != null
+ ? originalEntries[i]
+ : originalSummary != null ? originalSummary : "";
+ }
+ }
+ }
+ return originalSummary != null ? originalSummary : "";
+ }
+ return originalSummary != null ? originalSummary : "";
+ }
+
+ /**
+ * Checks if this search result item matches the provided query.
+ * Uses case-insensitive matching against the searchable text.
+ */
+ @Override
+ boolean matchesQuery(String query) {
+ return searchableText.contains(Utils.normalizeTextToLowercase(query));
+ }
+
+ /**
+ * Get highlighted entries to show in dialog.
+ */
+ public CharSequence[] getHighlightedEntries() {
+ return highlightedEntries;
+ }
+
+ /**
+ * Whether highlighting is applied to entries.
+ */
+ public boolean isEntriesHighlightingApplied() {
+ return entriesHighlightingApplied;
+ }
+
+ /**
+ * Highlights the search query in the title and summary.
+ */
+ @Override
+ void applyHighlighting(Pattern queryPattern) {
+ this.lastQueryPattern = queryPattern;
+ // Highlight the title.
+ highlightedTitle = highlightSearchQuery(originalTitle, queryPattern);
+
+ // Get the current effective summary and highlight it.
+ CharSequence currentSummary = getCurrentEffectiveSummary();
+ highlightedSummary = highlightSearchQuery(currentSummary, queryPattern);
+
+ // Highlight the entries.
+ if (preference instanceof ListPreference && originalEntries != null) {
+ highlightedEntries = new CharSequence[originalEntries.length];
+ for (int i = 0, length = originalEntries.length; i < length; i++) {
+ if (originalEntries[i] != null) {
+ highlightedEntries[i] = highlightSearchQuery(originalEntries[i], queryPattern);
+ } else {
+ highlightedEntries[i] = null;
+ }
+ }
+ entriesHighlightingApplied = true;
+ }
+
+ highlightingApplied = true;
+ }
+
+ /**
+ * Clears all search query highlighting and restores original state completely.
+ */
+ @Override
+ void clearHighlighting() {
+ if (!highlightingApplied) return;
+
+ // Restore original title.
+ highlightedTitle = originalTitle;
+
+ // Restore current effective summary without highlighting.
+ highlightedSummary = getCurrentEffectiveSummary();
+
+ // Restore original entries.
+ if (originalEntries != null && highlightedEntries != null) {
+ System.arraycopy(originalEntries, 0, highlightedEntries, 0,
+ Math.min(originalEntries.length, highlightedEntries.length));
+ }
+
+ entriesHighlightingApplied = false;
+ highlightingApplied = false;
+ lastQueryPattern = null;
+ }
+
+ /**
+ * Refreshes highlighting for dynamic summaries (like switch preferences).
+ * Should be called when the preference state changes.
+ */
+ public void refreshHighlighting() {
+ if (highlightingApplied && lastQueryPattern != null) {
+ CharSequence currentSummary = getCurrentEffectiveSummary();
+ highlightedSummary = highlightSearchQuery(currentSummary, lastQueryPattern);
+ }
+ }
+
+ public void setColor(int newColor) {
+ this.color = newColor;
+ }
+
+ @ColorInt
+ public int getColor() {
+ return color;
+ }
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/BaseSearchResultsAdapter.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/BaseSearchResultsAdapter.java
new file mode 100644
index 0000000000..04d69c6b6b
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/BaseSearchResultsAdapter.java
@@ -0,0 +1,621 @@
+package app.revanced.extension.shared.settings.search;
+
+import static app.revanced.extension.shared.Utils.getResourceIdentifierOrThrow;
+
+import android.animation.AnimatorSet;
+import android.animation.ArgbEvaluator;
+import android.animation.ObjectAnimator;
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.preference.ListPreference;
+import android.preference.Preference;
+import android.preference.PreferenceGroup;
+import android.preference.PreferenceScreen;
+import android.preference.SwitchPreference;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AbsListView;
+import android.widget.ArrayAdapter;
+import android.widget.ImageView;
+import android.widget.ListAdapter;
+import android.widget.ListView;
+import android.widget.Switch;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.lang.reflect.Method;
+import java.util.List;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.ResourceType;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.preference.ColorPickerPreference;
+import app.revanced.extension.shared.settings.preference.CustomDialogListPreference;
+import app.revanced.extension.shared.settings.preference.URLLinkPreference;
+import app.revanced.extension.shared.ui.ColorDot;
+
+/**
+ * Abstract adapter for displaying search results in overlay ListView with ViewHolder pattern.
+ */
+@SuppressWarnings("deprecation")
+public abstract class BaseSearchResultsAdapter extends ArrayAdapter {
+ protected final LayoutInflater inflater;
+ protected final BaseSearchViewController.BasePreferenceFragment fragment;
+ protected final BaseSearchViewController searchViewController;
+ protected AnimatorSet currentAnimator;
+ protected abstract PreferenceScreen getMainPreferenceScreen();
+
+ protected static final int BLINK_DURATION = 400;
+ protected static final int PAUSE_BETWEEN_BLINKS = 100;
+
+ protected static final int ID_PREFERENCE_TITLE = getResourceIdentifierOrThrow(
+ ResourceType.ID, "preference_title");
+ protected static final int ID_PREFERENCE_SUMMARY = getResourceIdentifierOrThrow(
+ ResourceType.ID, "preference_summary");
+ protected static final int ID_PREFERENCE_PATH = getResourceIdentifierOrThrow(
+ ResourceType.ID, "preference_path");
+ protected static final int ID_PREFERENCE_SWITCH = getResourceIdentifierOrThrow(
+ ResourceType.ID, "preference_switch");
+ protected static final int ID_PREFERENCE_COLOR_DOT = getResourceIdentifierOrThrow(
+ ResourceType.ID, "preference_color_dot");
+
+ protected static class RegularViewHolder {
+ TextView titleView;
+ TextView summaryView;
+ }
+
+ protected static class SwitchViewHolder {
+ TextView titleView;
+ TextView summaryView;
+ Switch switchWidget;
+ }
+
+ protected static class ColorViewHolder {
+ TextView titleView;
+ TextView summaryView;
+ View colorDot;
+ }
+
+ protected static class GroupHeaderViewHolder {
+ TextView pathView;
+ }
+
+ protected static class NoResultsViewHolder {
+ TextView titleView;
+ TextView summaryView;
+ ImageView iconView;
+ }
+
+ public BaseSearchResultsAdapter(Context context, List items,
+ BaseSearchViewController.BasePreferenceFragment fragment,
+ BaseSearchViewController searchViewController) {
+ super(context, 0, items);
+ this.inflater = LayoutInflater.from(context);
+ this.fragment = fragment;
+ this.searchViewController = searchViewController;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ BaseSearchResultItem item = getItem(position);
+ return item == null ? 0 : item.preferenceType.ordinal();
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return BaseSearchResultItem.ViewType.values().length;
+ }
+
+ @NonNull
+ @Override
+ public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
+ BaseSearchResultItem item = getItem(position);
+ if (item == null) return new View(getContext());
+ // Use the ViewType enum.
+ BaseSearchResultItem.ViewType viewType = item.preferenceType;
+ // Create or reuse preference view based on type.
+ return createPreferenceView(item, convertView, viewType, parent);
+ }
+
+ @Override
+ public boolean isEnabled(int position) {
+ BaseSearchResultItem item = getItem(position);
+ // Disable for NO_RESULTS items to prevent ripple/selection.
+ return item != null && item.preferenceType != BaseSearchResultItem.ViewType.NO_RESULTS;
+ }
+
+ /**
+ * Creates or reuses a view for the given SearchResultItem.
+ *
+ * Thanks to {@link #getItemViewType(int)} and {@link #getViewTypeCount()}, ListView knows
+ * how many different row types exist and keeps a separate "recycling pool" for each.
+ * That means convertView passed here is ALWAYS of the correct type for this position.
+ * So only need to check if (view == null), and if so – inflate a new layout and create the proper ViewHolder.
+ */
+ protected View createPreferenceView(BaseSearchResultItem item, View convertView,
+ BaseSearchResultItem.ViewType viewType, ViewGroup parent) {
+ View view = convertView;
+ if (view == null) {
+ view = inflateViewForType(viewType, parent);
+ createViewHolderForType(view, viewType);
+ }
+
+ // Retrieve the cached ViewHolder.
+ Object holder = view.getTag();
+ bindDataToViewHolder(item, holder, viewType, view);
+ return view;
+ }
+
+ protected View inflateViewForType(BaseSearchResultItem.ViewType viewType, ViewGroup parent) {
+ return inflater.inflate(viewType.getLayoutResourceId(), parent, false);
+ }
+
+ protected void createViewHolderForType(View view, BaseSearchResultItem.ViewType viewType) {
+ switch (viewType) {
+ case REGULAR, LIST, URL_LINK -> {
+ RegularViewHolder regularHolder = new RegularViewHolder();
+ regularHolder.titleView = view.findViewById(ID_PREFERENCE_TITLE);
+ regularHolder.summaryView = view.findViewById(ID_PREFERENCE_SUMMARY);
+ view.setTag(regularHolder);
+ }
+ case SWITCH -> {
+ SwitchViewHolder switchHolder = new SwitchViewHolder();
+ switchHolder.titleView = view.findViewById(ID_PREFERENCE_TITLE);
+ switchHolder.summaryView = view.findViewById(ID_PREFERENCE_SUMMARY);
+ switchHolder.switchWidget = view.findViewById(ID_PREFERENCE_SWITCH);
+ view.setTag(switchHolder);
+ }
+ case COLOR_PICKER -> {
+ ColorViewHolder colorHolder = new ColorViewHolder();
+ colorHolder.titleView = view.findViewById(ID_PREFERENCE_TITLE);
+ colorHolder.summaryView = view.findViewById(ID_PREFERENCE_SUMMARY);
+ colorHolder.colorDot = view.findViewById(ID_PREFERENCE_COLOR_DOT);
+ view.setTag(colorHolder);
+ }
+ case GROUP_HEADER -> {
+ GroupHeaderViewHolder groupHolder = new GroupHeaderViewHolder();
+ groupHolder.pathView = view.findViewById(ID_PREFERENCE_PATH);
+ view.setTag(groupHolder);
+ }
+ case NO_RESULTS -> {
+ NoResultsViewHolder noResultsHolder = new NoResultsViewHolder();
+ noResultsHolder.titleView = view.findViewById(ID_PREFERENCE_TITLE);
+ noResultsHolder.summaryView = view.findViewById(ID_PREFERENCE_SUMMARY);
+ noResultsHolder.iconView = view.findViewById(android.R.id.icon);
+ view.setTag(noResultsHolder);
+ }
+ default -> throw new IllegalStateException("Unknown viewType: " + viewType);
+ }
+ }
+
+ protected void bindDataToViewHolder(BaseSearchResultItem item, Object holder,
+ BaseSearchResultItem.ViewType viewType, View view) {
+ switch (viewType) {
+ case REGULAR, URL_LINK, LIST -> bindRegularViewHolder(item, (RegularViewHolder) holder, view);
+ case SWITCH -> bindSwitchViewHolder(item, (SwitchViewHolder) holder, view);
+ case COLOR_PICKER -> bindColorViewHolder(item, (ColorViewHolder) holder, view);
+ case GROUP_HEADER -> bindGroupHeaderViewHolder(item, (GroupHeaderViewHolder) holder, view);
+ case NO_RESULTS -> bindNoResultsViewHolder(item, (NoResultsViewHolder) holder);
+ default -> throw new IllegalStateException("Unknown viewType: " + viewType);
+ }
+ }
+
+ protected void bindRegularViewHolder(BaseSearchResultItem item, RegularViewHolder holder, View view) {
+ BaseSearchResultItem.PreferenceSearchItem prefItem = (BaseSearchResultItem.PreferenceSearchItem) item;
+ prefItem.refreshHighlighting();
+ holder.titleView.setText(item.highlightedTitle);
+ holder.summaryView.setText(item.highlightedSummary);
+ holder.summaryView.setVisibility(TextUtils.isEmpty(item.highlightedSummary) ? View.GONE : View.VISIBLE);
+ setupPreferenceView(view, holder.titleView, holder.summaryView, prefItem.preference,
+ () -> {
+ handlePreferenceClick(prefItem.preference);
+ if (prefItem.preference instanceof ListPreference) {
+ prefItem.refreshHighlighting();
+ holder.summaryView.setText(prefItem.getCurrentEffectiveSummary());
+ holder.summaryView.setVisibility(TextUtils.isEmpty(prefItem.highlightedSummary) ? View.GONE : View.VISIBLE);
+ notifyDataSetChanged();
+ }
+ },
+ () -> navigateAndScrollToPreference(item));
+ }
+
+ protected void bindSwitchViewHolder(BaseSearchResultItem item, SwitchViewHolder holder, View view) {
+ BaseSearchResultItem.PreferenceSearchItem prefItem = (BaseSearchResultItem.PreferenceSearchItem) item;
+ SwitchPreference switchPref = (SwitchPreference) prefItem.preference;
+ holder.titleView.setText(item.highlightedTitle);
+ holder.switchWidget.setBackground(null); // Remove ripple/highlight.
+ // Sync switch state with preference without animation.
+ boolean currentState = switchPref.isChecked();
+ if (holder.switchWidget.isChecked() != currentState) {
+ holder.switchWidget.setChecked(currentState);
+ holder.switchWidget.jumpDrawablesToCurrentState();
+ }
+ prefItem.refreshHighlighting();
+ holder.summaryView.setText(prefItem.highlightedSummary);
+ holder.summaryView.setVisibility(TextUtils.isEmpty(prefItem.highlightedSummary) ? View.GONE : View.VISIBLE);
+ setupPreferenceView(view, holder.titleView, holder.summaryView, switchPref,
+ () -> {
+ boolean newState = !switchPref.isChecked();
+ switchPref.setChecked(newState);
+ holder.switchWidget.setChecked(newState);
+ prefItem.refreshHighlighting();
+ holder.summaryView.setText(prefItem.getCurrentEffectiveSummary());
+ holder.summaryView.setVisibility(TextUtils.isEmpty(prefItem.highlightedSummary) ? View.GONE : View.VISIBLE);
+ if (switchPref.getOnPreferenceChangeListener() != null) {
+ switchPref.getOnPreferenceChangeListener().onPreferenceChange(switchPref, newState);
+ }
+ notifyDataSetChanged();
+ },
+ () -> navigateAndScrollToPreference(item));
+ holder.switchWidget.setEnabled(switchPref.isEnabled());
+ }
+
+ protected void bindColorViewHolder(BaseSearchResultItem item, ColorViewHolder holder, View view) {
+ BaseSearchResultItem.PreferenceSearchItem prefItem = (BaseSearchResultItem.PreferenceSearchItem) item;
+ holder.titleView.setText(item.highlightedTitle);
+ holder.summaryView.setText(item.highlightedSummary);
+ holder.summaryView.setVisibility(TextUtils.isEmpty(item.highlightedSummary) ? View.GONE : View.VISIBLE);
+ ColorDot.applyColorDot(holder.colorDot, prefItem.getColor(), prefItem.preference.isEnabled());
+ setupPreferenceView(view, holder.titleView, holder.summaryView, prefItem.preference,
+ () -> handlePreferenceClick(prefItem.preference),
+ () -> navigateAndScrollToPreference(item));
+ }
+
+ protected void bindGroupHeaderViewHolder(BaseSearchResultItem item, GroupHeaderViewHolder holder, View view) {
+ holder.pathView.setText(item.highlightedTitle);
+ view.setOnClickListener(v -> navigateToTargetScreen(item));
+ }
+
+ protected void bindNoResultsViewHolder(BaseSearchResultItem item, NoResultsViewHolder holder) {
+ holder.titleView.setText(item.highlightedTitle);
+ holder.summaryView.setText(item.highlightedSummary);
+ holder.summaryView.setVisibility(TextUtils.isEmpty(item.highlightedSummary) ? View.GONE : View.VISIBLE);
+ holder.iconView.setImageResource(BaseSearchViewController.getSearchIcon());
+ }
+
+ /**
+ * Sets up a preference view with click listeners and proper enabled state handling.
+ */
+ protected void setupPreferenceView(View view, TextView titleView, TextView summaryView, Preference preference,
+ Runnable onClickAction, Runnable onLongClickAction) {
+ boolean enabled = preference.isEnabled();
+
+ // To enable long-click navigation for disabled settings, manually control the enabled state of the title
+ // and summary and disable the ripple effect instead of using 'view.setEnabled(enabled)'.
+
+ titleView.setEnabled(enabled);
+ summaryView.setEnabled(enabled);
+
+ if (!enabled) view.setBackground(null); // Disable ripple effect.
+
+ // In light mode, alpha 0.5 is applied to a disabled title automatically,
+ // but in dark mode it needs to be applied manually.
+ if (Utils.isDarkModeEnabled()) {
+ titleView.setAlpha(enabled ? 1.0f : ColorPickerPreference.DISABLED_ALPHA);
+ }
+ // Set up click and long-click listeners.
+ view.setOnClickListener(enabled ? v -> onClickAction.run() : null);
+ view.setOnLongClickListener(v -> {
+ onLongClickAction.run();
+ return true;
+ });
+ }
+
+ /**
+ * Navigates to the settings screen containing the given search result item and triggers scrolling.
+ */
+ protected void navigateAndScrollToPreference(BaseSearchResultItem item) {
+ // No navigation for URL_LINK items.
+ if (item.preferenceType == BaseSearchResultItem.ViewType.URL_LINK) return;
+
+ PreferenceScreen targetScreen = navigateToTargetScreen(item);
+ if (targetScreen == null) return;
+ if (!(item instanceof BaseSearchResultItem.PreferenceSearchItem prefItem)) return;
+
+ Preference targetPreference = prefItem.preference;
+
+ fragment.getView().post(() -> {
+ ListView listView = targetScreen == getMainPreferenceScreen()
+ ? getPreferenceListView()
+ : targetScreen.getDialog().findViewById(android.R.id.list);
+
+ if (listView == null) return;
+
+ int targetPosition = findPreferencePosition(targetPreference, listView);
+ if (targetPosition == -1) return;
+
+ int firstVisible = listView.getFirstVisiblePosition();
+ int lastVisible = listView.getLastVisiblePosition();
+
+ if (targetPosition >= firstVisible && targetPosition <= lastVisible) {
+ // The preference is already visible, but still scroll it to the bottom of the list for consistency.
+ View child = listView.getChildAt(targetPosition - firstVisible);
+ if (child != null) {
+ // Calculate how much to scroll so the item is aligned at the bottom.
+ int scrollAmount = child.getBottom() - listView.getHeight();
+ if (scrollAmount > 0) {
+ // Perform smooth scroll animation for better user experience.
+ listView.smoothScrollBy(scrollAmount, 300);
+ }
+ }
+ // Highlight the preference once it is positioned.
+ highlightPreferenceAtPosition(listView, targetPosition);
+ } else {
+ // The preference is outside of the current visible range, scroll to it from the top.
+ listView.smoothScrollToPositionFromTop(targetPosition, 0);
+
+ Handler handler = new Handler(Looper.getMainLooper());
+ // Fallback runnable in case the OnScrollListener does not trigger.
+ Runnable fallback = () -> {
+ listView.setOnScrollListener(null);
+ highlightPreferenceAtPosition(listView, targetPosition);
+ };
+ // Post fallback with a small delay.
+ handler.postDelayed(fallback, 350);
+
+ listView.setOnScrollListener(new AbsListView.OnScrollListener() {
+ private boolean isScrolling = false;
+
+ @Override
+ public void onScrollStateChanged(AbsListView view, int scrollState) {
+ if (scrollState == SCROLL_STATE_TOUCH_SCROLL || scrollState == SCROLL_STATE_FLING) {
+ // Mark that scrolling has started.
+ isScrolling = true;
+ }
+ if (scrollState == SCROLL_STATE_IDLE && isScrolling) {
+ // Scrolling is finished, cleanup listener and cancel fallback.
+ isScrolling = false;
+ listView.setOnScrollListener(null);
+ handler.removeCallbacks(fallback);
+ // Highlight the target preference when scrolling is done.
+ highlightPreferenceAtPosition(listView, targetPosition);
+ }
+ }
+
+ @Override
+ public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {}
+ });
+ }
+ });
+ }
+
+ /**
+ * Navigates to the final PreferenceScreen using preference keys or titles as fallback.
+ */
+ protected PreferenceScreen navigateToTargetScreen(BaseSearchResultItem item) {
+ PreferenceScreen currentScreen = getMainPreferenceScreen();
+ Preference targetPref = null;
+
+ // Try key-based navigation first.
+ if (item.navigationKeys != null && !item.navigationKeys.isEmpty()) {
+ String finalKey = item.navigationKeys.get(item.navigationKeys.size() - 1);
+ targetPref = findPreferenceByKey(currentScreen, finalKey);
+ }
+
+ // Fallback to title-based navigation.
+ if (targetPref == null && !TextUtils.isEmpty(item.navigationPath)) {
+ String[] pathSegments = item.navigationPath.split(" > ");
+ String finalSegment = pathSegments[pathSegments.length - 1].trim();
+ if (!TextUtils.isEmpty(finalSegment)) {
+ targetPref = findPreferenceByTitle(currentScreen, finalSegment);
+ }
+ }
+
+ if (targetPref instanceof PreferenceScreen targetScreen) {
+ handlePreferenceClick(targetScreen);
+ return targetScreen;
+ }
+
+ return currentScreen;
+ }
+
+ /**
+ * Recursively searches for a preference by title in a preference group.
+ */
+ protected Preference findPreferenceByTitle(PreferenceGroup group, String title) {
+ for (int i = 0; i < group.getPreferenceCount(); i++) {
+ Preference pref = group.getPreference(i);
+ CharSequence prefTitle = pref.getTitle();
+ if (prefTitle != null && (prefTitle.toString().trim().equalsIgnoreCase(title)
+ || normalizeString(prefTitle.toString()).equals(normalizeString(title)))) {
+ return pref;
+ }
+ if (pref instanceof PreferenceGroup) {
+ Preference found = findPreferenceByTitle((PreferenceGroup) pref, title);
+ if (found != null) {
+ return found;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Normalizes string for comparison (removes extra characters, spaces etc.).
+ */
+ protected String normalizeString(String input) {
+ if (TextUtils.isEmpty(input)) return "";
+ return input.trim().toLowerCase().replaceAll("\\s+", " ").replaceAll("[^\\w\\s]", "");
+ }
+
+ /**
+ * Gets the ListView from the PreferenceFragment.
+ */
+ protected ListView getPreferenceListView() {
+ View fragmentView = fragment.getView();
+ if (fragmentView != null) {
+ ListView listView = findListViewInViewGroup(fragmentView);
+ if (listView != null) {
+ return listView;
+ }
+ }
+ return fragment.getActivity().findViewById(android.R.id.list);
+ }
+
+ /**
+ * Recursively searches for a ListView in a ViewGroup.
+ */
+ protected ListView findListViewInViewGroup(View view) {
+ if (view instanceof ListView) {
+ return (ListView) view;
+ }
+ if (view instanceof ViewGroup group) {
+ for (int i = 0; i < group.getChildCount(); i++) {
+ ListView result = findListViewInViewGroup(group.getChildAt(i));
+ if (result != null) {
+ return result;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Finds the position of a preference in the ListView adapter.
+ */
+ protected int findPreferencePosition(Preference targetPreference, ListView listView) {
+ ListAdapter adapter = listView.getAdapter();
+ if (adapter == null) {
+ return -1;
+ }
+
+ for (int i = 0, count = adapter.getCount(); i < count; i++) {
+ Object item = adapter.getItem(i);
+ if (item == targetPreference) {
+ return i;
+ }
+ if (item instanceof Preference pref && targetPreference.getKey() != null) {
+ if (targetPreference.getKey().equals(pref.getKey())) {
+ return i;
+ }
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Highlights a preference at the specified position with a blink effect.
+ */
+ protected void highlightPreferenceAtPosition(ListView listView, int position) {
+ int firstVisible = listView.getFirstVisiblePosition();
+ if (position < firstVisible || position > listView.getLastVisiblePosition()) {
+ return;
+ }
+
+ View itemView = listView.getChildAt(position - firstVisible);
+ if (itemView != null) {
+ blinkView(itemView);
+ }
+ }
+
+ /**
+ * Creates a smooth double-blink effect on a view's background without affecting the text.
+ * @param view The View to apply the animation to.
+ */
+ protected void blinkView(View view) {
+ // If a previous animation is still running, cancel it to prevent conflicts.
+ if (currentAnimator != null && currentAnimator.isRunning()) {
+ currentAnimator.cancel();
+ }
+ final int startColor = Utils.getAppBackgroundColor();
+ final int highlightColor = Utils.adjustColorBrightness(
+ startColor,
+ Utils.isDarkModeEnabled() ? 1.25f : 0.8f
+ );
+ // Animator for transitioning from the start color to the highlight color.
+ ObjectAnimator fadeIn = ObjectAnimator.ofObject(
+ view,
+ "backgroundColor",
+ new ArgbEvaluator(),
+ startColor,
+ highlightColor
+ );
+ fadeIn.setDuration(BLINK_DURATION);
+ // Animator to return to the start color.
+ ObjectAnimator fadeOut = ObjectAnimator.ofObject(
+ view,
+ "backgroundColor",
+ new ArgbEvaluator(),
+ highlightColor,
+ startColor
+ );
+ fadeOut.setDuration(BLINK_DURATION);
+
+ currentAnimator = new AnimatorSet();
+ // Create the sequence: fadeIn -> fadeOut -> (pause) -> fadeIn -> fadeOut.
+ AnimatorSet firstBlink = new AnimatorSet();
+ firstBlink.playSequentially(fadeIn, fadeOut);
+ AnimatorSet secondBlink = new AnimatorSet();
+ secondBlink.playSequentially(fadeIn.clone(), fadeOut.clone()); // Use clones for the second blink.
+
+ currentAnimator.play(secondBlink).after(firstBlink).after(PAUSE_BETWEEN_BLINKS);
+ currentAnimator.start();
+ }
+
+ /**
+ * Recursively finds a preference by key in a preference group.
+ */
+ protected Preference findPreferenceByKey(PreferenceGroup group, String key) {
+ if (group == null || TextUtils.isEmpty(key)) {
+ return null;
+ }
+
+ // First search on current level.
+ for (int i = 0, count = group.getPreferenceCount(); i < count; i++) {
+ Preference pref = group.getPreference(i);
+ if (key.equals(pref.getKey())) {
+ return pref;
+ }
+ if (pref instanceof PreferenceGroup) {
+ Preference found = findPreferenceByKey((PreferenceGroup) pref, key);
+ if (found != null) {
+ return found;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Handles preference click actions by invoking the preference's performClick method via reflection.
+ */
+ @SuppressWarnings("all")
+ private void handlePreferenceClick(Preference preference) {
+ try {
+ if (preference instanceof CustomDialogListPreference listPref) {
+ BaseSearchResultItem.PreferenceSearchItem searchItem =
+ searchViewController.findSearchItemByPreference(preference);
+ if (searchItem != null && searchItem.isEntriesHighlightingApplied()) {
+ listPref.setHighlightedEntriesForDialog(searchItem.getHighlightedEntries());
+ }
+ }
+
+ Method m = Preference.class.getDeclaredMethod("performClick", PreferenceScreen.class);
+ m.setAccessible(true);
+ m.invoke(preference, fragment.getPreferenceScreenForSearch());
+ } catch (Exception e) {
+ Logger.printException(() -> "Failed to invoke performClick()", e);
+ }
+ }
+
+ /**
+ * Checks if a preference has navigation capability (can open a new screen).
+ */
+ boolean hasNavigationCapability(Preference preference) {
+ // PreferenceScreen always allows navigation.
+ if (preference instanceof PreferenceScreen) return true;
+ // URLLinkPreference does not navigate to a new screen, it opens an external URL.
+ if (preference instanceof URLLinkPreference) return false;
+ // Other group types that might have their own screens.
+ if (preference instanceof PreferenceGroup) {
+ // Check if it has its own fragment or intent.
+ return preference.getIntent() != null || preference.getFragment() != null;
+ }
+ return false;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/BaseSearchViewController.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/BaseSearchViewController.java
new file mode 100644
index 0000000000..3be942f6f6
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/BaseSearchViewController.java
@@ -0,0 +1,704 @@
+package app.revanced.extension.shared.settings.search;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.Utils.getResourceIdentifierOrThrow;
+
+import android.app.Activity;
+import android.content.Context;
+import android.graphics.Color;
+import android.graphics.drawable.GradientDrawable;
+import android.os.Build;
+import android.preference.Preference;
+import android.preference.PreferenceCategory;
+import android.preference.PreferenceGroup;
+import android.preference.PreferenceScreen;
+import android.text.TextUtils;
+import android.view.Gravity;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.WindowManager;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+import android.widget.ListView;
+import android.widget.SearchView;
+import android.widget.Toolbar;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.RequiresApi;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.ResourceType;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.AppLanguage;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.settings.preference.ColorPickerPreference;
+import app.revanced.extension.shared.settings.preference.CustomDialogListPreference;
+import app.revanced.extension.shared.settings.preference.NoTitlePreferenceCategory;
+import app.revanced.extension.shared.ui.Dim;
+
+/**
+ * Abstract controller for managing the overlay search view in ReVanced settings.
+ * Subclasses must implement app-specific preference handling.
+ */
+@SuppressWarnings("deprecation")
+public abstract class BaseSearchViewController {
+ protected SearchView searchView;
+ protected FrameLayout searchContainer;
+ protected FrameLayout overlayContainer;
+ protected final Toolbar toolbar;
+ protected final Activity activity;
+ protected final BasePreferenceFragment fragment;
+ protected final CharSequence originalTitle;
+ protected BaseSearchResultsAdapter searchResultsAdapter;
+ protected final List allSearchItems;
+ protected final List filteredSearchItems;
+ protected final Map keyToSearchItem;
+ protected final InputMethodManager inputMethodManager;
+ protected SearchHistoryManager searchHistoryManager;
+ protected boolean isSearchActive;
+ protected boolean isShowingSearchHistory;
+
+ protected static final int MAX_SEARCH_RESULTS = 50; // Maximum number of search results displayed.
+
+ protected static final int ID_REVANCED_SEARCH_VIEW = getResourceIdentifierOrThrow(
+ ResourceType.ID, "revanced_search_view");
+ protected static final int ID_REVANCED_SEARCH_VIEW_CONTAINER = getResourceIdentifierOrThrow(
+ ResourceType.ID, "revanced_search_view_container");
+ protected static final int ID_ACTION_SEARCH = getResourceIdentifierOrThrow(
+ ResourceType.ID, "action_search");
+ protected static final int ID_REVANCED_SETTINGS_FRAGMENTS = getResourceIdentifierOrThrow(
+ ResourceType.ID, "revanced_settings_fragments");
+ private static final int DRAWABLE_REVANCED_SETTINGS_SEARCH_ICON = getResourceIdentifierOrThrow(
+ ResourceType.DRAWABLE, "revanced_settings_search_icon");
+ private static final int DRAWABLE_REVANCED_SETTINGS_SEARCH_ICON_BOLD = getResourceIdentifierOrThrow(
+ ResourceType.DRAWABLE, "revanced_settings_search_icon_bold");
+ protected static final int MENU_REVANCED_SEARCH_MENU = getResourceIdentifierOrThrow(
+ ResourceType.MENU, "revanced_search_menu");
+
+ /**
+ * @return The search icon, either bold or not bold, depending on the ReVanced UI setting.
+ */
+ public static int getSearchIcon() {
+ return Utils.appIsUsingBoldIcons()
+ ? DRAWABLE_REVANCED_SETTINGS_SEARCH_ICON_BOLD
+ : DRAWABLE_REVANCED_SETTINGS_SEARCH_ICON;
+ }
+
+ /**
+ * Constructs a new BaseSearchViewController instance.
+ *
+ * @param activity The activity hosting the search view.
+ * @param toolbar The toolbar containing the search action.
+ * @param fragment The preference fragment to manage search preferences.
+ */
+ protected BaseSearchViewController(Activity activity, Toolbar toolbar, BasePreferenceFragment fragment) {
+ this.activity = activity;
+ this.toolbar = toolbar;
+ this.fragment = fragment;
+ this.originalTitle = toolbar.getTitle();
+ this.allSearchItems = new ArrayList<>();
+ this.filteredSearchItems = new ArrayList<>();
+ this.keyToSearchItem = new HashMap<>();
+ this.inputMethodManager = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
+ this.isShowingSearchHistory = false;
+
+ // Initialize components
+ initializeSearchView();
+ initializeOverlayContainer();
+ initializeSearchHistoryManager();
+ setupToolbarMenu();
+ setupListeners();
+ }
+
+ /**
+ * Initializes the search view with proper configurations, such as background, query hint, and RTL support.
+ */
+ private void initializeSearchView() {
+ // Retrieve SearchView and container from XML.
+ searchView = activity.findViewById(ID_REVANCED_SEARCH_VIEW);
+ EditText searchEditText = searchView.findViewById(Utils.getResourceIdentifierOrThrow(
+ null, "android:id/search_src_text"));
+ // Disable fullscreen keyboard mode.
+ searchEditText.setImeOptions(searchEditText.getImeOptions() | EditorInfo.IME_FLAG_NO_EXTRACT_UI);
+
+ searchContainer = activity.findViewById(ID_REVANCED_SEARCH_VIEW_CONTAINER);
+
+ // Set background and query hint.
+ searchView.setBackground(createBackgroundDrawable());
+ searchView.setQueryHint(str("revanced_settings_search_hint"));
+
+ // Set text size.
+ searchEditText.setTextSize(16);
+
+ // Set cursor color.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ setCursorColor(searchEditText);
+ }
+
+ // Configure RTL support based on app language.
+ AppLanguage appLanguage = BaseSettings.REVANCED_LANGUAGE.get();
+ if (Utils.isRightToLeftLocale(appLanguage.getLocale())) {
+ searchView.setTextDirection(View.TEXT_DIRECTION_RTL);
+ searchView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_END);
+ }
+ }
+
+ /**
+ * Sets the cursor color (for Android 10+ devices).
+ */
+ @RequiresApi(api = Build.VERSION_CODES.Q)
+ private void setCursorColor(EditText editText) {
+ // Get the cursor color based on the current theme.
+ final int cursorColor = Utils.isDarkModeEnabled() ? Color.WHITE : Color.BLACK;
+
+ // Create cursor drawable.
+ GradientDrawable cursorDrawable = new GradientDrawable();
+ cursorDrawable.setShape(GradientDrawable.RECTANGLE);
+ cursorDrawable.setSize(Dim.dp2, -1); // Width: 2dp, Height: match text height.
+ cursorDrawable.setColor(cursorColor);
+
+ // Set cursor drawable.
+ editText.setTextCursorDrawable(cursorDrawable);
+ }
+
+ /**
+ * Initializes the overlay container for displaying search results and history.
+ */
+ private void initializeOverlayContainer() {
+ // Create overlay container for search results and history.
+ overlayContainer = new FrameLayout(activity);
+ overlayContainer.setVisibility(View.GONE);
+ overlayContainer.setBackgroundColor(Utils.getAppBackgroundColor());
+ overlayContainer.setElevation(Dim.dp8);
+
+ // Container for search results.
+ FrameLayout searchResultsContainer = new FrameLayout(activity);
+ searchResultsContainer.setVisibility(View.VISIBLE);
+
+ // Create a ListView for the results.
+ ListView searchResultsListView = new ListView(activity);
+ searchResultsListView.setDivider(null);
+ searchResultsListView.setDividerHeight(0);
+ searchResultsAdapter = createSearchResultsAdapter();
+ searchResultsListView.setAdapter(searchResultsAdapter);
+
+ // Add results list into container.
+ searchResultsContainer.addView(searchResultsListView, new FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT));
+
+ // Add results container into overlay.
+ overlayContainer.addView(searchResultsContainer, new FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT));
+
+ // Add overlay to the main content container.
+ FrameLayout mainContainer = activity.findViewById(ID_REVANCED_SETTINGS_FRAGMENTS);
+ if (mainContainer != null) {
+ FrameLayout.LayoutParams overlayParams = new FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT);
+ overlayParams.gravity = Gravity.TOP;
+ mainContainer.addView(overlayContainer, overlayParams);
+ }
+ }
+
+ /**
+ * Initializes the search history manager with the specified overlay container and listener.
+ */
+ private void initializeSearchHistoryManager() {
+ searchHistoryManager = new SearchHistoryManager(activity, overlayContainer, query -> {
+ searchView.setQuery(query, true);
+ hideSearchHistory();
+ });
+ }
+
+ // Abstract methods that subclasses must implement.
+ protected abstract BaseSearchResultsAdapter createSearchResultsAdapter();
+ @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+ protected abstract boolean isSpecialPreferenceGroup(Preference preference);
+ protected abstract void setupSpecialPreferenceListeners(BaseSearchResultItem item);
+
+ // Abstract interface for preference fragments.
+ public interface BasePreferenceFragment {
+ PreferenceScreen getPreferenceScreenForSearch();
+ android.view.View getView();
+ Activity getActivity();
+ }
+
+ /**
+ * Determines whether a preference should be included in the search index.
+ *
+ * @param preference The preference to evaluate.
+ * @param currentDepth The current depth in the preference hierarchy.
+ * @param includeDepth The maximum depth to include in the search index.
+ * @return True if the preference should be included, false otherwise.
+ */
+ protected boolean shouldIncludePreference(Preference preference, int currentDepth, int includeDepth) {
+ return includeDepth <= currentDepth
+ && !(preference instanceof PreferenceCategory)
+ && !isSpecialPreferenceGroup(preference)
+ && !(preference instanceof PreferenceScreen);
+ }
+
+ /**
+ * Sets up the toolbar menu for the search action.
+ */
+ protected void setupToolbarMenu() {
+ toolbar.inflateMenu(MENU_REVANCED_SEARCH_MENU);
+ toolbar.setOnMenuItemClickListener(item -> {
+ if (item.getItemId() == ID_ACTION_SEARCH && !isSearchActive) {
+ openSearch();
+ return true;
+ }
+ return false;
+ });
+
+ // Set bold icon if needed.
+ MenuItem search = toolbar.getMenu().findItem(ID_ACTION_SEARCH);
+ search.setIcon(getSearchIcon());
+ }
+
+ /**
+ * Configures listeners for the search view and toolbar navigation.
+ */
+ protected void setupListeners() {
+ searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
+ @Override
+ public boolean onQueryTextSubmit(String query) {
+ try {
+ String queryTrimmed = query.trim();
+ if (!queryTrimmed.isEmpty()) {
+ searchHistoryManager.saveSearchQuery(queryTrimmed);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onQueryTextSubmit failure", ex);
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onQueryTextChange(String newText) {
+ try {
+ Logger.printDebug(() -> "Search query: " + newText);
+
+ String trimmedText = newText.trim();
+ if (!isSearchActive) {
+ Logger.printDebug(() -> "Search is not active, skipping query processing");
+ return true;
+ }
+
+ if (trimmedText.isEmpty()) {
+ // If empty query: show history.
+ hideSearchResults();
+ showSearchHistory();
+ } else {
+ // If has search text: hide history and show search results.
+ hideSearchHistory();
+ filterAndShowResults(newText);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onQueryTextChange failure", ex);
+ }
+ return true;
+ }
+ });
+ // Set navigation click listener.
+ toolbar.setNavigationOnClickListener(view -> {
+ if (isSearchActive) {
+ closeSearch();
+ } else {
+ activity.finish();
+ }
+ });
+ }
+
+ /**
+ * Initializes search data by collecting all searchable preferences from the fragment.
+ * This method should be called after the preference fragment is fully loaded.
+ * Runs on the UI thread to ensure proper access to preference components.
+ */
+ public void initializeSearchData() {
+ allSearchItems.clear();
+ keyToSearchItem.clear();
+ // Wait until fragment is properly initialized.
+ activity.runOnUiThread(() -> {
+ try {
+ PreferenceScreen screen = fragment.getPreferenceScreenForSearch();
+ if (screen != null) {
+ collectSearchablePreferences(screen);
+ for (BaseSearchResultItem item : allSearchItems) {
+ if (item instanceof BaseSearchResultItem.PreferenceSearchItem prefItem) {
+ String key = prefItem.preference.getKey();
+ if (key != null) {
+ keyToSearchItem.put(key, item);
+ }
+ }
+ }
+ setupPreferenceListeners();
+ Logger.printDebug(() -> "Collected " + allSearchItems.size() + " searchable preferences");
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to initialize search data", ex);
+ }
+ });
+ }
+
+ /**
+ * Sets up listeners for preferences to keep search results in sync when preference values change.
+ */
+ protected void setupPreferenceListeners() {
+ for (BaseSearchResultItem item : allSearchItems) {
+ // Skip non-preference items.
+ if (!(item instanceof BaseSearchResultItem.PreferenceSearchItem prefItem)) continue;
+ Preference pref = prefItem.preference;
+
+ if (pref instanceof ColorPickerPreference colorPref) {
+ colorPref.setOnColorChangeListener((prefKey, newColor) -> {
+ BaseSearchResultItem.PreferenceSearchItem searchItem =
+ (BaseSearchResultItem.PreferenceSearchItem) keyToSearchItem.get(prefKey);
+ if (searchItem != null) {
+ searchItem.setColor(newColor);
+ refreshSearchResults();
+ }
+ });
+ } else if (pref instanceof CustomDialogListPreference listPref) {
+ listPref.setOnPreferenceChangeListener((preference, newValue) -> {
+ BaseSearchResultItem.PreferenceSearchItem searchItem =
+ (BaseSearchResultItem.PreferenceSearchItem) keyToSearchItem.get(preference.getKey());
+ if (searchItem == null) return true;
+
+ int index = listPref.findIndexOfValue(newValue.toString());
+ if (index >= 0) {
+ // Check if a static summary is set.
+ boolean isStaticSummary = listPref.getStaticSummary() != null;
+ if (!isStaticSummary) {
+ // Only update summary if it is not static.
+ CharSequence newSummary = listPref.getEntries()[index];
+ listPref.setSummary(newSummary);
+ }
+ }
+
+ listPref.clearHighlightedEntriesForDialog();
+ searchItem.refreshHighlighting();
+ refreshSearchResults();
+ return true;
+ });
+ }
+
+ // Let subclasses handle special preferences.
+ setupSpecialPreferenceListeners(item);
+ }
+ }
+
+ /**
+ * Collects searchable preferences from a preference group.
+ */
+ protected void collectSearchablePreferences(PreferenceGroup group) {
+ collectSearchablePreferencesWithKeys(group, "", new ArrayList<>(), 1, 0);
+ }
+
+ /**
+ * Collects searchable preferences with their navigation paths and keys.
+ *
+ * @param group The preference group to collect from.
+ * @param parentPath The navigation path of the parent group.
+ * @param parentKeys The keys of parent preferences.
+ * @param includeDepth The maximum depth to include in the search index.
+ * @param currentDepth The current depth in the preference hierarchy.
+ */
+ protected void collectSearchablePreferencesWithKeys(PreferenceGroup group, String parentPath,
+ List parentKeys, int includeDepth, int currentDepth) {
+ if (group == null) return;
+
+ for (int i = 0, count = group.getPreferenceCount(); i < count; i++) {
+ Preference preference = group.getPreference(i);
+
+ // Add to search results only if it is not a category, special group, or PreferenceScreen.
+ if (shouldIncludePreference(preference, currentDepth, includeDepth)) {
+ allSearchItems.add(new BaseSearchResultItem.PreferenceSearchItem(
+ preference, parentPath, parentKeys));
+ }
+
+ // If the preference is a group, recurse into it.
+ if (preference instanceof PreferenceGroup subGroup) {
+ String newPath = parentPath;
+ List newKeys = new ArrayList<>(parentKeys);
+
+ // Append the group title to the path and save key for navigation.
+ if (!isSpecialPreferenceGroup(preference)
+ && !(preference instanceof NoTitlePreferenceCategory)) {
+ CharSequence title = preference.getTitle();
+ if (!TextUtils.isEmpty(title)) {
+ newPath = TextUtils.isEmpty(parentPath)
+ ? title.toString()
+ : parentPath + " > " + title;
+ }
+
+ // Add key for navigation if this is a PreferenceScreen or group with navigation capability.
+ String key = preference.getKey();
+ if (!TextUtils.isEmpty(key) && (preference instanceof PreferenceScreen
+ || searchResultsAdapter.hasNavigationCapability(preference))) {
+ newKeys.add(key);
+ }
+ }
+
+ collectSearchablePreferencesWithKeys(subGroup, newPath, newKeys, includeDepth, currentDepth + 1);
+ }
+ }
+ }
+
+ /**
+ * Filters all search items based on the provided query and displays results in the overlay.
+ * Applies highlighting to matching text and shows a "no results" message if nothing matches.
+ */
+ protected void filterAndShowResults(String query) {
+ hideSearchHistory();
+ // Keep track of the previously displayed items to clear their highlights.
+ List previouslyDisplayedItems = new ArrayList<>(filteredSearchItems);
+
+ filteredSearchItems.clear();
+
+ String queryLower = Utils.normalizeTextToLowercase(query);
+ Pattern queryPattern = Pattern.compile(Pattern.quote(queryLower), Pattern.CASE_INSENSITIVE);
+
+ // Clear highlighting only for items that were previously visible.
+ // This avoids iterating through all items on every keystroke during filtering.
+ for (BaseSearchResultItem item : previouslyDisplayedItems) {
+ item.clearHighlighting();
+ }
+
+ // Collect matched items first.
+ List matched = new ArrayList<>();
+ int matchCount = 0;
+ for (BaseSearchResultItem item : allSearchItems) {
+ if (matchCount >= MAX_SEARCH_RESULTS) break; // Stop after collecting max results.
+ if (item.matchesQuery(queryLower)) {
+ item.applyHighlighting(queryPattern);
+ matched.add(item);
+ matchCount++;
+ }
+ }
+
+ // Build filteredSearchItems, inserting parent enablers for disabled dependents.
+ Set addedParentKeys = new HashSet<>(2 * matched.size());
+ for (BaseSearchResultItem item : matched) {
+ if (item instanceof BaseSearchResultItem.PreferenceSearchItem prefItem) {
+ String key = prefItem.preference.getKey();
+ Setting> setting = (key != null) ? Setting.getSettingFromPath(key) : null;
+ if (setting != null && !setting.isAvailable()) {
+ List> parentSettings = setting.getParentSettings();
+ for (Setting> parentSetting : parentSettings) {
+ BaseSearchResultItem parentItem = keyToSearchItem.get(parentSetting.key);
+ if (parentItem != null && !addedParentKeys.contains(parentSetting.key)) {
+ if (!parentItem.matchesQuery(queryLower)) {
+ // Apply highlighting to parent items even if they don't match the query.
+ // This ensures they get their current effective summary calculated.
+ parentItem.applyHighlighting(queryPattern);
+ filteredSearchItems.add(parentItem);
+ }
+ addedParentKeys.add(parentSetting.key);
+ }
+ }
+ }
+ filteredSearchItems.add(item);
+ if (key != null) {
+ addedParentKeys.add(key);
+ }
+ }
+ }
+
+ if (!filteredSearchItems.isEmpty()) {
+ //noinspection ComparatorCombinators
+ Collections.sort(filteredSearchItems, (o1, o2) ->
+ o1.navigationPath.compareTo(o2.navigationPath)
+ );
+ List displayItems = new ArrayList<>();
+ String currentPath = null;
+ for (BaseSearchResultItem item : filteredSearchItems) {
+ if (!item.navigationPath.equals(currentPath)) {
+ BaseSearchResultItem header = new BaseSearchResultItem.GroupHeaderItem(item.navigationPath, item.navigationKeys);
+ displayItems.add(header);
+ currentPath = item.navigationPath;
+ }
+ displayItems.add(item);
+ }
+ filteredSearchItems.clear();
+ filteredSearchItems.addAll(displayItems);
+ }
+ // Show "No results found" if search results are empty.
+ if (filteredSearchItems.isEmpty()) {
+ Preference noResultsPreference = new Preference(activity);
+ noResultsPreference.setKey("no_results_placeholder");
+ noResultsPreference.setTitle(str("revanced_settings_search_no_results_title", query));
+ noResultsPreference.setSummary(str("revanced_settings_search_no_results_summary"));
+ noResultsPreference.setSelectable(false);
+ noResultsPreference.setIcon(getSearchIcon());
+ filteredSearchItems.add(new BaseSearchResultItem.PreferenceSearchItem(noResultsPreference, "", Collections.emptyList()));
+ }
+
+ searchResultsAdapter.notifyDataSetChanged();
+ overlayContainer.setVisibility(View.VISIBLE);
+ }
+
+ /**
+ * Opens the search interface by showing the search view and hiding the menu item.
+ * Configures the UI for search mode, shows the keyboard, and displays search suggestions.
+ */
+ protected void openSearch() {
+ isSearchActive = true;
+ toolbar.getMenu().findItem(ID_ACTION_SEARCH).setVisible(false);
+ toolbar.setTitle("");
+ searchContainer.setVisibility(View.VISIBLE);
+ searchView.requestFocus();
+ // Configure soft input mode to adjust layout and show keyboard.
+ activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN
+ | WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);
+ inputMethodManager.showSoftInput(searchView, InputMethodManager.SHOW_IMPLICIT);
+ // Always show search history when opening search.
+ showSearchHistory();
+ }
+
+ /**
+ * Closes the search interface and restores the normal UI state.
+ * Hides the overlay, clears search results, dismisses the keyboard, and removes highlighting.
+ */
+ public void closeSearch() {
+ isSearchActive = false;
+ isShowingSearchHistory = false;
+
+ searchHistoryManager.hideSearchHistoryContainer();
+ overlayContainer.setVisibility(View.GONE);
+
+ filteredSearchItems.clear();
+
+ searchContainer.setVisibility(View.GONE);
+ toolbar.getMenu().findItem(ID_ACTION_SEARCH).setVisible(true);
+ toolbar.setTitle(originalTitle);
+ searchView.setQuery("", false);
+ // Hide keyboard and reset soft input mode.
+ inputMethodManager.hideSoftInputFromWindow(searchView.getWindowToken(), 0);
+ activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN);
+ // Clear highlighting for all search items.
+ for (BaseSearchResultItem item : allSearchItems) {
+ item.clearHighlighting();
+ }
+
+ searchResultsAdapter.notifyDataSetChanged();
+ }
+
+ /**
+ * Shows the search history if enabled.
+ */
+ protected void showSearchHistory() {
+ if (searchHistoryManager.isSearchHistoryEnabled()) {
+ overlayContainer.setVisibility(View.VISIBLE);
+ searchHistoryManager.showSearchHistory();
+ isShowingSearchHistory = true;
+ } else {
+ hideAllOverlays();
+ }
+ }
+
+ /**
+ * Hides the search history container.
+ */
+ protected void hideSearchHistory() {
+ searchHistoryManager.hideSearchHistoryContainer();
+ isShowingSearchHistory = false;
+ }
+
+ /**
+ * Hides all overlay containers, including search results and history.
+ */
+ protected void hideAllOverlays() {
+ hideSearchHistory();
+ hideSearchResults();
+ }
+
+ /**
+ * Hides the search results overlay and clears the filtered results.
+ */
+ protected void hideSearchResults() {
+ overlayContainer.setVisibility(View.GONE);
+ filteredSearchItems.clear();
+ searchResultsAdapter.notifyDataSetChanged();
+ for (BaseSearchResultItem item : allSearchItems) {
+ item.clearHighlighting();
+ }
+ }
+
+ /**
+ * Refreshes the search results display if the search is active and history is not shown.
+ */
+ protected void refreshSearchResults() {
+ if (isSearchActive && !isShowingSearchHistory) {
+ searchResultsAdapter.notifyDataSetChanged();
+ }
+ }
+
+ /**
+ * Finds a search item corresponding to the given preference.
+ *
+ * @param preference The preference to find a search item for.
+ * @return The corresponding PreferenceSearchItem, or null if not found.
+ */
+ public BaseSearchResultItem.PreferenceSearchItem findSearchItemByPreference(Preference preference) {
+ // First, search in filtered results.
+ for (BaseSearchResultItem item : filteredSearchItems) {
+ if (item instanceof BaseSearchResultItem.PreferenceSearchItem prefItem) {
+ if (prefItem.preference == preference) {
+ return prefItem;
+ }
+ }
+ }
+ // If not found, search in all items.
+ for (BaseSearchResultItem item : allSearchItems) {
+ if (item instanceof BaseSearchResultItem.PreferenceSearchItem prefItem) {
+ if (prefItem.preference == preference) {
+ return prefItem;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Gets the background color for search view components based on current theme.
+ */
+ @ColorInt
+ public static int getSearchViewBackground() {
+ return Utils.adjustColorBrightness(Utils.getDialogBackgroundColor(), Utils.isDarkModeEnabled() ? 1.11f : 0.95f);
+ }
+
+ /**
+ * Creates a rounded background drawable for the main search view.
+ */
+ protected static GradientDrawable createBackgroundDrawable() {
+ GradientDrawable background = new GradientDrawable();
+ background.setShape(GradientDrawable.RECTANGLE);
+ background.setCornerRadius(Dim.dp28);
+ background.setColor(getSearchViewBackground());
+ return background;
+ }
+
+ /**
+ * Return if a search is currently active.
+ */
+ public boolean isSearchActive() {
+ return isSearchActive;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/SearchHistoryManager.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/SearchHistoryManager.java
new file mode 100644
index 0000000000..773c8a9241
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/SearchHistoryManager.java
@@ -0,0 +1,402 @@
+package app.revanced.extension.shared.settings.search;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.Utils.getResourceIdentifierOrThrow;
+import static app.revanced.extension.shared.settings.BaseSettings.SETTINGS_SEARCH_ENTRIES;
+import static app.revanced.extension.shared.settings.BaseSettings.SETTINGS_SEARCH_HISTORY;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.Context;
+import android.util.Pair;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Deque;
+import java.util.LinkedList;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.ResourceType;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.preference.BulletPointPreference;
+import app.revanced.extension.shared.ui.CustomDialog;
+
+/**
+ * Manager for search history functionality.
+ */
+public class SearchHistoryManager {
+ /**
+ * Interface for handling history item selection.
+ */
+ private static final int MAX_HISTORY_SIZE = 5; // Maximum history items stored.
+
+ private static final int ID_CLEAR_HISTORY_BUTTON = getResourceIdentifierOrThrow(
+ ResourceType.ID, "clear_history_button");
+ private static final int ID_HISTORY_TEXT = getResourceIdentifierOrThrow(
+ ResourceType.ID, "history_text");
+ private static final int ID_HISTORY_ICON = getResourceIdentifierOrThrow(
+ ResourceType.ID, "history_icon");
+ private static final int ID_DELETE_ICON = getResourceIdentifierOrThrow(
+ ResourceType.ID, "delete_icon");
+ private static final int ID_EMPTY_HISTORY_TITLE = getResourceIdentifierOrThrow(
+ ResourceType.ID, "empty_history_title");
+ private static final int ID_EMPTY_HISTORY_SUMMARY = getResourceIdentifierOrThrow(
+ ResourceType.ID, "empty_history_summary");
+ private static final int ID_SEARCH_HISTORY_HEADER = getResourceIdentifierOrThrow(
+ ResourceType.ID, "search_history_header");
+ private static final int ID_SEARCH_TIPS_SUMMARY = getResourceIdentifierOrThrow(
+ ResourceType.ID, "revanced_settings_search_tips_summary");
+ private static final int LAYOUT_REVANCED_PREFERENCE_SEARCH_HISTORY_SCREEN = getResourceIdentifierOrThrow(
+ ResourceType.LAYOUT, "revanced_preference_search_history_screen");
+ private static final int LAYOUT_REVANCED_PREFERENCE_SEARCH_HISTORY_ITEM = getResourceIdentifierOrThrow(
+ ResourceType.LAYOUT, "revanced_preference_search_history_item");
+ private static final int ID_SEARCH_HISTORY_LIST = getResourceIdentifierOrThrow(
+ ResourceType.ID, "search_history_list");
+ private static final int ID_SEARCH_REMOVE_ICON = getResourceIdentifierOrThrow(
+ ResourceType.DRAWABLE, "revanced_settings_search_remove");
+ private static final int ID_SEARCH_REMOVE_ICON_BOLD = getResourceIdentifierOrThrow(
+ ResourceType.DRAWABLE, "revanced_settings_search_remove_bold");
+ private static final int ID_SEARCH_ARROW_TIME_ICON = getResourceIdentifierOrThrow(
+ ResourceType.DRAWABLE, "revanced_settings_arrow_time");
+ private static final int ID_SEARCH_ARROW_TIME_ICON_BOLD = getResourceIdentifierOrThrow(
+ ResourceType.DRAWABLE, "revanced_settings_arrow_time_bold");
+
+ private final Deque searchHistory;
+ private final Activity activity;
+ private final SearchHistoryAdapter searchHistoryAdapter;
+ private final boolean showSettingsSearchHistory;
+ private final FrameLayout searchHistoryContainer;
+
+ public interface OnSelectHistoryItemListener {
+ void onSelectHistoryItem(String query);
+ }
+
+ /**
+ * Constructor for SearchHistoryManager.
+ *
+ * @param activity The parent activity.
+ * @param overlayContainer The overlay container to hold the search history container.
+ * @param onSelectHistoryItemAction Callback for when a history item is selected.
+ */
+ SearchHistoryManager(Activity activity, FrameLayout overlayContainer,
+ OnSelectHistoryItemListener onSelectHistoryItemAction) {
+ this.activity = activity;
+ this.showSettingsSearchHistory = SETTINGS_SEARCH_HISTORY.get();
+ this.searchHistory = new LinkedList<>();
+
+ // Initialize search history from settings.
+ if (showSettingsSearchHistory) {
+ String entries = SETTINGS_SEARCH_ENTRIES.get();
+ if (!entries.isBlank()) {
+ searchHistory.addAll(Arrays.asList(entries.split("\n")));
+ }
+ } else {
+ // Clear old saved history if the feature is disabled.
+ SETTINGS_SEARCH_ENTRIES.resetToDefault();
+ }
+
+ // Create search history container.
+ this.searchHistoryContainer = new FrameLayout(activity);
+ searchHistoryContainer.setVisibility(View.GONE);
+
+ // Inflate search history layout.
+ LayoutInflater inflater = LayoutInflater.from(activity);
+ View historyView = inflater.inflate(LAYOUT_REVANCED_PREFERENCE_SEARCH_HISTORY_SCREEN,
+ searchHistoryContainer, false);
+ searchHistoryContainer.addView(historyView, new FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT));
+
+ // Add history container to overlay.
+ FrameLayout.LayoutParams overlayParams = new FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT);
+ overlayParams.gravity = Gravity.TOP;
+ overlayContainer.addView(searchHistoryContainer, overlayParams);
+
+ // Find the LinearLayout for the history list within the container.
+ LinearLayout searchHistoryListView = searchHistoryContainer.findViewById(ID_SEARCH_HISTORY_LIST);
+ if (searchHistoryListView == null) {
+ throw new IllegalStateException("Search history list view not found in container");
+ }
+
+ // Set up history adapter. Use a copy of the search history.
+ this.searchHistoryAdapter = new SearchHistoryAdapter(activity, searchHistoryListView,
+ new ArrayList<>(searchHistory), onSelectHistoryItemAction);
+
+ // Set up clear history button.
+ TextView clearHistoryButton = searchHistoryContainer.findViewById(ID_CLEAR_HISTORY_BUTTON);
+ clearHistoryButton.setOnClickListener(v -> createAndShowDialog(
+ str("revanced_settings_search_clear_history"),
+ str("revanced_settings_search_clear_history_message"),
+ this::clearAllSearchHistory
+ ));
+
+ // Set up search tips summary.
+ CharSequence text = BulletPointPreference.formatIntoBulletPoints(
+ str("revanced_settings_search_tips_summary"));
+ TextView tipsSummary = historyView.findViewById(ID_SEARCH_TIPS_SUMMARY);
+ tipsSummary.setText(text);
+ }
+
+ /**
+ * Shows search history screen - either with history items or empty history message.
+ */
+ public void showSearchHistory() {
+ if (!showSettingsSearchHistory) {
+ return;
+ }
+
+ // Find all view elements.
+ TextView emptyHistoryTitle = searchHistoryContainer.findViewById(ID_EMPTY_HISTORY_TITLE);
+ TextView emptyHistorySummary = searchHistoryContainer.findViewById(ID_EMPTY_HISTORY_SUMMARY);
+ TextView historyHeader = searchHistoryContainer.findViewById(ID_SEARCH_HISTORY_HEADER);
+ LinearLayout historyList = searchHistoryContainer.findViewById(ID_SEARCH_HISTORY_LIST);
+ TextView clearHistoryButton = searchHistoryContainer.findViewById(ID_CLEAR_HISTORY_BUTTON);
+
+ if (searchHistory.isEmpty()) {
+ // Show empty history state.
+ showEmptyHistoryViews(emptyHistoryTitle, emptyHistorySummary);
+ hideHistoryViews(historyHeader, historyList, clearHistoryButton);
+ } else {
+ // Show history list state.
+ hideEmptyHistoryViews(emptyHistoryTitle, emptyHistorySummary);
+ showHistoryViews(historyHeader, historyList, clearHistoryButton);
+
+ // Update adapter with current history.
+ searchHistoryAdapter.clear();
+ searchHistoryAdapter.addAll(searchHistory);
+ searchHistoryAdapter.notifyDataSetChanged();
+ }
+
+ // Show the search history container.
+ showSearchHistoryContainer();
+ }
+
+ /**
+ * Saves a search query to the history, maintaining the size limit.
+ */
+ public void saveSearchQuery(String query) {
+ if (!showSettingsSearchHistory) return;
+
+ searchHistory.remove(query); // Remove if already exists to update position.
+ searchHistory.addFirst(query); // Add to the most recent.
+
+ // Remove extra old entries.
+ while (searchHistory.size() > MAX_HISTORY_SIZE) {
+ String last = searchHistory.removeLast();
+ Logger.printDebug(() -> "Removing search history query: " + last);
+ }
+
+ saveSearchHistory();
+ }
+
+ /**
+ * Saves the search history to shared preferences.
+ */
+ protected void saveSearchHistory() {
+ Logger.printDebug(() -> "Saving search history: " + searchHistory);
+ SETTINGS_SEARCH_ENTRIES.save(String.join("\n", searchHistory));
+ }
+
+ /**
+ * Removes a search query from the history.
+ */
+ public void removeSearchQuery(String query) {
+ searchHistory.remove(query);
+ saveSearchHistory();
+ }
+
+ /**
+ * Clears all search history.
+ */
+ public void clearAllSearchHistory() {
+ searchHistory.clear();
+ saveSearchHistory();
+ searchHistoryAdapter.clear();
+ searchHistoryAdapter.notifyDataSetChanged();
+ showSearchHistory();
+ }
+
+ /**
+ * Checks if search history feature is enabled.
+ */
+ public boolean isSearchHistoryEnabled() {
+ return showSettingsSearchHistory;
+ }
+
+ /**
+ * Shows the search history container and overlay.
+ */
+ public void showSearchHistoryContainer() {
+ searchHistoryContainer.setVisibility(View.VISIBLE);
+ }
+
+ /**
+ * Hides the search history container.
+ */
+ public void hideSearchHistoryContainer() {
+ searchHistoryContainer.setVisibility(View.GONE);
+ }
+
+ /**
+ * Helper method to show empty history views.
+ */
+ protected void showEmptyHistoryViews(TextView emptyTitle, TextView emptySummary) {
+ emptyTitle.setVisibility(View.VISIBLE);
+ emptyTitle.setText(str("revanced_settings_search_empty_history_title"));
+ emptySummary.setVisibility(View.VISIBLE);
+ emptySummary.setText(str("revanced_settings_search_empty_history_summary"));
+ }
+
+ /**
+ * Helper method to hide empty history views.
+ */
+ protected void hideEmptyHistoryViews(TextView emptyTitle, TextView emptySummary) {
+ emptyTitle.setVisibility(View.GONE);
+ emptySummary.setVisibility(View.GONE);
+ }
+
+ /**
+ * Helper method to show history list views.
+ */
+ protected void showHistoryViews(TextView header, LinearLayout list, TextView clearButton) {
+ header.setVisibility(View.VISIBLE);
+ list.setVisibility(View.VISIBLE);
+ clearButton.setVisibility(View.VISIBLE);
+ }
+
+ /**
+ * Helper method to hide history list views.
+ */
+ protected void hideHistoryViews(TextView header, LinearLayout list, TextView clearButton) {
+ header.setVisibility(View.GONE);
+ list.setVisibility(View.GONE);
+ clearButton.setVisibility(View.GONE);
+ }
+
+ /**
+ * Creates and shows a dialog with the specified title, message, and confirmation action.
+ *
+ * @param title The title of the dialog.
+ * @param message The message to display in the dialog.
+ * @param confirmAction The action to perform when the dialog is confirmed.
+ */
+ protected void createAndShowDialog(String title, String message, Runnable confirmAction) {
+ Pair dialogPair = CustomDialog.create(
+ activity,
+ title,
+ message,
+ null,
+ null,
+ confirmAction,
+ () -> {},
+ null,
+ null,
+ false
+ );
+
+ Dialog dialog = dialogPair.first;
+ dialog.setCancelable(true);
+ dialog.show();
+ }
+
+
+ /**
+ * Custom adapter for search history items.
+ */
+ protected class SearchHistoryAdapter {
+ protected final Collection history;
+ protected final LayoutInflater inflater;
+ protected final LinearLayout container;
+ protected final OnSelectHistoryItemListener onSelectHistoryItemListener;
+
+ public SearchHistoryAdapter(Context context, LinearLayout container, Collection history,
+ OnSelectHistoryItemListener listener) {
+ this.history = history;
+ this.inflater = LayoutInflater.from(context);
+ this.container = container;
+ this.onSelectHistoryItemListener = listener;
+ }
+
+ /**
+ * Updates the container with current history items.
+ */
+ public void notifyDataSetChanged() {
+ container.removeAllViews();
+ for (String query : history) {
+ View view = inflater.inflate(LAYOUT_REVANCED_PREFERENCE_SEARCH_HISTORY_ITEM,
+ container, false);
+ // Set click listener for main item (select query).
+ view.setOnClickListener(v -> onSelectHistoryItemListener.onSelectHistoryItem(query));
+
+ // Set history icon.
+ ImageView historyIcon = view.findViewById(ID_HISTORY_ICON);
+ historyIcon.setImageResource(Utils.appIsUsingBoldIcons()
+ ? ID_SEARCH_ARROW_TIME_ICON_BOLD
+ : ID_SEARCH_ARROW_TIME_ICON
+ );
+
+ TextView historyText = view.findViewById(ID_HISTORY_TEXT);
+ historyText.setText(query);
+
+ // Set click listener for delete icon.
+ ImageView deleteIcon = view.findViewById(ID_DELETE_ICON);
+
+ deleteIcon.setImageResource(Utils.appIsUsingBoldIcons()
+ ? ID_SEARCH_REMOVE_ICON_BOLD
+ : ID_SEARCH_REMOVE_ICON
+ );
+
+ deleteIcon.setOnClickListener(v -> createAndShowDialog(
+ query,
+ str("revanced_settings_search_remove_message"),
+ () -> {
+ removeSearchQuery(query);
+ remove(query);
+ notifyDataSetChanged();
+ }
+ ));
+
+ container.addView(view);
+ }
+ }
+
+ /**
+ * Clears all views from the container and history list.
+ */
+ public void clear() {
+ history.clear();
+ container.removeAllViews();
+ }
+
+ /**
+ * Adds all provided history items to the container.
+ */
+ public void addAll(Collection items) {
+ history.addAll(items);
+ notifyDataSetChanged();
+ }
+
+ /**
+ * Removes a query from the history and updates the container.
+ */
+ public void remove(String query) {
+ history.remove(query);
+ if (history.isEmpty()) {
+ // If history is now empty, show the empty history state.
+ showSearchHistory();
+ } else {
+ notifyDataSetChanged();
+ }
+ }
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/spoof/ClientType.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/spoof/ClientType.java
new file mode 100644
index 0000000000..9abd430719
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/spoof/ClientType.java
@@ -0,0 +1,270 @@
+package app.revanced.extension.shared.spoof;
+
+import android.os.Build;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Locale;
+import java.util.Objects;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+
+@SuppressWarnings("ConstantLocale")
+public enum ClientType {
+ /**
+ * Video not playable: Paid, Movie, Private, Age-restricted.
+ * Uses non-adaptive bitrate.
+ * AV1 codec available.
+ */
+ ANDROID_REEL(
+ 3,
+ "ANDROID",
+ "com.google.android.youtube",
+ Build.MANUFACTURER,
+ Build.MODEL,
+ "Android",
+ Build.VERSION.RELEASE,
+ String.valueOf(Build.VERSION.SDK_INT),
+ Build.ID,
+ "20.44.38",
+ // This client has been used by most open-source YouTube stream extraction tools since 2024, including NewPipe Extractor, SmartTube, and Grayjay.
+ // This client can log in, but if an access token is used in the request, GVS can more easily identify the request as coming from ReVanced.
+ // This means that the GVS server can strengthen its validation of the ANDROID_REEL client.
+ true,
+ true,
+ false,
+ "Android Reel"
+ ),
+ /**
+ * Video not playable: Kids / Paid / Movie / Private / Age-restricted.
+ * This client can only be used when logged out.
+ */
+ // https://dumps.tadiphone.dev/dumps/oculus/eureka
+ ANDROID_VR_1_61_48(
+ 28,
+ "ANDROID_VR",
+ "com.google.android.apps.youtube.vr.oculus",
+ "Oculus",
+ "Quest 3",
+ "Android",
+ "12",
+ // Android 12.1
+ "32",
+ "SQ3A.220605.009.A1",
+ "1.61.48",
+ false,
+ false,
+ true,
+ "Android VR 1.61"
+ ),
+ /**
+ * Uses non adaptive bitrate, which fixes audio stuttering with YT Music.
+ * Does not use AV1.
+ */
+ ANDROID_VR_1_43_32(
+ ANDROID_VR_1_61_48.id,
+ ANDROID_VR_1_61_48.clientName,
+ Objects.requireNonNull(ANDROID_VR_1_61_48.packageName),
+ ANDROID_VR_1_61_48.deviceMake,
+ ANDROID_VR_1_61_48.deviceModel,
+ ANDROID_VR_1_61_48.osName,
+ ANDROID_VR_1_61_48.osVersion,
+ Objects.requireNonNull(ANDROID_VR_1_61_48.androidSdkVersion),
+ Objects.requireNonNull(ANDROID_VR_1_61_48.buildId),
+ "1.43.32",
+ ANDROID_VR_1_61_48.useAuth,
+ ANDROID_VR_1_61_48.supportsMultiAudioTracks,
+ ANDROID_VR_1_61_48.usePlayerEndpoint,
+ "Android VR 1.43"
+ ),
+ /**
+ * Cannot play livestreams and lacks HDR, but can play videos with music and labeled "for children".
+ * Google Pixel 9 Pro Fold
+ */
+ ANDROID_CREATOR(
+ 14,
+ "ANDROID_CREATOR",
+ "com.google.android.apps.youtube.creator",
+ "Google",
+ "Pixel 9 Pro Fold",
+ "Android",
+ "15",
+ "35",
+ "AP3A.241005.015.A2",
+ "23.47.101",
+ true,
+ false,
+ true,
+ "Android Studio"
+ ),
+ /**
+ * Internal YT client for an unreleased YT client. May stop working at any time.
+ */
+ VISIONOS(101,
+ "VISIONOS",
+ "Apple",
+ "RealityDevice14,1",
+ "visionOS",
+ "1.3.21O771",
+ "0.1",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15",
+ false,
+ false,
+ true,
+ "visionOS"
+ );
+
+ /**
+ * YouTube
+ * client type
+ */
+ public final int id;
+
+ public final String clientName;
+
+ /**
+ * App package name.
+ */
+ @Nullable
+ private final String packageName;
+
+ /**
+ * Player user-agent.
+ */
+ public final String userAgent;
+
+ /**
+ * Device model, equivalent to {@link Build#MANUFACTURER} (System property: ro.product.vendor.manufacturer)
+ */
+ public final String deviceMake;
+
+ /**
+ * Device model, equivalent to {@link Build#MODEL} (System property: ro.product.vendor.model)
+ */
+ public final String deviceModel;
+
+ /**
+ * Device OS name.
+ */
+ public final String osName;
+
+ /**
+ * Device OS version.
+ */
+ public final String osVersion;
+
+ /**
+ * 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;
+
+ /**
+ * Android build id, equivalent to {@link Build#ID}.
+ * Field is null if not applicable.
+ */
+ @Nullable
+ private final String buildId;
+
+ /**
+ * App version.
+ */
+ public final String clientVersion;
+
+ /**
+ * If the client should use authentication if available.
+ */
+ public final boolean useAuth;
+
+ /**
+ * If the client supports multiple audio tracks.
+ */
+ public final boolean supportsMultiAudioTracks;
+
+ /**
+ * If the client should use the player endpoint for stream extraction.
+ */
+ public final boolean usePlayerEndpoint;
+
+ /**
+ * Friendly name displayed in stats for nerds.
+ */
+ public final String friendlyName;
+
+ /**
+ * Android constructor.
+ */
+ ClientType(int id,
+ String clientName,
+ @NonNull String packageName,
+ String deviceMake,
+ String deviceModel,
+ String osName,
+ String osVersion,
+ @NonNull String androidSdkVersion,
+ @NonNull String buildId,
+ String clientVersion,
+ boolean useAuth,
+ boolean supportsMultiAudioTracks,
+ boolean usePlayerEndpoint,
+ String friendlyName) {
+ this.id = id;
+ this.clientName = clientName;
+ this.packageName = packageName;
+ this.deviceMake = deviceMake;
+ this.deviceModel = deviceModel;
+ this.osName = osName;
+ this.osVersion = osVersion;
+ this.androidSdkVersion = androidSdkVersion;
+ this.buildId = buildId;
+ this.clientVersion = clientVersion;
+ this.useAuth = useAuth;
+ this.supportsMultiAudioTracks = supportsMultiAudioTracks;
+ this.usePlayerEndpoint = usePlayerEndpoint;
+ this.friendlyName = friendlyName;
+
+ Locale defaultLocale = Locale.getDefault();
+ this.userAgent = String.format("%s/%s (Linux; U; Android %s; %s; %s; Build/%s)",
+ packageName,
+ clientVersion,
+ osVersion,
+ defaultLocale,
+ deviceModel,
+ buildId
+ );
+ Logger.printDebug(() -> "userAgent: " + this.userAgent);
+ }
+
+ @SuppressWarnings("ConstantLocale")
+ ClientType(int id,
+ String clientName,
+ String deviceMake,
+ String deviceModel,
+ String osName,
+ String osVersion,
+ String clientVersion,
+ String userAgent,
+ boolean useAuth,
+ boolean supportsMultiAudioTracks,
+ boolean usePlayerEndpoint,
+ String friendlyName) {
+ this.id = id;
+ this.clientName = clientName;
+ this.deviceMake = deviceMake;
+ this.deviceModel = deviceModel;
+ this.osName = osName;
+ this.osVersion = osVersion;
+ this.clientVersion = clientVersion;
+ this.userAgent = userAgent;
+ this.useAuth = useAuth;
+ this.supportsMultiAudioTracks = supportsMultiAudioTracks;
+ this.usePlayerEndpoint = usePlayerEndpoint;
+ this.friendlyName = friendlyName;
+ this.packageName = null;
+ this.androidSdkVersion = null;
+ this.buildId = null;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/spoof/SpoofVideoStreamsPatch.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/spoof/SpoofVideoStreamsPatch.java
new file mode 100644
index 0000000000..0c861510fe
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/spoof/SpoofVideoStreamsPatch.java
@@ -0,0 +1,322 @@
+package app.revanced.extension.shared.spoof;
+
+import android.net.Uri;
+import android.text.TextUtils;
+
+import androidx.annotation.Nullable;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.AppLanguage;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.spoof.requests.StreamingDataRequest;
+
+@SuppressWarnings("unused")
+public class SpoofVideoStreamsPatch {
+
+ /**
+ * Domain used for internet connectivity verification.
+ * It has an empty response body and is only used to check for a 204 response code.
+ *
+ * If an unreachable IP address (127.0.0.1) is used, no response code is provided.
+ *
+ * YouTube handles unreachable IP addresses without issue.
+ * YouTube Music has an issue with waiting for the Cronet connect timeout of 30s on mobile networks.
+ *
+ * Using a VPN or DNS can temporarily resolve this issue,
+ * But the ideal workaround is to avoid using an unreachable IP address.
+ */
+ private static final String INTERNET_CONNECTION_CHECK_URI_STRING = "https://www.google.com/gen_204";
+ private static final Uri INTERNET_CONNECTION_CHECK_URI = Uri.parse(INTERNET_CONNECTION_CHECK_URI_STRING);
+
+ private static final boolean SPOOF_STREAMING_DATA = BaseSettings.SPOOF_VIDEO_STREAMS.get();
+
+ @Nullable
+ private static volatile AppLanguage languageOverride;
+
+ private static volatile ClientType preferredClient = ClientType.ANDROID_REEL;
+
+ /**
+ * @return If this patch was included during patching.
+ */
+ public static boolean isPatchIncluded() {
+ return false; // Modified during patching.
+ }
+
+ @Nullable
+ public static AppLanguage getLanguageOverride() {
+ return languageOverride;
+ }
+
+ /**
+ * @param language Language override for non-authenticated requests.
+ */
+ public static void setLanguageOverride(@Nullable AppLanguage language) {
+ languageOverride = language;
+ }
+
+ public static void setClientsToUse(List availableClients, ClientType client) {
+ preferredClient = Objects.requireNonNull(client);
+ StreamingDataRequest.setClientOrderToUse(availableClients, client);
+ }
+
+ public static ClientType getPreferredClient() {
+ return preferredClient;
+ }
+
+ public static boolean spoofingToClientWithNoMultiAudioStreams() {
+ return isPatchIncluded()
+ && SPOOF_STREAMING_DATA
+ && !preferredClient.supportsMultiAudioTracks;
+ }
+
+ /**
+ * 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 internet connection check uri");
+
+ return INTERNET_CONNECTION_CHECK_URI;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "blockGetWatchRequest failure", ex);
+ }
+ }
+
+ return playerRequestUri;
+ }
+
+ /**
+ * Injection point.
+ *
+ * Blocks /get_watch requests by returning an unreachable URI.
+ * /att/get requests are used to obtain a PoToken challenge.
+ * See: botGuardScript.js#L15
+ *
+ * Since the Spoof streaming data patch was implemented because a valid PoToken cannot be obtained,
+ * Blocking /att/get requests are not a problem.
+ */
+ public static String blockGetAttRequest(String originalUrlString) {
+ if (SPOOF_STREAMING_DATA) {
+ try {
+ var originalUri = Uri.parse(originalUrlString);
+ String path = originalUri.getPath();
+
+ if (path != null && path.contains("att/get")) {
+ Logger.printDebug(() -> "Blocking 'att/get' by returning internet connection check uri");
+
+ return INTERNET_CONNECTION_CHECK_URI_STRING;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "blockGetAttRequest failure", ex);
+ }
+ }
+
+ return originalUrlString;
+ }
+
+ /**
+ * 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 internet connection check uri");
+
+ return INTERNET_CONNECTION_CHECK_URI_STRING;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "blockInitPlaybackRequest failure", ex);
+ }
+ }
+
+ return originalUrlString;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean isSpoofingEnabled() {
+ return SPOOF_STREAMING_DATA;
+ }
+
+ /**
+ * Injection point.
+ * Only invoked when playing a livestream on an Apple client.
+ */
+ public static boolean fixHLSCurrentTime(boolean original) {
+ if (!SPOOF_STREAMING_DATA) {
+ return original;
+ }
+ return false;
+ }
+
+ /*
+ * Injection point.
+ * Fix audio stuttering in YouTube Music.
+ */
+ public static boolean disableSABR() {
+ return SPOOF_STREAMING_DATA;
+ }
+
+ /**
+ * Injection point.
+ * Turns off a feature flag that interferes with spoofing.
+ */
+ public static boolean useMediaFetchHotConfigReplacement(boolean original) {
+ if (original) {
+ Logger.printDebug(() -> "useMediaFetchHotConfigReplacement is set on");
+ }
+
+ if (!SPOOF_STREAMING_DATA) {
+ return original;
+ }
+ return false;
+ }
+
+ /**
+ * Injection point.
+ * Turns off a feature flag that interferes with video playback.
+ */
+ public static boolean usePlaybackStartFeatureFlag(boolean original) {
+ if (original) {
+ Logger.printDebug(() -> "usePlaybackStartFeatureFlag is set on");
+ }
+
+ if (!SPOOF_STREAMING_DATA) {
+ return original;
+ }
+ return false;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void fetchStreams(String url, Map requestHeaders) {
+ if (SPOOF_STREAMING_DATA) {
+ try {
+ Uri uri = Uri.parse(url);
+ String path = uri.getPath();
+ if (path == null || !path.contains("player")) {
+ return;
+ }
+
+ // 'get_drm_license' has no video id and appears to happen when waiting for a paid video to start.
+ // 'heartbeat' has no video id and appears to be only after playback has started.
+ // 'refresh' has no video id and appears to happen when waiting for a livestream to start.
+ // 'ad_break' has no video id.
+ if (path.contains("get_drm_license") || path.contains("heartbeat")
+ || path.contains("refresh") || path.contains("ad_break")) {
+ Logger.printDebug(() -> "Ignoring path: " + path);
+ return;
+ }
+
+ String id = uri.getQueryParameter("id");
+ if (id == null) {
+ Logger.printException(() -> "Ignoring request with no id: " + url);
+ return;
+ }
+
+ StreamingDataRequest.fetchRequest(id, 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 byte[] getStreamingData(String videoId) {
+ if (SPOOF_STREAMING_DATA) {
+ try {
+ StreamingDataRequest request = StreamingDataRequest.getRequestForVideoId(videoId);
+ if (request != null) {
+ // This hook is always called off the main thread,
+ // but this can later be called for the same video id from the main thread.
+ // This is not a concern, since the fetch will always be finished
+ // and never block the main thread.
+ // But if debugging, then still verify this is the situation.
+ if (BaseSettings.DEBUG.get() && !request.fetchCompleted() && Utils.isCurrentlyOnMainThread()) {
+ Logger.printException(() -> "Error: Blocking main thread");
+ }
+
+ var stream = request.getStream();
+ if (stream != null) {
+ Logger.printDebug(() -> "Overriding video stream: " + videoId);
+ return stream;
+ }
+ }
+
+ Logger.printDebug(() -> "Not overriding streaming data (video stream is null): " + videoId);
+ } catch (Exception ex) {
+ Logger.printException(() -> "getStreamingData failure", ex);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Injection point.
+ * Called after {@link #getStreamingData(String)}.
+ */
+ @Nullable
+ public static byte[] removeVideoPlaybackPostBody(Uri uri, int method, byte[] postData) {
+ if (SPOOF_STREAMING_DATA) {
+ try {
+ final int methodPost = 2;
+ if (method == methodPost) {
+ String path = uri.getPath();
+ if (path != null && path.contains("videoplayback")) {
+ return null;
+ }
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "removeVideoPlaybackPostBody failure", ex);
+ }
+ }
+
+ return postData;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static String appendSpoofedClient(String videoFormat) {
+ try {
+ if (SPOOF_STREAMING_DATA && BaseSettings.SPOOF_STREAMING_DATA_STATS_FOR_NERDS.get()
+ && !TextUtils.isEmpty(videoFormat)) {
+ // Force LTR layout, to match the same LTR video time/length layout YouTube uses for all languages.
+ return "\u202D" + videoFormat + "\u2009(" // u202D = left to right override
+ + StreamingDataRequest.getLastSpoofedClientName() + ")";
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "appendSpoofedClient failure", ex);
+ }
+
+ return videoFormat;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/spoof/requests/PlayerRoutes.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/spoof/requests/PlayerRoutes.java
new file mode 100644
index 0000000000..959048d1e2
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/spoof/requests/PlayerRoutes.java
@@ -0,0 +1,109 @@
+package app.revanced.extension.shared.spoof.requests;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.util.Locale;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.requests.Requester;
+import app.revanced.extension.shared.requests.Route;
+import app.revanced.extension.shared.settings.AppLanguage;
+import app.revanced.extension.shared.spoof.ClientType;
+import app.revanced.extension.shared.spoof.SpoofVideoStreamsPatch;
+
+final class PlayerRoutes {
+ static final Route.CompiledRoute GET_PLAYER_STREAMING_DATA = new Route(
+ Route.Method.POST,
+ "player" +
+ "?fields=streamingData" +
+ "&alt=proto"
+ ).compile();
+
+ static final Route.CompiledRoute GET_REEL_STREAMING_DATA = new Route(
+ Route.Method.POST,
+ "reel/reel_item_watch" +
+ "?fields=playerResponse.playabilityStatus,playerResponse.streamingData" +
+ "&alt=proto"
+ ).compile();
+
+ private static final String YT_API_URL = "https://youtubei.googleapis.com/youtubei/v1/";
+
+ /**
+ * TCP connection and HTTP read timeout
+ */
+ private static final int CONNECTION_TIMEOUT_MILLISECONDS = 10 * 1000; // 10 Seconds.
+
+ private PlayerRoutes() {
+ }
+
+ static String createInnertubeBody(ClientType clientType, String videoId) {
+ JSONObject innerTubeBody = new JSONObject();
+
+ try {
+ JSONObject context = new JSONObject();
+
+ AppLanguage language = SpoofVideoStreamsPatch.getLanguageOverride();
+ if (language == null) {
+ // Force original audio has not overrode the language.
+ language = AppLanguage.DEFAULT;
+ }
+ //noinspection ExtractMethodRecommender
+ Locale streamLocale = language.getLocale();
+
+ JSONObject client = new JSONObject();
+
+ client.put("deviceMake", clientType.deviceMake);
+ client.put("deviceModel", clientType.deviceModel);
+ client.put("clientName", clientType.clientName);
+ client.put("clientVersion", clientType.clientVersion);
+ client.put("osName", clientType.osName);
+ client.put("osVersion", clientType.osVersion);
+ if (clientType.androidSdkVersion != null) {
+ client.put("androidSdkVersion", clientType.androidSdkVersion);
+ }
+ client.put("hl", streamLocale.getLanguage());
+ client.put("gl", streamLocale.getCountry());
+ context.put("client", client);
+
+ innerTubeBody.put("context", context);
+
+ if (clientType.usePlayerEndpoint) {
+ innerTubeBody.put("contentCheckOk", true);
+ innerTubeBody.put("racyCheckOk", true);
+ innerTubeBody.put("videoId", videoId);
+ } else {
+ JSONObject playerRequest = new JSONObject();
+ playerRequest.put("contentCheckOk", true);
+ playerRequest.put("racyCheckOk", true);
+ playerRequest.put("videoId", videoId);
+ innerTubeBody.put("playerRequest", playerRequest);
+ innerTubeBody.put("disablePlayerResponse", false);
+ }
+ } catch (JSONException e) {
+ Logger.printException(() -> "Failed to create innerTubeBody", e);
+ }
+
+ return innerTubeBody.toString();
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route, ClientType clientType) throws IOException {
+ var connection = Requester.getConnectionFromCompiledRoute(YT_API_URL, route);
+
+ connection.setRequestProperty("Content-Type", "application/json");
+ connection.setRequestProperty("User-Agent", clientType.userAgent);
+ // Not a typo. "Client-Name" uses the client type id.
+ connection.setRequestProperty("X-YouTube-Client-Name", String.valueOf(clientType.id));
+ connection.setRequestProperty("X-YouTube-Client-Version", clientType.clientVersion);
+
+ connection.setUseCaches(false);
+ connection.setDoOutput(true);
+
+ connection.setConnectTimeout(CONNECTION_TIMEOUT_MILLISECONDS);
+ connection.setReadTimeout(CONNECTION_TIMEOUT_MILLISECONDS);
+ return connection;
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/spoof/requests/StreamingDataRequest.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/spoof/requests/StreamingDataRequest.java
new file mode 100644
index 0000000000..fb8a8e79e8
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/spoof/requests/StreamingDataRequest.java
@@ -0,0 +1,318 @@
+package app.revanced.extension.shared.spoof.requests;
+
+import static app.revanced.extension.shared.ByteTrieSearch.convertStringsToBytes;
+import static app.revanced.extension.shared.Utils.isNotEmpty;
+import static app.revanced.extension.shared.spoof.requests.PlayerRoutes.GET_PLAYER_STREAMING_DATA;
+import static app.revanced.extension.shared.spoof.requests.PlayerRoutes.GET_REEL_STREAMING_DATA;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.SocketTimeoutException;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import app.revanced.extension.shared.ByteTrieSearch;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.innertube.PlayerResponseOuterClass;
+import app.revanced.extension.shared.innertube.PlayerResponseOuterClass.PlayerResponse;
+import app.revanced.extension.shared.innertube.PlayerResponseOuterClass.StreamingData;
+import app.revanced.extension.shared.innertube.ReelItemWatchResponseOuterClass.ReelItemWatchResponse;
+import app.revanced.extension.shared.requests.Route;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.spoof.ClientType;
+
+/**
+ * Video streaming data. Fetching is tied to the behavior YT uses,
+ * where this class fetches the streams only when YT fetches.
+ *
+ * Effectively the cache expiration of these fetches is the same as the stock app,
+ * since the stock app would not use expired streams and therefor
+ * the extension replace stream hook is called only if YT
+ * did use its own client streams.
+ */
+public class StreamingDataRequest {
+
+ private static volatile ClientType[] clientOrderToUse = ClientType.values();
+
+ public static void setClientOrderToUse(List availableClients, ClientType preferredClient) {
+ Objects.requireNonNull(preferredClient);
+
+ int availableClientSize = availableClients.size();
+ if (!availableClients.contains(preferredClient)) {
+ availableClientSize++;
+ }
+
+ clientOrderToUse = new ClientType[availableClientSize];
+ clientOrderToUse[0] = preferredClient;
+
+ int i = 1;
+ for (ClientType c : availableClients) {
+ if (c != preferredClient) {
+ clientOrderToUse[i++] = c;
+ }
+ }
+
+ Logger.printDebug(() -> "Available spoof clients: " + Arrays.toString(clientOrderToUse));
+ }
+
+ private static final String AUTHORIZATION_HEADER = "Authorization";
+
+ private static final String[] REQUEST_HEADER_KEYS = {
+ AUTHORIZATION_HEADER, // Available only to logged-in users.
+ "X-GOOG-API-FORMAT-VERSION",
+ "X-Goog-Visitor-Id"
+ };
+
+ /**
+ * TCP connection and HTTP read timeout.
+ */
+ private static final int HTTP_TIMEOUT_MILLISECONDS = 10 * 1000;
+
+ /**
+ * Any arbitrarily large value, but must be at least twice {@link #HTTP_TIMEOUT_MILLISECONDS}
+ */
+ private static final int MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000;
+
+ /**
+ * 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 Map cache = Collections.synchronizedMap(
+ Utils.createSizeRestrictedMap(50));
+
+ /**
+ * Strings found in the response if the video is a livestream.
+ */
+ private static final ByteTrieSearch liveStreamBufferSearch = new ByteTrieSearch(
+ convertStringsToBytes(
+ "yt_live_broadcast",
+ "yt_premiere_broadcast"
+ )
+ );
+
+ private static volatile ClientType lastSpoofedClientType;
+
+ public static String getLastSpoofedClientName() {
+ ClientType client = lastSpoofedClientType;
+ return client == null ? "Unknown" : client.friendlyName;
+ }
+
+ 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 static void fetchRequest(String videoId, Map fetchHeaders) {
+ // Always fetch, even if there is an 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);
+ }
+
+ private static void handleDebugToast(String toastMessage, ClientType clientType) {
+ if (BaseSettings.DEBUG.get() && BaseSettings.DEBUG_TOAST_ON_ERROR.get()) {
+ Utils.showToastShort(String.format(toastMessage, clientType));
+ }
+ }
+
+ @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();
+
+ try {
+ Route.CompiledRoute route = clientType.usePlayerEndpoint ?
+ GET_PLAYER_STREAMING_DATA : GET_REEL_STREAMING_DATA;
+
+ HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(route, clientType);
+ connection.setConnectTimeout(HTTP_TIMEOUT_MILLISECONDS);
+ connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS);
+
+ boolean authHeadersIncludes = false;
+
+ for (String key : REQUEST_HEADER_KEYS) {
+ String value = playerHeaders.get(key);
+
+ if (value != null) {
+ if (key.equals(AUTHORIZATION_HEADER)) {
+ if (!clientType.useAuth) {
+ Logger.printDebug(() -> "Not including request header: " + key);
+ continue;
+ }
+ authHeadersIncludes = true;
+ }
+
+ Logger.printDebug(() -> "Including request header: " + key);
+ connection.setRequestProperty(key, value);
+ }
+ }
+
+ if (!authHeadersIncludes && clientType.useAuth) {
+ Logger.printDebug(() -> "Skipping client since user is not logged in: " + clientType
+ + " videoId: " + videoId);
+ return null;
+ }
+
+ Logger.printDebug(() -> "Fetching video streams for: " + videoId + " using client: " + clientType);
+
+ String innerTubeBody = 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;
+
+ // This situation likely means the patches are outdated.
+ // Use a toast message that suggests updating.
+ handleConnectionError("Playback error (App is outdated?) " + clientType + ": "
+ + responseCode + " response: " + 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 byte[] 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 : clientOrderToUse) {
+ // Show an error if the last client type fails, or if debug is enabled then show for all attempts.
+ final boolean showErrorToast = (++i == clientOrderToUse.length) || debugEnabled;
+
+ HttpURLConnection connection = send(clientType, videoId, playerHeaders, showErrorToast);
+ if (connection != null) {
+ byte[] playerResponseBuffer = buildPlayerResponseBuffer(clientType, connection);
+ if (playerResponseBuffer != null) {
+ lastSpoofedClientType = clientType;
+
+ return playerResponseBuffer;
+ }
+ }
+ }
+
+ lastSpoofedClientType = null;
+ handleConnectionError("Could not fetch any client streams", null, true);
+ return null;
+ }
+
+ @Nullable
+ private static byte[] buildPlayerResponseBuffer(ClientType clientType,
+ HttpURLConnection connection) {
+ // gzip encoding doesn't response with content length (-1),
+ // but empty response body does.
+ if (connection.getContentLength() == 0) {
+ handleDebugToast("Debug: Ignoring empty spoof stream client (%s)", clientType);
+ return null;
+ }
+
+ try (InputStream inputStream = connection.getInputStream()) {
+ PlayerResponse playerResponse = clientType.usePlayerEndpoint
+ ? PlayerResponse.parseFrom(inputStream)
+ : ReelItemWatchResponse.parseFrom(inputStream).getPlayerResponse();
+
+ var playabilityStatus = playerResponse.getPlayabilityStatus();
+ if (playabilityStatus.getStatus() != PlayerResponseOuterClass.Status.OK) {
+ handleDebugToast("Debug: Ignoring unplayable video (%s)", clientType);
+ String reason = playabilityStatus.getReason();
+ if (isNotEmpty(reason)) {
+ Logger.printDebug(() -> String.format("Debug: Ignoring unplayable video (%s), reason: %s", clientType, reason));
+ }
+
+ return null;
+ }
+
+ PlayerResponse.Builder responseBuilder = playerResponse.toBuilder();
+ if (!playerResponse.hasStreamingData()) {
+ handleDebugToast("Debug: Ignoring empty streaming data (%s)", clientType);
+ return null;
+ }
+
+ // Android Studio only supports the HLS protocol for live streams.
+ // HLS protocol can theoretically be played with ExoPlayer,
+ // but the related code has not yet been implemented.
+ // If DASH protocol is not available, the client will be skipped.
+ StreamingData streamingData = playerResponse.getStreamingData();
+ if (streamingData.getAdaptiveFormatsCount() == 0) {
+ handleDebugToast("Debug: Ignoring empty adaptiveFormat (%s)", clientType);
+ return null;
+ }
+
+ return responseBuilder.build().toByteArray();
+ } catch (IOException ex) {
+ Logger.printException(() -> "Failed to write player response to buffer array", ex);
+ return null;
+ }
+ }
+
+ public boolean fetchCompleted() {
+ return future.isDone();
+ }
+
+ @Nullable
+ public byte[] getStream() {
+ try {
+ return future.get(MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS);
+ } catch (TimeoutException ex) {
+ Logger.printInfo(() -> "getStream timed out", ex);
+ } catch (InterruptedException ex) {
+ Logger.printException(() -> "getStream interrupted", ex);
+ Thread.currentThread().interrupt(); // Restore interrupt status flag.
+ } catch (ExecutionException ex) {
+ Logger.printException(() -> "getStream failure", ex);
+ }
+
+ return null;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "StreamingDataRequest{" + "videoId='" + videoId + '\'' + '}';
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/theme/BaseThemePatch.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/theme/BaseThemePatch.java
new file mode 100644
index 0000000000..2d12b0c1f3
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/theme/BaseThemePatch.java
@@ -0,0 +1,48 @@
+package app.revanced.extension.shared.theme;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.Utils;
+
+@SuppressWarnings("unused")
+public abstract class BaseThemePatch {
+ // Background colors.
+ protected static final int BLACK_COLOR = Utils.getResourceColor("yt_black1");
+ protected static final int WHITE_COLOR = Utils.getResourceColor("yt_white1");
+
+ /**
+ * Check if a value matches any of the provided values.
+ *
+ * @param value The value to check.
+ * @param of The array of values to compare against.
+ * @return True if the value matches any of the provided values.
+ */
+ protected static boolean anyEquals(int value, int... of) {
+ for (int v : of) {
+ if (value == v) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Helper method to process color values for Litho components.
+ *
+ * @param originalValue The original color value.
+ * @param darkValues Array of dark mode color values to match.
+ * @param lightValues Array of light mode color values to match.
+ * @return The new or original color value.
+ */
+ protected static int processColorValue(int originalValue, int[] darkValues, @Nullable int[] lightValues) {
+ if (Utils.isDarkModeEnabled()) {
+ if (anyEquals(originalValue, darkValues)) {
+ return BLACK_COLOR;
+ }
+ } else if (lightValues != null && anyEquals(originalValue, lightValues)) {
+ return WHITE_COLOR;
+ }
+
+ return originalValue;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/ui/ColorDot.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/ui/ColorDot.java
new file mode 100644
index 0000000000..07e9b41135
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/ui/ColorDot.java
@@ -0,0 +1,60 @@
+package app.revanced.extension.shared.ui;
+
+import static app.revanced.extension.shared.Utils.adjustColorBrightness;
+import static app.revanced.extension.shared.Utils.getAppBackgroundColor;
+import static app.revanced.extension.shared.Utils.isDarkModeEnabled;
+import static app.revanced.extension.shared.settings.preference.ColorPickerPreference.DISABLED_ALPHA;
+
+import android.graphics.Color;
+import android.graphics.drawable.GradientDrawable;
+import android.view.View;
+
+import androidx.annotation.ColorInt;
+
+public class ColorDot {
+ private static final int STROKE_WIDTH = Dim.dp(1.5f);
+
+ /**
+ * Creates a circular drawable with a main fill and a stroke.
+ * Stroke adapts to dark/light theme and transparency, applied only when color is transparent or matches app background.
+ */
+ public static GradientDrawable createColorDotDrawable(@ColorInt int color) {
+ final boolean isDarkTheme = isDarkModeEnabled();
+ final boolean isTransparent = Color.alpha(color) == 0;
+ final int opaqueColor = color | 0xFF000000;
+ final int appBackground = getAppBackgroundColor();
+ final int strokeColor;
+ final int strokeWidth;
+
+ // Determine stroke color.
+ if (isTransparent || (opaqueColor == appBackground)) {
+ final int baseColor = isTransparent ? appBackground : opaqueColor;
+ strokeColor = adjustColorBrightness(baseColor, isDarkTheme ? 1.2f : 0.8f);
+ strokeWidth = STROKE_WIDTH;
+ } else {
+ strokeColor = 0;
+ strokeWidth = 0;
+ }
+
+ // Create circular drawable with conditional stroke.
+ GradientDrawable circle = new GradientDrawable();
+ circle.setShape(GradientDrawable.OVAL);
+ circle.setColor(color);
+ circle.setStroke(strokeWidth, strokeColor);
+
+ return circle;
+ }
+
+ /**
+ * Applies the color dot drawable to the target view.
+ */
+ public static void applyColorDot(View targetView, @ColorInt int color, boolean enabled) {
+ if (targetView == null) return;
+ targetView.setBackground(createColorDotDrawable(color));
+ targetView.setAlpha(enabled ? 1.0f : DISABLED_ALPHA);
+ if (!isDarkModeEnabled()) {
+ targetView.setClipToOutline(true);
+ targetView.setElevation(Dim.dp2);
+ }
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/ui/CustomDialog.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/ui/CustomDialog.java
new file mode 100644
index 0000000000..15d80c916e
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/ui/CustomDialog.java
@@ -0,0 +1,461 @@
+package app.revanced.extension.shared.ui;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.graphics.drawable.ShapeDrawable;
+import android.graphics.drawable.shapes.RoundRectShape;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.method.LinkMovementMethod;
+import android.util.Pair;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.ScrollView;
+import android.widget.TextView;
+import androidx.annotation.Nullable;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A utility class for creating a customizable dialog with a title, message or EditText, and up to three buttons (OK, Cancel, Neutral).
+ * The dialog supports themed colors, rounded corners, and dynamic button layout based on screen width. It is dismissible by default.
+ */
+public class CustomDialog {
+ private final Context context;
+ private final Dialog dialog;
+ private final LinearLayout mainLayout;
+
+ /**
+ * Creates a custom dialog with a styled layout, including a title, message, buttons, and an optional EditText.
+ * The dialog's appearance adapts to the app's dark mode setting, with rounded corners and customizable button actions.
+ * Buttons adjust dynamically to their text content and are arranged in a single row if they fit within 80% of the
+ * screen width, with the Neutral button aligned to the left and OK/Cancel buttons centered on the right.
+ * If buttons do not fit, each is placed on a separate row, all aligned to the right.
+ *
+ * @param context Context used to create the dialog.
+ * @param title Title text of the dialog.
+ * @param message Message text of the dialog (supports Spanned for HTML), or null if replaced by EditText.
+ * @param editText EditText to include in the dialog, or null if no EditText is needed.
+ * @param okButtonText OK button text, or null to use the default "OK" string.
+ * @param onOkClick Action to perform when the OK button is clicked.
+ * @param onCancelClick Action to perform when the Cancel button is clicked, or null if no Cancel button is needed.
+ * @param neutralButtonText Neutral button text, or null if no Neutral button is needed.
+ * @param onNeutralClick Action to perform when the Neutral button is clicked, or null if no Neutral button is needed.
+ * @param dismissDialogOnNeutralClick If the dialog should be dismissed when the Neutral button is clicked.
+ * @return The Dialog and its main LinearLayout container.
+ */
+ public static Pair create(Context context, CharSequence title, CharSequence message,
+ @Nullable EditText editText, CharSequence okButtonText,
+ Runnable onOkClick, Runnable onCancelClick,
+ @Nullable CharSequence neutralButtonText,
+ @Nullable Runnable onNeutralClick,
+ boolean dismissDialogOnNeutralClick) {
+ Logger.printDebug(() -> "Creating custom dialog with title: " + title);
+ CustomDialog customDialog = new CustomDialog(context, title, message, editText,
+ okButtonText, onOkClick, onCancelClick,
+ neutralButtonText, onNeutralClick, dismissDialogOnNeutralClick);
+ return new Pair<>(customDialog.dialog, customDialog.mainLayout);
+ }
+
+ /**
+ * Initializes a custom dialog with the specified parameters.
+ *
+ * @param context Context used to create the dialog.
+ * @param title Title text of the dialog.
+ * @param message Message text of the dialog, or null if replaced by EditText.
+ * @param editText EditText to include in the dialog, or null if no EditText is needed.
+ * @param okButtonText OK button text, or null to use the default "OK" string.
+ * @param onOkClick Action to perform when the OK button is clicked.
+ * @param onCancelClick Action to perform when the Cancel button is clicked, or null if no Cancel button is needed.
+ * @param neutralButtonText Neutral button text, or null if no Neutral button is needed.
+ * @param onNeutralClick Action to perform when the Neutral button is clicked, or null if no Neutral button is needed.
+ * @param dismissDialogOnNeutralClick If the dialog should be dismissed when the Neutral button is clicked.
+ */
+ private CustomDialog(Context context, CharSequence title, CharSequence message, @Nullable EditText editText,
+ CharSequence okButtonText, Runnable onOkClick, Runnable onCancelClick,
+ @Nullable CharSequence neutralButtonText, @Nullable Runnable onNeutralClick,
+ boolean dismissDialogOnNeutralClick) {
+ this.context = context;
+ this.dialog = new Dialog(context);
+ this.dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); // Remove default title bar.
+
+ // Create main layout.
+ mainLayout = createMainLayout();
+ addTitle(title);
+ addContent(message, editText);
+ addButtons(okButtonText, onOkClick, onCancelClick, neutralButtonText, onNeutralClick, dismissDialogOnNeutralClick);
+
+ // Set dialog content and window attributes.
+ dialog.setContentView(mainLayout);
+ Window window = dialog.getWindow();
+ if (window != null) {
+ Utils.setDialogWindowParameters(window, Gravity.CENTER, 0, 90, false);
+ }
+ }
+
+ /**
+ * Creates the main layout for the dialog with vertical orientation and rounded corners.
+ *
+ * @return The configured LinearLayout for the dialog.
+ */
+ private LinearLayout createMainLayout() {
+ LinearLayout layout = new LinearLayout(context);
+ layout.setOrientation(LinearLayout.VERTICAL);
+ layout.setPadding(Dim.dp24, Dim.dp16, Dim.dp24, Dim.dp24);
+
+ // Set rounded rectangle background.
+ ShapeDrawable background = new ShapeDrawable(new RoundRectShape(
+ Dim.roundedCorners(28), null, null));
+ // Dialog background.
+ background.getPaint().setColor(Utils.getDialogBackgroundColor());
+ layout.setBackground(background);
+
+ return layout;
+ }
+
+ /**
+ * Adds a title to the dialog if provided.
+ *
+ * @param title The title text to display.
+ */
+ private void addTitle(CharSequence title) {
+ if (TextUtils.isEmpty(title)) return;
+
+ TextView titleView = new TextView(context);
+ titleView.setText(title);
+ titleView.setTypeface(Typeface.DEFAULT_BOLD);
+ titleView.setTextSize(18);
+ titleView.setTextColor(Utils.getAppForegroundColor());
+ titleView.setGravity(Gravity.CENTER);
+
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+ params.setMargins(0, 0, 0, Dim.dp16);
+ titleView.setLayoutParams(params);
+
+ mainLayout.addView(titleView);
+ }
+
+ /**
+ * Adds a message or EditText to the dialog within a ScrollView.
+ *
+ * @param message The message text to display (supports Spanned for HTML), or null if replaced by EditText.
+ * @param editText The EditText to include, or null if no EditText is needed.
+ */
+ private void addContent(CharSequence message, @Nullable EditText editText) {
+ // Create content container (message/EditText) inside a ScrollView only if message or editText is provided.
+ if (message == null && editText == null) return;
+
+ ScrollView scrollView = new ScrollView(context);
+ // Disable the vertical scrollbar.
+ scrollView.setVerticalScrollBarEnabled(false);
+ scrollView.setOverScrollMode(View.OVER_SCROLL_NEVER);
+
+ LinearLayout contentContainer = new LinearLayout(context);
+ contentContainer.setOrientation(LinearLayout.VERTICAL);
+ scrollView.addView(contentContainer);
+
+ // EditText (if provided).
+ if (editText != null) {
+ ShapeDrawable background = new ShapeDrawable(new RoundRectShape(
+ Dim.roundedCorners(10), null, null));
+ background.getPaint().setColor(Utils.getEditTextBackground());
+ scrollView.setPadding(Dim.dp8, Dim.dp8, Dim.dp8, Dim.dp8);
+ scrollView.setBackground(background);
+ scrollView.setClipToOutline(true);
+
+ // Remove EditText from its current parent, if any.
+ ViewGroup parent = (ViewGroup) editText.getParent();
+ if (parent != null) parent.removeView(editText);
+ // Style the EditText to match the dialog theme.
+ editText.setTextColor(Utils.getAppForegroundColor());
+ editText.setBackgroundColor(Color.TRANSPARENT);
+ editText.setPadding(0, 0, 0, 0);
+ contentContainer.addView(editText, new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT));
+ // Message (if not replaced by EditText).
+ } else {
+ TextView messageView = new TextView(context);
+ // Supports Spanned (HTML).
+ messageView.setText(message);
+ messageView.setTextSize(16);
+ messageView.setTextColor(Utils.getAppForegroundColor());
+ // Enable HTML link clicking if the message contains links.
+ if (message instanceof Spanned) {
+ messageView.setMovementMethod(LinkMovementMethod.getInstance());
+ }
+ contentContainer.addView(messageView, new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT));
+ }
+
+ // Weight to take available space.
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ 0,
+ 1.0f);
+ scrollView.setLayoutParams(params);
+ // Add ScrollView to main layout only if content exist.
+ mainLayout.addView(scrollView);
+ }
+
+ /**
+ * Adds buttons to the dialog, arranging them dynamically based on their widths.
+ *
+ * @param okButtonText OK button text, or null to use the default "OK" string.
+ * @param onOkClick Action for the OK button click.
+ * @param onCancelClick Action for the Cancel button click, or null if no Cancel button.
+ * @param neutralButtonText Neutral button text, or null if no Neutral button.
+ * @param onNeutralClick Action for the Neutral button click, or null if no Neutral button.
+ * @param dismissDialogOnNeutralClick If the dialog should dismiss on Neutral button click.
+ */
+ private void addButtons(CharSequence okButtonText, Runnable onOkClick, Runnable onCancelClick,
+ @Nullable CharSequence neutralButtonText, @Nullable Runnable onNeutralClick,
+ boolean dismissDialogOnNeutralClick) {
+ // Button container.
+ LinearLayout buttonContainer = new LinearLayout(context);
+ buttonContainer.setOrientation(LinearLayout.VERTICAL);
+ LinearLayout.LayoutParams buttonContainerParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT);
+ buttonContainerParams.setMargins(0, Dim.dp16, 0, 0);
+ buttonContainer.setLayoutParams(buttonContainerParams);
+
+ List buttons = new ArrayList<>();
+ List buttonWidths = new ArrayList<>();
+
+ // Create buttons in order: Neutral, Cancel, OK.
+ if (neutralButtonText != null && onNeutralClick != null) {
+ Button neutralButton = createButton(neutralButtonText, onNeutralClick, false, dismissDialogOnNeutralClick);
+ buttons.add(neutralButton);
+ buttonWidths.add(measureButtonWidth(neutralButton));
+ }
+ if (onCancelClick != null) {
+ Button cancelButton = createButton(context.getString(android.R.string.cancel), onCancelClick, false, true);
+ buttons.add(cancelButton);
+ buttonWidths.add(measureButtonWidth(cancelButton));
+ }
+ if (onOkClick != null) {
+ Button okButton = createButton(
+ okButtonText != null ? okButtonText : context.getString(android.R.string.ok),
+ onOkClick, true, true);
+ buttons.add(okButton);
+ buttonWidths.add(measureButtonWidth(okButton));
+ }
+
+ // Handle button layout.
+ layoutButtons(buttonContainer, buttons, buttonWidths);
+ mainLayout.addView(buttonContainer);
+ }
+
+ /**
+ * Creates a styled button with customizable text, click behavior, and appearance.
+ *
+ * @param text The button text to display.
+ * @param onClick The action to perform on button click.
+ * @param isOkButton If this is the OK button, which uses distinct styling.
+ * @param dismissDialog If the dialog should dismiss when the button is clicked.
+ * @return The created Button.
+ */
+ private Button createButton(CharSequence text, Runnable onClick, boolean isOkButton, boolean dismissDialog) {
+ Button button = new Button(context, null, 0);
+ button.setText(text);
+ button.setTextSize(14);
+ button.setAllCaps(false);
+ button.setSingleLine(true);
+ button.setEllipsize(TextUtils.TruncateAt.END);
+ button.setGravity(Gravity.CENTER);
+ // Set internal padding.
+ button.setPadding(Dim.dp16, 0, Dim.dp16, 0);
+
+ // Background color for OK button (inversion).
+ // Background color for Cancel or Neutral buttons.
+ ShapeDrawable background = new ShapeDrawable(new RoundRectShape(
+ Dim.roundedCorners(20), null, null));
+ background.getPaint().setColor(isOkButton
+ ? Utils.getOkButtonBackgroundColor()
+ : Utils.getCancelOrNeutralButtonBackgroundColor());
+ button.setBackground(background);
+
+ button.setTextColor(Utils.isDarkModeEnabled()
+ ? (isOkButton ? Color.BLACK : Color.WHITE)
+ : (isOkButton ? Color.WHITE : Color.BLACK));
+
+ button.setOnClickListener(v -> {
+ if (onClick != null) onClick.run();
+ if (dismissDialog) dialog.dismiss();
+ });
+
+ return button;
+ }
+
+ /**
+ * Measures the width of a button.
+ */
+ private int measureButtonWidth(Button button) {
+ button.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED);
+ return button.getMeasuredWidth();
+ }
+
+ /**
+ * Arranges buttons in the dialog, either in a single row or multiple rows based on their total width.
+ *
+ * @param buttonContainer The container for the buttons.
+ * @param buttons The list of buttons to arrange.
+ * @param buttonWidths The measured widths of the buttons.
+ */
+ private void layoutButtons(LinearLayout buttonContainer, List buttons, List buttonWidths) {
+ if (buttons.isEmpty()) return;
+
+ // Check if buttons fit in one row.
+ int totalWidth = 0;
+ for (Integer width : buttonWidths) {
+ totalWidth += width;
+ }
+ if (buttonWidths.size() > 1) {
+ // Add margins for gaps.
+ totalWidth += (buttonWidths.size() - 1) * Dim.dp8;
+ }
+
+ // Single button: stretch to full width.
+ if (buttons.size() == 1) {
+ layoutSingleButton(buttonContainer, buttons.get(0));
+ } else if (totalWidth <= Dim.pctWidth(80)) {
+ // Single row: Neutral, Cancel, OK.
+ layoutButtonsInRow(buttonContainer, buttons, buttonWidths);
+ } else {
+ // Multiple rows: OK, Cancel, Neutral.
+ layoutButtonsInColumns(buttonContainer, buttons);
+ }
+ }
+
+ /**
+ * Arranges a single button, stretching it to full width.
+ *
+ * @param buttonContainer The container for the button.
+ * @param button The button to arrange.
+ */
+ private void layoutSingleButton(LinearLayout buttonContainer, Button button) {
+ LinearLayout singleContainer = new LinearLayout(context);
+ singleContainer.setOrientation(LinearLayout.HORIZONTAL);
+ singleContainer.setGravity(Gravity.CENTER);
+
+ ViewGroup parent = (ViewGroup) button.getParent();
+ if (parent != null) parent.removeView(button);
+
+ button.setLayoutParams(new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ Dim.dp36));
+ singleContainer.addView(button);
+ buttonContainer.addView(singleContainer);
+ }
+
+ /**
+ * Arranges buttons in a single horizontal row with proportional widths.
+ *
+ * @param buttonContainer The container for the buttons.
+ * @param buttons The list of buttons to arrange.
+ * @param buttonWidths The measured widths of the buttons.
+ */
+ private void layoutButtonsInRow(LinearLayout buttonContainer, List buttons, List buttonWidths) {
+ LinearLayout rowContainer = new LinearLayout(context);
+ rowContainer.setOrientation(LinearLayout.HORIZONTAL);
+ rowContainer.setGravity(Gravity.CENTER);
+ rowContainer.setLayoutParams(new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT));
+
+ // Add all buttons with proportional weights and specific margins.
+ for (int i = 0; i < buttons.size(); i++) {
+ Button button = getButton(buttons, buttonWidths, i);
+ rowContainer.addView(button);
+ }
+
+ buttonContainer.addView(rowContainer);
+ }
+
+ @NotNull
+ private Button getButton(List