createNode(char nodeCharacterValue) {
+ return new ByteTrieNode(nodeCharacterValue);
+ }
+ @Override
+ char getCharValue(byte[] text, int index) {
+ return (char) text[index];
+ }
+ @Override
+ int getTextLength(byte[] text) {
+ return text.length;
+ }
+ }
+
+ /**
+ * Helper method for the common usage of converting Strings to raw UTF-8 bytes.
+ */
+ public static byte[][] convertStringsToBytes(String... strings) {
+ final int length = strings.length;
+ byte[][] replacement = new byte[length][];
+ for (int i = 0; i < length; i++) {
+ replacement[i] = strings[i].getBytes(StandardCharsets.UTF_8);
+ }
+ return replacement;
+ }
+
+ public ByteTrieSearch(byte[]... patterns) {
+ super(new ByteTrieNode(), patterns);
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/Logger.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/Logger.java
new file mode 100644
index 0000000..bd32a75
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/Logger.java
@@ -0,0 +1,214 @@
+package app.morphe.extension.shared;
+
+import static app.morphe.extension.shared.settings.BaseSettings.DEBUG;
+import static app.morphe.extension.shared.settings.BaseSettings.DEBUG_STACKTRACE;
+import static app.morphe.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.morphe.extension.shared.settings.BaseSettings;
+import app.morphe.extension.shared.settings.preference.LogBufferManager;
+
+/**
+ * Morphe specific logger. Logging is done to standard device log (accessible through ADB),
+ * and additionally accessible through {@link LogBufferManager}.
+ *
+ * All methods are thread safe, and are safe to call even
+ * if {@link Utils#getContext()} is not available.
+ */
+public class Logger {
+
+ /**
+ * Log messages using lambdas.
+ */
+ @FunctionalInterface
+ public interface LogMessage {
+ /**
+ * @return Logger string message. This method is only called if logging is enabled.
+ */
+ @NonNull
+ String buildMessageString();
+ }
+
+ private enum LogLevel {
+ DEBUG,
+ INFO,
+ ERROR
+ }
+
+ /**
+ * Log tag prefix. Only used for system logging.
+ */
+ private static final String MORPHE_LOG_TAG_PREFIX = "morphe: ";
+
+ 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 'morphe:' 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 "morphe:" prefix in clipboard logs.
+ String managerToastString = className + ": " + logText;
+ LogBufferManager.appendToLogBuffer(managerToastString);
+
+ String logTag = MORPHE_LOG_TAG_PREFIX + className;
+ switch (logLevel) {
+ case DEBUG:
+ if (ex == null) Log.d(logTag, logText);
+ else Log.d(logTag, logText, ex);
+ break;
+ case INFO:
+ if (ex == null) Log.i(logTag, logText);
+ else Log.i(logTag, logText, ex);
+ break;
+ case ERROR:
+ if (ex == null) Log.e(logTag, logText);
+ else Log.e(logTag, logText, ex);
+ break;
+ }
+
+ if (showToast) {
+ Utils.showToastLong(managerToastString);
+ }
+ }
+
+ private static boolean shouldLogDebug() {
+ // If the app is still starting up and the context is not yet set,
+ // then allow debug logging regardless what the debug setting actually is.
+ return Utils.context == null || DEBUG.get();
+ }
+
+ private static boolean shouldShowErrorToast() {
+ return Utils.context != null && DEBUG_TOAST_ON_ERROR.get();
+ }
+
+ private static boolean includeStackTrace() {
+ return Utils.context != null && DEBUG_STACKTRACE.get();
+ }
+
+ /**
+ * Logs debug messages under the outer class name of the code calling this method.
+ *
+ * Whenever possible, the log string should be constructed entirely inside
+ * {@link LogMessage#buildMessageString()} so the performance cost of
+ * building strings is paid only if {@link BaseSettings#DEBUG} is enabled.
+ */
+ public static void printDebug(LogMessage message) {
+ printDebug(message, null);
+ }
+
+ /**
+ * Logs debug messages under the outer class name of the code calling this method.
+ *
+ * Whenever possible, the log string should be constructed entirely inside
+ * {@link LogMessage#buildMessageString()} so the performance cost of
+ * building strings is paid only if {@link BaseSettings#DEBUG} is enabled.
+ */
+ public static void printDebug(LogMessage message, @Nullable Exception ex) {
+ if (shouldLogDebug()) {
+ logInternal(LogLevel.DEBUG, message, ex, includeStackTrace(), false);
+ }
+ }
+
+ /**
+ * Logs information messages using the outer class name of the code calling this method.
+ */
+ public static void printInfo(LogMessage message) {
+ printInfo(message, null);
+ }
+
+ /**
+ * Logs information messages using the outer class name of the code calling this method.
+ */
+ public static void printInfo(LogMessage message, @Nullable Exception ex) {
+ logInternal(LogLevel.INFO, message, ex, includeStackTrace(), false);
+ }
+
+ /**
+ * Logs exceptions under the outer class name of the code calling this method.
+ * Appends the log message, exception (if present), and toast message (if enabled) to logBuffer.
+ */
+ public static void printException(LogMessage message) {
+ printException(message, null);
+ }
+
+ /**
+ * Logs exceptions under the outer class name of the code calling this method.
+ *
+ * If the calling code is showing its own error toast,
+ * instead use {@link #printInfo(LogMessage, Exception)}
+ *
+ * @param message log message
+ * @param ex exception (optional)
+ */
+ public static void printException(LogMessage message, @Nullable Throwable ex) {
+ logInternal(LogLevel.ERROR, message, ex, includeStackTrace(), shouldShowErrorToast());
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/ResourceType.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/ResourceType.java
new file mode 100644
index 0000000..08925b0
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/ResourceType.java
@@ -0,0 +1,57 @@
+package app.morphe.extension.shared;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public enum ResourceType {
+ ANIM("anim"),
+ ANIMATOR("animator"),
+ ARRAY("array"),
+ ATTR("attr"),
+ BOOL("bool"),
+ COLOR("color"),
+ DIMEN("dimen"),
+ DRAWABLE("drawable"),
+ FONT("font"),
+ FRACTION("fraction"),
+ ID("id"),
+ INTEGER("integer"),
+ INTERPOLATOR("interpolator"),
+ LAYOUT("layout"),
+ MENU("menu"),
+ MIPMAP("mipmap"),
+ NAVIGATION("navigation"),
+ PLURALS("plurals"),
+ RAW("raw"),
+ STRING("string"),
+ STYLE("style"),
+ STYLEABLE("styleable"),
+ TRANSITION("transition"),
+ VALUES("values"),
+ XML("xml");
+
+ private static final Map VALUE_MAP;
+
+ static {
+ ResourceType[] values = values();
+ VALUE_MAP = new HashMap<>(2 * values.length);
+
+ for (ResourceType type : values) {
+ VALUE_MAP.put(type.type, type);
+ }
+ }
+
+ public final String type;
+
+ public static ResourceType fromValue(String value) {
+ ResourceType type = VALUE_MAP.get(value);
+ if (type == null) {
+ throw new IllegalArgumentException("Unknown resource type: " + value);
+ }
+ return type;
+ }
+
+ ResourceType(String type) {
+ this.type = type;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/ResourceUtils.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/ResourceUtils.java
new file mode 100644
index 0000000..5479e7c
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/ResourceUtils.java
@@ -0,0 +1,252 @@
+package app.morphe.extension.shared;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Color;
+import android.graphics.drawable.Drawable;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+
+import androidx.annotation.Nullable;
+
+import java.io.InputStream;
+import java.util.Scanner;
+
+import app.morphe.extension.shared.settings.Setting;
+
+@SuppressWarnings({"unused", "deprecation", "DiscouragedApi"})
+public class ResourceUtils {
+
+ public static boolean useActivityContextIfAvailable = true;
+
+ private ResourceUtils() {
+ } // utility class
+
+ private static Context getActivityOrContext() {
+ if (useActivityContextIfAvailable) {
+ Activity mActivity = Utils.getActivity();
+ if (mActivity != null) return mActivity;
+ }
+ return Utils.getContext();
+ }
+
+ /**
+ * @param type Resource type, or null to search all types for the first declaration.
+ * @return zero, if the resource is not found.
+ */
+ public static int getIdentifier(@Nullable ResourceType type, String name) {
+ Context mContext = getActivityOrContext();
+ if (mContext == null) {
+ handleException(type, name);
+ return 0;
+ }
+ return getIdentifier(mContext, type, name);
+ }
+
+ /**
+ * @return the resource identifier, or throws an exception if not found.
+ * @param type Resource type, or null to search all types for the first declaration.
+ * @see #getIdentifier(ResourceType, String)
+ */
+ public static int getIdentifierOrThrow(@Nullable ResourceType type, String name) {
+ return getIdentifierOrThrow(getActivityOrContext(), type, name);
+ }
+
+ /**
+ * @return zero if the resource is not found.
+ * @param type Resource type, or null to search all types for the first declaration.
+ */
+ public static int getIdentifier(Context context, @Nullable ResourceType type, String name) {
+ try {
+ return context.getResources().getIdentifier(name,
+ type == null ? null : type.type,
+ context.getPackageName());
+ } catch (Exception ex) {
+ handleException(type, name);
+ }
+ return 0;
+ }
+
+ public static int getIdentifierOrThrow(Context context, @Nullable ResourceType type, String name) {
+ final int resourceId = getIdentifier(context, type, name);
+ if (resourceId == 0) {
+ throw new Resources.NotFoundException("No resource id exists with name: " + name
+ + " type: " + type);
+ }
+ return resourceId;
+ }
+
+ public static int getAnimIdentifier(String name) {
+ return getIdentifier(ResourceType.ANIM, name);
+ }
+
+ public static int getArrayIdentifier(String name) {
+ return getIdentifier(ResourceType.ARRAY, name);
+ }
+
+ public static int getAttrIdentifier(String name) {
+ return getIdentifier(ResourceType.ATTR, name);
+ }
+
+ public static int getColorIdentifier(String name) {
+ return getIdentifier(ResourceType.COLOR, name);
+ }
+
+ public static int getColor(String name) throws Resources.NotFoundException {
+ if (name.startsWith("#")) {
+ return Color.parseColor(name);
+ }
+ final int identifier = getColorIdentifier(name);
+ if (identifier == 0) {
+ handleException(ResourceType.COLOR, name);
+ return 0;
+ }
+ return Utils.getResources().getColor(identifier);
+ }
+
+ public static int getDimenIdentifier(String name) {
+ return getIdentifier(ResourceType.DIMEN, name);
+ }
+
+ public static int getDrawableIdentifier(String name) {
+ return getIdentifier(ResourceType.DRAWABLE, name);
+ }
+
+ public static int getFontIdentifier(String name) {
+ return getIdentifier(ResourceType.FONT, name);
+ }
+
+ public static int getIdIdentifier(String name) {
+ return getIdentifier(ResourceType.ID, name);
+ }
+
+ public static int getIntegerIdentifier(String name) {
+ return getIdentifier(ResourceType.INTEGER, name);
+ }
+
+ public static int getLayoutIdentifier(String name) {
+ return getIdentifier(ResourceType.LAYOUT, name);
+ }
+
+ public static int getMenuIdentifier(String name) {
+ return getIdentifier(ResourceType.MENU, name);
+ }
+
+ public static int getMipmapIdentifier(String name) {
+ return getIdentifier(ResourceType.MIPMAP, name);
+ }
+
+ public static int getRawIdentifier(String name) {
+ return getIdentifier(ResourceType.RAW, name);
+ }
+
+ public static int getStringIdentifier(String name) {
+ return getIdentifier(ResourceType.STRING, name);
+ }
+
+ public static int getStyleIdentifier(String name) {
+ return getIdentifier(ResourceType.STYLE, name);
+ }
+
+ public static int getXmlIdentifier(String name) {
+ return getIdentifier(ResourceType.XML, name);
+ }
+
+ public static Animation getAnimation(String name) {
+ int identifier = getAnimIdentifier(name);
+ if (identifier == 0) {
+ handleException(ResourceType.ANIM, name);
+ identifier = android.R.anim.fade_in;
+ }
+ return AnimationUtils.loadAnimation(getActivityOrContext(), identifier);
+ }
+
+ public static float getDimension(String name) throws Resources.NotFoundException {
+ return getActivityOrContext().getResources().getDimension(getIdentifierOrThrow(ResourceType.DIMEN, name));
+ }
+
+ public static int getDimensionPixelSize(String name) throws Resources.NotFoundException {
+ return getActivityOrContext().getResources().getDimensionPixelSize(getIdentifierOrThrow(ResourceType.DIMEN, name));
+ }
+
+ @Nullable
+ public static Drawable getDrawable(String name) {
+ final int identifier = getDrawableIdentifier(name);
+ if (identifier == 0) {
+ handleException(ResourceType.DRAWABLE, name);
+ return null;
+ }
+ return getActivityOrContext().getDrawable(identifier);
+ }
+
+ public static Drawable getDrawableOrThrow(String name) {
+ return checkResourceNotNull(getDrawable(name), ResourceType.DRAWABLE, name);
+ }
+
+ @Nullable
+ public static String getString(String name) {
+ final int identifier = getStringIdentifier(name);
+ if (identifier == 0) {
+ handleException(ResourceType.STRING, name);
+ return name;
+ }
+ return getActivityOrContext().getString(identifier);
+ }
+
+ public static String getStringOrThrow(String name) {
+ return checkResourceNotNull(getString(name), ResourceType.STRING, name);
+ }
+
+ public static String[] getStringArray(String name) {
+ final int identifier = getArrayIdentifier(name);
+ if (identifier == 0) {
+ handleException(ResourceType.ARRAY, name);
+ return new String[0];
+ }
+ return Utils.getResources().getStringArray(identifier);
+ }
+
+ public static String[] getStringArray(Setting> setting, String suffix) {
+ return getStringArray(setting.key + suffix);
+ }
+
+ /**
+ * @return zero if the resource is not found.
+ */
+ public static int getInteger(String name) {
+ final int identifier = getIntegerIdentifier(name);
+ if (identifier == 0) {
+ handleException(ResourceType.INTEGER, name);
+ return 0;
+ }
+ return Utils.getResources().getInteger(identifier);
+ }
+
+ @Nullable
+ public static String getRawResource(String name) {
+ final int identifier = getRawIdentifier(name);
+ if (identifier == 0) {
+ handleException(ResourceType.RAW, name);
+ return null;
+ }
+ try (InputStream is = Utils.getResources().openRawResource(identifier)) {
+ //noinspection CharsetObjectCanBeUsed
+ return new Scanner(is, "UTF-8").useDelimiter("\\A").next();
+ } catch (Exception ex) {
+ Logger.printException(() -> "getRawResource failed", ex);
+ return null;
+ }
+ }
+
+ private static void handleException(ResourceType type, String name) {
+ Logger.printException(() -> "R." + type.type + "." + name + " is null");
+ }
+
+ private static T checkResourceNotNull(T resource, ResourceType type, String name) {
+ if (resource == null) {
+ throw new IllegalArgumentException("Could not find: " + type + " name: " + name);
+ }
+ return resource;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/StringRef.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/StringRef.java
new file mode 100644
index 0000000..71893c7
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/StringRef.java
@@ -0,0 +1,123 @@
+package app.morphe.extension.shared;
+
+import androidx.annotation.NonNull;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+public class StringRef {
+ /**
+ * Simple class to assist with apps that currently cannot use resource patches.
+ */
+ public record StringKeyLookup(Map stringMap) {
+ public StringKeyLookup(Map stringMap) {
+ this.stringMap = Objects.requireNonNull(stringMap);
+ }
+
+ public String getString(String key, Object... args) {
+ String str = stringMap.get(key);
+ if (str == null) {
+ Logger.printException(() -> "Unknown string key: " + key);
+ return key;
+ }
+
+ return String.format(str, args);
+ }
+ }
+
+ // must use a thread safe map, as this class is used both on and off the main thread
+ private static final Map strings = Collections.synchronizedMap(new HashMap<>());
+
+ /**
+ * Returns a cached instance.
+ * Should be used if the same String could be loaded more than once.
+ *
+ * @param id string resource name/id
+ * @see #sf(String)
+ */
+ @NonNull
+ public static StringRef sfc(@NonNull String id) {
+ StringRef ref = strings.get(id);
+ if (ref == null) {
+ ref = new StringRef(id);
+ strings.put(id, ref);
+ }
+ return ref;
+ }
+
+ /**
+ * Creates a new instance, but does not cache the value.
+ * Should be used for Strings that are loaded exactly once.
+ *
+ * @param id string resource name/id
+ * @see #sfc(String)
+ */
+ @NonNull
+ public static StringRef sf(@NonNull String id) {
+ return new StringRef(id);
+ }
+
+ /**
+ * Gets string value by string id, shorthand for sfc(id).toString()
+ *
+ * @param id string resource name/id
+ * @return String value from string.xml
+ */
+ @NonNull
+ public static String str(@NonNull String id) {
+ return sfc(id).toString();
+ }
+
+ /**
+ * Gets string value by string id, shorthand for sfc(id).toString() and formats the string
+ * with given args.
+ *
+ * @param id string resource name/id
+ * @param args the args to format the string with
+ * @return String value from string.xml formatted with given args
+ */
+ @NonNull
+ public static String str(@NonNull String id, Object... args) {
+ return String.format(str(id), args);
+ }
+
+ /**
+ * Creates a StringRef object that'll not change its value
+ *
+ * @param value value which toString() method returns when invoked on returned object
+ * @return Unique StringRef instance, its value will never change
+ */
+ @NonNull
+ public static StringRef constant(@NonNull String value) {
+ final StringRef ref = new StringRef(value);
+ ref.resolved = true;
+ return ref;
+ }
+
+ /**
+ * Shorthand for constant("")
+ * Its value always resolves to empty string
+ */
+ @NonNull
+ public static final StringRef empty = constant("");
+
+ private String value;
+ private boolean resolved;
+
+ public StringRef(@NonNull String resName) {
+ this.value = resName;
+ }
+
+ @Override
+ @NonNull
+ public synchronized String toString() {
+ if (!resolved) {
+ String resolvedValue = ResourceUtils.getString(value);
+ value = resolvedValue == null ? "Unknown string" : resolvedValue;
+ resolved = true;
+ }
+ return value;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/StringTrieSearch.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/StringTrieSearch.java
new file mode 100644
index 0000000..2995ac5
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/StringTrieSearch.java
@@ -0,0 +1,32 @@
+package app.morphe.extension.shared;
+
+/**
+ * Text pattern searching using a prefix tree (trie).
+ */
+public final class StringTrieSearch extends TrieSearch {
+
+ private static final class StringTrieNode extends TrieNode {
+ StringTrieNode() {
+ super();
+ }
+ StringTrieNode(char nodeCharacterValue) {
+ super(nodeCharacterValue);
+ }
+ @Override
+ TrieNode createNode(char nodeValue) {
+ return new StringTrieNode(nodeValue);
+ }
+ @Override
+ char getCharValue(String text, int index) {
+ return text.charAt(index);
+ }
+ @Override
+ int getTextLength(String text) {
+ return text.length();
+ }
+ }
+
+ public StringTrieSearch(String... patterns) {
+ super(new StringTrieNode(), patterns);
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/TrieSearch.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/TrieSearch.java
new file mode 100644
index 0000000..1132411
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/TrieSearch.java
@@ -0,0 +1,425 @@
+package app.morphe.extension.shared;
+
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Searches for a group of different patterns using a trie (prefix tree).
+ * Can significantly speed up searching for multiple patterns.
+ */
+public abstract class TrieSearch {
+
+ public interface TriePatternMatchedCallback {
+ /**
+ * Called when a pattern is matched.
+ *
+ * @param textSearched Text that was searched.
+ * @param matchedStartIndex Start index of the search text, where the pattern was matched.
+ * @param matchedLength Length of the match.
+ * @param callbackParameter Optional parameter passed into {@link TrieSearch#matches(Object, Object)}.
+ * @return True, if the search should stop here.
+ * If false, searching will continue to look for other matches.
+ */
+ boolean patternMatched(T textSearched, int matchedStartIndex, int matchedLength, Object callbackParameter);
+ }
+
+ /**
+ * Represents a compressed tree path for a single pattern that shares no sibling nodes.
+ *
+ * For example, if a tree contains the patterns: "foobar", "football", "feet",
+ * it would contain 3 compressed paths of: "bar", "tball", "eet".
+ *
+ * And the tree would contain children arrays only for the first level containing 'f',
+ * the second level containing 'o',
+ * and the third level containing 'o'.
+ *
+ * This is done to reduce memory usage, which can be substantial if many long patterns are used.
+ */
+ private static final class TrieCompressedPath {
+ final T pattern;
+ final int patternStartIndex;
+ final int patternLength;
+ final TriePatternMatchedCallback callback;
+
+ TrieCompressedPath(T pattern, int patternStartIndex, int patternLength, TriePatternMatchedCallback callback) {
+ this.pattern = pattern;
+ this.patternStartIndex = patternStartIndex;
+ this.patternLength = patternLength;
+ this.callback = callback;
+ }
+ boolean matches(TrieNode enclosingNode, // Used only for the get character method.
+ T searchText, int searchTextLength, int searchTextIndex, Object callbackParameter) {
+ if (searchTextLength - searchTextIndex < patternLength - patternStartIndex) {
+ return false; // Remaining search text is shorter than the remaining leaf pattern and they cannot match.
+ }
+
+ for (int i = searchTextIndex, j = patternStartIndex; j < patternLength; i++, j++) {
+ if (enclosingNode.getCharValue(searchText, i) != enclosingNode.getCharValue(pattern, j)) {
+ return false;
+ }
+ }
+
+ return callback == null || callback.patternMatched(searchText,
+ searchTextIndex - patternStartIndex, patternLength, callbackParameter);
+ }
+ }
+
+ static abstract class TrieNode {
+ /**
+ * Dummy value used for root node. Value can be anything as it's never referenced.
+ */
+ private static final char ROOT_NODE_CHARACTER_VALUE = 0; // ASCII null character.
+
+ /**
+ * How much to expand the children array when resizing.
+ */
+ private static final int CHILDREN_ARRAY_INCREASE_SIZE_INCREMENT = 2;
+
+ /**
+ * Character this node represents.
+ * This field is ignored for the root node (which does not represent any character).
+ */
+ private final char nodeValue;
+
+ /**
+ * A compressed graph path that represents the remaining pattern characters of a single child node.
+ *
+ * If present then child array is always null, although callbacks for other
+ * end of patterns can also exist on this same node.
+ */
+ @Nullable
+ private TrieCompressedPath leaf;
+
+ /**
+ * All child nodes. Only present if no compressed leaf exist.
+ *
+ * Array is dynamically increased in size as needed,
+ * and uses perfect hashing for the elements it contains.
+ *
+ * So if the array contains a given character,
+ * the character will always map to the node with index: (character % arraySize).
+ *
+ * Elements not contained can collide with elements the array does contain,
+ * so must compare the nodes character value.
+ *
+ /*
+ * Alternatively, this could be implemented as a sorted, densely packed array
+ * with lookups performed via binary search.
+ * This approach would save a small amount of memory by eliminating null
+ * child entries. However, it would result in a worst-case lookup time of
+ * O(n log m), where:
+ * - n is the number of characters in the input text, and
+ * - m is the maximum size of the sorted character arrays.
+ * In contrast, using a hash-based array guarantees O(n) lookup time.
+ * Given that the total memory usage is already very small (all Litho filters
+ * together use approximately 10KB), the hash-based implementation is preferred
+ * for its superior performance.
+ */
+ @Nullable
+ private TrieNode[] children;
+
+ /**
+ * Callbacks for all patterns that end at this node.
+ */
+ @Nullable
+ private List> endOfPatternCallback;
+
+ TrieNode() {
+ this.nodeValue = ROOT_NODE_CHARACTER_VALUE;
+ }
+ TrieNode(char nodeCharacterValue) {
+ this.nodeValue = nodeCharacterValue;
+ }
+
+ /**
+ * @param pattern Pattern to add.
+ * @param patternIndex Current recursive index of the pattern.
+ * @param patternLength Length of the pattern.
+ * @param callback Callback, where a value of NULL indicates to always accept a pattern match.
+ */
+ private void addPattern(T pattern, int patternIndex, int patternLength,
+ @Nullable TriePatternMatchedCallback callback) {
+ if (patternIndex == patternLength) { // Reached the end of the pattern.
+ if (endOfPatternCallback == null) {
+ endOfPatternCallback = new ArrayList<>(1);
+ }
+ endOfPatternCallback.add(callback);
+ return;
+ }
+
+ if (leaf != null) {
+ // Reached end of the graph and a leaf exist.
+ // Recursively call back into this method and push the existing leaf down 1 level.
+ if (children != null) throw new IllegalStateException();
+ //noinspection unchecked
+ children = new TrieNode[1];
+ TrieCompressedPath temp = leaf;
+ leaf = null;
+ addPattern(temp.pattern, temp.patternStartIndex, temp.patternLength, temp.callback);
+ // Continue onward and add the parameter pattern.
+ } else if (children == null) {
+ leaf = new TrieCompressedPath<>(pattern, patternIndex, patternLength, callback);
+ return;
+ }
+
+ final char character = getCharValue(pattern, patternIndex);
+ final int arrayIndex = hashIndexForTableSize(children.length, character);
+ TrieNode child = children[arrayIndex];
+ if (child == null) {
+ child = createNode(character);
+ children[arrayIndex] = child;
+ } else if (child.nodeValue != character) {
+ // Hash collision. Resize the table until perfect hashing is found.
+ child = createNode(character);
+ expandChildArray(child);
+ }
+ child.addPattern(pattern, patternIndex + 1, patternLength, callback);
+ }
+
+ /**
+ * Resizes the children table until all nodes hash to exactly one array index.
+ */
+ private void expandChildArray(TrieNode child) {
+ int replacementArraySize = Objects.requireNonNull(children).length;
+ while (true) {
+ replacementArraySize += CHILDREN_ARRAY_INCREASE_SIZE_INCREMENT;
+ //noinspection unchecked
+ TrieNode[] replacement = new TrieNode[replacementArraySize];
+ addNodeToArray(replacement, child);
+
+ boolean collision = false;
+ for (TrieNode existingChild : children) {
+ if (existingChild != null) {
+ if (!addNodeToArray(replacement, existingChild)) {
+ collision = true;
+ break;
+ }
+ }
+ }
+ if (collision) {
+ continue;
+ }
+
+ children = replacement;
+ return;
+ }
+ }
+
+ private static boolean addNodeToArray(TrieNode[] array, TrieNode childToAdd) {
+ final int insertIndex = hashIndexForTableSize(array.length, childToAdd.nodeValue);
+ if (array[insertIndex] != null ) {
+ return false; // Collision.
+ }
+ array[insertIndex] = childToAdd;
+ return true;
+ }
+
+ private static int hashIndexForTableSize(int arraySize, char nodeValue) {
+ return nodeValue % arraySize;
+ }
+
+ /**
+ * This method is static and uses a loop to avoid all recursion.
+ * This is done for performance since the JVM does not optimize tail recursion.
+ *
+ * @param startNode Node to start the search from.
+ * @param searchText Text to search for patterns in.
+ * @param searchTextIndex Start index, inclusive.
+ * @param searchTextEndIndex End index, exclusive.
+ * @return If any pattern matches, and it's associated callback halted the search.
+ */
+ private static boolean matches(final TrieNode startNode, final T searchText,
+ int searchTextIndex, final int searchTextEndIndex,
+ final Object callbackParameter) {
+ TrieNode node = startNode;
+ int currentMatchLength = 0;
+
+ while (true) {
+ TrieCompressedPath leaf = node.leaf;
+ if (leaf != null && leaf.matches(startNode, searchText, searchTextEndIndex, searchTextIndex, callbackParameter)) {
+ return true; // Leaf exists and it matched the search text.
+ }
+
+ List> endOfPatternCallback = node.endOfPatternCallback;
+ if (endOfPatternCallback != null) {
+ final int matchStartIndex = searchTextIndex - currentMatchLength;
+ for (@Nullable TriePatternMatchedCallback callback : endOfPatternCallback) {
+ if (callback == null) {
+ return true; // No callback and all matches are valid.
+ }
+ if (callback.patternMatched(searchText, matchStartIndex, currentMatchLength, callbackParameter)) {
+ return true; // Callback confirmed the match.
+ }
+ }
+ }
+
+ TrieNode[] children = node.children;
+ if (children == null) {
+ return false; // Reached a graph end point and there's no further patterns to search.
+ }
+ if (searchTextIndex == searchTextEndIndex) {
+ return false; // Reached end of the search text and found no matches.
+ }
+
+ // Use the start node to reduce VM method lookup, since all nodes are the same class type.
+ final char character = startNode.getCharValue(searchText, searchTextIndex);
+ final int arrayIndex = hashIndexForTableSize(children.length, character);
+ TrieNode child = children[arrayIndex];
+ if (child == null || child.nodeValue != character) {
+ return false;
+ }
+
+ node = child;
+ searchTextIndex++;
+ currentMatchLength++;
+ }
+ }
+
+ /**
+ * Gives an approximate memory usage.
+ *
+ * @return Estimated number of memory pointers used, starting from this node and including all children.
+ */
+ private int estimatedNumberOfPointersUsed() {
+ int numberOfPointers = 4; // Number of fields in this class.
+ if (leaf != null) {
+ numberOfPointers += 4; // Number of fields in leaf node.
+ }
+
+ if (endOfPatternCallback != null) {
+ numberOfPointers += endOfPatternCallback.size();
+ }
+
+ if (children != null) {
+ numberOfPointers += children.length;
+ for (TrieNode child : children) {
+ if (child != null) {
+ numberOfPointers += child.estimatedNumberOfPointersUsed();
+ }
+ }
+ }
+ return numberOfPointers;
+ }
+
+ abstract TrieNode createNode(char nodeValue);
+ abstract char getCharValue(T text, int index);
+ abstract int getTextLength(T text);
+ }
+
+ /**
+ * Root node, and it's children represent the first pattern characters.
+ */
+ private final TrieNode root;
+
+ /**
+ * Patterns to match.
+ */
+ private final List patterns = new ArrayList<>();
+
+ @SafeVarargs
+ TrieSearch(TrieNode root, T... patterns) {
+ this.root = Objects.requireNonNull(root);
+ addPatterns(patterns);
+ }
+
+ @SafeVarargs
+ public final void addPatterns(T... patterns) {
+ for (T pattern : patterns) {
+ addPattern(pattern);
+ }
+ }
+
+ /**
+ * Adds a pattern that will always return a positive match if found.
+ *
+ * @param pattern Pattern to add. Calling this with a zero length pattern does nothing.
+ */
+ public void addPattern(T pattern) {
+ addPattern(pattern, root.getTextLength(pattern), null);
+ }
+
+ /**
+ * @param pattern Pattern to add. Calling this with a zero length pattern does nothing.
+ * @param callback Callback to determine if searching should halt when a match is found.
+ */
+ public void addPattern(T pattern, TriePatternMatchedCallback callback) {
+ addPattern(pattern, root.getTextLength(pattern), Objects.requireNonNull(callback));
+ }
+
+ void addPattern(T pattern, int patternLength, @Nullable TriePatternMatchedCallback callback) {
+ if (patternLength == 0) return; // Nothing to match
+
+ patterns.add(pattern);
+ root.addPattern(pattern, 0, patternLength, callback);
+ }
+
+ public final boolean matches(T textToSearch) {
+ return matches(textToSearch, 0);
+ }
+
+ public boolean matches(T textToSearch, Object callbackParameter) {
+ return matches(textToSearch, 0, root.getTextLength(textToSearch),
+ Objects.requireNonNull(callbackParameter));
+ }
+
+ public boolean matches(T textToSearch, int startIndex) {
+ return matches(textToSearch, startIndex, root.getTextLength(textToSearch));
+ }
+
+ public final boolean matches(T textToSearch, int startIndex, int endIndex) {
+ return matches(textToSearch, startIndex, endIndex, null);
+ }
+
+ /**
+ * Searches through text, looking for any substring that matches any pattern in this tree.
+ *
+ * @param textToSearch Text to search through.
+ * @param startIndex Index to start searching, inclusive value.
+ * @param endIndex Index to stop matching, exclusive value.
+ * @param callbackParameter Optional parameter passed to the callbacks.
+ * @return If any pattern matched, and it's callback halted searching.
+ */
+ public boolean matches(T textToSearch, int startIndex, int endIndex, @Nullable Object callbackParameter) {
+ return matches(textToSearch, root.getTextLength(textToSearch), startIndex, endIndex, callbackParameter);
+ }
+
+ private boolean matches(T textToSearch, int textToSearchLength, int startIndex, int endIndex,
+ @Nullable Object callbackParameter) {
+ if (endIndex > textToSearchLength) {
+ throw new IllegalArgumentException("endIndex: " + endIndex
+ + " is greater than texToSearchLength: " + textToSearchLength);
+ }
+ if (patterns.isEmpty()) {
+ return false; // No patterns were added.
+ }
+ for (int i = startIndex; i < endIndex; i++) {
+ if (TrieNode.matches(root, textToSearch, i, endIndex, callbackParameter)) return true;
+ }
+ return false;
+ }
+
+ /**
+ * @return Estimated memory size (in kilobytes) of this instance.
+ */
+ public int getEstimatedMemorySize() {
+ if (patterns.isEmpty()) {
+ return 0;
+ }
+ // Assume the device has less than 32GB of ram (and can use pointer compression),
+ // or the device is 32-bit.
+ final int numberOfBytesPerPointer = 4;
+ return (int) Math.ceil((numberOfBytesPerPointer * root.estimatedNumberOfPointersUsed()) / 1024.0);
+ }
+
+ public int numberOfPatterns() {
+ return patterns.size();
+ }
+
+ public List getPatterns() {
+ return Collections.unmodifiableList(patterns);
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/Utils.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/Utils.java
new file mode 100644
index 0000000..cff9e89
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/Utils.java
@@ -0,0 +1,1241 @@
+package app.morphe.extension.shared;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.graphics.Color;
+import android.net.ConnectivityManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.preference.Preference;
+import android.preference.PreferenceGroup;
+import android.preference.PreferenceScreen;
+import android.util.Pair;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.Toast;
+
+import androidx.annotation.ChecksSdkIntAtLeast;
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.lang.ref.WeakReference;
+import java.text.Bidi;
+import java.text.Collator;
+import java.text.Normalizer;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+
+import app.morphe.extension.shared.settings.AppLanguage;
+import app.morphe.extension.shared.settings.BaseSettings;
+import app.morphe.extension.shared.settings.BooleanSetting;
+import app.morphe.extension.shared.settings.preference.MorpheAboutPreference;
+import app.morphe.extension.shared.ui.Dim;
+
+@SuppressWarnings("NewApi")
+public class Utils {
+ private static WeakReference activityRef = new WeakReference<>(null);
+
+ @SuppressLint("StaticFieldLeak")
+ static volatile Context context;
+
+ private static String versionName;
+ private static String applicationLabel;
+
+ @ColorInt
+ private static int darkColor = Color.BLACK;
+ @ColorInt
+ private static int lightColor = Color.WHITE;
+
+ @Nullable
+ private static Boolean isDarkModeEnabled;
+
+ private static boolean appIsUsingBoldIcons;
+
+ // Cached Collator instance with its locale.
+ @Nullable
+ private static Locale cachedCollatorLocale;
+ @Nullable
+ private static Collator cachedCollator;
+
+ private static final Pattern PUNCTUATION_PATTERN = Pattern.compile("\\p{P}+");
+ private static final Pattern DIACRITICS_PATTERN = Pattern.compile("\\p{M}");
+
+ private Utils() {
+ } // utility class
+
+ /**
+ * Injection point.
+ *
+ * @return The manifest 'Version' entry of the patches.jar used during patching.
+ */
+ @SuppressWarnings("SameReturnValue")
+ public static String getPatchesReleaseVersion() {
+ return ""; // Value is replaced during patching.
+ }
+
+ public static boolean isPreReleasePatches() {
+ return getPatchesReleaseVersion().contains("dev");
+ }
+
+ private static PackageInfo getPackageInfo() throws PackageManager.NameNotFoundException {
+ final var packageName = Objects.requireNonNull(getContext()).getPackageName();
+
+ PackageManager packageManager = context.getPackageManager();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ return packageManager.getPackageInfo(
+ packageName,
+ PackageManager.PackageInfoFlags.of(0)
+ );
+ }
+
+ return packageManager.getPackageInfo(
+ packageName,
+ 0
+ );
+ }
+
+ /**
+ * @return The version name of the app, such as 20.13.41
+ */
+ public static String getAppVersionName() {
+ if (versionName == null) {
+ try {
+ versionName = getPackageInfo().versionName;
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to get package info", ex);
+ versionName = "Unknown";
+ }
+ }
+
+ return versionName;
+ }
+
+ public static String getApplicationName() {
+ if (applicationLabel == null) {
+ try {
+ ApplicationInfo applicationInfo = getPackageInfo().applicationInfo;
+ applicationLabel = (String) applicationInfo.loadLabel(context.getPackageManager());
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to get application name", ex);
+ applicationLabel = "Unknown";
+ }
+ }
+
+ return applicationLabel;
+ }
+
+ /**
+ * Hide a view by setting its layout height and width to 1dp.
+ *
+ * @param setting The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static void hideViewBy0dpUnderCondition(BooleanSetting setting, View view) {
+ if (hideViewBy0dpUnderCondition(setting.get(), view)) {
+ Logger.printDebug(() -> "View hidden by setting: " + setting);
+ }
+ }
+
+ /**
+ * Hide a view by setting its layout height and width to 0dp.
+ *
+ * @param condition The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static boolean hideViewBy0dpUnderCondition(boolean condition, View view) {
+ if (condition) {
+ hideViewByLayoutParams(view);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Hide a view by setting its visibility as GONE.
+ *
+ * @param setting The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static void hideViewUnderCondition(BooleanSetting setting, View view) {
+ if (hideViewUnderCondition(setting.get(), view)) {
+ Logger.printDebug(() -> "View hidden by setting: " + setting);
+ }
+ }
+
+ /**
+ * Hide a view by setting its visibility as GONE.
+ *
+ * @param condition The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static boolean hideViewUnderCondition(boolean condition, View view) {
+ if (condition) {
+ view.setVisibility(View.GONE);
+ return true;
+ }
+
+ return false;
+ }
+
+ public static void hideViewByRemovingFromParentUnderCondition(BooleanSetting setting, View view) {
+ if (hideViewByRemovingFromParentUnderCondition(setting.get(), view)) {
+ Logger.printDebug(() -> "View hidden by setting: " + setting);
+ }
+ }
+
+ public static boolean hideViewByRemovingFromParentUnderCondition(boolean condition, View view) {
+ if (condition) {
+ ViewParent parent = view.getParent();
+ if (parent instanceof ViewGroup parentGroup) {
+ parentGroup.removeView(view);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * General purpose pool for network calls and other background tasks.
+ * All tasks run at max thread priority.
+ */
+ private static final ThreadPoolExecutor backgroundThreadPool = new ThreadPoolExecutor(
+ 3, // 3 threads always ready to go.
+ Integer.MAX_VALUE,
+ 10, // For any threads over the minimum, keep them alive 10 seconds after they go idle.
+ TimeUnit.SECONDS,
+ new SynchronousQueue<>(),
+ r -> { // ThreadFactory
+ Thread t = new Thread(r);
+ t.setPriority(Thread.MAX_PRIORITY); // Run at max priority.
+ return t;
+ });
+
+ public static void runOnBackgroundThread(Runnable task) {
+ backgroundThreadPool.execute(task);
+ }
+
+ public static Future submitOnBackgroundThread(Callable call) {
+ return backgroundThreadPool.submit(call);
+ }
+
+ /**
+ * Simulates a delay by doing meaningless calculations.
+ * Used for debugging to verify UI timeout logic.
+ */
+ @SuppressWarnings("UnusedReturnValue")
+ public static long doNothingForDuration(long amountOfTimeToWaste) {
+ final long timeCalculationStarted = System.currentTimeMillis();
+ Logger.printDebug(() -> "Artificially creating delay of: " + amountOfTimeToWaste + "ms");
+
+ long meaninglessValue = 0;
+ while (System.currentTimeMillis() - timeCalculationStarted < amountOfTimeToWaste) {
+ // Could do a thread sleep, but that will trigger an exception if the thread is interrupted.
+ meaninglessValue += Long.numberOfLeadingZeros((long) Math.exp(Math.random()));
+ }
+ // Return the value, otherwise the compiler or VM might optimize and remove the meaningless time-wasting work,
+ // leaving an empty loop that hammers on the System.currentTimeMillis native call.
+ return meaninglessValue;
+ }
+
+ public static boolean containsAny(String value, String... targets) {
+ return indexOfFirstFound(value, targets) >= 0;
+ }
+
+ public static int indexOfFirstFound(String value, String... targets) {
+ if (isNotEmpty(value)) {
+ for (String string : targets) {
+ if (!string.isEmpty()) {
+ final int indexOf = value.indexOf(string);
+ if (indexOf >= 0) return indexOf;
+ }
+ }
+ }
+ return -1;
+ }
+
+ public static boolean equalsAny(String value, String...targets) {
+ if (isNotEmpty(value)) {
+ for (String string : targets) {
+ if (value.equals(string)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Checks if a specific app package is installed and enabled on the device.
+ *
+ * @param packageName The application package name to check (e.g., "app.morphe.android.apps.youtube.music").
+ * @return True if the package is installed and enabled, false otherwise.
+ */
+ public static boolean isPackageEnabled(String packageName) {
+ Context currentContext = getContext();
+ if (currentContext == null || !isNotEmpty(packageName)) {
+ return false;
+ }
+
+ try {
+ PackageManager pm = currentContext.getPackageManager();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ return pm.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0)).enabled;
+ } else {
+ return pm.getApplicationInfo(packageName, 0).enabled;
+ }
+ } catch (PackageManager.NameNotFoundException e) {
+ return false;
+ }
+ }
+
+ public static boolean startsWithAny(String value, String...targets) {
+ if (isNotEmpty(value)) {
+ for (String string : targets) {
+ if (isNotEmpty(string) && value.startsWith(string)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ public interface MatchFilter {
+ boolean matches(T object);
+ }
+
+ /**
+ * Includes sub children.
+ */
+ public static R getChildViewByResourceName(View view, String str) {
+ var child = view.findViewById(ResourceUtils.getIdentifierOrThrow(ResourceType.ID, str));
+ //noinspection unchecked
+ return (R) child;
+ }
+
+ /**
+ * @param searchRecursively If children ViewGroups should also be
+ * recursively searched using depth first search.
+ * @return The first child view that matches the filter.
+ */
+ @Nullable
+ public static T getChildView(ViewGroup viewGroup, boolean searchRecursively,
+ MatchFilter filter) {
+ for (int i = 0, childCount = viewGroup.getChildCount(); i < childCount; i++) {
+ View childAt = viewGroup.getChildAt(i);
+
+ if (filter.matches(childAt)) {
+ //noinspection unchecked
+ return (T) childAt;
+ }
+ // Must do recursive after filter check, in case the filter is looking for a ViewGroup.
+ if (searchRecursively && childAt instanceof ViewGroup) {
+ T match = getChildView((ViewGroup) childAt, true, filter);
+ if (match != null) return match;
+ }
+ }
+
+ return null;
+ }
+
+ @Nullable
+ public static ViewParent getParentView(View view, int nthParent) {
+ ViewParent parent = view.getParent();
+
+ int currentDepth = 0;
+ while (++currentDepth < nthParent && parent != null) {
+ parent = parent.getParent();
+ }
+
+ if (currentDepth == nthParent) {
+ return parent;
+ }
+
+ final int currentDepthLog = currentDepth;
+ Logger.printDebug(() -> "Could not find parent view of depth: " + nthParent
+ + " and instead found at: " + currentDepthLog + " view: " + view);
+ return null;
+ }
+
+ public static void restartApp(Context context) {
+ String packageName = context.getPackageName();
+ Intent intent = Objects.requireNonNull(context.getPackageManager().getLaunchIntentForPackage(packageName));
+ Intent mainIntent = Intent.makeRestartActivityTask(intent.getComponent());
+ // Required for API 34 and later
+ // Ref: https://developer.android.com/about/versions/14/behavior-changes-14#safer-intents
+ mainIntent.setPackage(packageName);
+ context.startActivity(mainIntent);
+ System.exit(0);
+ }
+
+ public static Resources getResources() {
+ return getResources(true);
+ }
+
+ public static Resources getResources(boolean useContext) {
+ if (useContext) {
+ if (context != null) {
+ return context.getResources();
+ }
+ Activity mActivity = activityRef.get();
+ if (mActivity != null) {
+ return mActivity.getResources();
+ }
+ }
+
+ return Resources.getSystem();
+ }
+
+ public static Activity getActivity() {
+ return activityRef.get();
+ }
+
+ public static void setActivity(Activity mainActivity) {
+ Logger.printInfo(() -> "Set activity: " + mainActivity);
+ activityRef = new WeakReference<>(mainActivity);
+ }
+
+ public static Context getContext() {
+ if (context == null) {
+ Logger.printException(() -> "Context is not set by extension hook, returning 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;
+
+ // Set activity if not already set.
+ if (appContext instanceof Activity activity && getActivity() == null) {
+ setActivity(activity);
+ }
+
+ AppLanguage language = BaseSettings.MORPHE_LANGUAGE.get();
+ if (language != AppLanguage.DEFAULT) {
+ // Create a new context with the desired language.
+ Logger.printDebug(() -> "Using app language: " + language);
+ Configuration config = new Configuration(appContext.getResources().getConfiguration());
+ config.setLocale(language.getLocale());
+ context = appContext.createConfigurationContext(config);
+ }
+
+ setThemeLightColor(getThemeColor(getThemeLightColorResourceName(), Color.WHITE));
+ setThemeDarkColor(getThemeColor(getThemeDarkColorResourceName(), Color.BLACK));
+ }
+
+ public static void setClipboard(CharSequence text) {
+ ClipboardManager clipboard = (ClipboardManager) context
+ .getSystemService(Context.CLIPBOARD_SERVICE);
+ ClipData clip = ClipData.newPlainText("Morphe", text);
+ clipboard.setPrimaryClip(clip);
+ }
+
+ public static boolean isNotEmpty(@Nullable String str) {
+ return str != null && !str.isEmpty();
+ }
+
+ 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 Morphe language override then instead use
+ * {@link #isRightToLeftLocale(Locale)} with {@link BaseSettings#MORPHE_LANGUAGE}.
+ * This is the default locale of the device, which may differ if
+ * {@link BaseSettings#MORPHE_LANGUAGE} is set to a different language.
+ */
+ public static boolean isRightToLeftLocale() {
+ if (isRightToLeftTextLayout == null) {
+ isRightToLeftTextLayout = isRightToLeftLocale(Locale.getDefault());
+ }
+ return isRightToLeftTextLayout;
+ }
+
+ /**
+ * @return If the locale uses right to left text layout (Hebrew, Arabic, etc.).
+ */
+ public static boolean isRightToLeftLocale(Locale locale) {
+ String displayLanguage = locale.getDisplayLanguage();
+ return new Bidi(displayLanguage, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT).isRightToLeft();
+ }
+
+ /**
+ * @return A UTF8 string containing a left-to-right or right-to-left
+ * character of the device locale. If this should match any Morphe language
+ * override then instead use {@link #getTextDirectionString(Locale)} with
+ * {@link BaseSettings#MORPHE_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);
+ }
+
+ /**
+ * Safe to call from any thread.
+ *
+ * @param messageToToast Message to show.
+ * @param toastDuration Either {@link Toast#LENGTH_SHORT} or {@link Toast#LENGTH_LONG}.
+ */
+ public static void showToast(String messageToToast, int toastDuration) {
+ Objects.requireNonNull(messageToToast);
+ runOnMainThreadNowOrLater(() -> {
+ Context currentContext = context;
+
+ if (currentContext == null) {
+ Logger.printException(() -> "Cannot show toast (context is null): " + messageToToast);
+ } else {
+ Logger.printDebug(() -> "Showing toast: " + messageToToast);
+ Toast.makeText(currentContext, messageToToast, toastDuration).show();
+ }
+ });
+ }
+
+ /**
+ * @return The current dark mode as set by any patch.
+ * Or if none is set, then the system dark mode status is returned.
+ */
+ public static boolean isDarkModeEnabled() {
+ Boolean isDarkMode = isDarkModeEnabled;
+ if (isDarkMode != null) {
+ return isDarkMode;
+ }
+
+ Configuration config = Resources.getSystem().getConfiguration();
+ final int currentNightMode = config.uiMode & Configuration.UI_MODE_NIGHT_MASK;
+ return currentNightMode == Configuration.UI_MODE_NIGHT_YES;
+ }
+
+ /**
+ * Overrides dark mode status as returned by {@link #isDarkModeEnabled()}.
+ */
+ public static void setIsDarkModeEnabled(boolean isDarkMode) {
+ isDarkModeEnabled = isDarkMode;
+ Logger.printDebug(() -> "Dark mode status: " + isDarkMode);
+ }
+
+ public static boolean isLandscapeOrientation() {
+ final int orientation = Resources.getSystem().getConfiguration().orientation;
+ return orientation == Configuration.ORIENTATION_LANDSCAPE;
+ }
+
+ /**
+ * Automatically logs any exceptions the runnable throws.
+ *
+ * @see #runOnMainThreadNowOrLater(Runnable)
+ */
+ public static void runOnMainThread(Runnable runnable) {
+ runOnMainThreadDelayed(runnable, 0);
+ }
+
+ /**
+ * Automatically logs any exceptions the runnable throws.
+ */
+ public static void runOnMainThreadDelayed(Runnable runnable, long delayMillis) {
+ Runnable loggingRunnable = () -> {
+ try {
+ runnable.run();
+ } catch (Exception ex) {
+ Logger.printException(() -> runnable.getClass().getSimpleName() + ": " + ex.getMessage(), ex);
+ }
+ };
+ new Handler(Looper.getMainLooper()).postDelayed(loggingRunnable, delayMillis);
+ }
+
+ /**
+ * If called from the main thread, the code is run immediately.
+ * If called off the main thread, this is the same as {@link #runOnMainThread(Runnable)}.
+ */
+ public static void runOnMainThreadNowOrLater(Runnable runnable) {
+ if (isCurrentlyOnMainThread()) {
+ runnable.run();
+ } else {
+ runOnMainThread(runnable);
+ }
+ }
+
+ /**
+ * @return if the calling thread is on the main thread.
+ */
+ public static boolean isCurrentlyOnMainThread() {
+ return Looper.getMainLooper().isCurrentThread();
+ }
+
+ /**
+ * @throws IllegalStateException if the calling thread is _off_ the main thread.
+ */
+ public static void verifyOnMainThread() throws IllegalStateException {
+ if (!isCurrentlyOnMainThread()) {
+ throw new IllegalStateException("Must call _on_ the main thread");
+ }
+ }
+
+ /**
+ * @throws IllegalStateException if the calling thread is _on_ the main thread.
+ */
+ public static void verifyOffMainThread() throws IllegalStateException {
+ if (isCurrentlyOnMainThread()) {
+ throw new IllegalStateException("Must call _off_ the main thread");
+ }
+ }
+
+ public static void openLink(String url) {
+ try {
+ Intent intent = new Intent("android.intent.action.VIEW", Uri.parse(url));
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+ Logger.printInfo(() -> "Opening link with external browser: " + intent);
+ getContext().startActivity(intent);
+ } catch (Exception ex) {
+ Logger.printException(() -> "openLink failure", ex);
+ }
+ }
+
+ public enum NetworkType {
+ NONE,
+ MOBILE,
+ OTHER,
+ }
+
+ /**
+ * Calling extension code must ensure the un-patched app has the permission
+ * android.permission.ACCESS_NETWORK_STATE,
+ * otherwise the app will crash if this method is used.
+ */
+ public static boolean isNetworkConnected() {
+ NetworkType networkType = getNetworkType();
+ return networkType == NetworkType.MOBILE
+ || networkType == NetworkType.OTHER;
+ }
+
+ /**
+ * Calling extension code must ensure the un-patched app has the permission
+ * android.permission.ACCESS_NETWORK_STATE,
+ * otherwise the app will crash if this method is used.
+ */
+ @SuppressWarnings({"MissingPermission", "deprecation"})
+ public static NetworkType getNetworkType() {
+ Context networkContext = getContext();
+ if (networkContext == null) {
+ return NetworkType.NONE;
+ }
+ ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ var networkInfo = cm.getActiveNetworkInfo();
+
+ if (networkInfo == null || !networkInfo.isConnected()) {
+ return NetworkType.NONE;
+ }
+ var type = networkInfo.getType();
+ return (type == ConnectivityManager.TYPE_MOBILE)
+ || (type == ConnectivityManager.TYPE_BLUETOOTH) ? NetworkType.MOBILE : NetworkType.OTHER;
+ }
+
+ /**
+ * Hides a view by setting its layout width and height to 0dp.
+ * Handles null layout params safely.
+ *
+ * @param view The view to hide. If null, does nothing.
+ */
+ public static void hideViewByLayoutParams(@Nullable View view) {
+ if (view == null) return;
+
+ ViewGroup.LayoutParams params = view.getLayoutParams();
+
+ if (params == null) {
+ // Create generic 0x0 layout params accepted by all ViewGroups.
+ params = new ViewGroup.LayoutParams(0, 0);
+ } else {
+ params.width = 0;
+ params.height = 0;
+ }
+
+ view.setLayoutParams(params);
+ }
+
+ /**
+ * Configures the parameters of a dialog window, including its width, gravity, vertical offset and background dimming.
+ * The width is calculated as a percentage of the screen's portrait width and the vertical offset is specified in DIP.
+ * The default dialog background is removed to allow for custom styling.
+ *
+ * @param window The {@link Window} object to configure.
+ * @param gravity The gravity for positioning the dialog (e.g., {@link Gravity#BOTTOM}).
+ * @param yOffsetDip The vertical offset from the gravity position in DIP.
+ * @param widthPercentage The width of the dialog as a percentage of the screen's portrait width (0-100).
+ * @param dimAmount If true, sets the background dim amount to 0 (no dimming); if false, leaves the default dim amount.
+ */
+ public static void setDialogWindowParameters(Window window, int gravity, int yOffsetDip, int widthPercentage, boolean dimAmount) {
+ WindowManager.LayoutParams params = window.getAttributes();
+
+ params.width = Dim.pctPortraitWidth(widthPercentage);
+ params.height = WindowManager.LayoutParams.WRAP_CONTENT;
+ params.gravity = gravity;
+ params.y = yOffsetDip > 0 ? Dim.dp(yOffsetDip) : 0;
+ if (dimAmount) {
+ params.dimAmount = 0f;
+ }
+
+ window.setAttributes(params); // Apply window attributes.
+ window.setBackgroundDrawable(null); // Remove default dialog background
+ }
+
+ /**
+ * The recommended app version of this app to patch. Set during patching.
+ * Returns an empty string if not set.
+ */
+ public static String getRecommendedAppVersion() {
+ return "";
+ }
+
+ /**
+ * @return If the unpatched app is currently using bold icons.
+ */
+ public static boolean appIsUsingBoldIcons() {
+ return appIsUsingBoldIcons;
+ }
+
+ /**
+ * Controls if Morphe bold icons are shown in various places.
+ * @param boldIcons If the app is currently using bold icons.
+ */
+ public static void setAppIsUsingBoldIcons(boolean boldIcons) {
+ appIsUsingBoldIcons = boldIcons;
+ }
+
+ /**
+ * Sets the theme light color used by the app.
+ */
+ public static void setThemeLightColor(@ColorInt int color) {
+ Logger.printDebug(() -> "Setting theme light color: " + getColorHexString(color));
+ lightColor = color;
+ }
+
+ /**
+ * Sets the theme dark used by the app.
+ */
+ public static void setThemeDarkColor(@ColorInt int color) {
+ Logger.printDebug(() -> "Setting theme dark color: " + getColorHexString(color));
+ darkColor = color;
+ }
+
+ /**
+ * Returns the themed light color, or {@link Color#WHITE} if no theme was set using
+ * {@link #setThemeLightColor(int).
+ */
+ @ColorInt
+ public static int getThemeLightColor() {
+ return lightColor;
+ }
+
+ /**
+ * Returns the themed dark color, or {@link Color#BLACK} if no theme was set using
+ * {@link #setThemeDarkColor(int)}.
+ */
+ @ColorInt
+ public static int getThemeDarkColor() {
+ return darkColor;
+ }
+
+ /**
+ * Injection point.
+ */
+ @SuppressWarnings("SameReturnValue")
+ private static String getThemeLightColorResourceName() {
+ // Value is changed by Settings patch.
+ return "#FFFFFFFF";
+ }
+
+ /**
+ * Injection point.
+ */
+ @SuppressWarnings("SameReturnValue")
+ private static String getThemeDarkColorResourceName() {
+ // Value is changed by Settings patch.
+ return "#FF000000";
+ }
+
+ @ColorInt
+ private static int getThemeColor(String resourceName, int defaultColor) {
+ try {
+ return getColorFromString(resourceName);
+ } catch (Exception ex) {
+ // This code can never be reached since a bad custom color will
+ // fail during resource compilation. So no localized strings are needed here.
+ Logger.printException(() -> "Invalid custom theme color: " + resourceName, ex);
+ return defaultColor;
+ }
+ }
+
+
+ @ColorInt
+ public static int getDialogBackgroundColor() {
+ if (isDarkModeEnabled()) {
+ final int darkColor = getThemeDarkColor();
+ return darkColor == Color.BLACK
+ // Lighten the background a little if using AMOLED dark theme
+ // as the dialogs are almost invisible.
+ ? 0xFF080808 // 3%
+ : darkColor;
+ }
+ return getThemeLightColor();
+ }
+
+ /**
+ * @return The current app background color.
+ */
+ @ColorInt
+ public static int getAppBackgroundColor() {
+ return isDarkModeEnabled() ? getThemeDarkColor() : getThemeLightColor();
+ }
+
+ /**
+ * @return The current app foreground color.
+ */
+ @ColorInt
+ public static int getAppForegroundColor() {
+ return isDarkModeEnabled()
+ ? getThemeLightColor()
+ : getThemeDarkColor();
+ }
+
+ @ColorInt
+ public static int getOkButtonBackgroundColor() {
+ return isDarkModeEnabled()
+ // Must be inverted color.
+ ? Color.WHITE
+ : Color.BLACK;
+ }
+
+ @ColorInt
+ public static int getCancelOrNeutralButtonBackgroundColor() {
+ return isDarkModeEnabled()
+ ? adjustColorBrightness(getDialogBackgroundColor(), 1.10f)
+ : adjustColorBrightness(getThemeLightColor(), 0.95f);
+ }
+
+ @ColorInt
+ public static int getEditTextBackground() {
+ return isDarkModeEnabled()
+ ? adjustColorBrightness(getDialogBackgroundColor(), 1.05f)
+ : adjustColorBrightness(getThemeLightColor(), 0.97f);
+ }
+
+ public static String getColorHexString(@ColorInt int color) {
+ return String.format("#%06X", (0x00FFFFFF & color));
+ }
+
+ /**
+ * {@link PreferenceScreen} and {@link PreferenceGroup} sorting styles.
+ */
+ private enum Sort {
+ /**
+ * Sort by the localized preference title.
+ */
+ BY_TITLE("_sort_by_title"),
+
+ /**
+ * Sort by the preference keys.
+ */
+ BY_KEY("_sort_by_key"),
+
+ /**
+ * Unspecified sorting.
+ */
+ UNSORTED("_sort_by_unsorted");
+
+ final String keySuffix;
+
+ Sort(String keySuffix) {
+ this.keySuffix = keySuffix;
+ }
+
+ static Sort fromKey(@Nullable String key, Sort defaultSort) {
+ if (key != null) {
+ for (Sort sort : values()) {
+ if (key.endsWith(sort.keySuffix)) {
+ return sort;
+ }
+ }
+ }
+ return defaultSort;
+ }
+ }
+
+ /**
+ * Removes punctuation and converts text to lowercase. Returns an empty string if input is null.
+ */
+ public static String removePunctuationToLowercase(@Nullable CharSequence original) {
+ if (original == null) return "";
+ return PUNCTUATION_PATTERN.matcher(original).replaceAll("")
+ .toLowerCase(BaseSettings.MORPHE_LANGUAGE.get().getLocale());
+ }
+
+ /**
+ * Normalizes text for search: applies NFD, removes diacritics, and lowercases (locale-neutral).
+ * Returns an empty string if input is null.
+ */
+ public static String normalizeTextToLowercase(@Nullable CharSequence original) {
+ if (original == null) return "";
+ return DIACRITICS_PATTERN.matcher(Normalizer.normalize(original, Normalizer.Form.NFD))
+ .replaceAll("").toLowerCase(Locale.ROOT);
+ }
+
+ /**
+ * Returns a cached Collator for the current locale, or creates a new one if locale changed.
+ */
+ private static Collator getCollator() {
+ Locale currentLocale = BaseSettings.MORPHE_LANGUAGE.get().getLocale();
+
+ if (cachedCollator == null || !currentLocale.equals(cachedCollatorLocale)) {
+ cachedCollatorLocale = currentLocale;
+ cachedCollator = Collator.getInstance(currentLocale);
+ cachedCollator.setStrength(Collator.SECONDARY); // Case-insensitive, diacritic-insensitive.
+ }
+
+ return cachedCollator;
+ }
+
+ /**
+ * Sorts a {@link PreferenceGroup} and all nested subgroups by title or key.
+ *
+ * The sort order is controlled by the {@link Sort} suffix present in the preference key.
+ * Preferences without a key or without a {@link Sort} suffix remain in their original order.
+ *
+ * Sorting is performed using {@link Collator} with the current user locale,
+ * ensuring correct alphabetical ordering for all supported languages
+ * (e.g., Ukrainian "і", German "ß", French accented characters, etc.).
+ *
+ * @param group the {@link PreferenceGroup} to sort
+ */
+ @SuppressWarnings("deprecation")
+ public static void sortPreferenceGroups(PreferenceGroup group) {
+ Sort groupSort = Sort.fromKey(group.getKey(), Sort.UNSORTED);
+ List> preferences = new ArrayList<>();
+
+ // Get cached Collator for locale-aware string comparison.
+ Collator collator = getCollator();
+
+ for (int i = 0, prefCount = group.getPreferenceCount(); i < prefCount; i++) {
+ Preference preference = group.getPreference(i);
+
+ final Sort preferenceSort;
+ if (preference instanceof PreferenceGroup subGroup) {
+ sortPreferenceGroups(subGroup);
+ preferenceSort = groupSort; // Sort value for groups is for it's content, not itself.
+ } else {
+ // Allow individual preferences to set a key sorting.
+ // Used to force a preference to the top or bottom of a group.
+ preferenceSort = Sort.fromKey(preference.getKey(), groupSort);
+ }
+
+ final String sortValue;
+ switch (preferenceSort) {
+ case BY_TITLE:
+ sortValue = removePunctuationToLowercase(preference.getTitle());
+ break;
+ case BY_KEY:
+ sortValue = preference.getKey();
+ break;
+ case UNSORTED:
+ continue; // Keep original sorting.
+ default:
+ throw new IllegalStateException();
+ }
+
+ preferences.add(new Pair<>(sortValue, preference));
+ }
+
+ // Sort the list using locale-specific collation rules.
+ Collections.sort(preferences, (pair1, pair2)
+ -> collator.compare(pair1.first, pair2.first));
+
+ // Reassign order values to reflect the new sorted sequence
+ int index = 0;
+ for (Pair pair : preferences) {
+ int order = index++;
+ Preference pref = pair.second;
+
+ // Move any screens, intents, and the one off About preference to the top.
+ if (pref instanceof PreferenceScreen || pref instanceof MorpheAboutPreference
+ || 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 morpheLocale = Utils.getContext().getResources().getConfiguration().locale.getLanguage();
+ if (morpheLocale.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 ResourceUtils.getColor(colorString);
+ }
+
+ /**
+ * Uses {@link #adjustColorBrightness(int, float)} depending on if light or dark mode is active.
+ */
+ @ColorInt
+ public static int adjustColorBrightness(@ColorInt int baseColor, float lightThemeFactor, float darkThemeFactor) {
+ return isDarkModeEnabled()
+ ? adjustColorBrightness(baseColor, darkThemeFactor)
+ : adjustColorBrightness(baseColor, lightThemeFactor);
+ }
+
+ /**
+ * Adjusts the brightness of a color by lightening or darkening it based on the given factor.
+ *
+ * If the factor is greater than 1, the color is lightened by interpolating toward white (#FFFFFF).
+ * If the factor is less than or equal to 1, the color is darkened by scaling its RGB components toward black (#000000).
+ * The alpha channel remains unchanged.
+ *
+ * @param color The input color to adjust, in ARGB format.
+ * @param factor The adjustment factor. Use values > 1.0f to lighten (e.g., 1.11f for slight lightening)
+ * or values <= 1.0f to darken (e.g., 0.95f for slight darkening).
+ * @return The adjusted color in ARGB format.
+ */
+ @ColorInt
+ public static int adjustColorBrightness(@ColorInt int color, float factor) {
+ final int alpha = Color.alpha(color);
+ int red = Color.red(color);
+ int green = Color.green(color);
+ int blue = Color.blue(color);
+
+ if (factor > 1.0f) {
+ // Lighten: Interpolate toward white (255).
+ final float t = 1.0f - (1.0f / factor); // Interpolation parameter.
+ red = Math.round(red + (255 - red) * t);
+ green = Math.round(green + (255 - green) * t);
+ blue = Math.round(blue + (255 - blue) * t);
+ } else {
+ // Darken or no change: Scale toward black.
+ red = Math.round(red * factor);
+ green = Math.round(green * factor);
+ blue = Math.round(blue * factor);
+ }
+
+ // Ensure values are within [0, 255].
+ red = clamp(red, 0, 255);
+ green = clamp(green, 0, 255);
+ blue = clamp(blue, 0, 255);
+
+ return Color.argb(alpha, red, green, blue);
+ }
+
+ public static int clamp(int value, int lower, int upper) {
+ return Math.max(lower, Math.min(value, upper));
+ }
+
+ public static float clamp(float value, float lower, float upper) {
+ return Math.max(lower, Math.min(value, upper));
+ }
+
+ /**
+ * @param maxSize The maximum number of elements to keep in the map.
+ * @return A {@link LinkedHashMap} that automatically evicts the oldest entry
+ * when the size exceeds {@code maxSize}.
+ */
+ public static Map createSizeRestrictedMap(int maxSize) {
+ return new LinkedHashMap<>(2 * maxSize) {
+ @Override
+ protected boolean removeEldestEntry(Entry eldest) {
+ return size() > maxSize;
+ }
+ };
+ }
+
+ /**
+ * @return whether the device's API level is higher than a specific SDK version.
+ */
+ @ChecksSdkIntAtLeast(parameter = 0)
+ public static boolean isSDKAbove(int sdk) {
+ return Build.VERSION.SDK_INT >= sdk;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/checks/Check.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/checks/Check.java
new file mode 100644
index 0000000..e3eab2c
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/checks/Check.java
@@ -0,0 +1,50 @@
+package app.morphe.extension.shared.checks;
+
+import androidx.annotation.Nullable;
+
+import app.morphe.extension.shared.Logger;
+import app.morphe.extension.shared.settings.BaseSettings;
+
+abstract class Check {
+ private static final int NUMBER_OF_TIMES_TO_IGNORE_WARNING_BEFORE_DISABLING = 2;
+
+ /**
+ * @return If the check conclusively passed or failed. A null value indicates it neither passed nor failed.
+ */
+ @Nullable
+ protected abstract Boolean check();
+
+ protected abstract String failureReason();
+
+ /**
+ * Specifies a sorting order for displaying the checks that failed.
+ * A lower value indicates to show first before other checks.
+ */
+ public abstract int uiSortingValue();
+
+ /**
+ * For debugging and development only.
+ * Forces all checks to be performed and the check failed dialog to be shown.
+ * Can be enabled by importing settings text with {@link BaseSettings#CHECK_ENVIRONMENT_WARNINGS_ISSUED}
+ * set to -1.
+ */
+ static boolean debugAlwaysShowWarning() {
+ final boolean alwaysShowWarning = BaseSettings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get() < 0;
+ if (alwaysShowWarning) {
+ Logger.printInfo(() -> "Debug forcing environment check warning to show");
+ }
+
+ return alwaysShowWarning;
+ }
+
+ static boolean shouldRun() {
+ return BaseSettings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get()
+ < NUMBER_OF_TIMES_TO_IGNORE_WARNING_BEFORE_DISABLING;
+ }
+
+ static void disableForever() {
+ Logger.printInfo(() -> "Environment checks disabled forever");
+
+ BaseSettings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.save(Integer.MAX_VALUE);
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/checks/CheckEnvironmentPatch.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/checks/CheckEnvironmentPatch.java
new file mode 100644
index 0000000..362c8d0
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/checks/CheckEnvironmentPatch.java
@@ -0,0 +1,362 @@
+package app.morphe.extension.shared.checks;
+
+import static app.morphe.extension.shared.StringRef.str;
+import static app.morphe.extension.shared.checks.Check.debugAlwaysShowWarning;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_BOARD;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_BOOTLOADER;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_BRAND;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_CPU_ABI;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_CPU_ABI2;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_DEVICE;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_DISPLAY;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_FINGERPRINT;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_HARDWARE;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_HOST;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_ID;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_MANUFACTURER;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_MODEL;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_PRODUCT;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_RADIO;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_TAGS;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_TYPE;
+import static app.morphe.extension.shared.checks.PatchInfo.Build.PATCH_USER;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.util.Base64;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import app.morphe.extension.shared.Logger;
+import app.morphe.extension.shared.Utils;
+
+/**
+ * This class is used to check if the app was patched by the user
+ * and not downloaded pre-patched, because pre-patched apps are difficult to trust.
+ *
+ * Various indicators help to detect if the app was patched by the user.
+ */
+@SuppressWarnings("unused")
+public final class CheckEnvironmentPatch {
+ private static final boolean DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG = debugAlwaysShowWarning();
+
+ private enum InstallationType {
+ /**
+ * CLI patching, manual installation of a previously patched using adb,
+ * or root installation if stock app is first installed using adb.
+ */
+ ADB((String) null),
+ ROOT_MOUNT_ON_APP_STORE("com.android.vending"),
+ MANAGER("app.morphe.manager",
+ "app.morphe.manager.debug");
+
+ @Nullable
+ static InstallationType installTypeFromPackageName(@Nullable String packageName) {
+ for (InstallationType type : values()) {
+ for (String installPackageName : type.packageNames) {
+ if (Objects.equals(installPackageName, packageName)) {
+ return type;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Array elements can be null.
+ */
+ final String[] packageNames;
+
+ InstallationType(String... packageNames) {
+ this.packageNames = packageNames;
+ }
+ }
+
+ /**
+ * Check if the app is installed by the manager, the app store, or through adb/CLI.
+ *
+ * Does not conclusively
+ * If the app is installed by the manager or the app store, it is likely, the app was patched using the manager,
+ * or installed manually via ADB (in the case of Morphe CLI for example).
+ *
+ * If the app is not installed by the manager or the app store, then the app was likely downloaded pre-patched
+ * and installed by the browser or another unknown app.
+ */
+ private static class CheckExpectedInstaller extends Check {
+ @Nullable
+ InstallationType installerFound;
+
+ @NonNull
+ @Override
+ protected Boolean check() {
+ final var context = Utils.getContext();
+
+ final var installerPackageName =
+ context.getPackageManager().getInstallerPackageName(context.getPackageName());
+
+ Logger.printInfo(() -> "Installed by: " + installerPackageName);
+
+ installerFound = InstallationType.installTypeFromPackageName(installerPackageName);
+ final boolean passed = (installerFound != null);
+
+ Logger.printInfo(() -> passed
+ ? "Apk was not installed from an unknown source"
+ : "Apk was installed from an unknown source");
+
+ return passed;
+ }
+
+ @Override
+ protected String failureReason() {
+ return str("morphe_check_environment_manager_not_expected_installer");
+ }
+
+ @Override
+ public int uiSortingValue() {
+ return -100; // Show first.
+ }
+ }
+
+ /**
+ * Check if the build properties are the same as during the patch.
+ *
+ * If the build properties are the same as during the patch, it is likely, the app was patched on the same device.
+ *
+ * If the build properties are different, the app was likely downloaded pre-patched or patched on another device.
+ */
+ private static class CheckWasPatchedOnSameDevice extends Check {
+ @SuppressLint({"NewApi", "HardwareIds"})
+ @Override
+ protected Boolean check() {
+ if (PATCH_BOARD.isEmpty()) {
+ // Did not patch with Manager, and cannot conclusively say where this was from.
+ Logger.printInfo(() -> "APK does not contain a hardware signature and cannot compare to current device");
+ return null;
+ }
+
+ //noinspection deprecation
+ final var passed = buildFieldEqualsHash("BOARD", Build.BOARD, PATCH_BOARD) &
+ buildFieldEqualsHash("BOOTLOADER", Build.BOOTLOADER, PATCH_BOOTLOADER) &
+ buildFieldEqualsHash("BRAND", Build.BRAND, PATCH_BRAND) &
+ buildFieldEqualsHash("CPU_ABI", Build.CPU_ABI, PATCH_CPU_ABI) &
+ buildFieldEqualsHash("CPU_ABI2", Build.CPU_ABI2, PATCH_CPU_ABI2) &
+ buildFieldEqualsHash("DEVICE", Build.DEVICE, PATCH_DEVICE) &
+ buildFieldEqualsHash("DISPLAY", Build.DISPLAY, PATCH_DISPLAY) &
+ buildFieldEqualsHash("FINGERPRINT", Build.FINGERPRINT, PATCH_FINGERPRINT) &
+ buildFieldEqualsHash("HARDWARE", Build.HARDWARE, PATCH_HARDWARE) &
+ buildFieldEqualsHash("HOST", Build.HOST, PATCH_HOST) &
+ buildFieldEqualsHash("ID", Build.ID, PATCH_ID) &
+ buildFieldEqualsHash("MANUFACTURER", Build.MANUFACTURER, PATCH_MANUFACTURER) &
+ buildFieldEqualsHash("MODEL", Build.MODEL, PATCH_MODEL) &
+ buildFieldEqualsHash("PRODUCT", Build.PRODUCT, PATCH_PRODUCT) &
+ buildFieldEqualsHash("RADIO", Build.RADIO, PATCH_RADIO) &
+ buildFieldEqualsHash("TAGS", Build.TAGS, PATCH_TAGS) &
+ buildFieldEqualsHash("TYPE", Build.TYPE, PATCH_TYPE) &
+ buildFieldEqualsHash("USER", Build.USER, PATCH_USER);
+
+ Logger.printInfo(() -> passed
+ ? "Device hardware signature matches current device"
+ : "Device hardware signature does not match current device");
+
+ return passed;
+ }
+
+ @Override
+ protected String failureReason() {
+ return str("morphe_check_environment_not_same_patching_device");
+ }
+
+ @Override
+ public int uiSortingValue() {
+ return 0; // Show in the middle.
+ }
+ }
+
+ /**
+ * Check if the app was installed within the last 30 minutes after being patched.
+ *
+ * If the app was installed within the last 30 minutes, it is likely, the app was patched by the user.
+ *
+ * If the app was installed much later than the patch time, it is likely the app was
+ * downloaded pre-patched or the user waited too long to install the app.
+ */
+ private static class CheckIsNearPatchTime extends Check {
+ /**
+ * How soon after patching the app must be installed to pass.
+ */
+ static final int INSTALL_AFTER_PATCHING_DURATION_THRESHOLD = 30 * 60 * 1000; // 30 minutes.
+
+ /**
+ * Milliseconds between the time the app was patched, and when it was installed/updated.
+ */
+ long durationBetweenPatchingAndInstallation;
+
+ @NonNull
+ @Override
+ protected Boolean check() {
+ try {
+ Context context = Utils.getContext();
+ PackageManager packageManager = context.getPackageManager();
+ PackageInfo packageInfo = packageManager.getPackageInfo(context.getPackageName(), 0);
+
+ // Duration since initial install or last update, whichever is sooner.
+ durationBetweenPatchingAndInstallation = packageInfo.lastUpdateTime - PatchInfo.PATCH_TIME;
+ Logger.printInfo(() -> "App was installed/updated: "
+ + (durationBetweenPatchingAndInstallation / (60 * 1000) + " minutes after patching"));
+
+ if (durationBetweenPatchingAndInstallation < 0) {
+ // Patch time is in the future and clearly wrong.
+ return false;
+ }
+
+ if (durationBetweenPatchingAndInstallation < INSTALL_AFTER_PATCHING_DURATION_THRESHOLD) {
+ return true;
+ }
+ } catch (PackageManager.NameNotFoundException ex) {
+ Logger.printException(() -> "Package name not found exception", ex); // Will never happen.
+ }
+
+ // User installed more than 30 minutes after patching.
+ return false;
+ }
+
+ @Override
+ protected String failureReason() {
+ if (durationBetweenPatchingAndInstallation < 0) {
+ // Could happen if the user has their device clock incorrectly set in the past,
+ // but assume that isn't the case and the apk was patched on a device with the wrong system time.
+ return str("morphe_check_environment_not_near_patch_time_invalid");
+ }
+
+ // If patched over 1 day ago, show how old this pre-patched apk is.
+ // Showing the age can help convey it's better to patch yourself and know it's the latest.
+ final long oneDay = 24 * 60 * 60 * 1000;
+ final long daysSincePatching = durationBetweenPatchingAndInstallation / oneDay;
+ if (daysSincePatching > 1) { // Use over 1 day to avoid singular vs plural strings.
+ return str("morphe_check_environment_not_near_patch_time_days", daysSincePatching);
+ }
+
+ return str("morphe_check_environment_not_near_patch_time");
+ }
+
+ @Override
+ public int uiSortingValue() {
+ return 100; // Show last.
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void check(Activity context) {
+ // If the warning was already issued twice, or if the check was successful in the past,
+ // do not run the checks again.
+ if (!Check.shouldRun() && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
+ Logger.printDebug(() -> "Environment checks are disabled");
+ return;
+ }
+
+ Utils.runOnBackgroundThread(() -> {
+ try {
+ Logger.printInfo(() -> "Running environment checks");
+ List failedChecks = new ArrayList<>();
+
+ CheckWasPatchedOnSameDevice sameHardware = new CheckWasPatchedOnSameDevice();
+ Boolean hardwareCheckPassed = sameHardware.check();
+ if (hardwareCheckPassed != null) {
+ if (hardwareCheckPassed && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
+ // Patched on the same device using Manager,
+ // and no further checks are needed.
+ Check.disableForever();
+ return;
+ }
+
+ failedChecks.add(sameHardware);
+ }
+
+ CheckExpectedInstaller installerCheck = new CheckExpectedInstaller();
+ if (installerCheck.check() && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
+ // If the installer package is Manager but this code is reached,
+ // that means it must not be the right Manager otherwise the hardware hash
+ // signatures would be present and this check would not have run.
+ if (installerCheck.installerFound == InstallationType.MANAGER) {
+ failedChecks.add(installerCheck);
+ // Also could not have been patched on this device.
+ failedChecks.add(sameHardware);
+ } else if (failedChecks.isEmpty()) {
+ // ADB install of CLI build. Allow even if patched a long time ago.
+ Check.disableForever();
+ return;
+ }
+ } else {
+ failedChecks.add(installerCheck);
+ }
+
+ CheckIsNearPatchTime nearPatchTime = new CheckIsNearPatchTime();
+ Boolean timeCheckPassed = nearPatchTime.check();
+ if (timeCheckPassed && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
+ // Allow installing recently patched APKs,
+ // even if the installation source is not Manager or ADB.
+ Check.disableForever();
+ return;
+ } else {
+ failedChecks.add(nearPatchTime);
+ }
+
+ if (DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
+ // Show all failures for debugging layout.
+ failedChecks = Arrays.asList(
+ sameHardware,
+ nearPatchTime,
+ installerCheck
+ );
+ }
+
+ //noinspection ComparatorCombinators
+ Collections.sort(failedChecks, (o1, o2) -> o1.uiSortingValue() - o2.uiSortingValue());
+
+ // FIXME: Nag screen is permanently turned off.
+ // But still use most of this class just to log the installer source.
+ Check.disableForever();
+ } catch (Exception ex) {
+ Logger.printException(() -> "check failure", ex);
+ }
+ });
+ }
+
+ private static boolean buildFieldEqualsHash(String buildFieldName, String buildFieldValue, @Nullable String hash) {
+ try {
+ final var sha1 = MessageDigest.getInstance("SHA-1")
+ .digest(buildFieldValue.getBytes(StandardCharsets.UTF_8));
+
+ // Must be careful to use same base64 encoding Kotlin uses.
+ String runtimeHash = new String(Base64.encode(sha1, Base64.NO_WRAP), StandardCharsets.ISO_8859_1);
+ final boolean equals = runtimeHash.equals(hash);
+ if (!equals) {
+ Logger.printInfo(() -> "Hashes do not match. " + buildFieldName + ": '" + buildFieldValue
+ + "' runtimeHash: '" + runtimeHash + "' patchTimeHash: '" + hash + "'");
+ }
+
+ return equals;
+ } catch (NoSuchAlgorithmException ex) {
+ Logger.printException(() -> "buildFieldEqualsHash failure", ex); // Will never happen.
+
+ return false;
+ }
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/checks/PatchInfo.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/checks/PatchInfo.java
new file mode 100644
index 0000000..01658d3
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/checks/PatchInfo.java
@@ -0,0 +1,32 @@
+package app.morphe.extension.shared.checks;
+
+/**
+ * Fields are set by the patch. Do not modify.
+ * Fields are not final, because the compiler is inlining them.
+ *
+ * @noinspection CanBeFinal
+ */
+final class PatchInfo {
+ static long PATCH_TIME = 0L;
+
+ final static class Build {
+ static String PATCH_BOARD = "";
+ static String PATCH_BOOTLOADER = "";
+ static String PATCH_BRAND = "";
+ static String PATCH_CPU_ABI = "";
+ static String PATCH_CPU_ABI2 = "";
+ static String PATCH_DEVICE = "";
+ static String PATCH_DISPLAY = "";
+ static String PATCH_FINGERPRINT = "";
+ static String PATCH_HARDWARE = "";
+ static String PATCH_HOST = "";
+ static String PATCH_ID = "";
+ static String PATCH_MANUFACTURER = "";
+ static String PATCH_MODEL = "";
+ static String PATCH_PRODUCT = "";
+ static String PATCH_RADIO = "";
+ static String PATCH_TAGS = "";
+ static String PATCH_TYPE = "";
+ static String PATCH_USER = "";
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/patches/ExperimentalAppNoticePatch.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/patches/ExperimentalAppNoticePatch.java
new file mode 100644
index 0000000..e930e0e
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/patches/ExperimentalAppNoticePatch.java
@@ -0,0 +1,101 @@
+package app.morphe.extension.shared.patches;
+
+import static app.morphe.extension.shared.StringRef.StringKeyLookup;
+import static app.morphe.extension.shared.StringRef.str;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.text.Html;
+import android.util.Pair;
+import android.widget.LinearLayout;
+
+import java.util.Map;
+
+import app.morphe.extension.shared.Logger;
+import app.morphe.extension.shared.ResourceType;
+import app.morphe.extension.shared.ResourceUtils;
+import app.morphe.extension.shared.Utils;
+import app.morphe.extension.shared.settings.BaseSettings;
+import app.morphe.extension.shared.ui.CustomDialog;
+
+@SuppressWarnings("unused")
+public class ExperimentalAppNoticePatch {
+
+ // Backup strings for Reddit. Remove this when Reddit gets resource patching or localized strings.
+ private static final StringKeyLookup strings = new StringKeyLookup(
+ Map.of("morphe_experimental_app_version_dialog_title",
+ "🚨 Experimental Alert 🚨",
+
+ "morphe_experimental_app_version_dialog_message",
+ """
+ You are using an experimental app version of ⚠️ %s
+
+ 🔧 Expect quirky app behavior or unidentified bugs as we fine tune the patches for this app version.
+
+ If you want the most trouble free experience, then uninstall this and patch the recommended app version of ✅ %s""",
+
+ "morphe_experimental_app_version_dialog_confirm",
+ "⚠️ I want experimental",
+
+ "morphe_experimental_app_version_dialog_open_homepage",
+ "✅ I want stable"
+ )
+ );
+
+ private static String getString(String key, Object... args) {
+ if (ResourceUtils.getIdentifier(ResourceType.STRING, key) == 0) {
+ return strings.getString(key, args);
+ }
+ return str(key, args);
+ }
+
+ public static boolean experimentalNoticeShouldBeShown() {
+ String appVersionName = Utils.getAppVersionName();
+ String recommendedAppVersion = Utils.getRecommendedAppVersion();
+
+ // The current app is the same or less than the recommended.
+ // YT 21.x uses nn.nn.nnn numbers but still sorts correctly compared to older releases.
+ if (appVersionName.compareTo(recommendedAppVersion) <= 0) {
+ return false;
+ }
+
+ // User already confirmed experimental.
+ return !BaseSettings.EXPERIMENTAL_APP_CONFIRMED.get().equals(appVersionName);
+ }
+
+ /**
+ * Injection point.
+ *
+ * Checks if YouTube watch history endpoint cannot be reached.
+ */
+ public static void showExperimentalNoticeIfNeeded(Activity activity) {
+ try {
+ if (!experimentalNoticeShouldBeShown()) {
+ return;
+ }
+
+ String appVersionName = Utils.getAppVersionName();
+ String recommendedAppVersion = Utils.getRecommendedAppVersion();
+
+ Pair