("revanced-patches-publication") {
- from(components["java"])
-
- pom {
- name = "Piko"
- description = "Patches for ReVanced."
- url = "https://your.homepage"
-
- licenses {
- license {
- name = "GNU General Public License v3.0"
- url = "https://www.gnu.org/licenses/gpl-3.0.en.html"
- }
- }
- developers {
- developer {
- id = "Your ID"
- name = "crimera"
- email = "contact@your.homepage"
- }
- }
- scm {
- connection = "scm:git:git://github.com/crimera/piko.git"
- developerConnection = "scm:git:git@github.com:crimera/piko.git"
- url = "https://github.com/crimera/piko"
- }
- }
- }
- }
+ alias(libs.plugins.android.library) apply false
}
diff --git a/dummy/build.gradle.kts b/dummy/build.gradle.kts
deleted file mode 100644
index 04b0d269..00000000
--- a/dummy/build.gradle.kts
+++ /dev/null
@@ -1,9 +0,0 @@
-plugins {
- id("java")
-}
-
-java {
- toolchain {
- languageVersion.set(JavaLanguageVersion.of(11))
- }
-}
\ No newline at end of file
diff --git a/extensions/proguard-rules.pro b/extensions/proguard-rules.pro
new file mode 100644
index 00000000..06a543ce
--- /dev/null
+++ b/extensions/proguard-rules.pro
@@ -0,0 +1,10 @@
+-dontobfuscate
+-dontoptimize
+-keepattributes *
+-keep class app.revanced.** {
+ *;
+}
+
+-keep class com.google.** {
+ *;
+}
diff --git a/extensions/shared/build.gradle.kts b/extensions/shared/build.gradle.kts
new file mode 100644
index 00000000..2da2e1e8
--- /dev/null
+++ b/extensions/shared/build.gradle.kts
@@ -0,0 +1,3 @@
+dependencies {
+ implementation(project(":extensions:shared:library"))
+}
diff --git a/extensions/shared/library/build.gradle.kts b/extensions/shared/library/build.gradle.kts
new file mode 100644
index 00000000..4177cb12
--- /dev/null
+++ b/extensions/shared/library/build.gradle.kts
@@ -0,0 +1,21 @@
+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)
+}
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 00000000..47f6da3e
--- /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 thru ADB),
+ * and additionally accessible thru {@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 it's 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/StringRef.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/StringRef.java
new file mode 100644
index 00000000..4390137d
--- /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 it's value
+ *
+ * @param value value which toString() method returns when invoked on returned object
+ * @return Unique StringRef instance, its value will never change
+ */
+ @NonNull
+ public static StringRef constant(@NonNull String value) {
+ final StringRef ref = new StringRef(value);
+ ref.resolved = true;
+ return ref;
+ }
+
+ /**
+ * Shorthand for constant("")
+ * Its value always resolves to empty string
+ */
+ @NonNull
+ public static final StringRef empty = constant("");
+
+ @NonNull
+ private String value;
+ private boolean resolved;
+
+ public StringRef(@NonNull String resName) {
+ this.value = resName;
+ }
+
+ @Override
+ @NonNull
+ public String toString() {
+ if (!resolved) {
+ if (resources == null || packageName == null) {
+ Context context = Utils.getContext();
+ resources = context.getResources();
+ packageName = context.getPackageName();
+ }
+ resolved = true;
+ if (resources != null) {
+ final int identifier = resources.getIdentifier(value, "string", packageName);
+ if (identifier == 0)
+ Logger.printException(() -> "Resource not found: " + value);
+ else
+ value = resources.getString(identifier);
+ } else {
+ Logger.printException(() -> "Could not resolve resources!");
+ }
+ }
+ return value;
+ }
+}
diff --git a/extensions/shared/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 00000000..f412faa0
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java
@@ -0,0 +1,1540 @@
+package app.revanced.extension.shared;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.Dialog;
+import android.app.DialogFragment;
+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.graphics.Typeface;
+import android.graphics.drawable.ShapeDrawable;
+import android.graphics.drawable.shapes.RoundRectShape;
+import android.net.ConnectivityManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.preference.Preference;
+import android.preference.PreferenceGroup;
+import android.preference.PreferenceScreen;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.method.LinkMovementMethod;
+import android.util.DisplayMetrics;
+import android.util.Pair;
+import android.util.TypedValue;
+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.Button;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+import android.widget.ScrollView;
+import android.widget.TextView;
+import android.widget.Toast;
+import android.widget.Toolbar;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.text.Bidi;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+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;
+
+public class Utils {
+
+ @SuppressLint("StaticFieldLeak")
+ public static 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 Utils() {
+ } // utility class
+
+
+ /**
+ * Injection point.
+ **/
+ public static void load() {
+ }
+ /**
+ * @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 19.11.43
+ */
+ 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;
+ }
+
+ 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 condition The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static void hideViewBy0dpUnderCondition(BooleanSetting condition, View view) {
+ if (hideViewBy0dpUnderCondition(condition.get(), view)) {
+ Logger.printDebug(() -> "View hidden by setting: " + condition);
+ }
+ }
+
+ /**
+ * Hide a view by setting its layout height and width to 0dp.
+ *
+ * @param condition The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static boolean hideViewBy0dpUnderCondition(boolean condition, View view) {
+ if (condition) {
+ hideViewByLayoutParams(view);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Hide a view by setting its visibility to GONE.
+ *
+ * @param condition The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static void hideViewUnderCondition(BooleanSetting condition, View view) {
+ if (hideViewUnderCondition(condition.get(), view)) {
+ Logger.printDebug(() -> "View hidden by setting: " + condition);
+ }
+ }
+
+ /**
+ * Hide a view by setting its visibility to GONE.
+ *
+ * @param condition The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static boolean hideViewUnderCondition(boolean condition, View view) {
+ if (condition) {
+ view.setVisibility(View.GONE);
+ return true;
+ }
+
+ return false;
+ }
+
+ public static void hideViewByRemovingFromParentUnderCondition(BooleanSetting condition, View view) {
+ if (hideViewByRemovingFromParentUnderCondition(condition.get(), view)) {
+ Logger.printDebug(() -> "View hidden by setting: " + condition);
+ }
+ }
+
+ public static boolean hideViewByRemovingFromParentUnderCondition(boolean setting, View view) {
+ if (setting) {
+ ViewParent parent = view.getParent();
+ if (parent instanceof ViewGroup 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) {
+ 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, String resourceIdentifierName, String type) {
+ return context.getResources().getIdentifier(resourceIdentifierName, type, context.getPackageName());
+ }
+
+ /**
+ * @return zero, if the resource is not found.
+ */
+ public static int getResourceIdentifier(String resourceIdentifierName, String type) {
+ return getResourceIdentifier(getContext(), resourceIdentifierName, type);
+ }
+
+ public static int getResourceInteger(String resourceIdentifierName) throws Resources.NotFoundException {
+ return getContext().getResources().getInteger(getResourceIdentifier(resourceIdentifierName, "integer"));
+ }
+
+ public static Animation getResourceAnimation(String resourceIdentifierName) throws Resources.NotFoundException {
+ return AnimationUtils.loadAnimation(getContext(), getResourceIdentifier(resourceIdentifierName, "anim"));
+ }
+
+ @ColorInt
+ public static int getResourceColor(String resourceIdentifierName) throws Resources.NotFoundException {
+ //noinspection deprecation
+ return getContext().getResources().getColor(getResourceIdentifier(resourceIdentifierName, "color"));
+ }
+
+ public static int getResourceDimensionPixelSize(String resourceIdentifierName) throws Resources.NotFoundException {
+ return getContext().getResources().getDimensionPixelSize(getResourceIdentifier(resourceIdentifierName, "dimen"));
+ }
+
+ public static float getResourceDimension(String resourceIdentifierName) throws Resources.NotFoundException {
+ return getContext().getResources().getDimension(getResourceIdentifier(resourceIdentifierName, "dimen"));
+ }
+
+ public static String[] getResourceStringArray(String resourceIdentifierName) throws Resources.NotFoundException {
+ return getContext().getResources().getStringArray(getResourceIdentifier(resourceIdentifierName, "array"));
+ }
+
+ public static String getResourceString(@NonNull String resourceIdentifierName) throws Resources.NotFoundException {
+ return getContext().getResources().getString(getResourceIdentifier(resourceIdentifierName, "string"));
+ }
+
+ public interface MatchFilter {
+ boolean matches(T object);
+ }
+
+ /**
+ * Includes sub children.
+ */
+ public static R getChildViewByResourceName(View view, String str) {
+ var child = view.findViewById(Utils.getResourceIdentifier(str, "id"));
+ if (child != null) {
+ //noinspection unchecked
+ return (R) child;
+ }
+
+ throw new IllegalArgumentException("View with resource name not found: " + str);
+ }
+
+ /**
+ * @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));
+
+ load();
+ }
+
+ public static void setClipboard(CharSequence text) {
+ android.content.ClipboardManager clipboard = (android.content.ClipboardManager) context
+ .getSystemService(Context.CLIPBOARD_SERVICE);
+ android.content.ClipData clip = android.content.ClipData.newPlainText("ReVanced", text);
+ clipboard.setPrimaryClip(clip);
+ }
+
+ public static boolean isTablet() {
+ return context.getResources().getConfiguration().smallestScreenWidthDp >= 600;
+ }
+
+ @Nullable
+ private static Boolean isRightToLeftTextLayout;
+
+ /**
+ * @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;
+ }
+
+ public static void shareText(String txt) {
+ final String appPackageName = context.getPackageName();
+ Intent sendIntent = new Intent();
+ sendIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ sendIntent.setAction(Intent.ACTION_SEND);
+ sendIntent.putExtra(Intent.EXTRA_TEXT, txt);
+ sendIntent.setType("text/plain");
+ context.startActivity(sendIntent);
+ }
+
+ /**
+ * @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());
+ }
+
+ 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);
+ }
+
+ private 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 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;
+ }
+
+ /**
+ * Hide a view by setting its layout params to 0x0
+ * @param view The view to hide.
+ */
+ public static void hideViewByLayoutParams(View view) {
+ if (view instanceof LinearLayout) {
+ LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(0, 0);
+ view.setLayoutParams(layoutParams);
+ } else if (view instanceof FrameLayout) {
+ FrameLayout.LayoutParams layoutParams2 = new FrameLayout.LayoutParams(0, 0);
+ view.setLayoutParams(layoutParams2);
+ } else if (view instanceof RelativeLayout) {
+ RelativeLayout.LayoutParams layoutParams3 = new RelativeLayout.LayoutParams(0, 0);
+ view.setLayoutParams(layoutParams3);
+ } else if (view instanceof Toolbar) {
+ Toolbar.LayoutParams layoutParams4 = new Toolbar.LayoutParams(0, 0);
+ view.setLayoutParams(layoutParams4);
+ } else if (view instanceof ViewGroup) {
+ ViewGroup.LayoutParams layoutParams5 = new ViewGroup.LayoutParams(0, 0);
+ view.setLayoutParams(layoutParams5);
+ } else {
+ ViewGroup.LayoutParams params = view.getLayoutParams();
+ params.width = 0;
+ params.height = 0;
+ view.setLayoutParams(params);
+ }
+ }
+
+ /**
+ * 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.
+ */
+ @SuppressWarnings("ExtractMethodRecommender")
+ public static Pair createCustomDialog(
+ Context context, String title, CharSequence message, @Nullable EditText editText,
+ String okButtonText, Runnable onOkClick, Runnable onCancelClick,
+ @Nullable String neutralButtonText, @Nullable Runnable onNeutralClick,
+ boolean dismissDialogOnNeutralClick
+ ) {
+ Logger.printDebug(() -> "Creating custom dialog with title: " + title);
+
+ Dialog dialog = new Dialog(context);
+ dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); // Remove default title bar.
+
+ // Preset size constants.
+ final int dip4 = dipToPixels(4);
+ final int dip8 = dipToPixels(8);
+ final int dip16 = dipToPixels(16);
+ final int dip24 = dipToPixels(24);
+
+ // Create main layout.
+ LinearLayout mainLayout = new LinearLayout(context);
+ mainLayout.setOrientation(LinearLayout.VERTICAL);
+ mainLayout.setPadding(dip24, dip16, dip24, dip24);
+ // Set rounded rectangle background.
+ ShapeDrawable mainBackground = new ShapeDrawable(new RoundRectShape(
+ createCornerRadii(28), null, null));
+ mainBackground.getPaint().setColor(getDialogBackgroundColor()); // Dialog background.
+ mainLayout.setBackground(mainBackground);
+
+ // Title.
+ if (!TextUtils.isEmpty(title)) {
+ TextView titleView = new TextView(context);
+ titleView.setText(title);
+ titleView.setTypeface(Typeface.DEFAULT_BOLD);
+ titleView.setTextSize(18);
+ titleView.setTextColor(getAppForegroundColor());
+ titleView.setGravity(Gravity.CENTER);
+ LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ );
+ layoutParams.setMargins(0, 0, 0, dip16);
+ titleView.setLayoutParams(layoutParams);
+ mainLayout.addView(titleView);
+ }
+
+ // Create content container (message/EditText) inside a ScrollView only if message or editText is provided.
+ ScrollView contentScrollView = null;
+ LinearLayout contentContainer;
+ if (message != null || editText != null) {
+ contentScrollView = new ScrollView(context);
+ contentScrollView.setVerticalScrollBarEnabled(false); // Disable the vertical scrollbar.
+ contentScrollView.setOverScrollMode(View.OVER_SCROLL_NEVER);
+ if (editText != null) {
+ ShapeDrawable scrollViewBackground = new ShapeDrawable(new RoundRectShape(
+ createCornerRadii(10), null, null));
+ scrollViewBackground.getPaint().setColor(getEditTextBackground());
+ contentScrollView.setPadding(dip8, dip8, dip8, dip8);
+ contentScrollView.setBackground(scrollViewBackground);
+ contentScrollView.setClipToOutline(true);
+ }
+ LinearLayout.LayoutParams contentParams = new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ 0,
+ 1.0f // Weight to take available space.
+ );
+ contentScrollView.setLayoutParams(contentParams);
+ contentContainer = new LinearLayout(context);
+ contentContainer.setOrientation(LinearLayout.VERTICAL);
+ contentScrollView.addView(contentContainer);
+
+ // Message (if not replaced by EditText).
+ if (editText == null) {
+ TextView messageView = new TextView(context);
+ messageView.setText(message); // Supports Spanned (HTML).
+ messageView.setTextSize(16);
+ messageView.setTextColor(getAppForegroundColor());
+ // Enable HTML link clicking if the message contains links.
+ if (message instanceof Spanned) {
+ messageView.setMovementMethod(LinkMovementMethod.getInstance());
+ }
+ LinearLayout.LayoutParams messageParams = new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ );
+ messageView.setLayoutParams(messageParams);
+ contentContainer.addView(messageView);
+ }
+
+ // EditText (if provided).
+ if (editText != null) {
+ // 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(getAppForegroundColor());
+ editText.setBackgroundColor(Color.TRANSPARENT);
+ editText.setPadding(0, 0, 0, 0);
+ LinearLayout.LayoutParams editTextParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ contentContainer.addView(editText, editTextParams);
+ }
+ }
+
+ // Button container.
+ LinearLayout buttonContainer = new LinearLayout(context);
+ buttonContainer.setOrientation(LinearLayout.VERTICAL);
+ buttonContainer.removeAllViews();
+ LinearLayout.LayoutParams buttonContainerParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ buttonContainerParams.setMargins(0, dip16, 0, 0);
+ buttonContainer.setLayoutParams(buttonContainerParams);
+
+ // Lists to track buttons.
+ List buttons = new ArrayList<>();
+ List buttonWidths = new ArrayList<>();
+
+ // Create buttons in order: Neutral, Cancel, OK.
+ if (neutralButtonText != null && onNeutralClick != null) {
+ Button neutralButton = addButton(
+ context,
+ neutralButtonText,
+ onNeutralClick,
+ false,
+ dismissDialogOnNeutralClick,
+ dialog
+ );
+ buttons.add(neutralButton);
+ neutralButton.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED);
+ buttonWidths.add(neutralButton.getMeasuredWidth());
+ }
+
+ if (onCancelClick != null) {
+ Button cancelButton = addButton(
+ context,
+ context.getString(android.R.string.cancel),
+ onCancelClick,
+ false,
+ true,
+ dialog
+ );
+ buttons.add(cancelButton);
+ cancelButton.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED);
+ buttonWidths.add(cancelButton.getMeasuredWidth());
+ }
+
+ if (onOkClick != null) {
+ Button okButton = addButton(
+ context,
+ okButtonText != null ? okButtonText : context.getString(android.R.string.ok),
+ onOkClick,
+ true,
+ true,
+ dialog
+ );
+ buttons.add(okButton);
+ okButton.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED);
+ buttonWidths.add(okButton.getMeasuredWidth());
+ }
+
+ // Handle button layout.
+ int screenWidth = context.getResources().getDisplayMetrics().widthPixels;
+ int totalWidth = 0;
+ for (Integer width : buttonWidths) {
+ totalWidth += width;
+ }
+ if (buttonWidths.size() > 1) {
+ totalWidth += (buttonWidths.size() - 1) * dip8; // Add margins for gaps.
+ }
+
+ if (buttons.size() == 1) {
+ // Single button: stretch to full width.
+ Button singleButton = buttons.get(0);
+ LinearLayout singleContainer = new LinearLayout(context);
+ singleContainer.setOrientation(LinearLayout.HORIZONTAL);
+ singleContainer.setGravity(Gravity.CENTER);
+ ViewGroup parent = (ViewGroup) singleButton.getParent();
+ if (parent != null) {
+ parent.removeView(singleButton);
+ }
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ dipToPixels(36)
+ );
+ params.setMargins(0, 0, 0, 0);
+ singleButton.setLayoutParams(params);
+ singleContainer.addView(singleButton);
+ buttonContainer.addView(singleContainer);
+ } else if (buttons.size() > 1) {
+ // Check if buttons fit in one row.
+ if (totalWidth <= screenWidth * 0.8) {
+ // Single row: Neutral, Cancel, OK.
+ 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 = buttons.get(i);
+ ViewGroup parent = (ViewGroup) button.getParent();
+ if (parent != null) {
+ parent.removeView(button);
+ }
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
+ 0,
+ dipToPixels(36),
+ buttonWidths.get(i) // Use measured width as weight.
+ );
+ // Set margins based on button type and combination.
+ if (buttons.size() == 2) {
+ // Neutral + OK or Cancel + OK.
+ if (i == 0) { // Neutral or Cancel.
+ params.setMargins(0, 0, dip4, 0);
+ } else { // OK
+ params.setMargins(dip4, 0, 0, 0);
+ }
+ } else if (buttons.size() == 3) {
+ if (i == 0) { // Neutral.
+ params.setMargins(0, 0, dip4, 0);
+ } else if (i == 1) { // Cancel
+ params.setMargins(dip4, 0, dip4, 0);
+ } else { // OK
+ params.setMargins(dip4, 0, 0, 0);
+ }
+ }
+ button.setLayoutParams(params);
+ rowContainer.addView(button);
+ }
+ buttonContainer.addView(rowContainer);
+ } else {
+ // Multiple rows: OK, Cancel, Neutral.
+ List reorderedButtons = new ArrayList<>();
+ // Reorder: OK, Cancel, Neutral.
+ if (onOkClick != null) {
+ reorderedButtons.add(buttons.get(buttons.size() - 1));
+ }
+ if (onCancelClick != null) {
+ reorderedButtons.add(buttons.get((neutralButtonText != null && onNeutralClick != null) ? 1 : 0));
+ }
+ if (neutralButtonText != null && onNeutralClick != null) {
+ reorderedButtons.add(buttons.get(0));
+ }
+
+ // Add each button in its own row with spacers.
+ for (int i = 0; i < reorderedButtons.size(); i++) {
+ Button button = reorderedButtons.get(i);
+ LinearLayout singleContainer = new LinearLayout(context);
+ singleContainer.setOrientation(LinearLayout.HORIZONTAL);
+ singleContainer.setGravity(Gravity.CENTER);
+ singleContainer.setLayoutParams(new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ dipToPixels(36)
+ ));
+ ViewGroup parent = (ViewGroup) button.getParent();
+ if (parent != null) {
+ parent.removeView(button);
+ }
+ LinearLayout.LayoutParams buttonParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ dipToPixels(36)
+ );
+ buttonParams.setMargins(0, 0, 0, 0);
+ button.setLayoutParams(buttonParams);
+ singleContainer.addView(button);
+ buttonContainer.addView(singleContainer);
+
+ // Add a spacer between the buttons (except the last one).
+ // Adding a margin between buttons is not suitable, as it conflicts with the single row layout.
+ if (i < reorderedButtons.size() - 1) {
+ View spacer = new View(context);
+ LinearLayout.LayoutParams spacerParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ dipToPixels(8)
+ );
+ spacer.setLayoutParams(spacerParams);
+ buttonContainer.addView(spacer);
+ }
+ }
+ }
+ }
+
+ // Add ScrollView to main layout only if content exist.
+ if (contentScrollView != null) {
+ mainLayout.addView(contentScrollView);
+ }
+ mainLayout.addView(buttonContainer);
+ dialog.setContentView(mainLayout);
+
+ // Set dialog window attributes.
+ Window window = dialog.getWindow();
+ if (window != null) {
+ setDialogWindowParameters(window);
+ }
+
+ return new Pair<>(dialog, mainLayout);
+ }
+
+ public static void setDialogWindowParameters(Window window) {
+ WindowManager.LayoutParams params = window.getAttributes();
+
+ DisplayMetrics displayMetrics = Resources.getSystem().getDisplayMetrics();
+ int portraitWidth = (int) (displayMetrics.widthPixels * 0.9);
+
+ if (Resources.getSystem().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ portraitWidth = (int) Math.min(portraitWidth, displayMetrics.heightPixels * 0.9);
+ }
+ params.width = portraitWidth;
+ params.height = WindowManager.LayoutParams.WRAP_CONTENT;
+ params.gravity = Gravity.CENTER;
+ window.setAttributes(params);
+ window.setBackgroundDrawable(null); // Remove default dialog background.
+ }
+
+ /**
+ * Adds a styled button to a dialog's button container with customizable text, click behavior, and appearance.
+ * The button's background and text colors adapt to the app's dark mode setting. Buttons stretch to full width
+ * when on separate rows or proportionally based on content when in a single row (Neutral, Cancel, OK order).
+ * When wrapped to separate rows, buttons are ordered OK, Cancel, Neutral.
+ *
+ * @param context Context to create the button and access resources.
+ * @param buttonText Button text to display.
+ * @param onClick Action to perform when the button is clicked, or null if no action is required.
+ * @param isOkButton If this is the OK button, which uses distinct background and text colors.
+ * @param dismissDialog If the dialog should be dismissed when the button is clicked.
+ * @param dialog The Dialog to dismiss when the button is clicked.
+ * @return The created Button.
+ */
+ private static Button addButton(Context context, String buttonText, Runnable onClick,
+ boolean isOkButton, boolean dismissDialog, Dialog dialog) {
+ Button button = new Button(context, null, 0);
+ button.setText(buttonText);
+ button.setTextSize(14);
+ button.setAllCaps(false);
+ button.setSingleLine(true);
+ button.setEllipsize(android.text.TextUtils.TruncateAt.END);
+ button.setGravity(Gravity.CENTER);
+
+ ShapeDrawable background = new ShapeDrawable(new RoundRectShape(createCornerRadii(20), null, null));
+ int backgroundColor = isOkButton
+ ? getOkButtonBackgroundColor() // Background color for OK button (inversion).
+ : getCancelOrNeutralButtonBackgroundColor(); // Background color for Cancel or Neutral buttons.
+ background.getPaint().setColor(backgroundColor);
+ button.setBackground(background);
+
+ button.setTextColor(isDarkModeEnabled()
+ ? (isOkButton ? Color.BLACK : Color.WHITE)
+ : (isOkButton ? Color.WHITE : Color.BLACK));
+
+ // Set internal padding.
+ final int dip16 = dipToPixels(16);
+ button.setPadding(dip16, 0, dip16, 0);
+
+ button.setOnClickListener(v -> {
+ if (onClick != null) {
+ onClick.run();
+ }
+ if (dismissDialog) {
+ dialog.dismiss();
+ }
+ });
+
+ return button;
+ }
+
+ /**
+ * Creates an array of corner radii for a rounded rectangle shape.
+ *
+ * @param dp Radius in density-independent pixels (dip) to apply to all corners.
+ * @return An array of eight float values representing the corner radii
+ * (top-left, top-right, bottom-right, bottom-left).
+ */
+ public static float[] createCornerRadii(float dp) {
+ final float radius = dipToPixels(dp);
+ return new float[]{radius, radius, radius, radius, radius, radius, radius, radius};
+ }
+
+ /**
+ * 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;
+ }
+ }
+
+ private static final Pattern punctuationPattern = Pattern.compile("\\p{P}+");
+
+ /**
+ * Strips all punctuation and converts to lower case. A null parameter returns an empty string.
+ */
+ public static String removePunctuationToLowercase(@Nullable CharSequence original) {
+ if (original == null) return "";
+ return punctuationPattern.matcher(original).replaceAll("")
+ .toLowerCase(BaseSettings.REVANCED_LANGUAGE.get().getLocale());
+ }
+
+ /**
+ * Sort a PreferenceGroup and all it's sub groups by title or key.
+ *
+ * Sort order is determined by the preferences key {@link Sort} suffix.
+ *
+ * If a preference has no key or no {@link Sort} suffix,
+ * then the preferences are left unsorted.
+ */
+ @SuppressWarnings("deprecation")
+ public static void sortPreferenceGroups(PreferenceGroup group) {
+ Sort groupSort = Sort.fromKey(group.getKey(), Sort.UNSORTED);
+ List> preferences = new ArrayList<>();
+
+ 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));
+ }
+
+ //noinspection ComparatorCombinators
+ Collections.sort(preferences, (pair1, pair2)
+ -> pair1.first.compareTo(pair2.first));
+
+ 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);
+ }
+
+ /**
+ * Converts dip value to actual device pixels.
+ *
+ * @param dip The density-independent pixels value.
+ * @return The device pixel value.
+ */
+ public static int dipToPixels(float dip) {
+ return (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ dip,
+ Resources.getSystem().getDisplayMetrics()
+ );
+ }
+
+ /**
+ * Converts a percentage of the screen height to actual device pixels.
+ *
+ * @param percentage The percentage of the screen height (e.g., 30 for 30%).
+ * @return The device pixel value corresponding to the percentage of screen height.
+ */
+ public static int percentageHeightToPixels(int percentage) {
+ DisplayMetrics metrics = context.getResources().getDisplayMetrics();
+ return (int) (metrics.heightPixels * (percentage / 100.0f));
+ }
+
+ /**
+ * Converts a percentage of the screen width to actual device pixels.
+ *
+ * @param percentage The percentage of the screen width (e.g., 30 for 30%).
+ * @return The device pixel value corresponding to the percentage of screen width.
+ */
+ public static int percentageWidthToPixels(int percentage) {
+ DisplayMetrics metrics = context.getResources().getDisplayMetrics();
+ return (int) (metrics.widthPixels * (percentage / 100.0f));
+ }
+
+ /**
+ * Uses {@link #adjustColorBrightness(int, float)} depending 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 *= factor;
+ green *= factor;
+ 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));
+ }
+}
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 00000000..c25e71d7
--- /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();
+ // Request data is in the URL parameters and no body is sent.
+ // The calling code must set a length if using a request body.
+ connection.setFixedLengthStreamingMode(0);
+ connection.setRequestMethod(route.getMethod().name());
+ String agentString = System.getProperty("http.agent")
+ + "; ReVanced/" + Utils.getAppVersionName()
+ + " (" + Utils.getPatchesReleaseVersion() + ")";
+ connection.setRequestProperty("User-Agent", agentString);
+
+ return connection;
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection}, and closes the underlying InputStream.
+ */
+ private static String parseInputStreamAndClose(InputStream inputStream) throws IOException {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
+ StringBuilder jsonBuilder = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ jsonBuilder.append(line);
+ jsonBuilder.append('\n');
+ }
+ return jsonBuilder.toString();
+ }
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} response as a String.
+ * This does not close the url connection. If further requests to this host are unlikely
+ * in the near future, then instead use {@link #parseStringAndDisconnect(HttpURLConnection)}.
+ */
+ public static String parseString(HttpURLConnection connection) throws IOException {
+ return parseInputStreamAndClose(connection.getInputStream());
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} response as a String, and disconnect.
+ *
+ * Should only be used if other requests to the server in the near future are unlikely
+ *
+ * @see #parseString(HttpURLConnection)
+ */
+ public static String parseStringAndDisconnect(HttpURLConnection connection) throws IOException {
+ String result = parseString(connection);
+ connection.disconnect();
+ return result;
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} error stream as a String.
+ * If the server sent no error response data, this returns an empty string.
+ */
+ public static String parseErrorString(HttpURLConnection connection) throws IOException {
+ InputStream errorStream = connection.getErrorStream();
+ if (errorStream == null) {
+ return "";
+ }
+ return parseInputStreamAndClose(errorStream);
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} error stream as a String, and disconnect.
+ * If the server sent no error response data, this returns an empty string.
+ *
+ * Should only be used if other requests to the server are unlikely in the near future.
+ *
+ * @see #parseErrorString(HttpURLConnection)
+ */
+ public static String parseErrorStringAndDisconnect(HttpURLConnection connection) throws IOException {
+ String result = parseErrorString(connection);
+ connection.disconnect();
+ return result;
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} response into a JSONObject.
+ * This does not close the url connection. If further requests to this host are unlikely
+ * in the near future, then instead use {@link #parseJSONObjectAndDisconnect(HttpURLConnection)}.
+ */
+ public static JSONObject parseJSONObject(HttpURLConnection connection) throws JSONException, IOException {
+ return new JSONObject(parseString(connection));
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection}, close the underlying InputStream, and disconnect.
+ *
+ * Should only be used if other requests to the server in the near future are unlikely
+ *
+ * @see #parseJSONObject(HttpURLConnection)
+ */
+ public static JSONObject parseJSONObjectAndDisconnect(HttpURLConnection connection) throws JSONException, IOException {
+ JSONObject object = parseJSONObject(connection);
+ connection.disconnect();
+ return object;
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection}, and closes the underlying InputStream.
+ * This does not close the url connection. If further requests to this host are unlikely
+ * in the near future, then instead use {@link #parseJSONArrayAndDisconnect(HttpURLConnection)}.
+ */
+ public static JSONArray parseJSONArray(HttpURLConnection connection) throws JSONException, IOException {
+ return new JSONArray(parseString(connection));
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection}, close the underlying InputStream, and disconnect.
+ *
+ * Should only be used if other requests to the server in the near future are unlikely
+ *
+ * @see #parseJSONArray(HttpURLConnection)
+ */
+ public static JSONArray parseJSONArrayAndDisconnect(HttpURLConnection connection) throws JSONException, IOException {
+ JSONArray array = parseJSONArray(connection);
+ connection.disconnect();
+ return array;
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/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 00000000..74428224
--- /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 00000000..944529f4
--- /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,
+ HI,
+ HE, // App uses obsolete 'IW' and not the modern 'HE' ISO code.
+ HR,
+ HU,
+ HY,
+ ID,
+ IS,
+ IT,
+ JA,
+ KA,
+ KK,
+ KM,
+ KN,
+ KO,
+ KY,
+ LO,
+ LT,
+ LV,
+ MK,
+ ML,
+ MN,
+ MR,
+ MS,
+ MY,
+ NE,
+ NL,
+ NB,
+ 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/BaseSettings.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/BaseSettings.java
new file mode 100644
index 00000000..5948d7fb
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/BaseSettings.java
@@ -0,0 +1,24 @@
+package app.revanced.extension.shared.settings;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+import static app.revanced.extension.shared.settings.Setting.parent;
+
+/**
+ * Settings shared across multiple apps.
+ *
+ * To ensure this class is loaded when the UI is created, app specific setting bundles should extend
+ * or reference this class.
+ */
+public class BaseSettings {
+ public static final BooleanSetting DEBUG = new BooleanSetting("revanced_debug", FALSE);
+ public static final BooleanSetting DEBUG_STACKTRACE = new BooleanSetting("revanced_debug_stacktrace", FALSE, parent(DEBUG));
+ public static final BooleanSetting DEBUG_TOAST_ON_ERROR = new BooleanSetting("revanced_debug_toast_on_error", TRUE, "revanced_debug_toast_on_error_user_dialog_message");
+
+ 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);
+}
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 00000000..58745a16
--- /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 intnded.
+ */
+ 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 00000000..88de4029
--- /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 00000000..59846e03
--- /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 00000000..ccf128df
--- /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 00000000..ea3adceb
--- /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 00000000..bbb59055
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/Setting.java
@@ -0,0 +1,486 @@
+package app.revanced.extension.shared.settings;
+
+import android.content.Context;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.StringRef;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.preference.SharedPrefCategory;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.*;
+
+import static app.revanced.extension.shared.StringRef.str;
+
+public abstract class Setting {
+
+ /**
+ * Indicates if a {@link Setting} is available to edit and use.
+ * Typically this is dependent upon other BooleanSetting(s) set to 'true',
+ * but this can be used to call into extension code and check other conditions.
+ */
+ public interface Availability {
+ boolean isAvailable();
+ }
+
+ /**
+ * Availability based on a single parent setting being enabled.
+ */
+ public static Availability parent(BooleanSetting parent) {
+ return parent::get;
+ }
+
+ /**
+ * Availability based on all parents being enabled.
+ */
+ public static Availability parentsAll(BooleanSetting... parents) {
+ return () -> {
+ for (BooleanSetting parent : parents) {
+ if (!parent.get()) return false;
+ }
+ return true;
+ };
+ }
+
+ /**
+ * Availability based on any parent being enabled.
+ */
+ public static Availability parentsAny(BooleanSetting... parents) {
+ return () -> {
+ for (BooleanSetting parent : parents) {
+ if (parent.get()) return true;
+ }
+ return false;
+ };
+ }
+
+ /**
+ * 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() {
+ 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) {
+ // Debug setting may not be created yet so using Logger may cause an initialization crash.
+ // Show a toast instead.
+ Utils.showToastLong(this.getClass().getSimpleName()
+ + " error: Duplicate Setting key found: " + key);
+ }
+
+ load();
+ }
+
+ /**
+ * Migrate a setting value if the path is renamed but otherwise the old and new settings are identical.
+ */
+ public static void migrateOldSettingToNew(Setting oldSetting, Setting newSetting) {
+ if (oldSetting == newSetting) throw new IllegalArgumentException();
+
+ if (!oldSetting.isSetToDefault()) {
+ Logger.printInfo(() -> "Migrating old setting value: " + oldSetting + " into replacement setting: " + newSetting);
+ newSetting.save(oldSetting.value);
+ oldSetting.resetToDefault();
+ }
+ }
+
+ /**
+ * Migrate an old Setting value previously stored in a different SharedPreference.
+ *
+ * This method will be deleted in the future.
+ */
+ @SuppressWarnings("rawtypes")
+ public static void migrateFromOldPreferences(SharedPrefCategory oldPrefs, Setting setting, String settingKey) {
+ if (!oldPrefs.preferences.contains(settingKey)) {
+ return; // Nothing to do.
+ }
+
+ Object newValue = setting.get();
+ final Object migratedValue;
+ if (setting instanceof BooleanSetting) {
+ migratedValue = oldPrefs.getBoolean(settingKey, (Boolean) newValue);
+ } else if (setting instanceof IntegerSetting) {
+ migratedValue = oldPrefs.getIntegerString(settingKey, (Integer) newValue);
+ } else if (setting instanceof LongSetting) {
+ migratedValue = oldPrefs.getLongString(settingKey, (Long) newValue);
+ } else if (setting instanceof FloatSetting) {
+ migratedValue = oldPrefs.getFloatString(settingKey, (Float) newValue);
+ } else if (setting instanceof StringSetting) {
+ migratedValue = oldPrefs.getString(settingKey, (String) newValue);
+ } else {
+ Logger.printException(() -> "Unknown setting: " + setting);
+ // Remove otherwise it'll show a toast on every launch
+ oldPrefs.preferences.edit().remove(settingKey).apply();
+ return;
+ }
+
+ oldPrefs.preferences.edit().remove(settingKey).apply(); // Remove the old setting.
+ if (migratedValue.equals(newValue)) {
+ Logger.printDebug(() -> "Value does not need migrating: " + settingKey);
+ return; // Old value is already equal to the new setting value.
+ }
+
+ Logger.printDebug(() -> "Migrating old preference value into current preference: " + settingKey);
+ //noinspection unchecked
+ setting.save(migratedValue);
+ }
+
+ /**
+ * Sets, but does _not_ persistently save the value.
+ * This method is only to be used by the Settings preference code.
+ *
+ * This intentionally is a static method to deter
+ * accidental usage when {@link #save(Object)} was intended.
+ */
+ public static void privateSetValueFromString(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();
+ }
+
+ /**
+ * @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);
+ }
+
+ Utils.showToastLong(numberOfSettingsImported == 0
+ ? str("revanced_settings_import_reset")
+ : str("revanced_settings_import_success", numberOfSettingsImported));
+
+ return rebootSettingChanged;
+ } catch (JSONException | IllegalArgumentException ex) {
+ Utils.showToastLong(str("revanced_settings_import_failure_parse", ex.getMessage()));
+ Logger.printInfo(() -> "", ex);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Import failure: " + ex.getMessage(), ex); // should never happen
+ }
+ return false;
+ }
+
+ // End import / export
+
+}
diff --git a/extensions/shared/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 00000000..adb9beaa
--- /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/preference/AbstractPreferenceFragment.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java
new file mode 100644
index 00000000..dc592b48
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java
@@ -0,0 +1,346 @@
+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.Utils;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.settings.Setting;
+
+@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 String restartDialogButtonText, restartDialogTitle, confirmDialogTitle, restartDialogMessage;
+
+ 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 = BaseSettings.SHOW_MENU_ICONS.get()
+ ? "revanced_prefs_icons"
+ : "revanced_prefs";
+ final var identifier = Utils.getResourceIdentifier(preferenceResourceName, "xml");
+ 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;
+
+ Pair dialogPair = Utils.createCustomDialog(
+ context,
+ confirmDialogTitle, // Title.
+ Objects.requireNonNull(setting.userDialogMessage).toString(), // No 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);
+
+ // 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 thru all Settings and check for any matching Preferences,
+ // but there are many more Settings than UI preferences so it's more efficient to only check
+ // the Preferences.
+ for (int i = 0, prefCount = 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 {
+ 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 = Utils.createCustomDialog(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/ColorPickerPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerPreference.java
new file mode 100644
index 00000000..a70a66e7
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerPreference.java
@@ -0,0 +1,449 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.Utils.getResourceIdentifier;
+import static app.revanced.extension.shared.Utils.dipToPixels;
+
+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.SpannableString;
+import android.text.Spanned;
+import android.text.TextWatcher;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.RelativeSizeSpan;
+import android.util.AttributeSet;
+import android.util.Pair;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.widget.*;
+
+import androidx.annotation.ColorInt;
+
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.settings.StringSetting;
+
+/**
+ * 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 {
+
+ /**
+ * Character to show the color appearance.
+ */
+ public static final String COLOR_DOT_STRING = "⬤";
+
+ /**
+ * Length of a valid color string of format #RRGGBB.
+ */
+ public static final int COLOR_STRING_LENGTH = 7;
+
+ /**
+ * 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.
+ */
+ private static final float DISABLED_ALPHA = 0.5f; // 50%
+
+ /**
+ * View displaying a colored dot in the widget area.
+ */
+ private View widgetColorDot;
+
+ /**
+ * Current color in RGB format (without alpha).
+ */
+ @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 TextView displaying a colored dot for the selected color preview in the dialog.
+ */
+ private TextView dialogColorPreview;
+
+ /**
+ * Dialog color picker view.
+ */
+ private ColorPickerView dialogColorPickerView;
+
+ /**
+ * Removes non valid hex characters, converts to all uppercase,
+ * and adds # character to the start if not present.
+ */
+ public static String cleanupColorCodeString(String colorString) {
+ // Remove non-hex chars, convert to uppercase, and ensure correct length
+ String result = "#" + PATTERN_NOT_HEX.matcher(colorString)
+ .replaceAll("").toUpperCase(Locale.ROOT);
+
+ if (result.length() < COLOR_STRING_LENGTH) {
+ return result;
+ }
+
+ return result.substring(0, COLOR_STRING_LENGTH);
+ }
+
+ /**
+ * @param color RGB color, without an alpha channel.
+ * @return #RRGGBB hex color string
+ */
+ public static String getColorString(@ColorInt int color) {
+ String colorString = String.format("#%06X", color);
+ if ((color & 0xFF000000) != 0) {
+ // Likely a bug somewhere.
+ Logger.printException(() -> "getColorString: color has alpha channel: " + colorString);
+ }
+ return colorString;
+ }
+
+ /**
+ * Creates a Spanned object for a colored dot using SpannableString.
+ *
+ * @param color The RGB color (without alpha).
+ * @return A Spanned object with the colored dot.
+ */
+ public static Spanned getColorDot(@ColorInt int color) {
+ SpannableString spannable = new SpannableString(COLOR_DOT_STRING);
+ spannable.setSpan(new ForegroundColorSpan(color | 0xFF000000), 0, COLOR_DOT_STRING.length(),
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ spannable.setSpan(new RelativeSizeSpan(1.5f), 0, 1,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ return spannable;
+ }
+
+ 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() {
+ colorSetting = (StringSetting) Setting.getSettingFromPath(getKey());
+ if (colorSetting == null) {
+ Logger.printException(() -> "Could not find color setting for: " + getKey());
+ }
+
+ 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(getResourceIdentifier("revanced_color_dot_widget", "layout"));
+ }
+
+ /**
+ * Sets the selected color and updates the UI and settings.
+ *
+ * @param colorString The color in hexadecimal format (e.g., "#RRGGBB").
+ * @throws IllegalArgumentException If the color string is invalid.
+ */
+ @Override
+ public final void setText(String colorString) {
+ try {
+ Logger.printDebug(() -> "setText: " + colorString);
+ super.setText(colorString);
+
+ currentColor = Color.parseColor(colorString) & 0x00FFFFFF;
+ if (colorSetting != null) {
+ colorSetting.save(getColorString(currentColor));
+ }
+ updateColorPreview();
+ updateWidgetColorDot();
+ } 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);
+ }
+ }
+
+ @Override
+ protected void onBindView(View view) {
+ super.onBindView(view);
+
+ widgetColorDot = view.findViewById(getResourceIdentifier(
+ "revanced_color_dot_widget", "id"));
+ widgetColorDot.setBackgroundResource(getResourceIdentifier(
+ "revanced_settings_circle_background", "drawable"));
+ widgetColorDot.getBackground().setTint(currentColor | 0xFF000000);
+ widgetColorDot.setAlpha(isEnabled() ? 1.0f : DISABLED_ALPHA);
+ }
+
+ /**
+ * Updates the color preview TextView with a colored dot.
+ */
+ private void updateColorPreview() {
+ if (dialogColorPreview != null) {
+ dialogColorPreview.setText(getColorDot(currentColor));
+ }
+ }
+
+ private void updateWidgetColorDot() {
+ if (widgetColorDot != null) {
+ widgetColorDot.getBackground().setTint(currentColor | 0xFF000000);
+ widgetColorDot.setAlpha(isEnabled() ? 1.0f : DISABLED_ALPHA);
+ }
+ }
+
+ /**
+ * Creates a TextWatcher to monitor changes in the EditText for color input.
+ *
+ * @return A TextWatcher that updates the color preview on valid 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);
+ if (!sanitizedColorString.equals(colorString)) {
+ edit.replace(0, colorString.length(), sanitizedColorString);
+ return;
+ }
+
+ if (sanitizedColorString.length() != COLOR_STRING_LENGTH) {
+ // User is still typing out the color.
+ return;
+ }
+
+ final int newColor = Color.parseColor(colorString);
+ if (currentColor != newColor) {
+ Logger.printDebug(() -> "afterTextChanged: " + sanitizedColorString);
+ currentColor = newColor;
+ updateColorPreview();
+ updateWidgetColorDot();
+ colorPickerView.setColor(newColor);
+ }
+ } catch (Exception ex) {
+ // Should never be reached since input is validated before using.
+ Logger.printException(() -> "afterTextChanged failure", ex);
+ }
+ }
+ };
+ }
+
+ /**
+ * Creates a Dialog with a color preview and EditText for hex color input.
+ */
+ @Override
+ protected void showDialog(Bundle state) {
+ Context context = getContext();
+
+ // Inflate color picker view.
+ View colorPicker = LayoutInflater.from(context).inflate(
+ getResourceIdentifier("revanced_color_picker", "layout"), null);
+ dialogColorPickerView = colorPicker.findViewById(
+ getResourceIdentifier("revanced_color_picker_view", "id"));
+ dialogColorPickerView.setColor(currentColor);
+
+ // Horizontal layout for preview and EditText.
+ LinearLayout inputLayout = new LinearLayout(context);
+ inputLayout.setOrientation(LinearLayout.HORIZONTAL);
+
+ dialogColorPreview = new TextView(context);
+ LinearLayout.LayoutParams previewParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ previewParams.setMargins(dipToPixels(15), 0, dipToPixels(10), 0); // text dot has its own indents so 15, instead 16.
+ dialogColorPreview.setLayoutParams(previewParams);
+ inputLayout.addView(dialogColorPreview);
+ updateColorPreview();
+
+ 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);
+ 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);
+
+ // Create content container for color picker and input layout.
+ LinearLayout contentContainer = new LinearLayout(context);
+ contentContainer.setOrientation(LinearLayout.VERTICAL);
+ contentContainer.addView(colorPicker);
+ contentContainer.addView(inputLayout);
+
+ // Create ScrollView to wrap the content container.
+ ScrollView contentScrollView = new ScrollView(context);
+ contentScrollView.setVerticalScrollBarEnabled(false); // Disable vertical scrollbar.
+ contentScrollView.setOverScrollMode(View.OVER_SCROLL_NEVER); // Disable overscroll effect.
+ LinearLayout.LayoutParams scrollViewParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ 0,
+ 1.0f
+ );
+ contentScrollView.setLayoutParams(scrollViewParams);
+ contentScrollView.addView(contentContainer);
+
+ // Create custom dialog.
+ final int originalColor = currentColor & 0x00FFFFFF;
+ Pair dialogPair = Utils.createCustomDialog(
+ context,
+ getTitle() != null ? getTitle().toString() : str("revanced_settings_color_picker_title"), // Title.
+ null, // No message.
+ null, // No EditText.
+ null, // OK button text.
+ () -> {
+ // OK button action.
+ try {
+ String colorString = editText.getText().toString();
+ if (colorString.length() != COLOR_STRING_LENGTH) {
+ Utils.showToastShort(str("revanced_settings_color_invalid"));
+ setText(getColorString(originalColor));
+ return;
+ }
+ setText(colorString);
+ } 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 {
+ // Restore the original color.
+ setText(getColorString(originalColor));
+ } catch (Exception ex) {
+ Logger.printException(() -> "Cancel button failure", ex);
+ }
+ },
+ str("revanced_settings_reset_color"), // Neutral button text.
+ () -> {
+ // Neutral button action.
+ try {
+ final int defaultColor = Color.parseColor(colorSetting.defaultValue) & 0x00FFFFFF;
+ // Setting view color causes listener callback into this class.
+ dialogColorPickerView.setColor(defaultColor);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Reset button failure", ex);
+ }
+ },
+ false // Do not dismiss dialog when onNeutralClick.
+ );
+
+ // 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);
+ Logger.printDebug(() -> "onColorChanged: " + updatedColorString);
+ currentColor = color;
+ editText.setText(updatedColorString);
+ editText.setSelection(updatedColorString.length());
+
+ updateColorPreview();
+ 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;
+ }
+
+ dialogColorPreview = null;
+ dialogColorPickerView = null;
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+ updateWidgetColorDot();
+ }
+}
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 00000000..4d8efb2d
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerView.java
@@ -0,0 +1,500 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.Utils.dipToPixels;
+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;
+
+/**
+ * A custom color picker view that allows the user to select a color using a hue slider and a saturation-value selector.
+ * This implementation is density-independent and responsive across different screen sizes and DPIs.
+ *
+ *
+ * This view displays two 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.
+ *
+ *
+ *
+ * The view uses {@link LinearGradient} and {@link ComposeShader} to create the color gradients for the hue bar 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.
+ *
+ * Important: Callback color uses RGB format with zero alpha channel.
+ *
+ * @param color The new selected color.
+ */
+ void onColorChanged(@ColorInt int color);
+ }
+
+ /** Expanded touch area for the hue bar to increase the touch-sensitive area. */
+ public static final float TOUCH_EXPANSION = dipToPixels(20f);
+
+ private static final float MARGIN_BETWEEN_AREAS = dipToPixels(24);
+ private static final float VIEW_PADDING = dipToPixels(16);
+ private static final float HUE_BAR_HEIGHT = dipToPixels(12);
+ private static final float HUE_CORNER_RADIUS = dipToPixels(6);
+ private static final float SELECTOR_RADIUS = dipToPixels(12);
+ private static final float SELECTOR_STROKE_WIDTH = 8;
+ /**
+ * Hue 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;
+ 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");
+
+ 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});
+ }
+ }
+
+ /** Hue bar. */
+ private final Paint huePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ /** Saturation-value selector. */
+ private final Paint saturationValuePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ /** Draggable selector. */
+ 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 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;
+
+ /** The currently selected color in RGB format with no alpha channel. */
+ @ColorInt
+ private int selectedColor;
+
+ private OnColorChangedListener colorChangedListener;
+
+ /** Track if we're currently dragging the hue or saturation handle. */
+ private boolean isDraggingHue;
+ private boolean isDraggingSaturation;
+
+ 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);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ final float DESIRED_ASPECT_RATIO = 0.8f; // height = width * 0.8
+
+ final int minWidth = Utils.dipToPixels(250);
+ final int minHeight = (int) (minWidth * DESIRED_ASPECT_RATIO) + (int) (HUE_BAR_HEIGHT + MARGIN_BETWEEN_AREAS);
+
+ 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);
+ if (MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY) {
+ height = desiredHeight;
+ }
+
+ setMeasuredDimension(width, height);
+ }
+
+ /**
+ * Called when the size of the view changes.
+ * This method calculates and sets the bounds of the hue bar and saturation-value selector.
+ * It also creates the necessary shaders for the gradients.
+ */
+ @Override
+ protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
+ super.onSizeChanged(width, height, oldWidth, oldHeight);
+
+ // Calculate bounds with hue bar at the bottom.
+ final float effectiveWidth = width - (2 * VIEW_PADDING);
+ final float effectiveHeight = height - (2 * VIEW_PADDING) - HUE_BAR_HEIGHT - MARGIN_BETWEEN_AREAS;
+
+ // 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,
+ VIEW_PADDING + effectiveWidth,
+ height - VIEW_PADDING
+ );
+
+ // Update the shaders.
+ updateHueShader();
+ updateSaturationValueShader();
+ }
+
+ /**
+ * Updates the hue full spectrum (0-360 degrees).
+ */
+ 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 saturation-value selector based on the currently selected hue.
+ * This method creates a combined shader that blends a saturation gradient with a value gradient.
+ */
+ 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).
+ //noinspection ExtractMethodRecommender
+ 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 view on the canvas.
+ * This method draws the saturation-value selector, the hue bar with rounded corners,
+ * and the draggable handles.
+ *
+ * @param canvas The canvas on which to draw.
+ */
+ @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);
+
+ 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 handle filled with the selected color.
+ hsvArray[0] = hue;
+ final int hueHandleColor = Color.HSVToColor(0xFF, hsvArray);
+ selectorPaint.setStyle(Paint.Style.FILL_AND_STROKE);
+
+ selectorPaint.setColor(hueHandleColor);
+ canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_FILL_RADIUS, selectorPaint);
+
+ selectorPaint.setColor(selectedColor | 0xFF000000);
+ canvas.drawCircle(satSelectorX, satSelectorY, 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);
+
+ // 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);
+ }
+
+ /**
+ * Handles touch events on the view.
+ * This method determines whether the touch event occurred within the hue bar or the saturation-value selector,
+ * updates the corresponding values (hue, saturation, value), and invalidates the view to trigger a redraw.
+ *
+ * In addition to testing if the touch is within the strict rectangles, an expanded hit area (by selectorRadius)
+ * is used so that the draggable handles remain active even when half of the handle is outside the drawn bounds.
+ *
+ * @param event The motion event.
+ * @return True if the event was handled, false otherwise.
+ */
+ @SuppressLint("ClickableViewAccessibility") // performClick is not overridden, but not needed in this case.
+ @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 bar.
+ RectF expandedHueRect = new RectF(
+ hueRect.left,
+ hueRect.top - TOUCH_EXPANSION,
+ hueRect.right,
+ hueRect.bottom + TOUCH_EXPANSION
+ );
+
+ 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();
+
+ // Create hit areas for both 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
+ );
+
+ // Check if the touch started on a handle or within the expanded hue bar area.
+ if (hueHitRect.contains(x, y)) {
+ isDraggingHue = true;
+ updateHueFromTouch(x);
+ } else if (satValHitRect.contains(x, y)) {
+ isDraggingSaturation = true;
+ updateSaturationValueFromTouch(x, y);
+ } 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);
+ }
+ 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);
+ }
+ break;
+
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ isDraggingHue = false;
+ isDraggingSaturation = false;
+ break;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onTouchEvent failure", ex);
+ }
+
+ return true;
+ }
+
+ /**
+ * Updates the hue value based on touch position, clamping to valid range.
+ *
+ * @param x The x-coordinate of the touch position.
+ */
+ 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();
+ updateSelectedColor();
+ }
+
+ /**
+ * Updates saturation and value based on touch position, clamping to valid range.
+ *
+ * @param x The x-coordinate of the touch position.
+ * @param y The y-coordinate of the touch position.
+ */
+ 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;
+ updateSelectedColor();
+ }
+
+ /**
+ * Updates the selected color and notifies listeners.
+ */
+ private void updateSelectedColor() {
+ final int updatedColor = Color.HSVToColor(0, new float[]{hue, saturation, value});
+
+ 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 currently selected color.
+ *
+ * @param color The color to set in either ARGB or RGB format.
+ */
+ public void setColor(@ColorInt int color) {
+ color &= 0x00FFFFFF;
+ if (selectedColor == color) {
+ return;
+ }
+
+ // Update the selected color.
+ selectedColor = color;
+ Logger.printDebug(() -> "setColor: " + getColorString(selectedColor));
+
+ // 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];
+
+ // Update the saturation-value shader based on the new hue.
+ updateSaturationValueShader();
+
+ // 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.
+ *
+ * @return The selected color in RGB format with no alpha channel.
+ */
+ @ColorInt
+ public int getColor() {
+ return selectedColor;
+ }
+
+ /**
+ * Sets the listener to be notified when the selected color changes.
+ *
+ * @param listener The listener to set.
+ */
+ public void setOnColorChangedListener(OnColorChangedListener listener) {
+ colorChangedListener = listener;
+ }
+}
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 00000000..4d0c1d5c
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/CustomDialogListPreference.java
@@ -0,0 +1,171 @@
+package app.revanced.extension.shared.settings.preference;
+
+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.*;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.shared.Utils;
+
+/**
+ * A custom ListPreference that uses a styled custom dialog with a custom checkmark indicator.
+ */
+@SuppressWarnings({"unused", "deprecation"})
+public class CustomDialogListPreference extends ListPreference {
+
+ /**
+ * 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.checkIcon = view.findViewById(Utils.getResourceIdentifier(
+ "revanced_check_icon", "id"));
+ holder.placeholder = view.findViewById(Utils.getResourceIdentifier(
+ "revanced_check_icon_placeholder", "id"));
+ holder.itemText = view.findViewById(Utils.getResourceIdentifier(
+ "revanced_item_text", "id"));
+ view.setTag(holder);
+ } else {
+ holder = (SubViewDataContainer) view.getTag();
+ }
+
+ // Set text.
+ holder.itemText.setText(getItem(position));
+ 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();
+
+ // 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,
+ Utils.getResourceIdentifier("revanced_custom_list_item_checked", "layout"),
+ getEntries(),
+ getEntryValues(),
+ getValue()
+ );
+ listView.setAdapter(adapter);
+
+ // Set checked item.
+ String currentValue = getValue();
+ if (currentValue != null) {
+ CharSequence[] entryValues = getEntryValues();
+ 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 = Utils.createCustomDialog(
+ context,
+ getTitle() != null ? getTitle().toString() : "",
+ null,
+ null,
+ null, // No OK button text.
+ null, // No OK button action.
+ () -> {}, // Cancel button action (just dismiss).
+ null,
+ null,
+ true
+ );
+
+ // 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 = getEntryValues()[position].toString();
+ if (callChangeListener(selectedValue)) {
+ setValue(selectedValue);
+ adapter.setSelectedValue(selectedValue);
+ adapter.notifyDataSetChanged();
+ }
+ dialogPair.first.dismiss();
+ });
+
+ // Show the dialog.
+ dialogPair.first.show();
+ }
+}
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 00000000..45c1d36b
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java
@@ -0,0 +1,125 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.Utils.dipToPixels;
+
+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.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Pair;
+import android.util.TypedValue;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.graphics.Color;
+import android.graphics.drawable.ShapeDrawable;
+import android.graphics.drawable.shapes.RoundRectShape;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.Setting;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class ImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener {
+
+ private String existingSettings;
+
+ private void init() {
+ setSelectable(true);
+
+ EditText editText = getEditText();
+ editText.setTextIsSelectable(true);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ editText.setAutofillHints((String) null);
+ }
+ editText.setInputType(editText.getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ editText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 7); // Use a smaller font to reduce text wrap.
+
+ setOnPreferenceClickListener(this);
+ }
+
+ public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init();
+ }
+ public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+ public ImportExportPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+ public ImportExportPreference(Context context) {
+ super(context);
+ init();
+ }
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ try {
+ // Must set text before 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 = Utils.createCustomDialog(
+ 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.
+ );
+
+ // 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 00000000..4bd54c65
--- /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 00000000..d6b895f2
--- /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 00000000..d639d39b
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ReVancedAboutPreference.java
@@ -0,0 +1,363 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.Utils.dipToPixels;
+import static app.revanced.extension.shared.requests.Route.Method.GET;
+
+import android.annotation.SuppressLint;
+import android.app.Dialog;
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.content.Intent;
+import android.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.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;
+
+/**
+ * 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 -> {
+ // 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(handler, showDialogRunnable, progress));
+ } else {
+ // No network call required and can run now.
+ fetchLinksAndShowDialog(null, null, null);
+ }
+
+ return false;
+ });
+ }
+
+ private void fetchLinksAndShowDialog(@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);
+ }
+ if (progress != null) {
+ progress.dismiss();
+ }
+ new WebViewDialog(getContext(), htmlDialog).show();
+ });
+ }
+
+ public ReVancedAboutPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+ public ReVancedAboutPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+ public ReVancedAboutPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public ReVancedAboutPreference(Context context) {
+ super(context);
+ }
+}
+
+/**
+ * Displays html content as a dialog. Any links a user taps on are opened in an external browser.
+ */
+class WebViewDialog extends Dialog {
+
+ private final String htmlContent;
+
+ public WebViewDialog(@NonNull Context context, @NonNull String htmlContent) {
+ super(context);
+ this.htmlContent = htmlContent;
+ }
+
+ // JS required to hide any broken images. No remote javascript is ever loaded.
+ @SuppressLint("SetJavaScriptEnabled")
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE); // Remove default title bar.
+
+ // Create main layout.
+ LinearLayout mainLayout = new LinearLayout(getContext());
+ mainLayout.setOrientation(LinearLayout.VERTICAL);
+
+ final int padding = dipToPixels(10);
+ mainLayout.setPadding(padding, padding, padding, padding);
+ // Set rounded rectangle background.
+ ShapeDrawable mainBackground = new ShapeDrawable(new RoundRectShape(
+ Utils.createCornerRadii(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);
+ }
+ }
+
+ 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 00000000..13de26bf
--- /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.Utils;
+import app.revanced.extension.shared.settings.Setting;
+
+@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 = Utils.createCustomDialog(
+ 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 00000000..fce2aeb6
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/SharedPrefCategory.java
@@ -0,0 +1,223 @@
+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 org.json.JSONObject;
+import java.util.*;
+
+/**
+ * Shared categories, and helper methods.
+ *
+ * The various save methods store numbers as Strings,
+ * which is required if using {@link PreferenceFragment}.
+ *
+ * If saved numbers will not be used with a preference fragment,
+ * then store the primitive numbers using the {@link #preferences} itself.
+ */
+public class SharedPrefCategory {
+ @NonNull
+ public final String name;
+ @NonNull
+ public final SharedPreferences preferences;
+
+ public SharedPrefCategory(@NonNull String name) {
+ this.name = Objects.requireNonNull(name);
+ preferences = Objects.requireNonNull(Utils.getContext()).getSharedPreferences(name, Context.MODE_PRIVATE);
+ }
+
+ private void removeConflictingPreferenceKeyValue(@NonNull String key) {
+ Logger.printException(() -> "Found conflicting preference: " + key);
+ removeKey(key);
+ }
+
+ private void saveObjectAsString(@NonNull String key, @Nullable Object value) {
+ preferences.edit().putString(key, (value == null ? null : value.toString())).apply();
+ }
+
+
+ public Set getSet(@NonNull String key, Set _default) {
+ try {
+ return preferences.getStringSet(key, _default);
+ } catch (ClassCastException ex) {
+ // Value stored is a completely different type (should never happen).
+ removeConflictingPreferenceKeyValue(key);
+ return _default;
+ }
+ }
+
+ public JSONObject getAll(){
+ try{
+ Map allEntries = preferences.getAll();
+ return new JSONObject(allEntries);
+ }catch (Exception ex){
+ return new JSONObject();
+ }
+ }
+
+ /**
+ * Removes any preference data type that has the specified key.
+ */
+ public void removeKey(@NonNull String key) {
+ preferences.edit().remove(Objects.requireNonNull(key)).apply();
+ }
+
+ public boolean clearAll(){
+ try{
+ preferences.edit().clear().commit();
+ return true;
+ }catch(Exception e){
+ Utils.showToastShort(e.toString());
+ return false;
+ }
+ }
+
+ public void saveBoolean(@NonNull String key, boolean value) {
+ preferences.edit().putBoolean(key, value).apply();
+ }
+
+ public void saveSet(@NonNull String key, Set value) {
+ preferences.edit().putStringSet(key, value).apply();
+ }
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveEnumAsString(@NonNull String key, @Nullable Enum> value) {
+ saveObjectAsString(key, value);
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveIntegerString(@NonNull String key, @Nullable Integer value) {
+ saveObjectAsString(key, value);
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveLongString(@NonNull String key, @Nullable Long value) {
+ saveObjectAsString(key, value);
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveFloatString(@NonNull String key, @Nullable Float value) {
+ saveObjectAsString(key, value);
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveString(@NonNull String key, @Nullable String value) {
+ saveObjectAsString(key, value);
+ }
+
+ @NonNull
+ public String getString(@NonNull String key, @NonNull String _default) {
+ Objects.requireNonNull(_default);
+ try {
+ return preferences.getString(key, _default);
+ } catch (ClassCastException ex) {
+ // Value stored is a completely different type (should never happen).
+ removeConflictingPreferenceKeyValue(key);
+ return _default;
+ }
+ }
+
+ @NonNull
+ public > T getEnum(@NonNull String key, @NonNull T _default) {
+ Objects.requireNonNull(_default);
+ try {
+ String enumName = preferences.getString(key, null);
+ if (enumName != null) {
+ try {
+ // noinspection unchecked
+ return (T) Enum.valueOf(_default.getClass(), enumName);
+ } catch (IllegalArgumentException ex) {
+ // Info level to allow removing enum values in the future without showing any user errors.
+ Logger.printInfo(() -> "Using default, and ignoring unknown enum value: " + enumName);
+ removeKey(key);
+ }
+ }
+ } catch (ClassCastException ex) {
+ // Value stored is a completely different type (should never happen).
+ removeConflictingPreferenceKeyValue(key);
+ }
+ return _default;
+ }
+
+ public boolean getBoolean(@NonNull String key, boolean _default) {
+ try {
+ return preferences.getBoolean(key, _default);
+ } catch (ClassCastException ex) {
+ // Value stored is a completely different type (should never happen).
+ removeConflictingPreferenceKeyValue(key);
+ return _default;
+ }
+ }
+
+ @NonNull
+ public Integer getIntegerString(@NonNull String key, @NonNull Integer _default) {
+ try {
+ String value = preferences.getString(key, null);
+ if (value != null) {
+ return Integer.valueOf(value);
+ }
+ } catch (ClassCastException | NumberFormatException ex) {
+ try {
+ // Old data previously stored as primitive.
+ return preferences.getInt(key, _default);
+ } catch (ClassCastException ex2) {
+ // Value stored is a completely different type (should never happen).
+ removeConflictingPreferenceKeyValue(key);
+ }
+ }
+ return _default;
+ }
+
+ @NonNull
+ public Long getLongString(@NonNull String key, @NonNull Long _default) {
+ try {
+ String value = preferences.getString(key, null);
+ if (value != null) {
+ return Long.valueOf(value);
+ }
+ } catch (ClassCastException | NumberFormatException ex) {
+ try {
+ return preferences.getLong(key, _default);
+ } catch (ClassCastException ex2) {
+ removeConflictingPreferenceKeyValue(key);
+ }
+ }
+ return _default;
+ }
+
+ @NonNull
+ public Float getFloatString(@NonNull String key, @NonNull Float _default) {
+ try {
+ String value = preferences.getString(key, null);
+ if (value != null) {
+ return Float.valueOf(value);
+ }
+ } catch (ClassCastException | NumberFormatException ex) {
+ try {
+ return preferences.getFloat(key, _default);
+ } catch (ClassCastException ex2) {
+ removeConflictingPreferenceKeyValue(key);
+ }
+ }
+ return _default;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return name;
+ }
+}
diff --git a/extensions/shared/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 00000000..fb32e7bc
--- /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/src/main/AndroidManifest.xml b/extensions/shared/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..9b65eb06
--- /dev/null
+++ b/extensions/shared/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/extensions/twitter/build.gradle.kts b/extensions/twitter/build.gradle.kts
new file mode 100644
index 00000000..56699a05
--- /dev/null
+++ b/extensions/twitter/build.gradle.kts
@@ -0,0 +1,12 @@
+android {
+ defaultConfig {
+ minSdk = 26
+ }
+}
+
+dependencies {
+ compileOnly(project(":extensions:shared:library"))
+ compileOnly(project(":extensions:twitter:stub"))
+ compileOnly(libs.annotation)
+ compileOnly(libs.appcompat)
+}
diff --git a/extensions/twitter/src/main/AndroidManifest.xml b/extensions/twitter/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..9b65eb06
--- /dev/null
+++ b/extensions/twitter/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/Pref.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/Pref.java
new file mode 100644
index 00000000..e2f5e9f1
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/Pref.java
@@ -0,0 +1,365 @@
+package app.revanced.extension.twitter;
+
+import android.util.Log;
+import app.revanced.extension.twitter.settings.Settings;
+import com.google.android.material.tabs.TabLayout$g;
+import java.util.*;
+
+@SuppressWarnings("unused")
+public class Pref {
+ public static boolean ROUND_OFF_NUMBERS,ENABLE_FORCE_HD, HIDE_COMM_BADGE,SHOW_SRC_LBL;
+ public static float POST_FONT_SIZE;
+ static{
+ ROUND_OFF_NUMBERS = isRoundOffNumbersEnabled();
+ ENABLE_FORCE_HD = enableForceHD();
+ POST_FONT_SIZE = setPostFontSize();
+ HIDE_COMM_BADGE = hideCommBadge();
+ SHOW_SRC_LBL = showSourceLabel();
+ }
+ public static float setPostFontSize() {
+ Float fontSize = 0.0f;
+ try{
+ fontSize = Float.valueOf(Utils.getStringPref(Settings.CUSTOM_POST_FONT_SIZE));
+ }catch (Exception ex){
+ fontSize = app.revanced.extension.shared.Utils.getResourceDimension("font_size_normal");
+ }
+ return fontSize;
+ }
+ public static boolean serverResponseLogging() {
+ return Utils.getBooleanPerf(Settings.LOG_RES);
+ }
+ public static boolean serverResponseLoggingOverwriteFile() {
+ return Utils.getBooleanPerf(Settings.LOG_RES_OVRD);
+ }
+
+ public static boolean showSourceLabel() {
+ return Utils.getBooleanPerf(Settings.TIMELINE_SHOW_SOURCE_LABEL);
+ }
+ public static boolean hideCommBadge() {
+ return Utils.getBooleanPerf(Settings.TIMELINE_HIDE_COMM_BADGE);
+ }
+
+ public static boolean showSensitiveMedia() {
+ return Utils.getBooleanPerf(Settings.TIMELINE_SHOW_SENSITIVE_MEDIA);
+ }
+
+ public static boolean hideTodaysNews() {
+ return Utils.getBooleanPerf(Settings.ADS_REMOVE_TODAYS_NEW);
+ }
+
+ public static boolean enableNativeDownloader() {
+ return Utils.getBooleanPerf(Settings.VID_NATIVE_DOWNLOADER);
+ }
+
+ public static int natveTranslatorProvider(){
+ return Integer.parseInt(Utils.getStringPref(Settings.NATIVE_TRANSLATOR_PROVIDERS));
+ }
+ public static boolean enableNativeTranslator() {
+ return Utils.getBooleanPerf(Settings.NATIVE_TRANSLATOR);
+ }
+
+ public static boolean enableNativeReaderMode() {
+ return Utils.getBooleanPerf(Settings.NATIVE_READER_MODE);
+ }
+ public static boolean hideNativeReaderPostTextOnlyMode() {
+ return Utils.getBooleanPerf(Settings.NATIVE_READER_MODE_TEXT_ONLY_MODE);
+ }
+ public static boolean hideNativeReaderHideQuotedPosts() {
+ return Utils.getBooleanPerf(Settings.NATIVE_READER_MODE_HIDE_QUOTED_POST);
+ }
+
+ public static boolean hideNativeReaderNoGrok() {
+ return Utils.getBooleanPerf(Settings.NATIVE_READER_MODE_NO_GROK);
+ }
+
+ public static String translatorLanguage() {
+ return Utils.getStringPref(Settings.NATIVE_TRANSLATOR_LANG);
+ }
+ public static boolean redirect(TabLayout$g g) {return Utils.redirect(g);}
+
+ public static boolean isRoundOffNumbersEnabled() {
+ return Utils.getBooleanPerf(Settings.MISC_ROUND_OFF_NUMBERS);
+ }
+
+ public static boolean isChirpFontEnabled() {
+ return Utils.getBooleanPerf(Settings.MISC_FONT);
+ }
+
+ public static boolean unShortUrl() {
+ return Utils.getBooleanPerf(Settings.TIMELINE_UNSHORT_URL);
+ }
+
+ public static String getPublicFolder() {
+ return Utils.getStringPref(Settings.VID_PUBLIC_FOLDER);
+ }
+
+ public static String getVideoFolder(String filename) {
+ return Utils.getStringPref(Settings.VID_SUBFOLDER) + "/" + filename;
+ }
+
+ public static int vidMediaHandle() {
+ String val = Utils.getStringPref(Settings.VID_MEDIA_HANDLE);
+ if(val.equals("download_media")){
+ return 1;
+ }
+ if (val.equals("copy_media_link")){
+ return 2;
+ }
+ return 3;
+ }
+
+ public static String getSharingLink(String link) {
+ String domain = Utils.getStringPref(Settings.CUSTOM_SHARING_DOMAIN);
+ return link.replaceFirst("x|twitter", domain);
+ }
+
+ public static ArrayList hideRecommendedUsers(ArrayList users) {
+ if (Utils.getBooleanPerf(Settings.MISC_HIDE_RECOMMENDED_USERS)) {
+ return null;
+ }
+ return users;
+ }
+
+ public static ArrayList liveThread(ArrayList fleets) {
+ if (Utils.getBooleanPerf(Settings.TIMELINE_HIDE_LIVETHREADS)) {
+ return null;
+ }
+ return fleets;
+ }
+
+ public static Map polls(Map map) {
+ if (Utils.getBooleanPerf(Settings.TIMELINE_SHOW_POLL_RESULTS)) {
+ try {
+ if (map.containsKey("counts_are_final")) {
+ if (map.get("counts_are_final").toString().equals("true")) {
+ return map;
+ }
+ }
+
+ HashMap newMap = new HashMap();
+
+ ArrayList labels = new ArrayList(Arrays.asList("choice1_label", "choice2_label", "choice3_label", "choice4_label"));
+ String[] counts = {"choice1_count", "choice2_count", "choice3_count", "choice4_count"};
+
+ // get sum
+ int totalVotes = 0;
+ for (String count : counts) {
+ if (!map.containsKey(count)) {
+ break;
+ }
+
+ totalVotes += Integer.parseInt(map.get(count).toString());
+ }
+
+ for (Object key : map.keySet()) {
+ Object idk = map.get(key);
+
+ if (labels.contains(key.toString())) {
+ String countLabel = counts[labels.indexOf(key.toString())];
+
+ int count = 0;
+ if (map.get(countLabel) != null) {
+ count = Integer.parseInt(map.get(countLabel).toString());
+ }
+
+ int percentage = Math.round(count * 100.0f / totalVotes);
+
+ newMap.put(
+ key,
+ idk.getClass().getConstructor(Object.class, String.class).newInstance(
+ idk + " - " + percentage + "%",
+ null
+ )
+ );
+
+ continue;
+ }
+
+ newMap.put(key, idk);
+ }
+
+ return newMap;
+ } catch (Exception e) {
+ Log.d("POLL_ERROR", map.toString());
+ }
+ }
+ return map;
+ }
+
+ public static boolean hideBanner() {
+ return !Utils.getBooleanPerf(Settings.TIMELINE_HIDE_BANNER);
+ }
+
+ public static int timelineTab() {
+ String val = Utils.getStringPref(Settings.CUSTOM_TIMELINE_TABS);
+ if(val.equals("hide_forYou")){
+ return 1;
+ }
+ if (val.equals("hide_following")){
+ return 2;
+ }
+ return 0;
+ }
+
+ public static boolean enableForceTranslate() {
+ return Utils.getBooleanPerf(Settings.TIMELINE_HIDE_FORCE_TRANSLATE);
+ }
+ public static boolean hidePromoteBtn() {
+ return Utils.getBooleanPerf(Settings.TIMELINE_HIDE_PROMOTE_BUTTON);
+ }
+ public static boolean hideFAB() {
+ return Utils.getBooleanPerf(Settings.MISC_HIDE_FAB);
+ }
+
+ public static boolean hideFABBtn() {
+ return !Utils.getBooleanPerf(Settings.MISC_HIDE_FAB_BTN);
+ }
+
+ public static boolean hideCommNotes() {
+ return Utils.getBooleanPerf(Settings.MISC_HIDE_COMM_NOTES);
+ }
+
+ public static boolean hideViewCount() {
+ return !Utils.getBooleanPerf(Settings.MISC_HIDE_VIEW_COUNT);
+ }
+
+ public static boolean hideInlineBookmark() {
+ return !Utils.getBooleanPerf(Settings.TIMELINE_HIDE_BMK_ICON);
+ }
+
+ public static boolean hideImmersivePlayer() {
+ return !Utils.getBooleanPerf(Settings.TIMELINE_HIDE_IMMERSIVE_PLAYER);
+ }
+
+ public static int enableVidAutoAdvance() {
+ if(Utils.getBooleanPerf(Settings.TIMELINE_ENABLE_VID_AUTO_ADVANCE)){
+ return 1;
+ }
+ return -1;
+ }
+
+ public static boolean hideHiddenReplies(boolean bool){
+ if(Utils.getBooleanPerf(Settings.TIMELINE_HIDE_HIDDEN_REPLIES)){
+ return false;
+ }
+ return bool;
+ }
+ public static boolean enableForceHD(){
+ return Utils.getBooleanPerf(Settings.TIMELINE_ENABLE_VID_FORCE_HD);
+ }
+
+ public static boolean hideNudgeButton() {
+ return Utils.getBooleanPerf(Settings.TIMELINE_HIDE_NUDGE_BUTTON);
+ }
+
+ public static boolean hideAds() {
+ return Utils.getBooleanPerf(Settings.ADS_HIDE_PROMOTED_POSTS);
+ }
+
+ public static boolean hideWTF() {
+ return Utils.getBooleanPerf(Settings.ADS_HIDE_WHO_TO_FOLLOW);
+ }
+
+ public static boolean hideCTS() {
+ return Utils.getBooleanPerf(Settings.ADS_HIDE_CREATORS_TO_SUB);
+ }
+
+ public static boolean hideCTJ() {
+ return Utils.getBooleanPerf(Settings.ADS_HIDE_COMM_TO_JOIN);
+ }
+
+ public static boolean hideRBMK() {
+ return Utils.getBooleanPerf(Settings.ADS_HIDE_REVISIT_BMK);
+ }
+
+ public static boolean hideTopPeopleSearch() {
+ return Utils.getBooleanPerf(Settings.ADS_HIDE_TOP_PEOPLE_SEARCH);
+ }
+
+ public static boolean removePremiumUpsell() {return !Utils.getBooleanPerf(Settings.ADS_REMOVE_PREMIUM_UPSELL);
+ }
+
+ public static boolean hideRPinnedPosts() {
+ return Utils.getBooleanPerf(Settings.ADS_HIDE_REVISIT_PINNED_POSTS);
+ }
+
+ public static boolean hideDetailedPosts() {
+ return Utils.getBooleanPerf(Settings.ADS_HIDE_DETAILED_POSTS);
+ }
+
+ public static boolean hidePremiumPrompt() {
+ return Utils.getBooleanPerf(Settings.ADS_HIDE_PREMIUM_PROMPT);
+ }
+
+ public static boolean enableUndoPosts() {
+ return Utils.getBooleanPerf(Settings.PREMIUM_UNDO_POSTS);
+ }
+
+ public static boolean enableForcePip() {
+ return Utils.getBooleanPerf(Settings.PREMIUM_ENABLE_FORCE_PIP);
+ }
+
+ public static boolean enableDebugMenu() {
+ return Utils.getBooleanPerf(Settings.MISC_DEBUG_MENU);
+ }
+ public static boolean hideSocialProof() {
+ return Utils.getBooleanPerf(Settings.MISC_HIDE_SOCIAL_PROOF);
+ }
+
+ private static ArrayList getList(String key){
+ ArrayList arrayList = new ArrayList();
+ try{
+ Set ch = Utils.getSetPerf(key,null);
+ if(!ch.isEmpty()) {
+ arrayList = new ArrayList(ch);
+ }
+ }catch (Exception e){}
+ return arrayList;
+ }
+ public static ArrayList customProfileTabs() {
+ return getList(Settings.CUSTOM_PROFILE_TABS.key);
+ }
+
+ public static ArrayList customSidebar() {
+ return getList(Settings.CUSTOM_SIDEBAR_TABS.key);
+ }
+ public static ArrayList customExploreTabs() {
+ return getList(Settings.CUSTOM_EXPLORE_TABS.key);
+ }
+
+ public static ArrayList customNavbar() {
+ return getList(Settings.CUSTOM_NAVBAR_TABS.key);
+ }
+
+ public static ArrayList inlineBar() {
+ return getList(Settings.CUSTOM_INLINE_TABS.key);
+ }
+
+ public static ArrayList searchTabs() {
+ return getList(Settings.CUSTOM_SEARCH_TABS.key);
+ }
+
+ public static String defaultReplySortFilter() {
+ String sortfilter = Utils.getStringPref(Settings.CUSTOM_DEF_REPLY_SORTING);
+ if(sortfilter.equals("LastPostion")){
+ sortfilter = Utils.getStringPref(Settings.REPLY_SORTING_LAST_FILTER);
+ }
+ return sortfilter;
+ }
+
+ public static void setReplySortFilter(String sortfilter) {
+ sortfilter = sortfilter.length()>0?sortfilter:"Relevance";
+ Utils.setStringPref(Settings.REPLY_SORTING_LAST_FILTER.key,sortfilter);
+ }
+
+ public static ArrayList customSearchTypeAhead() {
+ return getList(Settings.CUSTOM_SEARCH_TYPE_AHEAD.key);
+ }
+
+ public static int nativeDownloaderFileNameType() {
+ return Integer.parseInt(Utils.getStringPref(Settings.VID_NATIVE_DOWNLOADER_FILENAME));
+ }
+
+
+ //end
+}
\ No newline at end of file
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/Utils.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/Utils.java
new file mode 100644
index 00000000..775e142f
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/Utils.java
@@ -0,0 +1,418 @@
+package app.revanced.extension.twitter;
+
+import app.revanced.extension.twitter.entity.Debug;
+import android.annotation.SuppressLint;
+import android.app.AlertDialog;
+import android.app.DownloadManager;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+import android.util.Log;
+import android.widget.LinearLayout;
+import androidx.annotation.RequiresApi;
+import app.revanced.extension.shared.StringRef;
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.settings.StringSetting;
+import app.revanced.extension.shared.settings.preference.SharedPrefCategory;
+import app.revanced.extension.twitter.settings.Settings;
+import com.google.android.material.tabs.TabLayout$g;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.FileOutputStream;
+import java.io.BufferedReader;
+import java.io.File;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Set;
+import java.io.FileReader;
+import java.io.BufferedReader;
+import java.io.IOException;
+
+import java.lang.StackTraceElement;
+
+@SuppressWarnings("unused")
+public class Utils {
+ @SuppressLint("StaticFieldLeak")
+ private static final Context ctx = app.revanced.extension.shared.Utils.getContext();
+ private static final SharedPrefCategory sp = new SharedPrefCategory(Settings.SHARED_PREF_NAME);
+ private static final SharedPrefCategory defsp = new SharedPrefCategory(ctx.getPackageName() + "_preferences");
+
+ public static void openUrl(String url) {
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ intent.setPackage(ctx.getPackageName());
+ ctx.startActivity(intent);
+ }
+
+ public static void openDefaultLinks() {
+ Intent intent = new Intent(android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS);
+ intent.setData(Uri.parse("package:" + ctx.getPackageName()));
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ ctx.startActivity(intent);
+ }
+
+ private static void startActivity(Class cls) {
+ Intent intent = new Intent(ctx, cls);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ ctx.startActivity(intent);
+ }
+
+ public static void startActivityFromClassName(String className) {
+ try {
+ Class> clazz = Class.forName(className);
+ startActivity(clazz);
+ } catch (Exception ex) {
+ toast(ex.toString());
+ }
+ }
+
+ public static void startUndoPostActivity() {
+ String className = "com.twitter.feature.subscriptions.settings.undotweet.UndoTweetSettingsActivity";
+ startActivityFromClassName(className);
+ }
+
+ public static void startAppIconNNavIconActivity() {
+ String className = "com.twitter.feature.subscriptions.settings.extras.ExtrasSettingsActivity";
+ startActivityFromClassName(className);
+ }
+
+ private static void startBookmarkActivity() {
+ String className = "com.twitter.app.bookmarks.legacy.BookmarkActivity";
+ startActivityFromClassName(className);
+ }
+
+ public static void startXSettings() {
+ String className = "com.twitter.app.settings.SettingsRootCompatActivity";
+ startActivityFromClassName(className);
+ }
+
+ // thanks to @Ouxyl
+ public static boolean redirect(TabLayout$g g) {
+ try {
+ String tabName = g.c.toString();
+ if (tabName == strRes("bookmarks_title")) {
+ startBookmarkActivity();
+ return true;
+ }
+
+ } catch (Exception e) {
+ logger(e.toString());
+ }
+ return false;
+ }
+
+ public static Boolean setBooleanPerf(String key, Boolean val) {
+ try {
+ sp.saveBoolean(key, val);
+ return true;
+ } catch (Exception ex) {
+ toast(ex.toString());
+ }
+ return false;
+ }
+
+ public static Boolean setStringPref(String key, String val) {
+ try {
+ sp.saveString(key, val);
+ return true;
+ } catch (Exception ex) {
+ toast(ex.toString());
+ }
+ return false;
+ }
+
+ public static String getStringPref(StringSetting setting) {
+ String value = sp.getString(setting.key, setting.defaultValue);
+ if (value.isBlank()) {
+ return setting.defaultValue;
+ }
+ return value;
+ }
+
+ public static String strRes(String tag) {
+ try {
+ return StringRef.str(tag);
+ } catch (Exception e) {
+
+ app.revanced.extension.shared.Utils.showToastShort(tag + " not found");
+ }
+ return tag;
+ }
+
+ public static void showRestartAppDialog(Context context) {
+ AlertDialog.Builder dialog = new AlertDialog.Builder(context);
+
+ LinearLayout ln = new LinearLayout(context);
+ ln.setOrientation(LinearLayout.VERTICAL);
+
+ dialog.setTitle(strRes("settings_restart"));
+ dialog.setPositiveButton(strRes("ok"), (dialogInterface, i) -> {
+ app.revanced.extension.shared.Utils.restartApp(context);
+ });
+ dialog.setNegativeButton(strRes("cancel"), null);
+ dialog.show();
+ }
+
+ public static void deleteSharedPrefAB(Context context, boolean flag) {
+ AlertDialog.Builder dialog = new AlertDialog.Builder(context);
+
+ LinearLayout ln = new LinearLayout(context);
+ ln.setOrientation(LinearLayout.VERTICAL);
+
+ String content = flag ? "piko_title_feature_flags" : "notification_settings_preferences_category";
+
+ dialog.setTitle(strRes("delete"));
+
+ dialog.setMessage(strRes("delete") + " " + strRes(content) + " ?");
+ dialog.setPositiveButton(strRes("ok"), (dialogInterface, i) -> {
+ boolean success = false;
+ if (flag) {
+ sp.removeKey(Settings.MISC_FEATURE_FLAGS.key);
+ success = true;
+ } else {
+ success = sp.clearAll();
+ }
+ if (success) {
+ app.revanced.extension.shared.Utils.restartApp(context);
+ }
+ });
+ dialog.setNegativeButton(strRes("cancel"), null);
+ dialog.show();
+ }
+
+ public static Boolean getBooleanPerf(BooleanSetting setting) {
+ return sp.getBoolean(setting.key, setting.defaultValue);
+ }
+
+ public static String getAll(boolean no_flags) {
+ JSONObject prefs = sp.getAll();
+ if (no_flags) {
+ prefs.remove(Settings.MISC_FEATURE_FLAGS.key);
+ prefs.remove(Settings.MISC_FEATURE_FLAGS_SEARCH.key);
+ }
+ return prefs.toString();
+ }
+
+ public static Set getSetPerf(String key, Set defaultValue) {
+ return sp.getSet(key, defaultValue);
+ }
+
+ public static Boolean setSetPerf(String key, Set defaultValue) {
+ try {
+ sp.saveSet(key, defaultValue);
+ return true;
+ } catch (Exception ex) {
+ toast(ex.toString());
+ }
+ return false;
+ }
+
+ public static boolean setAll(String jsonString) {
+ boolean sts = false;
+ try {
+ JSONObject jsonObject = new JSONObject(jsonString);
+ Iterator keys = jsonObject.keys();
+ while (keys.hasNext()) {
+ String key = keys.next();
+ Object value = jsonObject.get(key);
+ if (value instanceof Boolean) {
+ setBooleanPerf(key, (Boolean) value);
+ } else if (value instanceof String) {
+ setStringPref(key, (String) value);
+ } else if (value instanceof JSONArray) {
+ int index = 0;
+ Set strings = new HashSet<>();
+
+ if (!(((JSONArray) value).get(0) instanceof String)) {
+ continue;
+ }
+
+ for (int i = 0; i < ((JSONArray) value).length(); i++) {
+ strings.add(((JSONArray) value).getString(i));
+ }
+
+ setSetPerf(key, strings);
+ }
+ }
+ sts = true;
+ } catch (Exception ex) {
+ toast(ex.toString());
+ }
+ return sts;
+ }
+
+ public static String[] addPref(String[] prefs, String pref) {
+ String[] bigger = Arrays.copyOf(prefs, prefs.length + 1);
+ bigger[prefs.length] = pref;
+ return bigger;
+ }
+
+ private static String getPath(String publicFolder, String subFolder, String filename) {
+ return publicFolder + "/" + subFolder + "/" + filename;
+ }
+
+ private static void postDownload(String filename, File tempFile, File file, Intent intent, long downloadId,
+ BroadcastReceiver broadcastReceiver) {
+ long id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
+ if (id == downloadId) {
+ boolean result = tempFile.renameTo(file);
+ if (!result) {
+ toast("Failed to rename temp file");
+ }
+
+ toast(strRes("exo_download_completed") + ": " + filename);
+ ctx.unregisterReceiver(broadcastReceiver);
+ }
+ }
+
+ @SuppressLint("UnspecifiedRegisterReceiverFlag")
+ public static void downloadFile(String url, String mediaName, String ext) {
+ String filename = mediaName + "." + ext;
+ boolean isPhoto = ext.equals("jpg");
+
+ DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url));
+ request.setDescription("Downloading " + filename);
+ request.setTitle(filename);
+ request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
+
+ String publicFolder = "Pictures";
+ String subFolder = "Twitter";
+
+ if (!isPhoto) {
+ publicFolder = Pref.getPublicFolder();
+ subFolder = Utils.getStringPref(Settings.VID_SUBFOLDER);
+ }
+ request.setDestinationInExternalPublicDir(publicFolder, subFolder + "/" + "temp_" + filename);
+
+ File file = new File(Environment.getExternalStorageDirectory(), getPath(publicFolder, subFolder, filename));
+ if (file.exists()) {
+ toast(strRes("exo_download_completed") + ": " + filename);
+ return;
+ }
+
+ DownloadManager manager = (DownloadManager) ctx.getSystemService(Context.DOWNLOAD_SERVICE);
+ long downloadId = manager.enqueue(request);
+
+ File tempFile = new File(
+ Environment.getExternalStorageDirectory(),
+ getPath(publicFolder, subFolder, "temp_" + filename));
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ ctx.registerReceiver(new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ postDownload(filename, tempFile, file, intent, downloadId, this);
+ }
+ }, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), Context.RECEIVER_EXPORTED);
+ } else {
+ ctx.registerReceiver(new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ postDownload(filename, tempFile, file, intent, downloadId, this);
+ }
+ }, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE));
+ }
+ }
+
+ public static int getTheme() {
+ // 0 = light, 1 = dark, 2 = dim
+ int theme = 0;
+ String three_state_night_mode = defsp.getString("three_state_night_mode", String.valueOf(theme));
+ if (!(three_state_night_mode.equals("0"))) {
+ String dark_mode_appr = defsp.getString("dark_mode_appearance", "lights_out");
+ if (dark_mode_appr.equals("lights_out"))
+ theme = 1;
+ else if (dark_mode_appr.equals("dim"))
+ theme = 2;
+ }
+ return theme;
+ }
+
+ public static boolean pikoWriteFile(String fileName,String data,boolean append){
+ File downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
+ File pikoDir = new File(downloadsDir, "Piko");
+
+ if (!pikoDir.exists()) {
+ pikoDir.mkdirs();
+ }
+
+ File outputFile = new File(pikoDir, fileName);
+ return writeFile(outputFile,data.getBytes(),append);
+ }
+
+ public static boolean writeFile(File fileName, byte[] data, boolean append) {
+ try {
+ FileOutputStream outputStream = new FileOutputStream(fileName, append);
+ outputStream.write(data);
+ outputStream.close();
+ return true;
+ } catch (Exception e) {
+ logger(e.toString());
+ }
+ return false;
+ }
+
+ public static String readFile(File fileName) {
+ try {
+ if (!fileName.exists())
+ return null;
+
+ StringBuilder content = new StringBuilder();
+ BufferedReader reader = null;
+ try {
+ reader = new BufferedReader(new FileReader(fileName));
+ String line;
+ while ((line = reader.readLine()) != null) {
+ content.append(line);
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ } finally {
+ if (reader != null) {
+ try {
+ reader.close();
+ } catch (IOException ignored) {
+ }
+ }
+ }
+ return content.toString();
+ } catch (Exception e) {
+ logger(e.toString());
+ }
+ return null;
+ }
+
+ public static void toast(String msg) {
+ app.revanced.extension.shared.Utils.showToastShort(msg);
+ }
+
+ public static void logger(Object e) {
+ String logName = "piko";
+ Log.d(logName, String.valueOf(e)+"\n");
+ if (e instanceof Exception) {
+ Exception ex = (Exception) e;
+ StackTraceElement[] stackTraceElements = ex.getStackTrace();
+ for (StackTraceElement element : stackTraceElements) {
+ Log.d(logName, "Exception occurred at line " + element.getLineNumber() + " in " + element.getClassName()
+ + "." + element.getMethodName());
+ }
+ }
+ }
+
+ /*** THIS FUNCTION SHOULD BE USED ONLY WHILE DEVELOPMENT ***/
+ public static void debugClass(Object obj) {
+ Debug cls = new Debug(obj);
+ try{
+ cls.describeClass();
+ }catch(Exception e){
+ logger(e);
+ }
+ }
+
+}
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/Debug.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/Debug.java
new file mode 100644
index 00000000..5dd23dda
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/Debug.java
@@ -0,0 +1,125 @@
+package app.revanced.extension.twitter.entity;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import app.revanced.extension.twitter.Utils;
+
+public class Debug {
+ protected Object obj;
+
+ public Debug(Object obj) {
+ this.obj = obj;
+ }
+
+ protected Class> getObjClass() throws ClassNotFoundException {
+ return this.obj.getClass();
+ }
+
+ protected Object getField(Class cls, Object clsObj, String fieldName) throws Exception {
+ Field field = cls.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return (Object) field.get(clsObj);
+ }
+
+ protected Object getField(Object clsObj, String fieldName) throws Exception {
+ return getField(clsObj.getClass(), clsObj, fieldName);
+ }
+
+ protected Object getField(String fieldName) throws Exception {
+ return getField(this.obj, fieldName);
+ }
+
+ protected Object getMethod(Object clsObj, String methodName) throws Exception {
+ return clsObj.getClass().getDeclaredMethod(methodName).invoke(clsObj);
+ }
+
+ protected Object getMethod(String methodName) throws Exception {
+ return this.getMethod(this.obj, methodName);
+ }
+
+ /*** THE BELOW FUNCTIONS SHOULD BE USED ONLY WHILE DEVELOPMENT ***/
+ protected String describeFields() throws Exception {
+ String line = "----------------------------";
+ StringBuilder sb = new StringBuilder();
+ Class> cls = this.getObjClass();
+
+ Field[] fields = cls.getDeclaredFields();
+ for (Field field : fields) {
+ field.setAccessible(true);
+ String name = field.getName();
+ String tyName = field.getType().getName();
+ Object value;
+ sb.append(name)
+ .append(" - ")
+ .append(tyName)
+ .append("\n ");
+
+ try {
+ value = field.get(this.obj);
+ } catch (IllegalAccessException e) {
+ value = "\n";
+ }
+ sb.append(value)
+ .append("\n"+line+"\n");
+ }
+ return sb.toString();
+ }
+
+ protected String describeMethods() throws Exception {
+ String line = "----------------------------";
+ StringBuilder sb = new StringBuilder();
+ Class> cls = this.getObjClass();
+ Method[] methods = cls.getDeclaredMethods();
+
+ for (Method method : methods) {
+ method.setAccessible(true);
+ sb.append(method.getName())
+ .append(" - ")
+ .append(method.getReturnType().getSimpleName())
+ .append("\n ");
+
+ Parameter[] params = method.getParameters();
+ if (params.length == 0) {
+ try {
+ Object result = method.invoke(this.obj);
+ sb.append(result);
+ } catch (Exception e) {
+ sb.append("");
+ }
+ } else {
+ for (Parameter param : params) {
+ sb.append(param.getType().getSimpleName())
+ .append(" ")
+ .append(param.getName())
+ .append(", ");
+ }
+ }
+ sb.append("\n"+line+"\n");
+ }
+ return sb.toString();
+ }
+
+ public void describeClass() throws Exception {
+ String className = this.getObjClass().getName();
+ String line = "----------------------------";
+
+ String fields = this.describeFields();
+ String methods = this.describeMethods();
+
+ StringBuilder sb = new StringBuilder();
+ sb.append(line)
+ .append("\n" + className)
+ .append("\n" + line + "\n" + line)
+ .append("\nFIELDS\n" + line +"\n"+ fields)
+ .append("\n" + line + "\n" + line)
+ .append("\nMETHODS\n" + line + "\n"+methods)
+ .append("\n" + line + "\n" + line);
+
+ String fileName = className+".txt";
+ Utils.pikoWriteFile(fileName,sb.toString(),false);
+ Utils.toast("DONE: "+className);
+
+ }
+}
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/ExtMediaEntities.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/ExtMediaEntities.java
new file mode 100644
index 00000000..8522db36
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/ExtMediaEntities.java
@@ -0,0 +1,66 @@
+package app.revanced.extension.twitter.entity;
+import app.revanced.extension.twitter.Utils;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import app.revanced.extension.twitter.entity.Video;
+import app.revanced.extension.twitter.entity.Media;
+import app.revanced.extension.twitter.Utils;
+
+// Lcom/twitter/model/core/entity/b0;
+public class ExtMediaEntities extends Debug{
+
+ private Object obj;
+
+ public ExtMediaEntities(Object obj) {
+ super(obj);
+ this.obj = obj;
+ }
+
+
+ public String getImageUrl()
+ throws Exception {
+ // q:String
+ return (String) super.getField("getThumbnailField");
+ }
+
+ public String getHighResImageUrl()
+ throws Exception {
+ return this.getImageUrl() + "?name=4096x4096&format=jpg";
+ }
+
+ public Video getHighResVideo() throws Exception {
+ // d() Lcom/twitter/model/core/entity/b0;
+ Object data = super.getMethod("highResVideoMethod");
+ return data!=null ?new Video(data):null;
+ }
+
+ public Media getMedia() throws Exception {
+ int type = 0;
+ String url = "";
+ String ext = "jpg";
+
+ Video video = this.getHighResVideo();
+
+ if(video!=null){
+ type = 1;
+ url = video.getMediaUrl();
+ ext = video.getExtension();
+ }else{
+ url = this.getHighResImageUrl();
+ }
+ return new Media(type,url,ext);
+ }
+
+ @Override
+ public String toString(){
+ try{
+ return "ExtMediaEntities [getImageUrl()=" + this.getImageUrl() + ", getHighResImageUrl()="
+ + this.getHighResImageUrl() + ", getHighResVideo()=" + this.getHighResVideo() + "]";
+ }catch(Exception e){
+ Utils.logger(e);
+ return e.getMessage();
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/Media.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/Media.java
new file mode 100644
index 00000000..67bfac66
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/Media.java
@@ -0,0 +1,28 @@
+package app.revanced.extension.twitter.entity;
+
+import app.revanced.extension.twitter.Utils;
+
+public class Media {
+ // 0-img, 1-video
+ public int type;
+ public String url;
+ public String ext;
+
+ public Media(int type, String url, String ext) {
+ this.type = type;
+ this.url = url;
+ this.ext = ext;
+ }
+
+ @Override
+ public String toString() {
+
+ try {
+ return "Media [type=" + this.type + ", url=" + this.url + ", ext=" + this.ext + "]";
+ } catch (Exception e) {
+ Utils.logger(e);
+ return e.getMessage();
+ }
+ }
+
+}
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/Tweet.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/Tweet.java
new file mode 100644
index 00000000..da021fe6
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/Tweet.java
@@ -0,0 +1,128 @@
+package app.revanced.extension.twitter.entity;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import app.revanced.extension.twitter.entity.ExtMediaEntities;
+import app.revanced.extension.twitter.entity.Video;
+import app.revanced.extension.twitter.entity.TweetInfo;
+import app.revanced.extension.twitter.entity.Debug;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.*;
+import app.revanced.extension.twitter.Utils;
+
+// All comments based of 11.14.beta-0
+// Lcom/twitter/model/core/entity/e;
+public class Tweet extends Debug {
+ private Object obj;
+
+ public Tweet(Object obj) {
+ super(obj);
+ this.obj = obj;
+ }
+
+ public Long getTweetId() throws Exception {
+ return (Long) super.getMethod("getId");
+ }
+
+ public String getTweetUsername() throws Exception {
+ return (String) super.getMethod("userNameMethod");
+ }
+
+ public String getTweetProfileName() throws Exception {
+ return (String) super.getMethod("profileNameMethod");
+ }
+
+ public Long getTweetUserId() throws Exception {
+
+ return (Long) super.getMethod("userIdMethod");
+ }
+
+ public ArrayList getMedias() throws Exception {
+ ArrayList mediaData = new ArrayList();
+
+ // c()Lcom/twitter/model/core/entity/c0;
+ Object mediaRootObject = super.getMethod("mediaMethod");
+ Class> mediaRootObjectClass = mediaRootObject.getClass();
+
+ // Lcom/twitter/model/core/entity/s;
+ Class> superClass = mediaRootObjectClass.getSuperclass();
+ Object superClassInstance = superClass.cast(mediaRootObject);
+
+ // a:List
+ List> list = (List>) super.getField(superClass, superClassInstance, "extMediaList");
+
+ assert list != null;
+ if (list.isEmpty()) {
+ return mediaData;
+ }
+
+ for (Object item : list) {
+ ExtMediaEntities mediaObj = new ExtMediaEntities(item);
+ Media media = mediaObj.getMedia();
+ mediaData.add(media);
+ }
+ return mediaData;
+ }
+
+ public TweetInfo getTweetInfo() throws Exception {
+ Object data = super.getField("tweetInfo");
+ return new TweetInfo(data);
+ }
+
+ public String getTweetLang() throws Exception {
+ TweetInfo tweetInfo = this.getTweetInfo();
+ return tweetInfo.getLang();
+ }
+
+ public String getLongText() throws Exception {
+ // j()Lcom/twitter/model/notetweet/b;
+ Object noteTweetObj = super.getMethod("noteTweetMethod");
+ String data = noteTweetObj != null ? (String) super.getField(noteTweetObj, "longTextField") : null;
+ return data;
+ }
+
+ public String getShortText() throws Exception {
+ // y()Lcom/twitter/model/core/entity/c1;
+ Object tweetObj = super.getMethod("tweetEntityClass");
+ // getText()
+ Object data = super.getMethod(tweetObj, "getText");
+ return (String) data;
+ }
+
+ public String getText() throws Exception {
+ String text = "";
+ try {
+ text = this.getLongText();
+ if (text == null) {
+ text = this.getShortText();
+ }
+ if (text.length() > 0) {
+ int mediaIndex = text.indexOf("pic.x.com");
+ if (mediaIndex > 0)
+ text = text.substring(0, mediaIndex);
+ }
+ } catch (Exception e) {
+ Utils.logger(e);
+ text = e.getMessage();
+ }
+ return text;
+
+ }
+
+ @Override
+ public String toString() {
+ try {
+ return "Tweet [getTweetId()=" + this.getTweetId() + ", getTweetUsername()=" + this.getTweetUsername()
+ + ", getTweetProfileName()=" + this.getTweetProfileName() + ", getTweetUserId()=" + this.getTweetUserId()
+ + ", getMedias()=" + this.getMedias() + ", getTweetInfo()=" + this.getTweetInfo() + ", getTweetLang()="
+ + this.getTweetLang() + ", getLongText()=" + this.getLongText() + ", getShortText()=" + this.getShortText() + "]";
+
+ } catch (Exception e) {
+ Utils.logger(e);
+ return e.getMessage();
+ }
+
+ }
+}
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/TweetInfo.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/TweetInfo.java
new file mode 100644
index 00000000..7c89b781
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/TweetInfo.java
@@ -0,0 +1,36 @@
+package app.revanced.extension.twitter.entity;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import app.revanced.extension.twitter.entity.Debug;
+import app.revanced.extension.twitter.Utils;
+
+// Lcom/twitter/model/core/entity/d;
+public class TweetInfo extends Debug {
+
+ private Object obj;
+
+ public TweetInfo(Object obj) {
+ super(obj);
+ this.obj = obj;
+ }
+
+ public String getLang() throws Exception {
+ // y:String
+ return (String) super.getField("tweetLang");
+ }
+
+ @Override
+ public String toString() {
+ try {
+ return "TweetInfo [getLang()=" + this.getLang() + "]";
+
+ } catch (Exception e) {
+ Utils.logger(e);
+ return e.getMessage();
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/Video.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/Video.java
new file mode 100644
index 00000000..d72e443b
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/entity/Video.java
@@ -0,0 +1,67 @@
+package app.revanced.extension.twitter.entity;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import app.revanced.extension.twitter.entity.Debug;
+import app.revanced.extension.twitter.Utils;
+
+// Lcom/twitter/media/av/model/b0;
+public class Video extends Debug {
+ private Object obj;
+
+ public Video(Object obj) {
+ super(obj);
+ this.obj = obj;
+ }
+
+ public Integer getBitrate()
+ throws Exception {
+ return (Integer) super.getField("a");
+ }
+
+ public String getMediaUrl()
+ throws Exception {
+ return (String) super.getField("b");
+ }
+
+ public String getCodec()
+ throws Exception {
+ return (String) super.getField("c");
+ }
+
+ public String getThumbnail()
+ throws Exception {
+ return (String) super.getField("d");
+ }
+
+ public String getExtension()
+ throws Exception {
+ String codec = this.getCodec();
+ if (codec.equals("video/mp4")) {
+ return "mp4";
+ }
+ if (codec.equals("video/webm")) {
+ return "webm";
+ }
+ if (codec.equals("application/x-mpegURL")) {
+ return "m3u8";
+ }
+ return "unknown";
+ }
+
+ @Override
+ public String toString() {
+ try {
+ return "Video [getBitrate()=" + this.getBitrate() + ", getMediaUrl()=" + this.getMediaUrl() + ", getCodec()="
+ + this.getCodec()
+ + ", getThumbnail()=" + this.getThumbnail() + ", getExtension()=" + this.getExtension() + "]";
+
+ } catch (Exception e) {
+ Utils.logger(e);
+ return e.getMessage();
+ }
+
+ }
+
+}
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/DatabasePatch.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/DatabasePatch.java
new file mode 100644
index 00000000..3cd2e4ea
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/DatabasePatch.java
@@ -0,0 +1,152 @@
+package app.revanced.extension.twitter.patches;
+
+import app.revanced.extension.twitter.Utils;
+import android.content.Context;
+import com.twitter.util.user.UserIdentifier;
+import android.util.*;
+import java.util.*;
+import java.io.File;
+import android.database.sqlite.SQLiteDatabase;
+import android.app.AlertDialog;
+import android.widget.LinearLayout;
+
+
+public class DatabasePatch {
+ private static Context ctx = app.revanced.extension.shared.Utils.getContext();
+ private static final String[] listItems = app.revanced.extension.shared.Utils.getResourceStringArray("piko_array_ads_hooks");
+
+ private static void logger(Object j){
+ Log.d("piko", j.toString());
+ }
+
+ private static String getDBPath(){
+ String dbName = UserIdentifier.getCurrent().getStringId()+"-66.db";
+ return ctx.getDatabasePath(dbName).getAbsolutePath();
+ }
+ private static void showItemDialog(Context context,String result){
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+
+ LinearLayout ln = new LinearLayout(context);
+ ln.setOrientation(LinearLayout.VERTICAL);
+
+ builder.setTitle(Utils.strRes("piko_pref_db_del_items"));
+ builder.setMessage(result);
+ builder.setNegativeButton(Utils.strRes("ok"), null);
+ builder.show();
+ }
+
+ public static void showDialog(Context context){
+ final boolean[] checkedItems = new boolean[listItems.length];
+ final List selectedItems = Arrays.asList(listItems);
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+
+ LinearLayout ln = new LinearLayout(context);
+ ln.setOrientation(LinearLayout.VERTICAL);
+
+ builder.setTitle(Utils.strRes("piko_pref_del_from_db"));
+ builder.setMultiChoiceItems(listItems, checkedItems, (dialog, which, isChecked) -> {
+ checkedItems[which] = isChecked;
+ String currentItem = selectedItems.get(which);
+ });
+ builder.setPositiveButton(Utils.strRes("ok"), (dialogInterface, i) -> {
+ StringBuilder items = removeFromDB(checkedItems);
+ if(items.length()!=0) showItemDialog(context,items.toString());
+ });
+ builder.setNegativeButton(Utils.strRes("cancel"), null);
+ builder.show();
+ }
+
+ private static StringBuilder removeFromDB(boolean[] checkedItems){
+ SQLiteDatabase database=null;
+ StringBuilder result = new StringBuilder();
+
+ try {
+ String DATABASE_PATH = getDBPath();
+ File f = new File(DATABASE_PATH);
+ if (!f.exists() && f.isDirectory()) {
+ Utils.toast(Utils.strRes("piko_pref_db_not_found"));
+ return result;
+ }
+ database = SQLiteDatabase.openDatabase(DATABASE_PATH, null, SQLiteDatabase.OPEN_READWRITE);
+ if (database != null && database.isOpen()) {
+ for (int i = 0; i < checkedItems.length; i++) {
+ List keywords = new ArrayList();
+ if (checkedItems[i]) {
+ switch (i) {
+ case 0: {
+ keywords.add("promoted%");
+ keywords.add("rtb%");
+ keywords.add("%promoted%");
+ break;
+ }
+ case 1: {
+ keywords.add("who-to-follow%");
+ break;
+ }
+ case 2: {
+ keywords.add("who-to-follow%");
+ // entry_id_str = "who-to-subscribe%";
+ break;
+ }
+ case 3: {
+ keywords.add("community-to-join%");
+ // entry_id_str = "community-to-join%";
+ break;
+ }
+ case 4: {
+ keywords.add("bookmarked%");
+ //entry_id_str = "bookmarked%";
+ break;
+ }
+ case 5: {
+ keywords.add("tweets%");
+ //entry_id_str = "pinned-tweets%";
+ break;
+ }
+ case 6: {
+ keywords.add("tweetdetailrelatedtweets%");
+ //entry_id_str = "tweetdetailrelatedtweets%";
+ break;
+ }
+ case 7: {
+ keywords.add("messageprompt%");
+ //entry_id_str = "messageprompt%";
+ break;
+ }
+ case 8: {
+ keywords.add("stories%");
+ //entry_id_str = "stories%";
+ break;
+ }
+ }
+
+ StringBuilder selection = new StringBuilder();
+ String[] selectionArgs = new String[keywords.size()];
+ for (int ind = 0; ind < keywords.size(); ind++) {
+ if (ind > 0) selection.append(" OR ");
+ selection.append("entity_id LIKE ?");
+ selectionArgs[ind] = "%" + keywords.get(ind) + "%";
+ }
+ int deletedRows = database.delete("timeline", selection.toString(), selectionArgs);
+ result.append("• "+listItems[i]+" = "+String.valueOf(deletedRows)+"\n");
+ }
+ }
+ } else {
+ Utils.toast(Utils.strRes("piko_pref_db_not_open"));
+ }
+
+ }
+ catch (Exception e){
+ logger(e.toString());
+ Utils.toast(e.toString());
+ }
+ if (database != null && database.isOpen()) {
+ database.close();
+ }
+
+ return result;
+ }
+
+ //classEnd
+}
\ No newline at end of file
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/DownloadPatch.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/DownloadPatch.java
new file mode 100644
index 00000000..02a082aa
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/DownloadPatch.java
@@ -0,0 +1,130 @@
+
+package app.revanced.extension.twitter.patches;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.widget.LinearLayout;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Type;
+import app.revanced.extension.twitter.Utils;
+import app.revanced.extension.twitter.Pref;
+
+public class DownloadPatch {
+
+ public static void mediaHandle(Object obj1, Object para1){
+ try{
+ int ch = Pref.vidMediaHandle();
+ switch(ch){
+ case 1:{
+ downloadVideoMedia(obj1, para1);
+ break;
+ }
+ case 2:{
+ copyVideoMediaLink(para1);
+ break;
+ }
+ case 3:{
+ alertbox(obj1, para1);
+ break;
+ }
+ }
+
+ }catch (Exception e){
+ Utils.toast(e.toString());
+ }
+ }
+
+ private static void downloadVideoMedia(Object obj1, Object para1){
+ try {
+ Class> clazz = obj1.getClass();
+ clazz.cast(obj1);
+ Method downloadClass = clazz.getDeclaredMethod("b", para1.getClass());
+ downloadClass.invoke(obj1, para1);
+ }
+ catch (Exception e){
+ Utils.toast(e.toString());
+ }
+ }
+
+ private static String getMediaLink(Object para1) {
+ try{
+ Class> clazz = para1.getClass();
+ clazz.cast(para1);
+ Field urlField = clazz.getDeclaredField("a");
+ String mediaLink = (String) urlField.get(para1);
+ return mediaLink;
+ }
+ catch (Exception e){
+ Utils.toast(e.toString());
+ }
+ return "";
+ }
+
+ private static void copyVideoMediaLink(Object para1) {
+ try{
+
+ String mediaLink = getMediaLink(para1);
+ app.revanced.extension.shared.Utils.setClipboard(mediaLink);
+ Utils.toast(strRes("link_copied_to_clipboard"));
+ }
+ catch (Exception e){
+ Utils.toast(e.toString());
+ }
+ }
+
+ private static void shareMediaLink(Object para1) {
+ try{
+ String mediaLink = getMediaLink(para1);
+ app.revanced.extension.shared.Utils.shareText(mediaLink);
+ }
+ catch (Exception e){
+ Utils.toast(e.toString());
+ }
+ }
+
+ private static void alertbox(Object obj1, Object para1) throws NoSuchFieldException, IllegalAccessException {
+ Class> clazz = obj1.getClass();
+ clazz.cast(obj1);
+ Field ctxField = clazz.getDeclaredField("a");
+ Activity context = (Activity) ctxField.get(obj1);
+
+ LinearLayout ln = new LinearLayout(context);
+ ln.setOrientation(LinearLayout.VERTICAL);
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ builder.setTitle(strRes("piko_pref_download_media_link_handle"));
+
+ String[] choices = {strRes("download_video_option"), strRes("piko_pref_download_media_link_handle_copy_media_link"),strRes("piko_pref_download_media_link_handle_share_media_link"), strRes("cancel")};
+ builder.setItems(choices, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialogInterface, int which) {
+ switch (which) {
+ case 0: {
+ downloadVideoMedia(obj1, para1);
+ break;
+ }
+ case 1: {
+ copyVideoMediaLink(para1);
+ break;
+ }
+ case 2: {
+ shareMediaLink(para1);
+ break;
+ }
+ }
+ dialogInterface.dismiss();
+ }
+ });
+ builder.show();
+
+ //endfunc
+ }
+
+ private static String strRes(String tag) {
+ return Utils.strRes(tag);
+ }
+ //end
+}
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/FeatureSwitchPatch.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/FeatureSwitchPatch.java
new file mode 100644
index 00000000..101640e5
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/FeatureSwitchPatch.java
@@ -0,0 +1,79 @@
+package app.revanced.extension.twitter.patches;
+
+import app.revanced.extension.twitter.Pref;
+import app.revanced.extension.twitter.Utils;
+import app.revanced.extension.twitter.settings.Settings;
+
+import java.util.HashMap;
+
+public class FeatureSwitchPatch {
+ public static String FLAGS_SEARCH = "";
+
+ private static final HashMap FLAGS = new HashMap<>();
+
+ private static void addFlag(String flag, Object val) {
+ FLAGS.put(flag, val);
+ }
+
+ private static void fabMenu() {
+ addFlag("android_compose_fab_menu_enabled", Pref.hideFABBtn());
+ }
+
+ private static void chirpFont() {
+ addFlag("af_ui_chirp_enabled", Pref.isChirpFontEnabled());
+ }
+
+ private static void hideGoogleAds() { addFlag("ssp_ads_dsp_client_context_enabled", !Pref.hideAds()); }
+ private static void viewCount() {
+ addFlag("view_counts_public_visibility_enabled", Pref.hideViewCount());
+ }
+
+ private static void bookmarkInTimeline() {
+ addFlag("bookmarks_in_timelines_enabled", Pref.hideInlineBookmark());
+ }
+
+ private static void navbarFix() {
+ addFlag("subscriptions_feature_1008", true);
+ }
+
+ private static void immersivePlayer() {
+ addFlag("explore_relaunch_enable_immersive_player_across_twitter", Pref.hideImmersivePlayer());
+ }
+
+ public static void getFeatureFlagSearchItems() {
+ FLAGS_SEARCH = Utils.getStringPref(Settings.MISC_FEATURE_FLAGS_SEARCH);
+ }
+
+ public static void addFeatureFlagSearchItem(String flag) {
+ if (FLAGS_SEARCH.contains(flag)) {
+ return;
+ }
+
+ FLAGS_SEARCH = FLAGS_SEARCH.concat(flag + ",");
+ Utils.setStringPref(Settings.MISC_FEATURE_FLAGS_SEARCH.key, FLAGS_SEARCH);
+ }
+
+ public static Object flagInfo(String flag, Object def) {
+ try {
+ if (def instanceof Boolean) {
+ addFeatureFlagSearchItem(flag);
+ }
+ if (FLAGS.containsKey(flag)) {
+ return FLAGS.get(flag);
+ }
+ } catch (Exception ex) {
+
+ }
+ return def;
+ }
+
+ public static void load() {
+ String flags = Utils.getStringPref(Settings.MISC_FEATURE_FLAGS);
+ if (!flags.isEmpty()) {
+ for (String flag : flags.split(",")) {
+ String[] item = flag.split(":");
+ addFlag(item[0], Boolean.valueOf(item[1]));
+ }
+ }
+ }
+}
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/TimelineEntry.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/TimelineEntry.java
new file mode 100644
index 00000000..c03f5ff4
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/TimelineEntry.java
@@ -0,0 +1,119 @@
+package app.revanced.extension.twitter.patches;
+
+import com.twitter.model.json.timeline.urt.JsonTimelineEntry;
+import com.twitter.model.json.core.JsonSensitiveMediaWarning;
+import com.twitter.model.json.timeline.urt.JsonTimelineModuleItem;
+import app.revanced.extension.twitter.Pref;
+import app.revanced.extension.twitter.settings.SettingsStatus;
+
+public class TimelineEntry {
+ public static final boolean hideAds;
+ private static final boolean hideWTF,hideCTS,hideCTJ,hideDetailedPosts,hideRBMK,hidePinnedPosts,hidePremiumPrompt,showSensitiveMedia,hideTopPeopleSearch,hideTodaysNews;
+ static {
+ hideAds = (Pref.hideAds() && SettingsStatus.hideAds);
+ hideWTF = (Pref.hideWTF() && SettingsStatus.hideWTF);
+ hideCTS = (Pref.hideCTS() && SettingsStatus.hideCTS);
+ hideCTJ = (Pref.hideCTJ() && SettingsStatus.hideCTJ);
+ hideDetailedPosts = (Pref.hideDetailedPosts() && SettingsStatus.hideDetailedPosts);
+ hideRBMK = (Pref.hideRBMK() && SettingsStatus.hideRBMK);
+ hidePinnedPosts = (Pref.hideRPinnedPosts() && SettingsStatus.hideRPinnedPosts);
+ hidePremiumPrompt = (Pref.hidePremiumPrompt() && SettingsStatus.hidePremiumPrompt);
+ showSensitiveMedia = Pref.showSensitiveMedia();
+ hideTopPeopleSearch = (Pref.hideTopPeopleSearch() && SettingsStatus.hideTopPeopleSearch);
+ hideTodaysNews = (Pref.hideTodaysNews() && SettingsStatus.hideTodaysNews);
+ }
+
+
+ private static boolean isEntryIdRemove(String entryId) {
+ String[] split = entryId.split("-");
+ String entryId2 = split[0];
+ if (!entryId2.equals("cursor") && !entryId2.equals("Guide") && !entryId2.startsWith("semantic_core")) {
+ if (entryId.contains("promoted") || (entryId2.equals("conversationthread") && split.length == 3) && hideAds) {
+ return true;
+ }
+ if ((entryId2.equals("superhero") || entryId2.equals("eventsummary")) && hideAds) {
+ return true;
+ }
+ if (entryId.contains("rtb") && hideAds) {
+ return true;
+ }
+ if (entryId2.equals("tweetdetailrelatedtweets") && hideDetailedPosts) {
+ return true;
+ }
+ if (entryId2.equals("bookmarked") && hideRBMK) {
+ return true;
+ }
+ if (entryId.startsWith("community-to-join") && hideCTJ) {
+ return true;
+ }
+ if (entryId.startsWith("who-to-follow") && hideWTF) {
+ return true;
+ }
+ if (entryId.startsWith("who-to-subscribe") && hideCTS) {
+ return true;
+ }
+ if (entryId.startsWith("pinned-tweets") && hidePinnedPosts) {
+ return true;
+ }
+ if (entryId.startsWith("messageprompt-") && hidePremiumPrompt) {
+ return true;
+ }
+ if ((entryId.startsWith("main-event-") || entryId2.equals("pivot")) && hideAds) {
+ return true;
+ }
+ if (entryId2.equals("toptabsrpusermodule") && hideTopPeopleSearch) {
+ return true;
+ }
+ if (entryId.startsWith("stories") && hideTodaysNews) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public static JsonTimelineEntry checkEntry(JsonTimelineEntry jsonTimelineEntry) {
+ try {
+ String entryId = jsonTimelineEntry.a;
+ if(isEntryIdRemove(entryId)){
+ return null;
+ }
+ } catch (Exception unused) {
+
+ }
+ return jsonTimelineEntry;
+ }
+
+ public static JsonTimelineModuleItem checkEntry(JsonTimelineModuleItem jsonTimelineModuleItem) {
+ try {
+ String entryId = jsonTimelineModuleItem.a;
+ if(isEntryIdRemove(entryId)){
+ return null;
+ }
+ } catch (Exception unused) {
+
+ }
+ return jsonTimelineModuleItem;
+ }
+
+ public static JsonSensitiveMediaWarning sensitiveMedia(JsonSensitiveMediaWarning jsonSensitiveMediaWarning) {
+ try {
+ if(showSensitiveMedia){
+ jsonSensitiveMediaWarning.a = false;
+ jsonSensitiveMediaWarning.b = false;
+ jsonSensitiveMediaWarning.c = false;
+ }
+ } catch (Exception unused) {
+
+ }
+ return jsonSensitiveMediaWarning;
+ }
+
+ public static boolean hidePromotedTrend(Object data) {
+ if (data != null && hideAds) {
+ return true;
+ }
+ return false;
+ }
+
+//end
+}
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/TweetInfo.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/TweetInfo.java
new file mode 100644
index 00000000..ddc438cb
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/TweetInfo.java
@@ -0,0 +1,68 @@
+package app.revanced.extension.twitter.patches;
+
+
+import com.twitter.api.model.json.core.JsonApiTweet;
+import com.twitter.model.json.core.JsonTweetQuickPromoteEligibility;
+import app.revanced.extension.twitter.Pref;
+import app.revanced.extension.twitter.settings.SettingsStatus;
+import java.lang.reflect.Field;
+
+public class TweetInfo {
+ private static final boolean hideCommNotes,hidePromoteBtn,forceTranslate;
+ private static String commNotesFieldName, promoteBtnFieldName, translateFieldName;
+
+ static {
+ hideCommNotes = (Pref.hideCommNotes() && SettingsStatus.hideCommunityNote);
+ hidePromoteBtn = (Pref.hidePromoteBtn() && SettingsStatus.hidePromoteButton);
+ forceTranslate = (Pref.enableForceTranslate() && SettingsStatus.forceTranslate);
+ commNotesFieldName = "";
+ promoteBtnFieldName = "";
+ translateFieldName = "";
+
+ }
+
+ private static void loader(Class jsonApiTweetCls){
+ commNotesFieldName = "";
+ promoteBtnFieldName = "";
+ translateFieldName = "";
+
+ Field[] fields = jsonApiTweetCls.getDeclaredFields();
+ for(Field field : fields){
+ if (field.getType() == boolean.class) {
+ if(commNotesFieldName.length()==0){
+ commNotesFieldName = field.getName();
+ continue;
+ }
+ translateFieldName = field.getName();
+ continue;
+ }
+ if (field.getType() == JsonTweetQuickPromoteEligibility.class) {
+ promoteBtnFieldName = field.getName();
+ }
+ }
+ }
+ public static JsonApiTweet checkEntry(JsonApiTweet jsonApiTweet) {
+ try {
+ Class jsonApiTweetCls = jsonApiTweet.getClass();
+ if( commNotesFieldName.length()==0 || promoteBtnFieldName.length()==0 || translateFieldName.length()==0 ){
+ loader(jsonApiTweetCls);
+ }
+ if(hideCommNotes){
+ Field f = jsonApiTweetCls.getDeclaredField(commNotesFieldName);
+ f.set(jsonApiTweet,null);
+ }
+ if(hidePromoteBtn){
+ Field f = jsonApiTweetCls.getDeclaredField(promoteBtnFieldName);
+ f.set(jsonApiTweet,null);
+ }
+ if(forceTranslate){
+ Field f = jsonApiTweetCls.getDeclaredField(translateFieldName);
+ f.set(jsonApiTweet,true);
+ }
+
+ } catch (Exception unused) {
+
+ }
+ return jsonApiTweet;
+ }
+}
\ No newline at end of file
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/customise/Customise.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/customise/Customise.java
new file mode 100644
index 00000000..67c37ad3
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/customise/Customise.java
@@ -0,0 +1,198 @@
+package app.revanced.extension.twitter.patches.customise;
+
+import android.util.*;
+import java.util.*;
+import java.lang.reflect.Field;
+import app.revanced.extension.twitter.Pref;
+import app.revanced.extension.twitter.Utils;
+import com.twitter.model.json.search.JsonTypeaheadResponse;
+
+public class Customise {
+
+ private static void logger(Object j){
+ Utils.logger(j);
+ }
+
+ public static List navBar(List inp){
+ try{
+ ArrayList choices = Pref.customNavbar();
+
+ if(choices.isEmpty()) return inp;
+
+ List list2 = new ArrayList<>(inp);
+ Iterator itr = list2.iterator();
+
+ while (itr.hasNext()) {
+ Object obj = itr.next();
+ String itemStr = obj.toString();
+ if(choices.contains(itemStr)){
+ inp.remove(obj);
+ }
+ }
+
+ }catch (Exception e){
+ logger(e);
+ }
+ return inp;
+ }
+
+ public static ArrayList profiletabs(ArrayList inp){
+ try{
+ ArrayList choices = Pref.customProfileTabs();
+
+ if(choices.isEmpty()) return inp;
+
+ Object inpObj = inp.clone();
+ ArrayList> arr = (ArrayList>) inpObj;
+ Iterator itr = inp.iterator();
+
+ while (itr.hasNext()) {
+
+ Object obj = itr.next();
+ Class> clazz = obj.getClass();
+ Field field = clazz.getDeclaredField("g");
+ String g = (String) field.get(obj);
+
+ if ((g!=null && choices.contains(g)) || (g==null && choices.contains("subs"))){
+ arr.remove(obj);
+ }
+ }
+ return arr;
+
+
+ }catch (Exception e){
+ logger(e);
+ }
+ return inp;
+ }
+
+ public static ArrayList exploretabs(ArrayList inp){
+ try{
+ ArrayList choices = Pref.customExploreTabs();
+
+ if(choices.isEmpty()) return inp;
+
+ Object inpObj = inp.clone();
+ ArrayList> arr = (ArrayList>) inpObj;
+ Iterator itr = inp.iterator();
+
+ while (itr.hasNext()) {
+
+ Object obj = itr.next();
+ Class> clazz = obj.getClass();
+ Field field = clazz.getDeclaredField("a");
+ String id = (String) field.get(obj);
+
+ if (id!=null && choices.contains(id)){
+ arr.remove(obj);
+ }
+ }
+ return arr;
+
+
+ }catch (Exception e){
+ logger(e);
+ }
+ return inp;
+ }
+
+ public static List inlineBar(List inp){
+ try{
+ ArrayList choices = Pref.inlineBar();
+
+ if(choices.isEmpty()) return inp;
+
+ List list2 = new ArrayList<>(inp);
+ Iterator itr = inp.iterator();
+
+ while (itr.hasNext()) {
+ Object obj = itr.next();
+ String itemStr = obj.toString();
+ if(choices.contains(itemStr)){
+ list2.remove(obj);
+ }
+ }
+ return list2;
+ }catch (Exception e){
+ logger(e);
+ }
+ return inp;
+ }
+
+ public static List sideBar(List inp){
+ try{
+ ArrayList choices = Pref.customSidebar();
+
+ if(choices.isEmpty()) return inp;
+
+ List list2 = new ArrayList<>(inp);
+ Iterator itr = list2.iterator();
+
+ while (itr.hasNext()) {
+ Object obj = itr.next();
+ String itemStr = obj.toString();
+ if(choices.contains(itemStr)){
+ inp.remove(obj);
+ }
+ }
+
+ }catch (Exception e){
+ logger(e);
+ }
+ return inp;
+ }
+
+ public static JsonTypeaheadResponse typeAheadResponse(JsonTypeaheadResponse jsonTypeaheadResponse){
+ try{
+ ArrayList choices = Pref.customSearchTypeAhead();
+ if(!choices.isEmpty())
+ {
+ if (choices.contains("users")) {
+ jsonTypeaheadResponse.a = new ArrayList<>();
+ }
+ if (choices.contains("topics")) {
+ jsonTypeaheadResponse.b = new ArrayList<>();
+ }
+ if (choices.contains("events")) {
+ jsonTypeaheadResponse.c = new ArrayList<>();
+ }
+ if (choices.contains("lists")) {
+ jsonTypeaheadResponse.d = new ArrayList<>();
+ }
+ if (choices.contains("ordered_section")) {
+ jsonTypeaheadResponse.e = new ArrayList<>();
+ }
+ }
+ }catch (Exception e){
+ logger(e);
+ }
+ return jsonTypeaheadResponse;
+ }
+
+ public static List searchTabs(List inp){
+ try{
+ ArrayList choices = Pref.searchTabs();
+
+ if(choices.isEmpty()) return inp;
+
+ List list2 = new ArrayList<>(inp);
+ Iterator itr = inp.iterator();
+
+ while (itr.hasNext()) {
+ Object obj = itr.next();
+ Class> clazz = obj.getClass();
+ Field field = clazz.getDeclaredField("a");
+ int itemVal = (int) field.get(obj);
+ if(choices.contains(String.valueOf(itemVal))){
+ list2.remove(obj);
+ }
+ }
+ return list2;
+ }catch (Exception e){
+ logger(e);
+ }
+ return inp;
+ }
+
+//class end
+}
\ No newline at end of file
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/links/HandleCustomDeepLinksPatch.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/links/HandleCustomDeepLinksPatch.java
new file mode 100644
index 00000000..f1a9f935
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/links/HandleCustomDeepLinksPatch.java
@@ -0,0 +1,35 @@
+package app.revanced.extension.twitter.patches.links;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import app.revanced.extension.shared.Utils;
+
+@SuppressWarnings("unused")
+@SuppressLint("DiscouragedApi")
+public class HandleCustomDeepLinksPatch {
+ private static String[] customLinkHosts = null;
+
+ public static void rewriteCustomDeepLinks(Activity activity) {
+ var intent = activity.getIntent();
+
+ var uri = intent.getData();
+ if (uri == null) return;
+
+ if (customLinkHosts == null)
+ customLinkHosts = Utils.getResourceStringArray("piko_custom_deeplink_hosts");
+
+ String host = uri.getHost();
+
+ for (String customHost : customLinkHosts) {
+ if (host.endsWith(customHost)) {
+ // Rewrite host
+ var newUri = uri.buildUpon()
+ .authority("x.com")
+ .build();
+
+ intent.setData(newUri);
+ return;
+ }
+ }
+ }
+}
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/links/UnshortenUrlsPatch.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/links/UnshortenUrlsPatch.java
new file mode 100644
index 00000000..097c3eab
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/links/UnshortenUrlsPatch.java
@@ -0,0 +1,28 @@
+package app.revanced.extension.twitter.patches.links;
+
+
+import android.util.Log;
+
+import com.twitter.model.json.core.JsonUrlEntity;
+import com.twitter.model.json.card.JsonCardInstanceData;
+import app.revanced.extension.twitter.Pref;
+import app.revanced.extension.twitter.settings.SettingsStatus;
+
+
+public class UnshortenUrlsPatch {
+ private static String TAG = "030-unshort";
+ private static boolean unShortUrl;
+ static {
+ unShortUrl = SettingsStatus.unshortenlink && Pref.unShortUrl();
+ }
+ public static JsonUrlEntity unshort(JsonUrlEntity entity) {
+ try {
+ if(unShortUrl){
+ entity.e = entity.c;
+ }
+ } catch (Exception ex) {
+ Log.e(TAG, ";-;", ex);
+ }
+ return entity;
+ }
+}
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/loggers/ResponseLogger.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/loggers/ResponseLogger.java
new file mode 100644
index 00000000..0b25891a
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/loggers/ResponseLogger.java
@@ -0,0 +1,48 @@
+package app.revanced.extension.twitter.patches.loggers;
+
+import java.io.File;
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import android.os.Environment;
+
+import app.revanced.extension.twitter.Pref;
+import app.revanced.extension.twitter.Utils;
+
+public class ResponseLogger {
+ private static boolean LOG_RES;
+ static{
+ LOG_RES = Pref.serverResponseLogging();
+ if(Pref.serverResponseLoggingOverwriteFile()){
+ writeFile("",false);
+// Utils.logger("Cleared response log file!!!");
+ }
+ }
+
+ public static InputStream saveInputStream(InputStream inputStream) throws Exception {
+ if(!LOG_RES) return inputStream;
+
+ StringBuilder sb = new StringBuilder();
+ BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
+ String line;
+ while ((line = reader.readLine()) != null) {
+ sb.append(line).append("\n");
+ }
+ sb.append("\n");
+ inputStream.close();
+ String contentBytes = sb.toString();
+ if(!(sb.indexOf("session_token") == 2 || sb.indexOf("guest_token") == 2)){
+ writeFile(contentBytes,true);
+ }
+
+ return new ByteArrayInputStream(contentBytes.getBytes());
+ }
+
+ private static boolean writeFile(String data,boolean append){
+ String fileName = "Server-Response-Log.txt";
+ return Utils.pikoWriteFile(fileName,data,append);
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/nativeFeatures/NativeDownloader.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/nativeFeatures/NativeDownloader.java
new file mode 100644
index 00000000..f8748427
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/nativeFeatures/NativeDownloader.java
@@ -0,0 +1,121 @@
+package app.revanced.extension.twitter.patches.nativeFeatures;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.widget.LinearLayout;
+import app.revanced.extension.twitter.Utils;
+import app.revanced.extension.twitter.Pref;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.*;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import app.revanced.extension.twitter.entity.Video;
+import app.revanced.extension.twitter.entity.Media;
+import app.revanced.extension.twitter.entity.Tweet;
+import app.revanced.extension.twitter.entity.ExtMediaEntities;
+
+public class NativeDownloader {
+ public static String downloadString() {
+ return Utils.strRes("piko_pref_native_downloader_alert_title");
+ }
+
+ private static String getExtension(String typ) {
+ if (typ.equals("video/mp4")) {
+ return "mp4";
+ }
+ if (typ.equals("video/webm")) {
+ return "webm";
+ }
+ if (typ.equals("application/x-mpegURL")) {
+ return "m3u8";
+ }
+ return "jpg";
+ }
+
+ private static String generateFileName(Tweet tweet) throws Exception {
+ String tweetId = ""+tweet.getTweetId();
+ int fileNameType = Pref.nativeDownloaderFileNameType();
+ switch (fileNameType) {
+ case 1:
+ return tweet.getTweetUsername() + "_" + tweetId;
+ case 2:
+ return tweet.getTweetProfileName() + "_" + tweetId;
+ case 3:
+ return tweet.getTweetUserId() + "_" + tweetId;
+ case 5:
+ return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
+ default:
+ return tweetId;
+ }
+ }
+
+ private static void alertBox(Context ctx, String filename, ArrayList mediaData) throws NoSuchFieldException, IllegalAccessException {
+ String photo = Utils.strRes("drafts_empty_photo");
+ String video = Utils.strRes("drafts_empty_video");
+
+ LinearLayout ln = new LinearLayout(ctx);
+ ln.setOrientation(LinearLayout.VERTICAL);
+ AlertDialog.Builder builder = new AlertDialog.Builder(ctx);
+ builder.setTitle(Utils.strRes("piko_pref_native_downloader_alert_title"));
+
+ int n = mediaData.size();
+ String[] choices = new String[n];
+ for (int i = 0; i < n; i++) {
+ Media media = mediaData.get(i);
+ String typ = media.type == 0?photo:video;
+ choices[i] = "• " + typ + " " + (i + 1);
+ }
+
+ builder.setItems(choices, (dialogInterface, which) -> {
+ Media media = mediaData.get(which);
+
+ Utils.toast(Utils.strRes("download_started"));
+ Utils.downloadFile(media.url, filename + (which + 1), media.ext);
+ });
+
+ builder.setNegativeButton(Utils.strRes("piko_pref_native_downloader_download_all"), (dialogInterface, index) -> {
+ Utils.toast(Utils.strRes("download_started"));
+
+ int i = 1;
+ for (Media media : mediaData) {
+ Utils.downloadFile(media.url, filename + i, media.ext);
+ i++;
+ }
+ dialogInterface.dismiss();
+ });
+
+ builder.show();
+ }
+
+ public static void downloader(Context activity, Object tweetObj) throws NoSuchMethodException,
+ InvocationTargetException, IllegalAccessException, NoSuchFieldException, ClassNotFoundException {
+ try {
+ Tweet tweet = new Tweet(tweetObj);
+ ArrayList media = tweet.getMedias();
+ for(Media m:media){
+ Utils.logger(m);
+ }
+ assert media != null;
+ if (media.isEmpty()) {
+ Utils.toast(Utils.strRes("piko_pref_native_downloader_no_media"));
+ return;
+ }
+
+ String fileName = generateFileName(tweet);
+
+ if (media.size() == 1) {
+ Media item = media.get(0);
+ Utils.toast(Utils.strRes("download_started"));
+ Utils.downloadFile(item.url, fileName, item.ext);
+ return;
+ }
+
+ alertBox(activity, fileName + "-", media);
+ } catch (Exception ex) {
+ Utils.logger(ex);
+ }
+ }
+
+}
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/nativeFeatures/readerMode/ReaderModeFragment.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/nativeFeatures/readerMode/ReaderModeFragment.java
new file mode 100644
index 00000000..9808be57
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/nativeFeatures/readerMode/ReaderModeFragment.java
@@ -0,0 +1,132 @@
+package app.revanced.extension.twitter.patches.nativeFeatures.readerMode;
+
+
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.ProgressBar;
+import app.revanced.extension.shared.Utils;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.app.Fragment;
+import android.graphics.Bitmap;
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import app.revanced.extension.shared.StringRef;
+import android.webkit.JavascriptInterface;
+
+public class ReaderModeFragment extends Fragment {
+
+ private WebView webView;
+
+ private String tweetId;
+
+ // JavaScript interface class
+ public class WebAppInterface {
+ Context mContext;
+
+ WebAppInterface(Context context) {
+ mContext = context;
+ }
+
+ @JavascriptInterface
+ public void copyText(String text) {
+ Utils.setClipboard(text);
+ Utils.showToastShort(StringRef.str("link_copied_to_clipboard"));
+ }
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (getArguments() != null) {
+ tweetId = getArguments().getString(ReaderModeUtils.ARG_TWEET_ID);
+ }
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(
+ @NonNull LayoutInflater inflater,
+ @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+
+ View rootView = inflater.inflate(Utils.getResourceIdentifier("webview", "layout"), container, false);
+
+ // View progressBarView = inflater.inflate(Utils.getResourceIdentifier("progress_bar", "layout"), container, false);
+ // ProgressBar progressBar = progressBarView.findViewById(Utils.getResourceIdentifier("progressbar", "id"));
+ // progressBar.setVisibility(View.VISIBLE);
+
+ webView = rootView.findViewById(Utils.getResourceIdentifier("webview", "id"));
+ webView.getSettings().setJavaScriptEnabled(true);
+ webView.addJavascriptInterface(new WebAppInterface(getContext()), "Android");
+ webView.getSettings().setLoadWithOverviewMode(true);
+ webView.getSettings().setUseWideViewPort(true);
+
+ webView.setWebViewClient(new WebViewClient() {
+ // @Override
+ // public void onPageStarted(WebView view, String url, Bitmap favicon) {
+ // progressBar.setVisibility(View.VISIBLE);
+ // }
+ @Override
+ public void onPageFinished(WebView view, String url) {
+ // progressBar.setVisibility(View.GONE);
+ webView.evaluateJavascript(ReaderModeUtils.injectJS(), null);
+ }
+ });
+
+ if (tweetId != null && !tweetId.isEmpty()) {
+ loadDynamicUrl(tweetId);
+ } else {
+ webView.loadData(ReaderModeUtils.NO_CONTENT,"text/html", "UTF-8");
+ }
+
+ return rootView;
+ }
+
+ private void loadDynamicUrl(String tweetId) {
+
+ new Thread(() -> {
+ final String finalHtml = ReaderModeUtils.buildHtml(tweetId);
+
+ if (isAdded() && getActivity() != null) {
+ getActivity().runOnUiThread(() -> webView.loadDataWithBaseURL(
+ null, finalHtml, "text/html", "UTF-8", null));
+ }
+ }).start();
+ }
+
+ private String getHtmlFromUrl(String urlString) {
+ StringBuilder content = new StringBuilder();
+ HttpURLConnection conn = null;
+ BufferedReader reader = null;
+
+ try {
+ URL url = new URL(urlString);
+ conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("GET");
+ conn.setRequestProperty("User-Agent", "TelegramBot");
+
+ reader = new BufferedReader(new InputStreamReader(conn.getInputStream()));
+ String line;
+ while ((line = reader.readLine()) != null) {
+ content.append(line);
+ }
+ reader.close();
+
+ } catch (Exception e) {
+ e.printStackTrace();
+ return null;
+ } finally {
+ if (conn != null) conn.disconnect();
+ try { if (reader != null) reader.close(); } catch (Exception ignore) {}
+ }
+ return content.toString();
+ }
+}
\ No newline at end of file
diff --git a/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/nativeFeatures/readerMode/ReaderModeTemplate.java b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/nativeFeatures/readerMode/ReaderModeTemplate.java
new file mode 100644
index 00000000..3829c841
--- /dev/null
+++ b/extensions/twitter/src/main/java/app/revanced/extension/twitter/patches/nativeFeatures/readerMode/ReaderModeTemplate.java
@@ -0,0 +1,497 @@
+package app.revanced.extension.twitter.patches.nativeFeatures.readerMode;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+import app.revanced.extension.twitter.Utils;
+import app.revanced.extension.shared.StringRef;
+
+import org.json.JSONObject;
+import org.json.JSONArray;
+
+public class ReaderModeTemplate {
+
+ private static final String GROK_ANALYSE_THREAD = StringRef.str("piko_native_reader_mode_grok_thread");
+ private static final String GROK_ANALYSE_AUTHOR = StringRef.str("piko_native_reader_mode_grok_author");
+ private static final String TITLE_SOURCE = StringRef.str("piko_native_reader_mode_source");
+ private static final String TITLE_PUBLISHED = StringRef.str("piko_native_reader_mode_published");
+ private static final String TITLE_TOT_POSTS = StringRef.str("piko_native_reader_mode_total_post");
+ private static final String SHARE_POST = StringRef.str("piko_native_reader_mode_copy_post");
+
+ private static String getGrokIcon(String slug, String text) {
+ return "\n" +
+ "\n" +
+ "\n"
+ +
+ " \n"
+ +
+ " \n" +
+ " \n" +
+ "" + text + " \n" +
+ " \n";
+ }
+
+ private static String getColorScheme() {
+ return ":root{\n" +
+ " --bg-body: #ffffff;\n" +
+ " --bg-container: #fefefe;\n" +
+ " --text-primary: #262626;\n" +
+ " --text-secondary: #606770;\n" +
+ " --text-accent: #1a73e8;\n" +
+ " --border-color: #dcdcdc;\n" +
+ " --media-shadow: rgba(26, 115, 232, 0.3);\n" +
+ " --highlight-bg: #e1ecff;\n" +
+ " --highlight-color: #1a56db;\n" +
+ " --quoted-bg: #f5f8ff;\n" +
+ " --quoted-border: #a3b4d9;\n" +
+ " --quoted-text: #38456d;\n" +
+ " --footer-border: #e1e4e8;\n" +
+ " --footer-text: #70757a;\n" +
+ " --link-color: #1a73e8;\n" +
+ " --link-hover: underline;\n" +
+ " --author-border: #d9d9d9;\n" +
+ " --author-desc: #5e6366;\n" +
+ " --author-handle: #65676b;\n" +
+ " }\n" +
+ "body.dark {\n" +
+ " --bg-body: #121212;\n" +
+ " --bg-container: #1e1e1e;\n" +
+ " --text-primary: #e0e0e0;\n" +
+ " --text-secondary: #8a9ba8;\n" +
+ " --text-accent: #80c8ff;\n" +
+ " --border-color: #333;\n" +
+ " --media-shadow: rgba(65, 105, 225, 0.8);\n" +
+ " --highlight-bg: #335577;\n" +
+ " --highlight-color: #c1d9ff;\n" +
+ " --quoted-bg: #2d2e2f;\n" +
+ " --quoted-border: #5a79a0;\n" +
+ " --quoted-text: #aac6ff;\n" +
+ " --footer-border: #333;\n" +
+ " --footer-text: #6a7b8d;\n" +
+ " --link-color: #7ab8ff;\n" +
+ " --link-hover: underline;\n" +
+ " --author-border: #2f3943;\n" +
+ " --author-desc: #89a1b0;\n" +
+ "}\n" +
+ "body.dim {\n" +
+ "--bg-body: #16202a ;\n" +
+ "--bg-container: #192734;\n" +
+ "--text-primary: #a3b1c2;\n" +
+ "--text-secondary: #7c8a9e;\n" +
+ "--text-accent: #6ea7ff;\n" +
+ "--border-color: #32475b;\n" +
+ "--media-shadow: rgba(77, 117, 179, 0.67);\n" +
+ "--highlight-bg: #24436e;\n" +
+ "--highlight-color: #afd1ff;\n" +
+ "--quoted-bg: #223544;\n" +
+ "--quoted-border: #5178ab;\n" +
+ "--quoted-text: #9dbadb;\n" +
+ "--footer-border: #32475b;\n" +
+ "--footer-text: #73859a;\n" +
+ "--link-color: #0070ff;\n" +
+ "--author-border: #32475b;\n" +
+ "--author-desc: #84a2c3;\n" +
+ "--author-handle: #7c8a9e;\n" +
+ "}\n";
+
+ }
+
+ public static String getHTMLHeader() {
+
+ String colorScheme = getColorScheme();
+
+ String rest = "body {\n" +
+ " background: var(--bg-body);\n" +
+ " font-family: 'Georgia', 'Times New Roman', serif;\n" +
+ " color: var(--text-primary);\n" +
+ " margin: 0;\n" +
+ " padding: 0 0 3rem 0;\n" +
+ " transition: background 0.3s ease, color 0.3s ease;\n" +
+ "}\n" +
+
+ ".news-container {\n" +
+ " max-width: 680px;\n" +
+ " margin: 3rem auto;\n" +
+ " background: var(--bg-container);\n" +
+ " box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);\n" +
+ " border-radius: 8px;\n" +
+ " padding: 2.3rem 2.3rem 2rem 2.3rem;\n" +
+ " color: var(--text-primary);\n" +
+ " transition: background 0.3s ease, color 0.3s ease;\n" +
+ "}\n" +
+
+ ".news-header {\n" +
+ " border-bottom: 3px solid var(--border-color);\n" +
+ " margin-bottom: 1.3rem;\n" +
+ " padding-bottom: 1.4rem;\n" +
+ " display: flex;\n" +
+ " flex-direction: column;\n" +
+ " gap: 0.4em;\n" +
+ "}\n" +
+
+ ".news-meta {\n" +
+ " font-size: 1em;\n" +
+ " color: var(--text-secondary);\n" +
+ " transition: color 0.3s ease;\n" +
+ "}\n" +
+
+ ".author-block {\n" +
+ "display: flex;\n" +
+ "align-items: center;\n" +
+ "gap: 1rem;\n" +
+ "padding-bottom: 1rem;\n" +
+ "max-width: 400px;\n" +
+ "font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;\n" +
+ "}\n" +
+
+ ".author-block img {\n" +
+ "width: 56px;\n" +
+ "height: 56px;\n" +
+ "border-radius: 50%;\n" +
+ "border: 2px solid #0078d4;\n" +
+ "object-fit: cover;\n" +
+ "}\n" +
+
+ ".author-details {\n" +
+ "display: flex;\n" +
+ "flex-direction: column;\n" +
+ "}\n" +
+
+ ".author-details .name {\n" +
+ "font-weight: 700;\n" +
+ "font-size: 1.1rem;\n" +
+ "color: var(--highlight-color);\n" +
+ "}\n" +
+
+ ".author-details a {\n" +
+ "font-size: 0.9rem;\n" +
+ "color: var(--text-secondary);\n" +
+ "text-decoration: none;\n" +
+ "}\n" +
+
+ ".article-body {\n" +
+ " font-size: 1.14rem;\n" +
+ " line-height: 1.7;\n" +
+ " margin-bottom: 2rem;\n" +
+ " color: var(--text-primary);\n" +
+ " transition: color 0.3s ease;\n" +
+ "}\n" +
+
+ ".media {\n" +
+ " margin: 1.1em 0;\n" +
+ " text-align: center;\n" +
+ "}\n" +
+
+ ".media img,\n" +
+ ".media video {\n" +
+ " max-width: 100%;\n" +
+ " border-radius: 10px;\n" +
+ " box-shadow: 0 0 12px var(--media-shadow);\n" +
+ " transition: box-shadow 0.3s ease;\n" +
+ "}\n" +
+
+ ".highlight {\n" +
+ " font-weight: bold;\n" +
+ " background: var(--highlight-bg);\n" +
+ " padding: 0.08em 0.25em;\n" +
+ " border-radius: 4px;\n" +
+ " color: var(--highlight-color);\n" +
+ " transition: background 0.3s ease, color 0.3s ease;\n" +
+ "}\n" +
+
+ ".quoted-section {\n" +
+ " background: var(--quoted-bg);\n" +
+ " border-left: 4px solid var(--quoted-border);\n" +
+ " padding: 1em 1em 1em 1.4em;\n" +
+ " margin: 1.9em 0 0.9em 0;\n" +
+ " border-radius: 7px;\n" +
+ " font-size: 1.02em;\n" +
+ " color: var(--quoted-text);\n" +
+ " transition: background 0.3s ease, border-color 0.3s ease, color 0.3s ease;\n" +
+ "}\n" +
+
+ ".quoted-author {\n" +
+ " font-weight: 600;\n" +
+ " color: var(--link-color);\n" +
+ " font-size: 0.97em;\n" +
+ " margin-bottom: 0.14em;\n" +
+ " display: block;\n" +
+ " transition: color 0.3s ease;\n" +
+ "}\n" +
+
+ ".article-footer {\n" +
+ " margin-top: 2.6em;\n" +
+ " padding-top: 1.4em;\n" +
+ " border-top: 1.5px solid var(--footer-border);\n" +
+ " font-size: 0.95em;\n" +
+ " color: var(--footer-text);\n" +
+ " transition: border-color 0.3s ease, color 0.3s ease;\n" +
+ "}\n" +
+ "a {\n" +
+ " color: var(--link-color);\n" +
+ " text-decoration: none;\n" +
+ " word-break: break-all;\n" +
+ " transition: color 0.3s ease;\n" +
+ "}\n" +
+ ".article-footer a {\n" +
+ " font-size: 1rem;\n" +
+ "}\n" +
+
+ "@media (max-width: 700px) {\n" +
+ " .news-container {\n" +
+ " padding: 1.2rem 0.8rem;\n" +
+ " }\n" +
+ "}\n" +
+ ".grok-button {\n" +
+ " display: inline-flex;\n" +
+ " align-items: center;\n" +
+ " gap: 0.5em;\n" +
+ " padding: 0.3em 1.2em;\n" +
+ " background-color:#000;\n" +
+ " border-radius: 9999px; /* oval shape */\n" +
+ " color: white;\n" +
+ " font-weight: 700;\n" +
+ " font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;\n" +
+ " font-size: 0.7rem;\n" +
+ " text-decoration: none;\n" +
+ " cursor: pointer;\n" +
+ " }\n" +
+ " .grok-icon svg {\n" +
+ " display: block;\n" +
+ " }" +
+ ".author-details .grok-button {\n" +
+ "font-size: 0.7rem;\n" +
+ "color: #fff;\n" +
+ "}\n" +
+ ".article-footer .grok-button{\n" +
+ "font-size: 0.7rem;\n" +
+ "color: #fff;\n" +
+ "}"+
+ " .toggle-container {\n" +
+ " position: absolute;\n" +
+ " top: 0.5rem;\n" +
+ " right: 1rem;\n" +
+ " background: var(--bg-container);\n" +
+ " border-radius: 20px;\n" +
+ " box-shadow: 0 2px 8px rgba(0,0,0,0.5);\n" +
+ " padding: 0.4rem 0.8rem;\n" +
+ " font-family: sans-serif;\n" +
+ " color: var(--text-primary);\n" +
+ " cursor: pointer;\n" +
+ " user-select: none;\n" +
+ " transition: background 0.3s ease, color 0.3s ease;\n" +
+ " z-index: 1000;\n" +
+ " }";
+
+ return " ";
+ }
+
+ private static String getHTMLTitle(JSONObject thread) throws Exception {
+ String html = "";
+ String template = " \n" +
+ "
\n" +
+ "
\n" +
+ "
{name} \n" +
+ "
@{username} \n" +
+ " {grokUser}\n" +
+ "
\n" +
+ " \n";
+
+ String tweetId = thread.getString("id");
+ JSONObject author = thread.getJSONObject("author");
+ String name = author.getString("name");
+ String username = author.getString("username");
+ String profilepic = author.getString("profileImageUrl");
+ String postLink = "https://x.com/" + username + "/status/" + tweetId;
+
+ long createdAt = thread.getLong("createdAt");
+ LocalDateTime dateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(createdAt), ZoneId.systemDefault());
+ DateTimeFormatter formatter = DateTimeFormatter.ofPattern("EEE MMM dd, yyyy");
+ String formattedDate = dateTime.format(formatter);
+
+ int totalTweets = thread.getInt("totalTweets");
+ html = template
+ .replace("{formattedDate}", formattedDate)
+ .replace("{username}", username)
+ .replace("{name}", name)
+ .replace("{profilepic}", profilepic)
+ .replace("{tweetId}", tweetId)
+ .replace("{postLink}", postLink)
+ .replace("{grokUser}", getGrokIcon("@" + username, GROK_ANALYSE_AUTHOR))
+ .replace("{grokPost}", getGrokIcon(postLink, GROK_ANALYSE_THREAD))
+ .replace("{totalTweets}", String.valueOf(totalTweets));
+ return html;
+
+ }
+
+ private static String[] getHTMLMedia(int type, JSONObject media) throws Exception {
+ String html = "{mediaTag}
";
+ String mediaTag = "";
+ // required to remove pic url.
+ String tco = media.getString("url");
+
+ // 0= img, 1 = vid
+ if (type == 0) {
+ var src = media.getString("src");
+ mediaTag = " ";
+ } else {
+ var thumbnail = media.getString("posterUrl");
+ var videoUrl = media.getJSONArray("sources").getJSONObject(0).getString("url");
+ mediaTag = " \n" +
+ " \n" +
+ " \n";
+ mediaTag = mediaTag.replace("{thumbnail}", thumbnail)
+ .replace("{videoUrl}", videoUrl);
+
+ }
+ return new String[] { tco, html.replace("{mediaTag}", mediaTag) };
+ }
+
+ private static String sanitizeText(JSONObject tweet) throws Exception {
+ String text = tweet.getString("text");
+ if (!tweet.isNull("urls")) {
+ JSONArray urls = tweet.getJSONArray("urls");
+ for (int i = 0; i < urls.length(); i++) {
+ JSONObject url = urls.getJSONObject(i);
+ String tco = url.getString("url");
+ String expUrl = url.getString("expandedUrl");
+ text = text.replace(tco, expUrl);
+ }
+
+ }
+ return text;
+ }
+
+ private static String getHTMLTweet(JSONObject tweet, boolean quoted)
+ throws Exception {
+ String html = "{content}";
+ String content = "";
+
+ if (quoted) {
+ String tweetId = tweet.getString("id");
+ JSONObject author = tweet.getJSONObject("author");
+ String name = author.getString("name");
+ String username = author.getString("username");
+ html = "\n" +
+ " {name} (@{username}) \n" +
+ // "{text}\n" +
+ " {content}\n" +
+ "\n"
+ +
+ "
";
+ html = html.replace("{username}", username)
+ .replace("{name}", name)
+ .replace("{tweetId}", tweetId);
+ }
+
+ if (!tweet.isNull("text")) {
+
+ content = sanitizeText(tweet) + " ";
+ }
+
+ if (!tweet.isNull("photos")) {
+ JSONArray images = tweet.getJSONArray("photos");
+ for (int i = 0; i < images.length(); i++) {
+ JSONObject item = images.getJSONObject(i);
+ String[] cnt = getHTMLMedia(0, item);
+ String tco = cnt[0];
+ content = content.replace(tco, "") + cnt[1];
+ }
+ }
+
+ if (!tweet.isNull("media")) {
+ JSONArray videos = tweet.getJSONArray("media");
+ for (int i = 0; i < videos.length(); i++) {
+ JSONObject item = videos.getJSONObject(i);
+ String[] cnt = getHTMLMedia(1, item);
+ String tco = cnt[0];
+ content = content.replace(tco, "") + cnt[1];
+ }
+ }
+
+ if (!tweet.isNull("quoted")) {
+ JSONObject quotedTweet = tweet.getJSONObject("quoted");
+ content += getHTMLTweet(quotedTweet, true);
+ }
+
+ return html.replace("{content}", content);
+ }
+
+ private static String getHTMLFooter(JSONObject thread) throws Exception {
+ String tweetId = thread.getString("id");
+ String postLink = "https://x.com/dummy/status/" + tweetId;
+
+ String template = "\n" +
+ getGrokIcon(postLink, GROK_ANALYSE_THREAD) + "\n" +
+ "
twitter-thread.com \n" +
+ "
\n";
+
+ return template
+ .replace("{tweetId}", tweetId);
+ }
+
+ public static String generateHtml(JSONObject threads) throws Exception {
+
+ String content = "";
+ JSONObject thread = threads.getJSONObject("thread");
+ String tweetId = thread.getString("id");
+ String title = getHTMLTitle(thread);
+ JSONArray tweets = thread.getJSONArray("tweets");
+ for (int i = 0; i < tweets.length(); i++) {
+ JSONObject tweet = tweets.getJSONObject(i);
+ content += getHTMLTweet(tweet, false);
+ }
+ String footer = getHTMLFooter(thread);
+ return generateHtml(tweetId,title + "\n" + content + "\n" + footer);
+ }
+
+ public static String generateHtml(String tweetId,String content) {
+ String script = ""+
+ "