diff --git a/CHANGELOG.md b/CHANGELOG.md index 461fbf0..13f5575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +# [2.1.0-dev.3](https://github.com/brosssh/morphe-patches/compare/v2.1.0-dev.2...v2.1.0-dev.3) (2026-03-18) + + +### Features + +* More Instagram patches ported from ReVanced ([790149f](https://github.com/brosssh/morphe-patches/commit/790149f2b280bf968c490a285e117bf3bef3989c)) + +# [2.1.0-dev.2](https://github.com/brosssh/morphe-patches/compare/v2.1.0-dev.1...v2.1.0-dev.2) (2026-03-17) + + +### Features + +* More Instagram patches ported from ReVanced ([08ef7b4](https://github.com/brosssh/morphe-patches/commit/08ef7b4344f52fb468a94da547bd8bbf9982bd37)) + +# [2.1.0-dev.1](https://github.com/brosssh/morphe-patches/compare/v2.0.0...v2.1.0-dev.1) (2026-03-17) + + +### Features + +* Some Instagram patches ported from ReVanced ([a121b18](https://github.com/brosssh/morphe-patches/commit/a121b189bbc6f94d9909601e729fa667c6b5578d)) + # [2.0.0](https://github.com/brosssh/morphe-patches/compare/v1.9.2...v2.0.0) (2026-03-15) diff --git a/extensions/instagram/build.gradle.kts b/extensions/instagram/build.gradle.kts new file mode 100644 index 0000000..9b476b1 --- /dev/null +++ b/extensions/instagram/build.gradle.kts @@ -0,0 +1,9 @@ +dependencies { + compileOnly(project(":extensions:shared:library")) +} + +android { + defaultConfig { + minSdk = 26 + } +} diff --git a/extensions/instagram/src/main/AndroidManifest.xml b/extensions/instagram/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9b65eb0 --- /dev/null +++ b/extensions/instagram/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/extensions/instagram/src/main/java/app/morphe/extension/instagram/feed/LimitFeedToFollowedProfiles.java b/extensions/instagram/src/main/java/app/morphe/extension/instagram/feed/LimitFeedToFollowedProfiles.java new file mode 100644 index 0000000..415d4c2 --- /dev/null +++ b/extensions/instagram/src/main/java/app/morphe/extension/instagram/feed/LimitFeedToFollowedProfiles.java @@ -0,0 +1,26 @@ +package app.morphe.extension.instagram.feed; + +import java.util.HashMap; +import java.util.Map; + +@SuppressWarnings("unused") +public class LimitFeedToFollowedProfiles { + + /** + * Injection point. + */ + public static Map setFollowingHeader(Map requestHeaderMap) { + String paginationHeaderName = "pagination_source"; + + // Patch the header only if it's trying to fetch the default feed + String currentHeader = requestHeaderMap.get(paginationHeaderName); + if (currentHeader != null && !currentHeader.equals("feed_recs")) { + return requestHeaderMap; + } + + // Create new map as original is unmodifiable. + Map patchedRequestHeaderMap = new HashMap<>(requestHeaderMap); + patchedRequestHeaderMap.put(paginationHeaderName, "following"); + return patchedRequestHeaderMap; + } +} diff --git a/extensions/instagram/src/main/java/app/morphe/extension/instagram/hide/navigation/HideNavigationButtonsPatch.java b/extensions/instagram/src/main/java/app/morphe/extension/instagram/hide/navigation/HideNavigationButtonsPatch.java new file mode 100644 index 0000000..3a7d942 --- /dev/null +++ b/extensions/instagram/src/main/java/app/morphe/extension/instagram/hide/navigation/HideNavigationButtonsPatch.java @@ -0,0 +1,33 @@ +package app.morphe.extension.instagram.hide.navigation; + +import java.lang.reflect.Field; +import java.util.List; + +@SuppressWarnings("unused") +public class HideNavigationButtonsPatch { + + /** + * Injection point. + * @param navigationButtonsList the list of navigation buttons, as an (obfuscated) Enum type + * @param buttonNameToRemove the name of the button we want to remove + * @param enumNameField the field in the nav button enum class which contains the name of the button + * @return the patched list of navigation buttons + */ + public static List removeNavigationButtonByName( + List navigationButtonsList, + String buttonNameToRemove, + String enumNameField + ) + throws IllegalAccessException, NoSuchFieldException { + for (Object button : navigationButtonsList) { + Field f = button.getClass().getDeclaredField(enumNameField); + String currentButtonEnumName = (String) f.get(button); + + if (buttonNameToRemove.equals(currentButtonEnumName)) { + navigationButtonsList.remove(button); + break; + } + } + return navigationButtonsList; + } +} diff --git a/extensions/instagram/src/main/java/app/morphe/extension/instagram/misc/share/privacy/SanitizeSharingLinksPatch.java b/extensions/instagram/src/main/java/app/morphe/extension/instagram/misc/share/privacy/SanitizeSharingLinksPatch.java new file mode 100644 index 0000000..8c22d82 --- /dev/null +++ b/extensions/instagram/src/main/java/app/morphe/extension/instagram/misc/share/privacy/SanitizeSharingLinksPatch.java @@ -0,0 +1,15 @@ +package app.morphe.extension.instagram.misc.share.privacy; + +import app.morphe.extension.shared.privacy.LinkSanitizer; + +@SuppressWarnings("unused") +public final class SanitizeSharingLinksPatch { + private static final LinkSanitizer sanitizer = new LinkSanitizer("igsh"); + + /** + * Injection point. + */ + public static String sanitizeSharingLink(String url) { + return sanitizer.sanitizeURLString(url); + } +} diff --git a/extensions/proguard-rules.pro b/extensions/proguard-rules.pro new file mode 100644 index 0000000..132b628 --- /dev/null +++ b/extensions/proguard-rules.pro @@ -0,0 +1,17 @@ +-dontobfuscate +-dontoptimize +-keepattributes * +-keep class app.morphe.** { + *; +} +-keep class com.google.** { + *; +} +-keep class com.eclipsesource.v8.** { + *; +} +# Proguard can strip away kotlin intrinsics methods that are used by extension Kotlin code. Unclear why. +-keep class kotlin.jvm.internal.Intrinsics { + public static *; +} +-dontwarn javax.lang.model.element.Modifier \ No newline at end of file diff --git a/extensions/shared/build.gradle.kts b/extensions/shared/build.gradle.kts new file mode 100644 index 0000000..2da2e1e --- /dev/null +++ b/extensions/shared/build.gradle.kts @@ -0,0 +1,3 @@ +dependencies { + implementation(project(":extensions:shared:library")) +} diff --git a/extensions/shared/library/build.gradle.kts b/extensions/shared/library/build.gradle.kts new file mode 100644 index 0000000..ffaab2b --- /dev/null +++ b/extensions/shared/library/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "app.morphe.extension" + compileSdk = 35 + + defaultConfig { + minSdk = 23 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + compileOnly(libs.annotation) +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/ByteTrieSearch.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/ByteTrieSearch.java new file mode 100644 index 0000000..42ef6bd --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/ByteTrieSearch.java @@ -0,0 +1,43 @@ +package app.morphe.extension.shared; + +import java.nio.charset.StandardCharsets; + +public final class ByteTrieSearch extends TrieSearch { + + private static final class ByteTrieNode extends TrieNode { + ByteTrieNode() { + super(); + } + ByteTrieNode(char nodeCharacterValue) { + super(nodeCharacterValue); + } + @Override + TrieNode createNode(char nodeCharacterValue) { + return new ByteTrieNode(nodeCharacterValue); + } + @Override + char getCharValue(byte[] text, int index) { + return (char) text[index]; + } + @Override + int getTextLength(byte[] text) { + return text.length; + } + } + + /** + * Helper method for the common usage of converting Strings to raw UTF-8 bytes. + */ + public static byte[][] convertStringsToBytes(String... strings) { + final int length = strings.length; + byte[][] replacement = new byte[length][]; + for (int i = 0; i < length; i++) { + replacement[i] = strings[i].getBytes(StandardCharsets.UTF_8); + } + return replacement; + } + + public ByteTrieSearch(byte[]... patterns) { + super(new ByteTrieNode(), patterns); + } +} diff --git a/extensions/shared/library/src/main/java/app/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 dialogPair = CustomDialog.create( + activity, + getString("morphe_experimental_app_version_dialog_title"), // Title. + Html.fromHtml(getString("morphe_experimental_app_version_dialog_message", appVersionName, recommendedAppVersion)), // Message. + null, // No EditText. + getString("morphe_experimental_app_version_dialog_open_homepage"), // OK button text. + () -> { + Utils.openLink("https://morphe.software"); // TODO? Send users to a unique page. + activity.finishAndRemoveTask(); // Shutdown the app. More proper than calling System.exit(). + }, // OK button action. + null, // Cancel button action. + getString("morphe_experimental_app_version_dialog_confirm"), // Neutral button text. + () -> BaseSettings.EXPERIMENTAL_APP_CONFIRMED.save(appVersionName), // Neutral button action. + true // Dismiss dialog on Neutral button click. + ); + + Utils.showDialog(activity, dialogPair.first, false, null); + } catch (Exception ex) { + Logger.printException(() -> "showExperimentalNoticeIfNeeded failure", ex); + } + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/privacy/LinkSanitizer.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/privacy/LinkSanitizer.java new file mode 100644 index 0000000..8fb80b5 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/privacy/LinkSanitizer.java @@ -0,0 +1,68 @@ +package app.morphe.extension.shared.privacy; + +import android.net.Uri; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +import app.morphe.extension.shared.Logger; + +/** + * Strips away specific parameters from URLs. + */ +public class LinkSanitizer { + + private final Collection parametersToRemove; + + public LinkSanitizer(String ... parametersToRemove) { + final int parameterCount = parametersToRemove.length; + + // List is faster if only checking a few parameters. + this.parametersToRemove = parameterCount > 4 + ? Set.of(parametersToRemove) + : List.of(parametersToRemove); + } + + public String sanitizeURLString(String url) { + try { + return sanitizeURI(Uri.parse(url)).toString(); + } catch (Exception ex) { + Logger.printException(() -> "sanitizeURLString failure: " + url, ex); + return url; + } + } + + public Uri sanitizeURI(Uri uri) { + try { + String scheme = uri.getScheme(); + if (scheme == null || !(scheme.equals("http") || scheme.equals("https"))) { + // Opening YouTube share sheet 'other' option passes the video title as a URI. + // Checking !uri.isHierarchical() works for all cases, except if the + // video title starts with / and then it's hierarchical but still an invalid URI. + Logger.printDebug(() -> "Ignoring URI: " + uri); + return uri; + } + + Uri.Builder builder = uri.buildUpon().clearQuery(); + + if (!parametersToRemove.isEmpty()) { + for (String paramName : uri.getQueryParameterNames()) { + if (!parametersToRemove.contains(paramName)) { + for (String value : uri.getQueryParameters(paramName)) { + builder.appendQueryParameter(paramName, value); + } + } + } + } + + Uri sanitizedURL = builder.build(); + Logger.printInfo(() -> "Sanitized URL: " + uri + " to: " + sanitizedURL); + + return sanitizedURL; + } catch (Exception ex) { + Logger.printException(() -> "sanitizeURI failure: " + uri, ex); + return uri; + } + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/requests/Requester.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/requests/Requester.java new file mode 100644 index 0000000..db84a13 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/requests/Requester.java @@ -0,0 +1,146 @@ +package app.morphe.extension.shared.requests; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; + +import app.morphe.extension.shared.Utils; + +public class Requester { + private Requester() { + } + + public static HttpURLConnection getConnectionFromRoute(String apiUrl, Route route, String... params) throws IOException { + return getConnectionFromCompiledRoute(apiUrl, route.compile(params)); + } + + public static HttpURLConnection getConnectionFromCompiledRoute(String apiUrl, Route.CompiledRoute route) throws IOException { + String url = apiUrl + route.getCompiledRoute(); + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + // This request sends data via URL query parameters. No request body is included. + // If a request body is added, the caller must set the appropriate Content-Length header. + connection.setFixedLengthStreamingMode(0); + connection.setRequestMethod(route.getMethod().name()); + String agentString = System.getProperty("http.agent") + + "; Morphe/" + Utils.getAppVersionName() + + " (" + Utils.getPatchesReleaseVersion() + ")"; + connection.setRequestProperty("User-Agent", agentString); + + return connection; + } + + /** + * Parse the {@link HttpURLConnection}, and closes the underlying InputStream. + */ + private static String parseInputStreamAndClose(InputStream inputStream) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + StringBuilder jsonBuilder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + jsonBuilder.append(line); + jsonBuilder.append('\n'); + } + return jsonBuilder.toString(); + } + } + + /** + * Parse the {@link HttpURLConnection} response as a String. + * This does not close the url connection. If further requests to this host are unlikely + * in the near future, then instead use {@link #parseStringAndDisconnect(HttpURLConnection)}. + */ + public static String parseString(HttpURLConnection connection) throws IOException { + return parseInputStreamAndClose(connection.getInputStream()); + } + + /** + * Parse the {@link HttpURLConnection} response as a String, and disconnect. + * + * Should only be used if other requests to the server in the near future are unlikely + * + * @see #parseString(HttpURLConnection) + */ + public static String parseStringAndDisconnect(HttpURLConnection connection) throws IOException { + String result = parseString(connection); + connection.disconnect(); + return result; + } + + /** + * Parse the {@link HttpURLConnection} error stream as a String. + * If the server sent no error response data, this returns an empty string. + */ + public static String parseErrorString(HttpURLConnection connection) throws IOException { + InputStream errorStream = connection.getErrorStream(); + if (errorStream == null) { + return ""; + } + return parseInputStreamAndClose(errorStream); + } + + /** + * Parse the {@link HttpURLConnection} error stream as a String, and disconnect. + * If the server sent no error response data, this returns an empty string. + * + * Should only be used if other requests to the server are unlikely in the near future. + * + * @see #parseErrorString(HttpURLConnection) + */ + public static String parseErrorStringAndDisconnect(HttpURLConnection connection) throws IOException { + String result = parseErrorString(connection); + connection.disconnect(); + return result; + } + + /** + * Parse the {@link HttpURLConnection} response into a JSONObject. + * This does not close the url connection. If further requests to this host are unlikely + * in the near future, then instead use {@link #parseJSONObjectAndDisconnect(HttpURLConnection)}. + */ + public static JSONObject parseJSONObject(HttpURLConnection connection) throws JSONException, IOException { + return new JSONObject(parseString(connection)); + } + + /** + * Parse the {@link HttpURLConnection}, close the underlying InputStream, and disconnect. + * + * Should only be used if other requests to the server in the near future are unlikely + * + * @see #parseJSONObject(HttpURLConnection) + */ + public static JSONObject parseJSONObjectAndDisconnect(HttpURLConnection connection) throws JSONException, IOException { + JSONObject object = parseJSONObject(connection); + connection.disconnect(); + return object; + } + + /** + * Parse the {@link HttpURLConnection}, and closes the underlying InputStream. + * This does not close the url connection. If further requests to this host are unlikely + * in the near future, then instead use {@link #parseJSONArrayAndDisconnect(HttpURLConnection)}. + */ + public static JSONArray parseJSONArray(HttpURLConnection connection) throws JSONException, IOException { + return new JSONArray(parseString(connection)); + } + + /** + * Parse the {@link HttpURLConnection}, close the underlying InputStream, and disconnect. + * + * Should only be used if other requests to the server in the near future are unlikely + * + * @see #parseJSONArray(HttpURLConnection) + */ + public static JSONArray parseJSONArrayAndDisconnect(HttpURLConnection connection) throws JSONException, IOException { + JSONArray array = parseJSONArray(connection); + connection.disconnect(); + return array; + } + +} \ No newline at end of file diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/requests/Route.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/requests/Route.java new file mode 100644 index 0000000..2bd5ff7 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/requests/Route.java @@ -0,0 +1,66 @@ +package app.morphe.extension.shared.requests; + +public class Route { + private final String route; + private final Method method; + private final int paramCount; + + public Route(Method method, String route) { + this.method = method; + this.route = route; + this.paramCount = countMatches(route, '{'); + + if (paramCount != countMatches(route, '}')) + throw new IllegalArgumentException("Not enough parameters"); + } + + public Method getMethod() { + return method; + } + + public CompiledRoute compile(String... params) { + if (params.length != paramCount) + throw new IllegalArgumentException("Error compiling route [" + route + "], incorrect amount of parameters provided. " + + "Expected: " + paramCount + ", provided: " + params.length); + + StringBuilder compiledRoute = new StringBuilder(route); + for (int i = 0; i < paramCount; i++) { + int paramStart = compiledRoute.indexOf("{"); + int paramEnd = compiledRoute.indexOf("}"); + compiledRoute.replace(paramStart, paramEnd + 1, params[i]); + } + return new CompiledRoute(this, compiledRoute.toString()); + } + + public static class CompiledRoute { + private final Route baseRoute; + private final String compiledRoute; + + private CompiledRoute(Route baseRoute, String compiledRoute) { + this.baseRoute = baseRoute; + this.compiledRoute = compiledRoute; + } + + public String getCompiledRoute() { + return compiledRoute; + } + + public Method getMethod() { + return baseRoute.method; + } + } + + private int countMatches(CharSequence seq, char c) { + int count = 0; + for (int i = 0, length = seq.length(); i < length; i++) { + if (seq.charAt(i) == c) + count++; + } + return count; + } + + public enum Method { + GET, + POST + } +} \ No newline at end of file diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/AppLanguage.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/AppLanguage.java new file mode 100644 index 0000000..69007bf --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/AppLanguage.java @@ -0,0 +1,120 @@ +package app.morphe.extension.shared.settings; + +import java.util.Locale; + +public enum AppLanguage { + /** + * The current app language. + */ + DEFAULT, + + // Languages codes not included with YouTube, but are translated on Crowdin + GA, + KMR, + + // Language codes found in locale_config.xml + // All region specific variants have been removed. + AF, + AM, + AR, + AS, + AZ, + BE, + BG, + BN, + BS, + CA, + CS, + DA, + DE, + EL, + EN, + ES, + ET, + EU, + FA, + FI, + FR, + GL, + GU, + HE, // App uses obsolete 'IW' and not the modern 'HE' ISO code. + HI, + HR, + HU, + HY, + ID, + IS, + IT, + JA, + KA, + KK, + KM, + KN, + KO, + KY, + LO, + LT, + LV, + MK, + ML, + MN, + MR, + MS, + MY, + NB, + NE, + NL, + OR, + PA, + PL, + PT, + RO, + RU, + SI, + SK, + SL, + SQ, + SR, + SV, + SW, + TA, + TE, + TH, + TL, + TR, + UK, + UR, + UZ, + VI, + ZH, + ZU; + + private final String language; + private final Locale locale; + + AppLanguage() { + language = name().toLowerCase(Locale.US); + locale = Locale.forLanguageTag(language); + } + + /** + * @return The 2-letter ISO 639_1 language code. + */ + public String getLanguage() { + // Changing the app language does not force the app to completely restart, + // so the default needs to be the current language and not a static field. + if (this == DEFAULT) { + return Locale.getDefault().getLanguage(); + } + + return language; + } + + public Locale getLocale() { + if (this == DEFAULT) { + return Locale.getDefault(); + } + + return locale; + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/BaseSettings.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/BaseSettings.java new file mode 100644 index 0000000..a1a752f --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/BaseSettings.java @@ -0,0 +1,44 @@ +package app.morphe.extension.shared.settings; + +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; +import static app.morphe.extension.shared.settings.Setting.parent; + +import app.morphe.extension.shared.Logger; + +/** + * Settings shared across multiple apps. + *

+ * To ensure this class is loaded when the UI is created, app specific setting bundles should extend + * or reference this class. + */ +public class BaseSettings { + public static final BooleanSetting DEBUG = new BooleanSetting("morphe_debug", FALSE); + public static final BooleanSetting DEBUG_STACKTRACE = new BooleanSetting("morphe_debug_stacktrace", FALSE, parent(DEBUG)); + public static final BooleanSetting DEBUG_TOAST_ON_ERROR = new BooleanSetting("morphe_debug_toast_on_error", TRUE, "morphe_debug_toast_on_error_user_dialog_message"); + + public static final IntegerSetting CHECK_ENVIRONMENT_WARNINGS_ISSUED = new IntegerSetting("morphe_check_environment_warnings_issued", 0, true, false); + + public static final EnumSetting MORPHE_LANGUAGE = new EnumSetting<>("morphe_language", AppLanguage.DEFAULT, true, "morphe_language_user_dialog_message"); + + /** + * Use the icons declared in the preferences created during patching. If no icons or styles are declared then this setting does nothing. + */ + public static final BooleanSetting SHOW_MENU_ICONS = new BooleanSetting("morphe_show_menu_icons", TRUE, true); + + /** + * The first time the app was launched with no previous app data (either a clean install, or after wiping app data). + */ + public static final LongSetting FIRST_TIME_APP_LAUNCHED = new LongSetting("morphe_last_time_app_was_launched", -1L, false, false); + + public static final StringSetting EXPERIMENTAL_APP_CONFIRMED = new StringSetting("morphe_experimental_app_target_confirmed", "", false, false); + + static { + final long now = System.currentTimeMillis(); + + if (FIRST_TIME_APP_LAUNCHED.get() < 0) { + Logger.printInfo(() -> "First launch of installation with no prior app data"); + FIRST_TIME_APP_LAUNCHED.save(now); + } + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/BooleanSetting.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/BooleanSetting.java new file mode 100644 index 0000000..4249a06 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/BooleanSetting.java @@ -0,0 +1,81 @@ +package app.morphe.extension.shared.settings; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Objects; + +@SuppressWarnings("unused") +public class BooleanSetting extends Setting { + public BooleanSetting(String key, Boolean defaultValue) { + super(key, defaultValue); + } + public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp) { + super(key, defaultValue, rebootApp); + } + public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, boolean includeWithImportExport) { + super(key, defaultValue, rebootApp, includeWithImportExport); + } + public BooleanSetting(String key, Boolean defaultValue, String userDialogMessage) { + super(key, defaultValue, userDialogMessage); + } + public BooleanSetting(String key, Boolean defaultValue, Availability availability) { + super(key, defaultValue, availability); + } + public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, String userDialogMessage) { + super(key, defaultValue, rebootApp, userDialogMessage); + } + public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, Availability availability) { + super(key, defaultValue, rebootApp, availability); + } + public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) { + super(key, defaultValue, rebootApp, userDialogMessage, availability); + } + public BooleanSetting(@NonNull String key, @NonNull Boolean defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) { + super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability); + } + + /** + * Sets, but does _not_ persistently save the value. + * This method is only to be used by the Settings preference code. + *

+ * This intentionally is a static method to deter + * accidental usage when {@link #save(Boolean)} was intended. + */ + public static void privateSetValue(@NonNull BooleanSetting setting, @NonNull Boolean newValue) { + setting.value = Objects.requireNonNull(newValue); + + if (setting.isSetToDefault()) { + setting.removeFromPreferences(); + } + } + + @Override + protected void load() { + value = preferences.getBoolean(key, defaultValue); + } + + @Override + protected Boolean readFromJSON(JSONObject json, String importExportKey) throws JSONException { + return json.getBoolean(importExportKey); + } + + @Override + protected void setValueFromString(@NonNull String newValue) { + value = Boolean.valueOf(Objects.requireNonNull(newValue)); + } + + @Override + public void saveToPreferences() { + preferences.saveBoolean(key, value); + } + + @NonNull + @Override + public Boolean get() { + return value; + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/EnumSetting.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/EnumSetting.java new file mode 100644 index 0000000..208dc88 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/EnumSetting.java @@ -0,0 +1,122 @@ +package app.morphe.extension.shared.settings; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Locale; +import java.util.Objects; + +import app.morphe.extension.shared.Logger; + +/** + * If an Enum value is removed or changed, any saved or imported data using the + * non-existent value will be reverted to the default value + * (the event is logged, but no user error is displayed). + *

+ * All saved JSON text is converted to lowercase to keep the output less obnoxious. + */ +@SuppressWarnings("unused") +public class EnumSetting> extends Setting { + public EnumSetting(String key, T defaultValue) { + super(key, defaultValue); + } + public EnumSetting(String key, T defaultValue, boolean rebootApp) { + super(key, defaultValue, rebootApp); + } + public EnumSetting(String key, T defaultValue, boolean rebootApp, boolean includeWithImportExport) { + super(key, defaultValue, rebootApp, includeWithImportExport); + } + public EnumSetting(String key, T defaultValue, String userDialogMessage) { + super(key, defaultValue, userDialogMessage); + } + public EnumSetting(String key, T defaultValue, Availability availability) { + super(key, defaultValue, availability); + } + public EnumSetting(String key, T defaultValue, boolean rebootApp, String userDialogMessage) { + super(key, defaultValue, rebootApp, userDialogMessage); + } + public EnumSetting(String key, T defaultValue, boolean rebootApp, Availability availability) { + super(key, defaultValue, rebootApp, availability); + } + public EnumSetting(String key, T defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) { + super(key, defaultValue, rebootApp, userDialogMessage, availability); + } + public EnumSetting(@NonNull String key, @NonNull T defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) { + super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability); + } + + @Override + protected void load() { + value = preferences.getEnum(key, defaultValue); + } + + @Override + protected T readFromJSON(JSONObject json, String importExportKey) throws JSONException { + String enumName = json.getString(importExportKey); + try { + return getEnumFromString(enumName); + } catch (IllegalArgumentException ex) { + // Info level to allow removing enum values in the future without showing any user errors. + Logger.printInfo(() -> "Using default, and ignoring unknown enum value: " + enumName, ex); + return defaultValue; + } + } + + @Override + protected void writeToJSON(JSONObject json, String importExportKey) throws JSONException { + // Use lowercase to keep the output less ugly. + json.put(importExportKey, value.name().toLowerCase(Locale.ENGLISH)); + } + + /** + * @param enumName Enum name. Casing does not matter. + * @return Enum of this type with the same declared name. + * @throws IllegalArgumentException if the name is not a valid enum of this type. + */ + protected T getEnumFromString(String enumName) { + //noinspection ConstantConditions + for (Enum value : defaultValue.getClass().getEnumConstants()) { + if (value.name().equalsIgnoreCase(enumName)) { + //noinspection unchecked + return (T) value; + } + } + + throw new IllegalArgumentException("Unknown enum value: " + enumName); + } + + @Override + protected void setValueFromString(@NonNull String newValue) { + value = getEnumFromString(Objects.requireNonNull(newValue)); + } + + @Override + public void saveToPreferences() { + preferences.saveEnumAsString(key, value); + } + + @NonNull + @Override + public T get() { + return value; + } + + /** + * Availability based on if this setting is currently set to any of the provided types. + */ + @SafeVarargs + public final Setting.Availability availability(T... types) { + Objects.requireNonNull(types); + + return () -> { + T currentEnumType = get(); + for (T enumType : types) { + if (currentEnumType == enumType) return true; + } + return false; + }; + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/FloatSetting.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/FloatSetting.java new file mode 100644 index 0000000..3d024b5 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/FloatSetting.java @@ -0,0 +1,67 @@ +package app.morphe.extension.shared.settings; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Objects; + +@SuppressWarnings("unused") +public class FloatSetting extends Setting { + + public FloatSetting(String key, Float defaultValue) { + super(key, defaultValue); + } + public FloatSetting(String key, Float defaultValue, boolean rebootApp) { + super(key, defaultValue, rebootApp); + } + public FloatSetting(String key, Float defaultValue, boolean rebootApp, boolean includeWithImportExport) { + super(key, defaultValue, rebootApp, includeWithImportExport); + } + public FloatSetting(String key, Float defaultValue, String userDialogMessage) { + super(key, defaultValue, userDialogMessage); + } + public FloatSetting(String key, Float defaultValue, Availability availability) { + super(key, defaultValue, availability); + } + public FloatSetting(String key, Float defaultValue, boolean rebootApp, String userDialogMessage) { + super(key, defaultValue, rebootApp, userDialogMessage); + } + public FloatSetting(String key, Float defaultValue, boolean rebootApp, Availability availability) { + super(key, defaultValue, rebootApp, availability); + } + public FloatSetting(String key, Float defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) { + super(key, defaultValue, rebootApp, userDialogMessage, availability); + } + public FloatSetting(@NonNull String key, @NonNull Float defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) { + super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability); + } + + @Override + protected void load() { + value = preferences.getFloatString(key, defaultValue); + } + + @Override + protected Float readFromJSON(JSONObject json, String importExportKey) throws JSONException { + return (float) json.getDouble(importExportKey); + } + + @Override + protected void setValueFromString(@NonNull String newValue) { + value = Float.valueOf(Objects.requireNonNull(newValue)); + } + + @Override + public void saveToPreferences() { + preferences.saveFloatString(key, value); + } + + @NonNull + @Override + public Float get() { + return value; + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/IntegerSetting.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/IntegerSetting.java new file mode 100644 index 0000000..c2c82ef --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/IntegerSetting.java @@ -0,0 +1,67 @@ +package app.morphe.extension.shared.settings; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Objects; + +@SuppressWarnings("unused") +public class IntegerSetting extends Setting { + + public IntegerSetting(String key, Integer defaultValue) { + super(key, defaultValue); + } + public IntegerSetting(String key, Integer defaultValue, boolean rebootApp) { + super(key, defaultValue, rebootApp); + } + public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, boolean includeWithImportExport) { + super(key, defaultValue, rebootApp, includeWithImportExport); + } + public IntegerSetting(String key, Integer defaultValue, String userDialogMessage) { + super(key, defaultValue, userDialogMessage); + } + public IntegerSetting(String key, Integer defaultValue, Availability availability) { + super(key, defaultValue, availability); + } + public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, String userDialogMessage) { + super(key, defaultValue, rebootApp, userDialogMessage); + } + public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, Availability availability) { + super(key, defaultValue, rebootApp, availability); + } + public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) { + super(key, defaultValue, rebootApp, userDialogMessage, availability); + } + public IntegerSetting(@NonNull String key, @NonNull Integer defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) { + super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability); + } + + @Override + protected void load() { + value = preferences.getIntegerString(key, defaultValue); + } + + @Override + protected Integer readFromJSON(JSONObject json, String importExportKey) throws JSONException { + return json.getInt(importExportKey); + } + + @Override + protected void setValueFromString(@NonNull String newValue) { + value = Integer.valueOf(Objects.requireNonNull(newValue)); + } + + @Override + public void saveToPreferences() { + preferences.saveIntegerString(key, value); + } + + @NonNull + @Override + public Integer get() { + return value; + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/LongSetting.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/LongSetting.java new file mode 100644 index 0000000..29e1c05 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/LongSetting.java @@ -0,0 +1,67 @@ +package app.morphe.extension.shared.settings; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Objects; + +@SuppressWarnings("unused") +public class LongSetting extends Setting { + + public LongSetting(String key, Long defaultValue) { + super(key, defaultValue); + } + public LongSetting(String key, Long defaultValue, boolean rebootApp) { + super(key, defaultValue, rebootApp); + } + public LongSetting(String key, Long defaultValue, boolean rebootApp, boolean includeWithImportExport) { + super(key, defaultValue, rebootApp, includeWithImportExport); + } + public LongSetting(String key, Long defaultValue, String userDialogMessage) { + super(key, defaultValue, userDialogMessage); + } + public LongSetting(String key, Long defaultValue, Availability availability) { + super(key, defaultValue, availability); + } + public LongSetting(String key, Long defaultValue, boolean rebootApp, String userDialogMessage) { + super(key, defaultValue, rebootApp, userDialogMessage); + } + public LongSetting(String key, Long defaultValue, boolean rebootApp, Availability availability) { + super(key, defaultValue, rebootApp, availability); + } + public LongSetting(String key, Long defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) { + super(key, defaultValue, rebootApp, userDialogMessage, availability); + } + public LongSetting(@NonNull String key, @NonNull Long defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) { + super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability); + } + + @Override + protected void load() { + value = preferences.getLongString(key, defaultValue); + } + + @Override + protected Long readFromJSON(JSONObject json, String importExportKey) throws JSONException { + return json.getLong(importExportKey); + } + + @Override + protected void setValueFromString(@NonNull String newValue) { + value = Long.valueOf(Objects.requireNonNull(newValue)); + } + + @Override + public void saveToPreferences() { + preferences.saveLongString(key, value); + } + + @NonNull + @Override + public Long get() { + return value; + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/Setting.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/Setting.java new file mode 100644 index 0000000..7fe1669 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/Setting.java @@ -0,0 +1,588 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-patches + * + * Original hard forked code: + * https://github.com/ReVanced/revanced-patches/commit/724e6d61b2ecd868c1a9a37d465a688e83a74799 + * + * See the included NOTICE file for GPLv3 §7(b) and §7(c) terms that apply to Morphe contributions. + */ + +package app.morphe.extension.shared.settings; + +import static app.morphe.extension.shared.StringRef.str; + +import android.app.Activity; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import app.morphe.extension.shared.Logger; +import app.morphe.extension.shared.ResourceType; +import app.morphe.extension.shared.ResourceUtils; +import app.morphe.extension.shared.StringRef; +import app.morphe.extension.shared.Utils; +import app.morphe.extension.shared.settings.preference.SharedPrefCategory; + +public abstract class Setting { + + /** + * Indicates if a {@link Setting} is available to edit and use. + * Typically, this is dependent upon other BooleanSetting(s) set to 'true', + * but this can be used to call into extension code and check other conditions. + */ + public interface Availability { + boolean isAvailable(); + + /** + * @return parent settings (dependencies) of this availability. + */ + default List> getParentSettings() { + return Collections.emptyList(); + } + } + + /** + * Availability based on a single parent setting being enabled. + */ + public static Availability parent(BooleanSetting parent) { + return new Availability() { + @Override + public boolean isAvailable() { + return parent.get(); + } + + @Override + public List> getParentSettings() { + return Collections.singletonList(parent); + } + }; + } + + /** + * Availability based on a single parent setting being disabled. + */ + public static Availability parentNot(BooleanSetting parent) { + return new Availability() { + @Override + public boolean isAvailable() { + return !parent.get(); + } + + @Override + public List> getParentSettings() { + return Collections.singletonList(parent); + } + }; + } + + /** + * Availability based on all parents being enabled. + */ + public static Availability parentsAll(BooleanSetting... parents) { + return new Availability() { + @Override + public boolean isAvailable() { + for (BooleanSetting parent : parents) { + if (!parent.get()) return false; + } + return true; + } + + @Override + public List> getParentSettings() { + return Collections.unmodifiableList(Arrays.asList(parents)); + } + }; + } + + /** + * Availability based on any parent being enabled. + */ + public static Availability parentsAny(BooleanSetting... parents) { + return new Availability() { + @Override + public boolean isAvailable() { + for (BooleanSetting parent : parents) { + if (parent.get()) return true; + } + return false; + } + + @Override + public List> getParentSettings() { + return Collections.unmodifiableList(Arrays.asList(parents)); + } + }; + } + + /** + * Callback for importing/exporting settings. + */ + public interface ImportExportCallback { + /** + * Called after all settings have been imported. + */ + void settingsImported(@Nullable Activity context); + + /** + * Called after all settings have been exported. + */ + void settingsExported(@Nullable Activity context); + } + + private static final List importExportCallbacks = new ArrayList<>(); + + /** + * Adds a callback for {@link #importFromJSON(Activity, String)} and {@link #exportToJson(Activity)}. + */ + public static void addImportExportCallback(ImportExportCallback callback) { + importExportCallbacks.add(Objects.requireNonNull(callback)); + } + + /** + * All settings that were instantiated. + * When a new setting is created, it is automatically added to this list. + */ + private static final List> SETTINGS = new ArrayList<>(); + + /** + * Map of setting path to setting object. + */ + private static final Map> PATH_TO_SETTINGS = new HashMap<>(); + + /** + * Preference all instances are saved to. + */ + public static final SharedPrefCategory preferences = new SharedPrefCategory("morphe_prefs"); + + @Nullable + public static Setting getSettingFromPath(String str) { + return PATH_TO_SETTINGS.get(str); + } + + /** + * @return All settings that have been created. + */ + public static List> allLoadedSettings() { + return Collections.unmodifiableList(SETTINGS); + } + + /** + * @return All settings that have been created, sorted by keys. + */ + private static List> allLoadedSettingsSorted() { + //noinspection ComparatorCombinators + Collections.sort(SETTINGS, (Setting o1, Setting o2) -> o1.key.compareTo(o2.key)); + return allLoadedSettings(); + } + + /** + * The key used to store the value in the shared preferences. + */ + public final String key; + + /** + * The default value of the setting. + */ + public final T defaultValue; + + /** + * If the app should be rebooted, if this setting is changed + */ + public final boolean rebootApp; + + /** + * If this setting should be included when importing/exporting settings. + */ + public final boolean includeWithImportExport; + + /** + * If this setting is available to edit and use. + * Not to be confused with its status returned from {@link #get()}. + */ + @Nullable + private final Availability availability; + + /** + * Confirmation message to display, if the user tries to change the setting from the default value. + */ + @Nullable + public final StringRef userDialogMessage; + + // Must be volatile, as some settings are read/write from different threads. + // Of note, the object value is persistently stored using SharedPreferences (which is thread safe). + /** + * The value of the setting. + */ + protected volatile T value; + + public Setting(String key, T defaultValue) { + this(key, defaultValue, false, true, null, null); + } + public Setting(String key, T defaultValue, boolean rebootApp) { + this(key, defaultValue, rebootApp, true, null, null); + } + public Setting(String key, T defaultValue, boolean rebootApp, boolean includeWithImportExport) { + this(key, defaultValue, rebootApp, includeWithImportExport, null, null); + } + public Setting(String key, T defaultValue, String userDialogMessage) { + this(key, defaultValue, false, true, userDialogMessage, null); + } + public Setting(String key, T defaultValue, Availability availability) { + this(key, defaultValue, false, true, null, availability); + } + public Setting(String key, T defaultValue, boolean rebootApp, String userDialogMessage) { + this(key, defaultValue, rebootApp, true, userDialogMessage, null); + } + public Setting(String key, T defaultValue, boolean rebootApp, Availability availability) { + this(key, defaultValue, rebootApp, true, null, availability); + } + public Setting(String key, T defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) { + this(key, defaultValue, rebootApp, true, userDialogMessage, availability); + } + + /** + * A setting backed by a shared preference. + * + * @param key The key used to store the value in the shared preferences. + * @param defaultValue The default value of the setting. + * @param rebootApp If the app should be rebooted, if this setting is changed. + * @param includeWithImportExport If this setting should be shown in the import/export dialog. + * @param userDialogMessage Confirmation message to display, if the user tries to change the setting from the default value. + * @param availability Condition that must be true, for this setting to be available to configure. + */ + public Setting(String key, + T defaultValue, + boolean rebootApp, + boolean includeWithImportExport, + @Nullable String userDialogMessage, + @Nullable Availability availability + ) { + this.key = Objects.requireNonNull(key); + this.value = this.defaultValue = Objects.requireNonNull(defaultValue); + this.rebootApp = rebootApp; + this.includeWithImportExport = includeWithImportExport; + this.userDialogMessage = (userDialogMessage == null) ? null : new StringRef(userDialogMessage); + this.availability = availability; + + SETTINGS.add(this); + if (PATH_TO_SETTINGS.put(key, this) != null) { + Logger.printException(() -> this.getClass().getSimpleName() + + " error: Duplicate Setting key found: " + key); + } + + load(); + } + + /** + * Migrate a setting value if the path is renamed but otherwise the old and new settings are identical. + */ + public static void migrateOldSettingToNew(Setting oldSetting, Setting newSetting) { + if (oldSetting == newSetting) throw new IllegalArgumentException(); + + if (!oldSetting.isSetToDefault()) { + Logger.printInfo(() -> "Migrating old setting value: " + oldSetting + " into replacement setting: " + newSetting); + newSetting.save(oldSetting.value); + oldSetting.resetToDefault(); + } + } + + /** + * Migrate an old Setting value previously stored in a different SharedPreference. + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public static void migrateFromOldPreferences(SharedPrefCategory oldPrefs, Setting setting) { + String settingKey = setting.key; + if (!oldPrefs.preferences.contains(settingKey)) { + return; // Nothing to do. + } + + Object newValue = setting.get(); + final Object migratedValue; + if (setting instanceof BooleanSetting) { + migratedValue = oldPrefs.getBoolean(settingKey, (Boolean) newValue); + } else if (setting instanceof IntegerSetting) { + migratedValue = oldPrefs.getIntegerString(settingKey, (Integer) newValue); + } else if (setting instanceof LongSetting) { + migratedValue = oldPrefs.getLongString(settingKey, (Long) newValue); + } else if (setting instanceof FloatSetting) { + migratedValue = oldPrefs.getFloatString(settingKey, (Float) newValue); + } else if (setting instanceof StringSetting) { + migratedValue = oldPrefs.getString(settingKey, (String) newValue); + } else { + Logger.printException(() -> "Unknown setting: " + setting); + // Remove otherwise it'll show a toast on every launch. + oldPrefs.preferences.edit().remove(settingKey).apply(); + return; + } + + oldPrefs.preferences.edit().remove(settingKey).apply(); // Remove the old setting. + if (migratedValue.equals(newValue)) { + Logger.printDebug(() -> "Value does not need migrating: " + settingKey); + return; // Old value is already equal to the new setting value. + } + + Logger.printDebug(() -> "Migrating old preference value into current preference: " + settingKey); + setting.save(migratedValue); + } + + /** + * Sets, but does _not_ persistently save the value. + * This method is only to be used by the Settings preference code. + *

+ * This intentionally is a static method to deter + * accidental usage when {@link #save(Object)} was intended. + */ + public static void privateSetValueFromString(Setting setting, String newValue) { + setting.setValueFromString(newValue); + + // Clear the preference value since default is used, to allow changing + // the default for a future release. Without this after upgrading + // the saved value will be whatever was the default when the app was first installed. + if (setting.isSetToDefault()) { + setting.removeFromPreferences(); + } + } + + /** + * Sets the value of {@link #value}, but do not save to {@link #preferences}. + */ + protected abstract void setValueFromString(String newValue); + + /** + * Load and set the value of {@link #value}. + */ + protected abstract void load(); + + /** + * Persistently saves the value. + */ + public final void save(T newValue) { + if (value.equals(newValue)) { + return; + } + + // Must set before saving to preferences (otherwise importing fails to update UI correctly). + value = Objects.requireNonNull(newValue); + + if (defaultValue.equals(newValue)) { + removeFromPreferences(); + } else { + saveToPreferences(); + } + } + + /** + * Save {@link #value} to {@link #preferences}. + */ + protected abstract void saveToPreferences(); + + /** + * Remove {@link #value} from {@link #preferences}. + */ + protected final void removeFromPreferences() { + Logger.printDebug(() -> "Clearing stored preference value (reset to default): " + key); + preferences.removeKey(key); + } + + @NonNull + public abstract T get(); + + /** + * Identical to calling {@link #save(Object)} using {@link #defaultValue}. + * + * @return The newly saved default value. + */ + public T resetToDefault() { + save(defaultValue); + return defaultValue; + } + + /** + * @return if this setting can be configured and used. + */ + public boolean isAvailable() { + return availability == null || availability.isAvailable(); + } + + /** + * Get the parent Settings that this setting depends on. + * @return List of parent Settings, or empty list if no dependencies exist. + * Defensive: handles null availability or missing getParentSettings() override. + */ + public List> getParentSettings() { + return availability == null + ? Collections.emptyList() + : Objects.requireNonNullElse(availability.getParentSettings(), Collections.emptyList()); + } + + /** + * @return if the currently set value is the same as {@link #defaultValue}. + */ + public boolean isSetToDefault() { + return value.equals(defaultValue); + } + + @NonNull + @Override + public String toString() { + return key + "=" + get(); + } + + // region Import / export + + /** + * If a setting path has this prefix, then remove it before importing/exporting. + */ + private static final String OPTIONAL_MORPHE_SETTINGS_PREFIX = "morphe_"; + + /** + * The path, minus any 'morphe' prefix to keep JSON concise. + */ + private String getImportExportKey() { + if (key.startsWith(OPTIONAL_MORPHE_SETTINGS_PREFIX)) { + return key.substring(OPTIONAL_MORPHE_SETTINGS_PREFIX.length()); + } + return key; + } + + /** + * @param importExportKey The JSON key. The JSONObject parameter will contain data for this key. + * @return the value stored using the import/export key. Do not set any values in this method. + */ + protected abstract T readFromJSON(JSONObject json, String importExportKey) throws JSONException; + + /** + * Saves this instance to JSON. + *

+ * To keep the JSON simple and readable, + * subclasses should not write out any embedded types (such as JSON Array or Dictionaries). + *

+ * If this instance is not a type supported natively by JSON (ie: it's not a String/Integer/Float/Long), + * then subclasses can override this method and write out a String value representing the value. + */ + protected void writeToJSON(JSONObject json, String importExportKey) throws JSONException { + json.put(importExportKey, value); + } + + public static String exportToJson(@Nullable Activity alertDialogContext) { + try { + JSONObject json = new JSONObject(); + for (Setting setting : allLoadedSettingsSorted()) { + String importExportKey = setting.getImportExportKey(); + if (json.has(importExportKey)) { + throw new IllegalArgumentException("duplicate key found: " + importExportKey); + } + + final boolean exportDefaultValues = false; // Enable to see what all settings look like in the UI. + //noinspection ConstantValue + if (setting.includeWithImportExport && (!setting.isSetToDefault() || exportDefaultValues)) { + setting.writeToJSON(json, importExportKey); + } + } + + for (ImportExportCallback callback : importExportCallbacks) { + callback.settingsExported(alertDialogContext); + } + + if (json.length() == 0) { + return ""; + } + + String export = json.toString(0); + + if (export.startsWith("{") && export.endsWith("}")) { + // Remove the outer JSON braces to make the output more compact, + // and leave less chance of the user forgetting to copy it + export = export.substring(1, export.length() - 1); + } + + export = export.replaceAll("^\\n+", "").replaceAll("\\n+$", ""); + + return export + ","; + } catch (JSONException e) { + Logger.printException(() -> "Export failure", e); // Should never happen + return ""; + } + } + + /** + * @return if any settings that require a reboot were changed. + */ + public static boolean importFromJSON(Activity alertDialogContext, String settingsJsonString) { + try { + settingsJsonString = settingsJsonString.trim(); + + if (settingsJsonString.endsWith(",")) { + settingsJsonString = settingsJsonString.substring(0, settingsJsonString.length() - 1); + } + + if (!settingsJsonString.trim().startsWith("{")) { + settingsJsonString = "{\n" + settingsJsonString + "\n}"; // Restore outer JSON braces + } + JSONObject json = new JSONObject(settingsJsonString); + + boolean rebootSettingChanged = false; + int numberOfSettingsImported = 0; + //noinspection rawtypes + for (Setting setting : SETTINGS) { + String key = setting.getImportExportKey(); + if (json.has(key)) { + Object value = setting.readFromJSON(json, key); + if (!setting.get().equals(value)) { + rebootSettingChanged |= setting.rebootApp; + //noinspection unchecked + setting.save(value); + } + numberOfSettingsImported++; + } else if (setting.includeWithImportExport && !setting.isSetToDefault()) { + Logger.printDebug(() -> "Resetting to default: " + setting); + rebootSettingChanged |= setting.rebootApp; + setting.resetToDefault(); + } + } + + for (ImportExportCallback callback : importExportCallbacks) { + callback.settingsImported(alertDialogContext); + } + + // Check if patch resource strings are available. + String resetKey = "morphe_settings_import_reset"; + // Use a delay, otherwise the toast can move about on screen from the dismissing dialog. + if (ResourceUtils.getIdentifier(ResourceType.STRING, resetKey) != 0) { + final int numberOfSettingsImportedFinal = numberOfSettingsImported; + Utils.runOnMainThreadDelayed(() -> Utils.showToastLong(numberOfSettingsImportedFinal == 0 + ? str(resetKey) + : str("morphe_settings_import_success", numberOfSettingsImportedFinal)), + 150); + } + + return rebootSettingChanged; + } catch (JSONException | IllegalArgumentException ex) { + String failureKey = "morphe_settings_import_failure_parse"; + String toastFormat = ResourceUtils.getIdentifier(ResourceType.STRING, failureKey) != 0 + ? str(failureKey) + : "Import failed: %s"; + Utils.showToastLong(String.format(toastFormat, ex.getMessage())); + Logger.printInfo(() -> "", ex); + } catch (Exception ex) { + Logger.printException(() -> "Import failure: " + ex.getMessage(), ex); // Should never happen. + } + return false; + } + + // End import / export + +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/StringSetting.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/StringSetting.java new file mode 100644 index 0000000..4e0f851 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/StringSetting.java @@ -0,0 +1,67 @@ +package app.morphe.extension.shared.settings; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Objects; + +@SuppressWarnings("unused") +public class StringSetting extends Setting { + + public StringSetting(String key, String defaultValue) { + super(key, defaultValue); + } + public StringSetting(String key, String defaultValue, boolean rebootApp) { + super(key, defaultValue, rebootApp); + } + public StringSetting(String key, String defaultValue, boolean rebootApp, boolean includeWithImportExport) { + super(key, defaultValue, rebootApp, includeWithImportExport); + } + public StringSetting(String key, String defaultValue, String userDialogMessage) { + super(key, defaultValue, userDialogMessage); + } + public StringSetting(String key, String defaultValue, Availability availability) { + super(key, defaultValue, availability); + } + public StringSetting(String key, String defaultValue, boolean rebootApp, String userDialogMessage) { + super(key, defaultValue, rebootApp, userDialogMessage); + } + public StringSetting(String key, String defaultValue, boolean rebootApp, Availability availability) { + super(key, defaultValue, rebootApp, availability); + } + public StringSetting(String key, String defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) { + super(key, defaultValue, rebootApp, userDialogMessage, availability); + } + public StringSetting(@NonNull String key, @NonNull String defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) { + super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability); + } + + @Override + protected void load() { + value = preferences.getString(key, defaultValue); + } + + @Override + protected String readFromJSON(JSONObject json, String importExportKey) throws JSONException { + return json.getString(importExportKey); + } + + @Override + protected void setValueFromString(@NonNull String newValue) { + value = Objects.requireNonNull(newValue); + } + + @Override + public void saveToPreferences() { + preferences.saveString(key, value); + } + + @NonNull + @Override + public String get() { + return value; + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/AbstractPreferenceFragment.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/AbstractPreferenceFragment.java new file mode 100644 index 0000000..8e0cd28 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/AbstractPreferenceFragment.java @@ -0,0 +1,612 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-patches + * + * Original hard forked code: + * https://github.com/ReVanced/revanced-patches/commit/724e6d61b2ecd868c1a9a37d465a688e83a74799 + * + * See the included NOTICE file for GPLv3 §7(b) and §7(c) terms that apply to Morphe contributions. + */ + +package app.morphe.extension.shared.settings.preference; + +import static app.morphe.extension.shared.StringRef.str; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.Dialog; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.EditTextPreference; +import android.preference.ListPreference; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.preference.PreferenceGroup; +import android.preference.PreferenceManager; +import android.preference.PreferenceScreen; +import android.preference.SwitchPreference; +import android.util.Pair; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import java.util.Scanner; + +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.settings.BooleanSetting; +import app.morphe.extension.shared.settings.Setting; +import app.morphe.extension.shared.ui.CustomDialog; + +@SuppressWarnings("deprecation") +public abstract class AbstractPreferenceFragment extends PreferenceFragment { + + @SuppressLint("StaticFieldLeak") + public static AbstractPreferenceFragment instance; + + /** + * Indicates that if a preference changes, + * to apply the change from the Setting to the UI component. + */ + public static boolean settingImportInProgress; + + /** + * Prevents recursive calls during preference <-> UI syncing from showing extra dialogs. + */ + private static boolean updatingPreference; + + /** + * Used to prevent showing reboot dialog, if user cancels a setting user dialog. + */ + private static boolean showingUserDialogMessage; + + /** + * Confirm and restart dialog button text and title. + * Set by subclasses if Strings cannot be added as a resource. + */ + @Nullable + protected static CharSequence restartDialogTitle, restartDialogMessage, restartDialogButtonText, confirmDialogTitle; + + private static final int READ_REQUEST_CODE = 42; + private static final int WRITE_REQUEST_CODE = 43; + private String existingSettings = ""; + + private EditText currentImportExportEditText; + + private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> { + try { + if (updatingPreference) { + Logger.printDebug(() -> "Ignoring preference change as sync is in progress"); + return; + } + + Setting setting = Setting.getSettingFromPath(Objects.requireNonNull(str)); + if (setting == null) { + return; + } + Preference pref = findPreference(str); + if (pref == null) { + return; + } + Logger.printDebug(() -> "Preference changed: " + setting.key); + + if (!settingImportInProgress && !showingUserDialogMessage) { + if (setting.userDialogMessage != null && !prefIsSetToDefault(pref, setting)) { + // Do not change the setting yet, to allow preserving whatever + // list/text value was previously set if it needs to be reverted. + showSettingUserDialogConfirmation(pref, setting); + return; + } else if (setting.rebootApp) { + showRestartDialog(getContext()); + } + } + + updatingPreference = true; + // Apply 'Setting <- Preference', unless during importing when it needs to be 'Setting -> Preference'. + // Updating here can cause a recursive call back into this same method. + updatePreference(pref, setting, true, settingImportInProgress); + // Update any other preference availability that may now be different. + updateUIAvailability(); + updatingPreference = false; + } catch (Exception ex) { + Logger.printException(() -> "OnSharedPreferenceChangeListener failure", ex); + } + }; + + /** + * Initialize this instance, and do any custom behavior. + *

+ * To ensure all {@link Setting} instances are correctly synced to the UI, + * it is important that subclasses make a call or otherwise reference their Settings class bundle + * so all app specific {@link Setting} instances are loaded before this method returns. + */ + protected void initialize() { + String preferenceResourceName; + if (BaseSettings.SHOW_MENU_ICONS.get()) { + preferenceResourceName = Utils.appIsUsingBoldIcons() + ? "morphe_prefs_icons_bold" + : "morphe_prefs_icons"; + } else { + preferenceResourceName = "morphe_prefs"; + } + + final var identifier = ResourceUtils.getIdentifier(ResourceType.XML, preferenceResourceName); + if (identifier == 0) return; + addPreferencesFromResource(identifier); + + PreferenceScreen screen = getPreferenceScreen(); + Utils.sortPreferenceGroups(screen); + Utils.setPreferenceTitlesToMultiLineIfNeeded(screen); + } + + private void showSettingUserDialogConfirmation(Preference pref, Setting setting) { + Utils.verifyOnMainThread(); + + final var context = getContext(); + if (confirmDialogTitle == null) { + confirmDialogTitle = str("morphe_settings_confirm_user_dialog_title"); + } + + showingUserDialogMessage = true; + + CharSequence message = BulletPointPreference.formatIntoBulletPoints( + Objects.requireNonNull(setting.userDialogMessage).toString()); + + Pair dialogPair = CustomDialog.create( + context, + confirmDialogTitle, // Title. + message, + null, // No EditText. + null, // OK button text. + () -> { + // OK button action. User confirmed, save to the Setting. + updatePreference(pref, setting, true, false); + + // Update availability of other preferences that may be changed. + updateUIAvailability(); + + if (setting.rebootApp) { + showRestartDialog(context); + } + }, + () -> { + // Cancel button action. Restore whatever the setting was before the change. + updatePreference(pref, setting, true, true); + }, + null, // No Neutral button. + null, // No Neutral button action. + true // Dismiss dialog when onNeutralClick. + ); + + dialogPair.first.setOnDismissListener(d -> showingUserDialogMessage = false); + dialogPair.first.setCancelable(false); + + // Show the dialog. + dialogPair.first.show(); + } + + /** + * Updates all Preferences values and their availability using the current values in {@link Setting}. + */ + protected void updateUIToSettingValues() { + updatePreferenceScreen(getPreferenceScreen(), true, true); + } + + /** + * Updates Preferences availability only using the status of {@link Setting}. + */ + protected void updateUIAvailability() { + updatePreferenceScreen(getPreferenceScreen(), false, false); + } + + /** + * @return If the preference is currently set to the default value of the Setting. + */ + protected boolean prefIsSetToDefault(Preference pref, Setting setting) { + Object defaultValue = setting.defaultValue; + if (pref instanceof SwitchPreference switchPref) { + return switchPref.isChecked() == (Boolean) defaultValue; + } + String defaultValueString = defaultValue.toString(); + if (pref instanceof EditTextPreference editPreference) { + return editPreference.getText().equals(defaultValueString); + } + if (pref instanceof ListPreference listPref) { + return listPref.getValue().equals(defaultValueString); + } + + throw new IllegalStateException("Must override method to handle preference type: " + pref.getClass()); + } + + /** + * Syncs all UI Preferences to any {@link Setting} they represent. + */ + private void updatePreferenceScreen(@NonNull PreferenceGroup group, + boolean syncSettingValue, + boolean applySettingToPreference) { + // Alternatively this could iterate through all Settings and check for any matching Preferences, + // but there are many more Settings than UI preferences so it's more efficient to only check + // the Preferences. + for (int i = 0, prefCount = group.getPreferenceCount(); i < prefCount; i++) { + Preference pref = group.getPreference(i); + if (pref instanceof PreferenceGroup subGroup) { + updatePreferenceScreen(subGroup, syncSettingValue, applySettingToPreference); + } else if (pref.hasKey()) { + String key = pref.getKey(); + Setting setting = Setting.getSettingFromPath(key); + + if (setting != null) { + updatePreference(pref, setting, syncSettingValue, applySettingToPreference); + } else if (BaseSettings.DEBUG.get() && (pref instanceof SwitchPreference + || pref instanceof EditTextPreference || pref instanceof ListPreference)) { + // Probably a typo in the patches preference declaration. + Logger.printException(() -> "Preference key has no setting: " + key); + } + } + } + } + + /** + * Handles syncing a UI Preference with the {@link Setting} that backs it. + * If needed, subclasses can override this to handle additional UI Preference types. + * + * @param applySettingToPreference If true, then apply {@link Setting} -> Preference. + * If false, then apply {@link Setting} <- Preference. + */ + protected void syncSettingWithPreference(@NonNull Preference pref, + @NonNull Setting setting, + boolean applySettingToPreference) { + if (pref instanceof SwitchPreference switchPref) { + BooleanSetting boolSetting = (BooleanSetting) setting; + if (applySettingToPreference) { + switchPref.setChecked(boolSetting.get()); + } else { + BooleanSetting.privateSetValue(boolSetting, switchPref.isChecked()); + } + } else if (pref instanceof EditTextPreference editPreference) { + if (applySettingToPreference) { + editPreference.setText(setting.get().toString()); + } else { + Setting.privateSetValueFromString(setting, editPreference.getText()); + } + } else if (pref instanceof ListPreference listPref) { + if (applySettingToPreference) { + listPref.setValue(setting.get().toString()); + } else { + Setting.privateSetValueFromString(setting, listPref.getValue()); + } + updateListPreferenceSummary(listPref, setting); + } else if (!pref.getClass().equals(Preference.class)) { + // Ignore root preference class because there is no data to sync. + Logger.printException(() -> "Setting cannot be handled: " + pref.getClass() + ": " + pref); + } + } + + /** + * Updates a UI Preference with the {@link Setting} that backs it. + * + * @param syncSetting If the UI should be synced {@link Setting} <-> Preference + * @param applySettingToPreference If true, then apply {@link Setting} -> Preference. + * If false, then apply {@link Setting} <- Preference. + */ + private void updatePreference(@NonNull Preference pref, @NonNull Setting setting, + boolean syncSetting, boolean applySettingToPreference) { + if (!syncSetting && applySettingToPreference) { + throw new IllegalArgumentException(); + } + + if (syncSetting) { + syncSettingWithPreference(pref, setting, applySettingToPreference); + } + + updatePreferenceAvailability(pref, setting); + } + + protected void updatePreferenceAvailability(@NonNull Preference pref, @NonNull Setting setting) { + pref.setEnabled(setting.isAvailable()); + } + + protected void updateListPreferenceSummary(ListPreference listPreference, Setting setting) { + String objectStringValue = setting.get().toString(); + final int entryIndex = listPreference.findIndexOfValue(objectStringValue); + if (entryIndex >= 0) { + listPreference.setSummary(listPreference.getEntries()[entryIndex]); + } else { + // Value is not an available option. + // User manually edited import data, or options changed and current selection is no longer available. + // Still show the value in the summary, so it's clear that something is selected. + listPreference.setSummary(objectStringValue); + } + } + + public static void showRestartDialog(Context context) { + Utils.verifyOnMainThread(); + if (restartDialogTitle == null) { + restartDialogTitle = str("morphe_settings_restart_title"); + } + if (restartDialogMessage == null) { + restartDialogMessage = str("morphe_settings_restart_dialog_message"); + } + if (restartDialogButtonText == null) { + restartDialogButtonText = str("morphe_settings_restart"); + } + + Pair dialogPair = CustomDialog.create( + context, + restartDialogTitle, // Title. + restartDialogMessage, // Message. + null, // No EditText. + restartDialogButtonText, // OK button text. + () -> Utils.restartApp(context), // OK button action. + () -> {}, // Cancel button action (dismiss only). + null, // No Neutral button text. + null, // No Neutral button action. + true // Dismiss dialog when onNeutralClick. + ); + + // Show the dialog. + dialogPair.first.show(); + } + + /** + * Import / Export Subroutines + */ + @NonNull + private Button createDialogButton(Context context, String text, int marginLeft, int marginRight, View.OnClickListener listener) { + int height = (int) android.util.TypedValue.applyDimension(android.util.TypedValue.COMPLEX_UNIT_DIP, 36f, context.getResources().getDisplayMetrics()); + int paddingHorizontal = (int) android.util.TypedValue.applyDimension(android.util.TypedValue.COMPLEX_UNIT_DIP, 16f, context.getResources().getDisplayMetrics()); + float radius = android.util.TypedValue.applyDimension(android.util.TypedValue.COMPLEX_UNIT_DIP, 20f, context.getResources().getDisplayMetrics()); + + Button btn = new Button(context, null, 0); + btn.setText(text); + btn.setAllCaps(false); + btn.setTextSize(14); + btn.setSingleLine(true); + btn.setEllipsize(android.text.TextUtils.TruncateAt.END); + btn.setGravity(android.view.Gravity.CENTER); + btn.setPadding(paddingHorizontal, 0, paddingHorizontal, 0); + btn.setTextColor(Utils.isDarkModeEnabled() ? android.graphics.Color.WHITE : android.graphics.Color.BLACK); + + android.graphics.drawable.GradientDrawable bg = new android.graphics.drawable.GradientDrawable(); + bg.setCornerRadius(radius); + bg.setColor(Utils.getCancelOrNeutralButtonBackgroundColor()); + btn.setBackground(bg); + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, height, 1.0f); + params.setMargins(marginLeft, 0, marginRight, 0); + btn.setLayoutParams(params); + btn.setOnClickListener(listener); + + return btn; + } + public void showImportExportTextDialog() { + try { + Activity context = getActivity(); + // Must set text before showing dialog, + // otherwise text is non-selectable if this preference is later reopened. + existingSettings = Setting.exportToJson(context); + currentImportExportEditText = getEditText(context); + + // Create a custom dialog with the EditText. + Pair dialogPair = CustomDialog.create( + context, + str("morphe_pref_import_export_title"), // Title. + null, // No message (EditText replaces it). + currentImportExportEditText, // Pass the EditText. + str("morphe_settings_save"), // OK button text. + () -> importSettingsText(context, currentImportExportEditText.getText().toString()), // OK button action. + () -> {}, // Cancel button action (dismiss only). + str("morphe_settings_import_copy"), // Neutral button (Copy) text. + () -> Utils.setClipboard(currentImportExportEditText.getText().toString()), // Neutral button (Copy) action. Show the user the settings in JSON format. + true // Dismiss dialog when onNeutralClick. + ); + + LinearLayout fileButtonsContainer = getLinearLayout(context); + int margin = (int) android.util.TypedValue.applyDimension(android.util.TypedValue.COMPLEX_UNIT_DIP, 4f, context.getResources().getDisplayMetrics()); + + Button btnExport = createDialogButton(context, str("morphe_settings_export_file"), 0, margin, v -> exportActivity()); + Button btnImport = createDialogButton(context, str("morphe_settings_import_file"), margin, 0, v -> importActivity()); + + fileButtonsContainer.addView(btnExport); + fileButtonsContainer.addView(btnImport); + + dialogPair.second.addView(fileButtonsContainer, 2); + + dialogPair.first.setOnDismissListener(d -> currentImportExportEditText = null); + + // If there are no settings yet, then show the on-screen keyboard and bring focus to + // the edit text. This makes it easier to paste saved settings after a reinstallation. + dialogPair.first.setOnShowListener(dialogInterface -> { + if (existingSettings.isEmpty() && currentImportExportEditText != null) { + currentImportExportEditText.postDelayed(() -> { + if (currentImportExportEditText != null) { + currentImportExportEditText.requestFocus(); + android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) imm.showSoftInput(currentImportExportEditText, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT); + } + }, 100); + } + }); + + // Show the dialog. + dialogPair.first.show(); + } catch (Exception ex) { + Logger.printException(() -> "showImportExportTextDialog failure", ex); + } + } + + @NonNull + private static LinearLayout getLinearLayout(Context context) { + LinearLayout fileButtonsContainer = new LinearLayout(context); + fileButtonsContainer.setOrientation(LinearLayout.HORIZONTAL); + LinearLayout.LayoutParams fbParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); + + int marginTop = (int) android.util.TypedValue.applyDimension(android.util.TypedValue.COMPLEX_UNIT_DIP, 16f, context.getResources().getDisplayMetrics()); + fbParams.setMargins(0, marginTop, 0, 0); + fileButtonsContainer.setLayoutParams(fbParams); + return fileButtonsContainer; + } + + @NonNull + private EditText getEditText(Context context) { + EditText editText = new EditText(context); + editText.setText(existingSettings); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + editText.setAutofillHints((String) null); + } + editText.setInputType(android.text.InputType.TYPE_CLASS_TEXT | + android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS | + android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE); + editText.setSingleLine(false); + editText.setTextSize(14); + return editText; + } + + public void exportActivity() { + try { + Setting.exportToJson(getActivity()); + + String formatDate = new java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US).format(new java.util.Date()); + String fileName = "Morphe_Settings_" + formatDate + ".txt"; + + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_TITLE, fileName); + startActivityForResult(intent, WRITE_REQUEST_CODE); + } catch (Exception ex) { + Logger.printException(() -> "exportActivity failure", ex); + } + } + + public void importActivity() { + try { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + startActivityForResult(intent, READ_REQUEST_CODE); + } catch (Exception ex) { + Logger.printException(() -> "importActivity failure", ex); + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == WRITE_REQUEST_CODE && resultCode == android.app.Activity.RESULT_OK && data != null) { + exportTextToFile(data.getData()); + } else if (requestCode == READ_REQUEST_CODE && resultCode == android.app.Activity.RESULT_OK && data != null) { + importTextFromFile(data.getData()); + } + } + + protected static void showLocalizedToast(String resourceKey, String fallbackMessage) { + if (ResourceUtils.getIdentifier(ResourceType.STRING, resourceKey) != 0) { + Utils.showToastLong(str(resourceKey)); + } else { + Utils.showToastLong(fallbackMessage); + } + } + + private void exportTextToFile(android.net.Uri uri) { + try { + OutputStream out = getContext().getContentResolver().openOutputStream(uri); + if (out != null) { + String textToExport = existingSettings; + if (currentImportExportEditText != null) { + textToExport = currentImportExportEditText.getText().toString(); + } + out.write(textToExport.getBytes(StandardCharsets.UTF_8)); + out.close(); + + showLocalizedToast("morphe_settings_export_file_success", "Settings exported successfully"); + } + } catch (Exception e) { + showLocalizedToast("morphe_settings_export_file_failed", "Failed to export settings"); + Logger.printException(() -> "exportTextToFile failure", e); + } + } + + @SuppressWarnings("CharsetObjectCanBeUsed") + private void importTextFromFile(android.net.Uri uri) { + try { + InputStream in = getContext().getContentResolver().openInputStream(uri); + if (in != null) { + Scanner scanner = new Scanner(in, StandardCharsets.UTF_8.name()).useDelimiter("\\A"); + String result = scanner.hasNext() ? scanner.next() : ""; + in.close(); + + if (currentImportExportEditText != null) { + currentImportExportEditText.setText(result); + showLocalizedToast("morphe_settings_import_file_success", "Settings imported successfully, tap Save to apply"); + } else { + importSettingsText(getContext(), result); + } + } + } catch (Exception e) { + showLocalizedToast("morphe_settings_import_file_failed", "Failed to import settings"); + Logger.printException(() -> "importTextFromFile failure", e); + } + } + + private void importSettingsText(Context context, String replacementSettings) { + try { + existingSettings = Setting.exportToJson(null); + if (replacementSettings.equals(existingSettings)) { + return; + } + settingImportInProgress = true; + final boolean rebootNeeded = Setting.importFromJSON(getActivity(), replacementSettings); + if (rebootNeeded) { + showRestartDialog(context); + } + } catch (Exception ex) { + Logger.printException(() -> "importSettingsText failure", ex); + } finally { + settingImportInProgress = false; + } + } + + @SuppressLint("ResourceType") + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + instance = this; + try { + PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setSharedPreferencesName(Setting.preferences.name); + + // Must initialize before adding change listener, + // otherwise the syncing of Setting -> UI + // causes a callback to the listener even though nothing changed. + initialize(); + updateUIToSettingValues(); + + preferenceManager.getSharedPreferences().registerOnSharedPreferenceChangeListener(listener); + } catch (Exception ex) { + Logger.printException(() -> "onCreate() failure", ex); + } + } + + @Override + public void onDestroy() { + if (instance == this) { + instance = null; + } + getPreferenceManager().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(listener); + super.onDestroy(); + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/BulletPointPreference.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/BulletPointPreference.java new file mode 100644 index 0000000..1ce3e56 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/BulletPointPreference.java @@ -0,0 +1,86 @@ +package app.morphe.extension.shared.settings.preference; + +import android.content.Context; +import android.preference.Preference; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.SpannedString; +import android.text.TextUtils; +import android.text.style.BulletSpan; +import android.util.AttributeSet; + +/** + * Formats the summary text bullet points into Spanned text for better presentation. + */ +@SuppressWarnings({"unused", "deprecation"}) +public class BulletPointPreference extends Preference { + + /** + * Replaces bullet points with styled spans. + */ + public static CharSequence formatIntoBulletPoints(CharSequence source) { + final char bulletPoint = '•'; + if (TextUtils.indexOf(source, bulletPoint) < 0) { + return source; // Nothing to do. + } + + SpannableStringBuilder builder = new SpannableStringBuilder(source); + + int lineStart = 0; + int length = builder.length(); + + while (lineStart < length) { + int lineEnd = TextUtils.indexOf(builder, '\n', lineStart); + if (lineEnd < 0) lineEnd = length; + + // Apply BulletSpan only if the line starts with the '•' character. + if (lineEnd > lineStart && builder.charAt(lineStart) == bulletPoint) { + int deleteEnd = lineStart + 1; // remove the bullet itself + + // If there's a single space right after the bullet, remove that too. + if (deleteEnd < builder.length() && builder.charAt(deleteEnd) == ' ') { + deleteEnd++; + } + + builder.delete(lineStart, deleteEnd); + + // Apply the BulletSpan to the remainder of that line. + builder.setSpan(new BulletSpan(20), + lineStart, + lineEnd - (deleteEnd - lineStart), // adjust for deleted chars. + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + + // Update total length and lineEnd after deletion. + length = builder.length(); + final int removed = deleteEnd - lineStart; + lineEnd -= removed; + } + + lineStart = lineEnd + 1; + } + + return new SpannedString(builder); + } + + public BulletPointPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public BulletPointPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public BulletPointPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public BulletPointPreference(Context context) { + super(context); + } + + @Override + public void setSummary(CharSequence summary) { + super.setSummary(formatIntoBulletPoints(summary)); + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/BulletPointSwitchPreference.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/BulletPointSwitchPreference.java new file mode 100644 index 0000000..7ec6d49 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/BulletPointSwitchPreference.java @@ -0,0 +1,45 @@ +package app.morphe.extension.shared.settings.preference; + +import static app.morphe.extension.shared.settings.preference.BulletPointPreference.formatIntoBulletPoints; + +import android.content.Context; +import android.preference.SwitchPreference; +import android.util.AttributeSet; + +/** + * Formats the summary text bullet points into Spanned text for better presentation. + */ +@SuppressWarnings({"unused", "deprecation"}) +public class BulletPointSwitchPreference extends SwitchPreference { + + public BulletPointSwitchPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public BulletPointSwitchPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public BulletPointSwitchPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public BulletPointSwitchPreference(Context context) { + super(context); + } + + @Override + public void setSummary(CharSequence summary) { + super.setSummary(formatIntoBulletPoints(summary)); + } + + @Override + public void setSummaryOn(CharSequence summaryOn) { + super.setSummaryOn(formatIntoBulletPoints(summaryOn)); + } + + @Override + public void setSummaryOff(CharSequence summaryOff) { + super.setSummaryOff(formatIntoBulletPoints(summaryOff)); + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ClearLogBufferPreference.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ClearLogBufferPreference.java new file mode 100644 index 0000000..a94d022 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ClearLogBufferPreference.java @@ -0,0 +1,33 @@ +package app.morphe.extension.shared.settings.preference; + +import android.content.Context; +import android.preference.Preference; +import android.util.AttributeSet; + +/** + * A custom preference that clears the Morphe debug log buffer when clicked. + * Invokes the {@link LogBufferManager#clearLogBuffer} method. + */ +@SuppressWarnings({"unused", "deprecation"}) +public class ClearLogBufferPreference extends Preference { + + { + setOnPreferenceClickListener(pref -> { + LogBufferManager.clearLogBuffer(); + return true; + }); + } + + public ClearLogBufferPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + public ClearLogBufferPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + public ClearLogBufferPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + public ClearLogBufferPreference(Context context) { + super(context); + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ColorPickerPreference.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ColorPickerPreference.java new file mode 100644 index 0000000..2289658 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ColorPickerPreference.java @@ -0,0 +1,476 @@ +package app.morphe.extension.shared.settings.preference; + +import static app.morphe.extension.shared.StringRef.str; +import static app.morphe.extension.shared.ResourceUtils.getIdentifierOrThrow; + +import android.app.Dialog; +import android.content.Context; +import android.graphics.Color; +import android.graphics.Typeface; +import android.os.Build; +import android.os.Bundle; +import android.preference.EditTextPreference; +import android.text.Editable; +import android.text.InputType; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.util.Pair; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ScrollView; + +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; + +import java.util.Locale; +import java.util.regex.Pattern; + +import app.morphe.extension.shared.Logger; +import app.morphe.extension.shared.ResourceType; +import app.morphe.extension.shared.Utils; +import app.morphe.extension.shared.settings.Setting; +import app.morphe.extension.shared.settings.StringSetting; +import app.morphe.extension.shared.ui.ColorDot; +import app.morphe.extension.shared.ui.CustomDialog; +import app.morphe.extension.shared.ui.Dim; + +/** + * A custom preference for selecting a color via a hexadecimal code or a color picker dialog. + * Extends {@link EditTextPreference} to display a colored dot in the widget area, + * reflecting the currently selected color. The dot is dimmed when the preference is disabled. + */ +@SuppressWarnings({"unused", "deprecation"}) +public class ColorPickerPreference extends EditTextPreference { + /** Length of a valid color string of format #RRGGBB (without alpha) or #AARRGGBB (with alpha). */ + public static final int COLOR_STRING_LENGTH_WITHOUT_ALPHA = 7; + public static final int COLOR_STRING_LENGTH_WITH_ALPHA = 9; + + /** Matches everything that is not a hex number/letter. */ + private static final Pattern PATTERN_NOT_HEX = Pattern.compile("[^0-9A-Fa-f]"); + + /** Alpha for dimming when the preference is disabled. */ + public static final float DISABLED_ALPHA = 0.5f; // 50% + + /** View displaying a colored dot in the widget area. */ + private View widgetColorDot; + + /** Dialog View displaying a colored dot for the selected color preview in the dialog. */ + private View dialogColorDot; + + /** Current color, including alpha channel if opacity slider is enabled. */ + @ColorInt + private int currentColor; + + /** Associated setting for storing the color value. */ + private StringSetting colorSetting; + + /** Dialog TextWatcher for the EditText to monitor color input changes. */ + private TextWatcher colorTextWatcher; + + /** Dialog color picker view. */ + protected ColorPickerView dialogColorPickerView; + + /** Listener for color changes. */ + protected OnColorChangeListener colorChangeListener; + + /** Whether the opacity slider is enabled. */ + private boolean opacitySliderEnabled = false; + + public static final int ID_MORPHE_COLOR_PICKER_VIEW = + getIdentifierOrThrow(ResourceType.ID, "morphe_color_picker_view"); + public static final int ID_PREFERENCE_COLOR_DOT = + getIdentifierOrThrow(ResourceType.ID, "preference_color_dot"); + public static final int LAYOUT_MORPHE_COLOR_DOT_WIDGET = + getIdentifierOrThrow(ResourceType.LAYOUT, "morphe_color_dot_widget"); + public static final int LAYOUT_MORPHE_COLOR_PICKER = + getIdentifierOrThrow(ResourceType.LAYOUT, "morphe_color_picker"); + + /** + * Removes non-valid hex characters, converts to all uppercase, + * and adds # character to the start if not present. + */ + public static String cleanupColorCodeString(String colorString, boolean includeAlpha) { + String result = "#" + PATTERN_NOT_HEX.matcher(colorString) + .replaceAll("").toUpperCase(Locale.ROOT); + + int maxLength = includeAlpha ? COLOR_STRING_LENGTH_WITH_ALPHA : COLOR_STRING_LENGTH_WITHOUT_ALPHA; + if (result.length() < maxLength) { + return result; + } + + return result.substring(0, maxLength); + } + + /** + * @param color Color, with or without alpha channel. + * @param includeAlpha Whether to include the alpha channel in the output string. + * @return #RRGGBB or #AARRGGBB hex color string + */ + public static String getColorString(@ColorInt int color, boolean includeAlpha) { + if (includeAlpha) { + return String.format("#%08X", color); + } + color = color & 0x00FFFFFF; // Mask to strip alpha. + return String.format("#%06X", color); + } + + /** + * Interface for notifying color changes. + */ + public interface OnColorChangeListener { + void onColorChanged(String key, int newColor); + } + + /** + * Sets the listener for color changes. + */ + public void setOnColorChangeListener(OnColorChangeListener listener) { + this.colorChangeListener = listener; + } + + /** + * Enables or disables the opacity slider in the color picker dialog. + */ + public void setOpacitySliderEnabled(boolean enabled) { + this.opacitySliderEnabled = enabled; + } + + public ColorPickerPreference(Context context) { + super(context); + init(); + } + + public ColorPickerPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + /** + * Initializes the preference by setting up the EditText, loading the color, and set the widget layout. + */ + private void init() { + if (getKey() != null) { + colorSetting = (StringSetting) Setting.getSettingFromPath(getKey()); + if (colorSetting == null) { + Logger.printException(() -> "Could not find color setting for: " + getKey()); + } + } else { + Logger.printDebug(() -> "initialized without key, settings will be loaded later"); + } + + EditText editText = getEditText(); + editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS + | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + editText.setAutofillHints((String) null); + } + + // Set the widget layout to a custom layout containing the colored dot. + setWidgetLayoutResource(LAYOUT_MORPHE_COLOR_DOT_WIDGET); + } + + /** + * Sets the selected color and updates the UI and settings. + */ + @Override + public void setText(String colorString) { + try { + Logger.printDebug(() -> "setText: " + colorString); + super.setText(colorString); + + currentColor = Color.parseColor(colorString); + if (colorSetting != null) { + colorSetting.save(getColorString(currentColor, opacitySliderEnabled)); + } + updateDialogColorDot(); + updateWidgetColorDot(); + + // Notify the listener about the color change. + if (colorChangeListener != null) { + colorChangeListener.onColorChanged(getKey(), currentColor); + } + } catch (IllegalArgumentException ex) { + // This code is reached if the user pastes settings JSON with an invalid color + // since this preference is updated with the new setting text. + Logger.printDebug(() -> "Parse color error: " + colorString, ex); + Utils.showToastShort(str("morphe_settings_color_invalid")); + setText(colorSetting.resetToDefault()); + } catch (Exception ex) { + Logger.printException(() -> "setText failure: " + colorString, ex); + } + } + + /** + * Creates a TextWatcher to monitor changes in the EditText for color input. + */ + private TextWatcher createColorTextWatcher(ColorPickerView colorPickerView) { + return new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable edit) { + try { + String colorString = edit.toString(); + String sanitizedColorString = cleanupColorCodeString(colorString, opacitySliderEnabled); + if (!sanitizedColorString.equals(colorString)) { + edit.replace(0, colorString.length(), sanitizedColorString); + return; + } + + int expectedLength = opacitySliderEnabled + ? COLOR_STRING_LENGTH_WITH_ALPHA + : COLOR_STRING_LENGTH_WITHOUT_ALPHA; + if (sanitizedColorString.length() != expectedLength) { + return; + } + + final int newColor = Color.parseColor(colorString); + if (currentColor != newColor) { + Logger.printDebug(() -> "afterTextChanged: " + sanitizedColorString); + currentColor = newColor; + updateDialogColorDot(); + updateWidgetColorDot(); + colorPickerView.setColor(newColor); + } + } catch (Exception ex) { + // Should never be reached since input is validated before using. + Logger.printException(() -> "afterTextChanged failure", ex); + } + } + }; + } + + /** + * Hook for subclasses to add a custom view to the top of the dialog. + */ + @Nullable + protected View createExtraDialogContentView(Context context) { + return null; // Default implementation returns no extra view. + } + + /** + * Hook for subclasses to handle the OK button click. + */ + protected void onDialogOkClicked() { + // Default implementation does nothing. + } + + /** + * Hook for subclasses to handle the Neutral button click. + */ + protected void onDialogNeutralClicked() { + // Default implementation. + try { + final int defaultColor = Color.parseColor(colorSetting.defaultValue); + dialogColorPickerView.setColor(defaultColor); + } catch (Exception ex) { + Logger.printException(() -> "Reset button failure", ex); + } + } + + @Override + protected void showDialog(Bundle state) { + Context context = getContext(); + + // Create content container for all dialog views. + LinearLayout contentContainer = new LinearLayout(context); + contentContainer.setOrientation(LinearLayout.VERTICAL); + + // Add extra view from subclass if it exists. + View extraView = createExtraDialogContentView(context); + if (extraView != null) { + contentContainer.addView(extraView); + } + + // Inflate color picker view. + View colorPicker = LayoutInflater.from(context).inflate(LAYOUT_MORPHE_COLOR_PICKER, null); + dialogColorPickerView = colorPicker.findViewById(ID_MORPHE_COLOR_PICKER_VIEW); + dialogColorPickerView.setOpacitySliderEnabled(opacitySliderEnabled); + dialogColorPickerView.setColor(currentColor); + contentContainer.addView(colorPicker); + + // Horizontal layout for preview and EditText. + LinearLayout inputLayout = new LinearLayout(context); + inputLayout.setOrientation(LinearLayout.HORIZONTAL); + inputLayout.setGravity(Gravity.CENTER_VERTICAL); + + dialogColorDot = new View(context); + LinearLayout.LayoutParams previewParams = new LinearLayout.LayoutParams(Dim.dp20,Dim.dp20); + previewParams.setMargins(Dim.dp16, 0, Dim.dp10, 0); + dialogColorDot.setLayoutParams(previewParams); + inputLayout.addView(dialogColorDot); + updateDialogColorDot(); + + EditText editText = getEditText(); + ViewParent parent = editText.getParent(); + if (parent instanceof ViewGroup parentViewGroup) { + parentViewGroup.removeView(editText); + } + editText.setLayoutParams(new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + String currentColorString = getColorString(currentColor, opacitySliderEnabled); + editText.setText(currentColorString); + editText.setSelection(currentColorString.length()); + editText.setTypeface(Typeface.MONOSPACE); + colorTextWatcher = createColorTextWatcher(dialogColorPickerView); + editText.addTextChangedListener(colorTextWatcher); + inputLayout.addView(editText); + + // Add a dummy view to take up remaining horizontal space, + // otherwise it will show an oversize underlined text view. + View paddingView = new View(context); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.MATCH_PARENT, + 1f + ); + paddingView.setLayoutParams(params); + inputLayout.addView(paddingView); + + contentContainer.addView(inputLayout); + + // Create ScrollView to wrap the content container. + ScrollView contentScrollView = new ScrollView(context); + contentScrollView.setVerticalScrollBarEnabled(false); + contentScrollView.setOverScrollMode(View.OVER_SCROLL_NEVER); + LinearLayout.LayoutParams scrollViewParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 0, + 1.0f + ); + contentScrollView.setLayoutParams(scrollViewParams); + contentScrollView.addView(contentContainer); + + final int originalColor = currentColor; + Pair dialogPair = CustomDialog.create( + context, + getTitle() != null ? getTitle().toString() : str("morphe_settings_color_picker_title"), + null, + null, + null, + () -> { // OK button action. + try { + String colorString = editText.getText().toString(); + int expectedLength = opacitySliderEnabled + ? COLOR_STRING_LENGTH_WITH_ALPHA + : COLOR_STRING_LENGTH_WITHOUT_ALPHA; + if (colorString.length() != expectedLength) { + Utils.showToastShort(str("morphe_settings_color_invalid")); + setText(getColorString(originalColor, opacitySliderEnabled)); + return; + } + setText(colorString); + + onDialogOkClicked(); + } catch (Exception ex) { + // Should never happen due to a bad color string, + // since the text is validated and fixed while the user types. + Logger.printException(() -> "OK button failure", ex); + } + }, + () -> { // Cancel button action. + try { + setText(getColorString(originalColor, opacitySliderEnabled)); + } catch (Exception ex) { + Logger.printException(() -> "Cancel button failure", ex); + } + }, + str("morphe_settings_reset_color"), // Neutral button text. + this::onDialogNeutralClicked, // Neutral button action. + false // Do not dismiss dialog. + ); + + // Add the ScrollView to the dialog's main layout. + LinearLayout dialogMainLayout = dialogPair.second; + dialogMainLayout.addView(contentScrollView, dialogMainLayout.getChildCount() - 1); + + // Set up color picker listener with debouncing. + // Add listener last to prevent callbacks from set calls above. + dialogColorPickerView.setOnColorChangedListener(color -> { + // Check if it actually changed, since this callback + // can be caused by updates in afterTextChanged(). + if (currentColor == color) { + return; + } + + String updatedColorString = getColorString(color, opacitySliderEnabled); + Logger.printDebug(() -> "onColorChanged: " + updatedColorString); + currentColor = color; + editText.setText(updatedColorString); + editText.setSelection(updatedColorString.length()); + + updateDialogColorDot(); + updateWidgetColorDot(); + }); + + // Configure and show the dialog. + Dialog dialog = dialogPair.first; + dialog.setCanceledOnTouchOutside(false); + dialog.show(); + } + + @Override + protected void onDialogClosed(boolean positiveResult) { + super.onDialogClosed(positiveResult); + + if (colorTextWatcher != null) { + getEditText().removeTextChangedListener(colorTextWatcher); + colorTextWatcher = null; + } + + dialogColorDot = null; + dialogColorPickerView = null; + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + updateWidgetColorDot(); + } + + @Override + protected void onBindView(View view) { + super.onBindView(view); + + widgetColorDot = view.findViewById(ID_PREFERENCE_COLOR_DOT); + updateWidgetColorDot(); + } + + private void updateWidgetColorDot() { + if (widgetColorDot == null) return; + + ColorDot.applyColorDot( + widgetColorDot, + currentColor, + widgetColorDot.isEnabled() + ); + } + + private void updateDialogColorDot() { + if (dialogColorDot == null) return; + + ColorDot.applyColorDot( + dialogColorDot, + currentColor, + true + ); + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ColorPickerView.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ColorPickerView.java new file mode 100644 index 0000000..559fa3c --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ColorPickerView.java @@ -0,0 +1,641 @@ +package app.morphe.extension.shared.settings.preference; + +import static app.morphe.extension.shared.settings.preference.ColorPickerPreference.getColorString; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ComposeShader; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.RectF; +import android.graphics.Shader; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.ColorInt; + +import app.morphe.extension.shared.Logger; +import app.morphe.extension.shared.Utils; +import app.morphe.extension.shared.ui.Dim; + +/** + * A custom color picker view that allows the user to select a color using a hue slider, a saturation-value selector + * and an optional opacity slider. + * This implementation is density-independent and responsive across different screen sizes and DPIs. + *

+ * This view displays three main components for color selection: + *

    + *
  • Hue Bar: A horizontal bar at the bottom that allows the user to select the hue component of the color. + *
  • Saturation-Value Selector: A rectangular area above the hue bar that allows the user to select the + * saturation and value (brightness) components of the color based on the selected hue. + *
  • Opacity Slider: An optional horizontal bar below the hue bar that allows the user to adjust + * the opacity (alpha channel) of the color. + *
+ *

+ * The view uses {@link LinearGradient} and {@link ComposeShader} to create the color gradients for the hue bar, + * opacity slider, and the saturation-value selector. It also uses {@link Paint} to draw the selectors (draggable handles). + *

+ * The selected color can be retrieved using {@link #getColor()} and can be set using {@link #setColor(int)}. + * An {@link OnColorChangedListener} can be registered to receive notifications when the selected color changes. + */ +@SuppressLint("DrawAllocation") +public class ColorPickerView extends View { + /** + * Interface definition for a callback to be invoked when the selected color changes. + */ + public interface OnColorChangedListener { + /** + * Called when the selected color has changed. + */ + void onColorChanged(@ColorInt int color); + } + + /** Expanded touch area for the hue and opacity bars to increase the touch-sensitive area. */ + public static final float TOUCH_EXPANSION = Dim.dp20; + + /** Margin between different areas of the view (saturation-value selector, hue bar, and opacity slider). */ + private static final float MARGIN_BETWEEN_AREAS = Dim.dp24; + + /** Padding around the view. */ + private static final float VIEW_PADDING = Dim.dp16; + + /** Height of the hue bar. */ + private static final float HUE_BAR_HEIGHT = Dim.dp12; + + /** Height of the opacity slider. */ + private static final float OPACITY_BAR_HEIGHT = Dim.dp12; + + /** Corner radius for the hue bar. */ + private static final float HUE_CORNER_RADIUS = Dim.dp6; + + /** Corner radius for the opacity slider. */ + private static final float OPACITY_CORNER_RADIUS = Dim.dp6; + + /** Radius of the selector handles. */ + private static final float SELECTOR_RADIUS = Dim.dp12; + + /** Stroke width for the selector handle outlines. */ + private static final float SELECTOR_STROKE_WIDTH = 8; + + /** + * Hue and opacity fill radius. Use slightly smaller radius for the selector handle fill, + * otherwise the antialiasing causes the fill color to bleed past the selector outline. + */ + private static final float SELECTOR_FILL_RADIUS = SELECTOR_RADIUS - SELECTOR_STROKE_WIDTH / 2; + + /** Thin dark outline stroke width for the selector rings. */ + private static final float SELECTOR_EDGE_STROKE_WIDTH = 1; + + /** Radius for the outer edge of the selector rings, including stroke width. */ + public static final float SELECTOR_EDGE_RADIUS = + SELECTOR_RADIUS + SELECTOR_STROKE_WIDTH / 2 + SELECTOR_EDGE_STROKE_WIDTH / 2; + + /** Selector outline inner color. */ + @ColorInt + private static final int SELECTOR_OUTLINE_COLOR = Color.WHITE; + + /** Dark edge color for the selector rings. */ + @ColorInt + private static final int SELECTOR_EDGE_COLOR = Color.parseColor("#CFCFCF"); + + /** Precomputed array of hue colors for the hue bar (0-360 degrees). */ + private static final int[] HUE_COLORS = new int[361]; + static { + for (int i = 0; i < 361; i++) { + HUE_COLORS[i] = Color.HSVToColor(new float[]{i, 1, 1}); + } + } + + /** Paint for the hue bar. */ + private final Paint huePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + /** Paint for the opacity slider. */ + private final Paint opacityPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + /** Paint for the saturation-value selector. */ + private final Paint saturationValuePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + /** Paint for the draggable selector handles. */ + private final Paint selectorPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + { + selectorPaint.setStrokeWidth(SELECTOR_STROKE_WIDTH); + } + + /** Bounds of the hue bar. */ + private final RectF hueRect = new RectF(); + + /** Bounds of the opacity slider. */ + private final RectF opacityRect = new RectF(); + + /** Bounds of the saturation-value selector. */ + private final RectF saturationValueRect = new RectF(); + + /** HSV color calculations to avoid allocations during drawing. */ + private final float[] hsvArray = {1, 1, 1}; + + /** Current hue value (0-360). */ + private float hue = 0f; + + /** Current saturation value (0-1). */ + private float saturation = 1f; + + /** Current value (brightness) value (0-1). */ + private float value = 1f; + + /** Current opacity value (0-1). */ + private float opacity = 1f; + + /** The currently selected color, including alpha channel if opacity slider is enabled. */ + @ColorInt + private int selectedColor; + + /** Listener for color change events. */ + private OnColorChangedListener colorChangedListener; + + /** Tracks if the hue selector is being dragged. */ + private boolean isDraggingHue; + + /** Tracks if the saturation-value selector is being dragged. */ + private boolean isDraggingSaturation; + + /** Tracks if the opacity selector is being dragged. */ + private boolean isDraggingOpacity; + + /** Flag to enable/disable the opacity slider. */ + private boolean opacitySliderEnabled = false; + + public ColorPickerView(Context context) { + super(context); + } + + public ColorPickerView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ColorPickerView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + /** + * Enables or disables the opacity slider. + */ + public void setOpacitySliderEnabled(boolean enabled) { + if (opacitySliderEnabled != enabled) { + opacitySliderEnabled = enabled; + if (!enabled) { + opacity = 1f; // Reset to fully opaque when disabled. + updateSelectedColor(); + } + updateOpacityShader(); + requestLayout(); // Trigger re-measure to account for opacity slider. + invalidate(); + } + } + + /** + * Measures the view, ensuring a consistent aspect ratio and minimum dimensions. + */ + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + final float DESIRED_ASPECT_RATIO = 0.8f; // height = width * 0.8 + + final int minWidth = Dim.dp(250); + final int minHeight = (int) (minWidth * DESIRED_ASPECT_RATIO) + (int) (HUE_BAR_HEIGHT + MARGIN_BETWEEN_AREAS) + + (opacitySliderEnabled ? (int) (OPACITY_BAR_HEIGHT + MARGIN_BETWEEN_AREAS) : 0); + + int width = resolveSize(minWidth, widthMeasureSpec); + int height = resolveSize(minHeight, heightMeasureSpec); + + // Ensure minimum dimensions for usability. + width = Math.max(width, minWidth); + height = Math.max(height, minHeight); + + // Adjust height to maintain desired aspect ratio if possible. + final int desiredHeight = (int) (width * DESIRED_ASPECT_RATIO) + (int) (HUE_BAR_HEIGHT + MARGIN_BETWEEN_AREAS) + + (opacitySliderEnabled ? (int) (OPACITY_BAR_HEIGHT + MARGIN_BETWEEN_AREAS) : 0); + if (MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY) { + height = desiredHeight; + } + + setMeasuredDimension(width, height); + } + + /** + * Updates the view's layout when its size changes, recalculating bounds and shaders. + */ + @Override + protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldWidth, oldHeight); + + // Calculate bounds with hue bar and optional opacity bar at the bottom. + final float effectiveWidth = width - (2 * VIEW_PADDING); + final float effectiveHeight = height - (2 * VIEW_PADDING) - HUE_BAR_HEIGHT - MARGIN_BETWEEN_AREAS + - (opacitySliderEnabled ? OPACITY_BAR_HEIGHT + MARGIN_BETWEEN_AREAS : 0); + + // Adjust rectangles to account for padding and density-independent dimensions. + saturationValueRect.set( + VIEW_PADDING, + VIEW_PADDING, + VIEW_PADDING + effectiveWidth, + VIEW_PADDING + effectiveHeight + ); + + hueRect.set( + VIEW_PADDING, + height - VIEW_PADDING - HUE_BAR_HEIGHT - (opacitySliderEnabled ? OPACITY_BAR_HEIGHT + MARGIN_BETWEEN_AREAS : 0), + VIEW_PADDING + effectiveWidth, + height - VIEW_PADDING - (opacitySliderEnabled ? OPACITY_BAR_HEIGHT + MARGIN_BETWEEN_AREAS : 0) + ); + + if (opacitySliderEnabled) { + opacityRect.set( + VIEW_PADDING, + height - VIEW_PADDING - OPACITY_BAR_HEIGHT, + VIEW_PADDING + effectiveWidth, + height - VIEW_PADDING + ); + } + + // Update the shaders. + updateHueShader(); + updateSaturationValueShader(); + updateOpacityShader(); + } + + /** + * Updates the shader for the hue bar to reflect the color gradient. + */ + private void updateHueShader() { + LinearGradient hueShader = new LinearGradient( + hueRect.left, hueRect.top, + hueRect.right, hueRect.top, + HUE_COLORS, + null, + Shader.TileMode.CLAMP + ); + + huePaint.setShader(hueShader); + } + + /** + * Updates the shader for the opacity slider to reflect the current RGB color with varying opacity. + */ + private void updateOpacityShader() { + if (!opacitySliderEnabled) { + opacityPaint.setShader(null); + return; + } + + // Create a linear gradient for opacity from transparent to opaque, using the current RGB color. + int rgbColor = Color.HSVToColor(0, new float[]{hue, saturation, value}); + LinearGradient opacityShader = new LinearGradient( + opacityRect.left, opacityRect.top, + opacityRect.right, opacityRect.top, + rgbColor & 0x00FFFFFF, // Fully transparent + rgbColor | 0xFF000000, // Fully opaque + Shader.TileMode.CLAMP + ); + + opacityPaint.setShader(opacityShader); + } + + /** + * Updates the shader for the saturation-value selector to reflect the current hue. + */ + @SuppressWarnings("ExtractMethodRecommender") + private void updateSaturationValueShader() { + // Create a saturation-value gradient based on the current hue. + // Calculate the start color (white with the selected hue) for the saturation gradient. + final int startColor = Color.HSVToColor(new float[]{hue, 0f, 1f}); + + // Calculate the middle color (fully saturated color with the selected hue) for the saturation gradient. + final int midColor = Color.HSVToColor(new float[]{hue, 1f, 1f}); + + // Create a linear gradient for the saturation from startColor to midColor (horizontal). + LinearGradient satShader = new LinearGradient( + saturationValueRect.left, saturationValueRect.top, + saturationValueRect.right, saturationValueRect.top, + startColor, + midColor, + Shader.TileMode.CLAMP + ); + + // Create a linear gradient for the value (brightness) from white to black (vertical). + LinearGradient valShader = new LinearGradient( + saturationValueRect.left, saturationValueRect.top, + saturationValueRect.left, saturationValueRect.bottom, + Color.WHITE, + Color.BLACK, + Shader.TileMode.CLAMP + ); + + // Combine the saturation and value shaders using PorterDuff.Mode.MULTIPLY to create the final color. + ComposeShader combinedShader = new ComposeShader(satShader, valShader, PorterDuff.Mode.MULTIPLY); + + // Set the combined shader for the saturation-value paint. + saturationValuePaint.setShader(combinedShader); + } + + /** + * Draws the color picker components, including the saturation-value selector, hue bar, opacity slider, and their respective handles. + */ + @Override + protected void onDraw(Canvas canvas) { + // Draw the saturation-value selector rectangle. + canvas.drawRect(saturationValueRect, saturationValuePaint); + + // Draw the hue bar. + canvas.drawRoundRect(hueRect, HUE_CORNER_RADIUS, HUE_CORNER_RADIUS, huePaint); + + // Draw the opacity bar if enabled. + if (opacitySliderEnabled) { + canvas.drawRoundRect(opacityRect, OPACITY_CORNER_RADIUS, OPACITY_CORNER_RADIUS, opacityPaint); + } + + final float hueSelectorX = hueRect.left + (hue / 360f) * hueRect.width(); + final float hueSelectorY = hueRect.centerY(); + + final float satSelectorX = saturationValueRect.left + saturation * saturationValueRect.width(); + final float satSelectorY = saturationValueRect.top + (1 - value) * saturationValueRect.height(); + + // Draw the saturation and hue selector handles filled with their respective colors (fully opaque). + hsvArray[0] = hue; + final int hueHandleColor = Color.HSVToColor(0xFF, hsvArray); // Force opaque for hue handle. + final int satHandleColor = Color.HSVToColor(0xFF, new float[]{hue, saturation, value}); // Force opaque for sat-val handle. + selectorPaint.setStyle(Paint.Style.FILL_AND_STROKE); + + selectorPaint.setColor(hueHandleColor); + canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_FILL_RADIUS, selectorPaint); + + selectorPaint.setColor(satHandleColor); + canvas.drawCircle(satSelectorX, satSelectorY, SELECTOR_FILL_RADIUS, selectorPaint); + + if (opacitySliderEnabled) { + final float opacitySelectorX = opacityRect.left + opacity * opacityRect.width(); + final float opacitySelectorY = opacityRect.centerY(); + selectorPaint.setColor(selectedColor); // Use full ARGB color to show opacity. + canvas.drawCircle(opacitySelectorX, opacitySelectorY, SELECTOR_FILL_RADIUS, selectorPaint); + } + + // Draw white outlines for the handles. + selectorPaint.setColor(SELECTOR_OUTLINE_COLOR); + selectorPaint.setStyle(Paint.Style.STROKE); + selectorPaint.setStrokeWidth(SELECTOR_STROKE_WIDTH); + canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_RADIUS, selectorPaint); + canvas.drawCircle(satSelectorX, satSelectorY, SELECTOR_RADIUS, selectorPaint); + if (opacitySliderEnabled) { + final float opacitySelectorX = opacityRect.left + opacity * opacityRect.width(); + final float opacitySelectorY = opacityRect.centerY(); + canvas.drawCircle(opacitySelectorX, opacitySelectorY, SELECTOR_RADIUS, selectorPaint); + } + + // Draw thin dark outlines for the handles at the outer edge of the white outline. + selectorPaint.setColor(SELECTOR_EDGE_COLOR); + selectorPaint.setStrokeWidth(SELECTOR_EDGE_STROKE_WIDTH); + canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_EDGE_RADIUS, selectorPaint); + canvas.drawCircle(satSelectorX, satSelectorY, SELECTOR_EDGE_RADIUS, selectorPaint); + if (opacitySliderEnabled) { + final float opacitySelectorX = opacityRect.left + opacity * opacityRect.width(); + final float opacitySelectorY = opacityRect.centerY(); + canvas.drawCircle(opacitySelectorX, opacitySelectorY, SELECTOR_EDGE_RADIUS, selectorPaint); + } + } + + /** + * Handles touch events to allow dragging of the hue, saturation-value, and opacity selectors. + * + * @param event The motion event. + * @return True if the event was handled, false otherwise. + */ + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouchEvent(MotionEvent event) { + try { + final float x = event.getX(); + final float y = event.getY(); + final int action = event.getAction(); + Logger.printDebug(() -> "onTouchEvent action: " + action + " x: " + x + " y: " + y); + + // Define touch expansion for the hue and opacity bars. + RectF expandedHueRect = new RectF( + hueRect.left, + hueRect.top - TOUCH_EXPANSION, + hueRect.right, + hueRect.bottom + TOUCH_EXPANSION + ); + RectF expandedOpacityRect = opacitySliderEnabled ? new RectF( + opacityRect.left, + opacityRect.top - TOUCH_EXPANSION, + opacityRect.right, + opacityRect.bottom + TOUCH_EXPANSION + ) : new RectF(); + + switch (action) { + case MotionEvent.ACTION_DOWN: + // Calculate current handle positions. + final float hueSelectorX = hueRect.left + (hue / 360f) * hueRect.width(); + final float hueSelectorY = hueRect.centerY(); + + final float satSelectorX = saturationValueRect.left + saturation * saturationValueRect.width(); + final float valSelectorY = saturationValueRect.top + (1 - value) * saturationValueRect.height(); + + final float opacitySelectorX = opacitySliderEnabled ? opacityRect.left + opacity * opacityRect.width() : 0; + final float opacitySelectorY = opacitySliderEnabled ? opacityRect.centerY() : 0; + + // Create hit areas for all handles. + RectF hueHitRect = new RectF( + hueSelectorX - SELECTOR_RADIUS, + hueSelectorY - SELECTOR_RADIUS, + hueSelectorX + SELECTOR_RADIUS, + hueSelectorY + SELECTOR_RADIUS + ); + RectF satValHitRect = new RectF( + satSelectorX - SELECTOR_RADIUS, + valSelectorY - SELECTOR_RADIUS, + satSelectorX + SELECTOR_RADIUS, + valSelectorY + SELECTOR_RADIUS + ); + RectF opacityHitRect = opacitySliderEnabled ? new RectF( + opacitySelectorX - SELECTOR_RADIUS, + opacitySelectorY - SELECTOR_RADIUS, + opacitySelectorX + SELECTOR_RADIUS, + opacitySelectorY + SELECTOR_RADIUS + ) : new RectF(); + + // Check if the touch started on a handle or within the expanded bar areas. + if (hueHitRect.contains(x, y)) { + isDraggingHue = true; + updateHueFromTouch(x); + } else if (satValHitRect.contains(x, y)) { + isDraggingSaturation = true; + updateSaturationValueFromTouch(x, y); + } else if (opacitySliderEnabled && opacityHitRect.contains(x, y)) { + isDraggingOpacity = true; + updateOpacityFromTouch(x); + } else if (expandedHueRect.contains(x, y)) { + // Handle touch within the expanded hue bar area. + isDraggingHue = true; + updateHueFromTouch(x); + } else if (saturationValueRect.contains(x, y)) { + isDraggingSaturation = true; + updateSaturationValueFromTouch(x, y); + } else if (opacitySliderEnabled && expandedOpacityRect.contains(x, y)) { + isDraggingOpacity = true; + updateOpacityFromTouch(x); + } + break; + + case MotionEvent.ACTION_MOVE: + // Continue updating values even if touch moves outside the view. + if (isDraggingHue) { + updateHueFromTouch(x); + } else if (isDraggingSaturation) { + updateSaturationValueFromTouch(x, y); + } else if (isDraggingOpacity) { + updateOpacityFromTouch(x); + } + break; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + isDraggingHue = false; + isDraggingSaturation = false; + isDraggingOpacity = false; + break; + } + } catch (Exception ex) { + Logger.printException(() -> "onTouchEvent failure", ex); + } + + return true; + } + + /** + * Updates the hue value based on a touch event. + */ + private void updateHueFromTouch(float x) { + // Clamp x to the hue rectangle bounds. + final float clampedX = Utils.clamp(x, hueRect.left, hueRect.right); + final float updatedHue = ((clampedX - hueRect.left) / hueRect.width()) * 360f; + if (hue == updatedHue) { + return; + } + + hue = updatedHue; + updateSaturationValueShader(); + updateOpacityShader(); + updateSelectedColor(); + } + + /** + * Updates the saturation and value based on a touch event. + */ + private void updateSaturationValueFromTouch(float x, float y) { + // Clamp x and y to the saturation-value rectangle bounds. + final float clampedX = Utils.clamp(x, saturationValueRect.left, saturationValueRect.right); + final float clampedY = Utils.clamp(y, saturationValueRect.top, saturationValueRect.bottom); + + final float updatedSaturation = (clampedX - saturationValueRect.left) / saturationValueRect.width(); + final float updatedValue = 1 - ((clampedY - saturationValueRect.top) / saturationValueRect.height()); + + if (saturation == updatedSaturation && value == updatedValue) { + return; + } + saturation = updatedSaturation; + value = updatedValue; + updateOpacityShader(); + updateSelectedColor(); + } + + /** + * Updates the opacity value based on a touch event. + */ + private void updateOpacityFromTouch(float x) { + if (!opacitySliderEnabled) { + return; + } + final float clampedX = Utils.clamp(x, opacityRect.left, opacityRect.right); + final float updatedOpacity = (clampedX - opacityRect.left) / opacityRect.width(); + if (opacity == updatedOpacity) { + return; + } + opacity = updatedOpacity; + updateSelectedColor(); + } + + /** + * Updates the selected color based on the current hue, saturation, value, and opacity. + */ + private void updateSelectedColor() { + final int rgbColor = Color.HSVToColor(0, new float[]{hue, saturation, value}); + final int updatedColor = opacitySliderEnabled + ? (rgbColor & 0x00FFFFFF) | (((int) (opacity * 255)) << 24) + : (rgbColor & 0x00FFFFFF) | 0xFF000000; + + if (selectedColor != updatedColor) { + selectedColor = updatedColor; + + if (colorChangedListener != null) { + colorChangedListener.onColorChanged(updatedColor); + } + } + + // Must always redraw, otherwise if saturation is pure grey or black + // then the hue slider cannot be changed. + invalidate(); + } + + /** + * Sets the selected color, updating the hue, saturation, value and opacity sliders accordingly. + */ + public void setColor(@ColorInt int color) { + if (selectedColor == color) { + return; + } + + // Update the selected color. + selectedColor = color; + Logger.printDebug(() -> "setColor: " + getColorString(selectedColor, opacitySliderEnabled)); + + // Convert the ARGB color to HSV values. + float[] hsv = new float[3]; + Color.colorToHSV(color, hsv); + + // Update the hue, saturation, and value. + hue = hsv[0]; + saturation = hsv[1]; + value = hsv[2]; + opacity = opacitySliderEnabled ? ((color >> 24) & 0xFF) / 255f : 1f; + + // Update the saturation-value shader based on the new hue. + updateSaturationValueShader(); + updateOpacityShader(); + + // Notify the listener if it's set. + if (colorChangedListener != null) { + colorChangedListener.onColorChanged(selectedColor); + } + + // Invalidate the view to trigger a redraw. + invalidate(); + } + + /** + * Gets the currently selected color. + */ + @ColorInt + public int getColor() { + return selectedColor; + } + + /** + * Sets a listener to be notified when the selected color changes. + */ + public void setOnColorChangedListener(OnColorChangedListener listener) { + colorChangedListener = listener; + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ColorPickerWithOpacitySliderPreference.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ColorPickerWithOpacitySliderPreference.java new file mode 100644 index 0000000..9fa0730 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ColorPickerWithOpacitySliderPreference.java @@ -0,0 +1,34 @@ +package app.morphe.extension.shared.settings.preference; + +import android.content.Context; +import android.util.AttributeSet; + +/** + * Extended ColorPickerPreference that enables the opacity slider for color selection. + */ +@SuppressWarnings("unused") +public class ColorPickerWithOpacitySliderPreference extends ColorPickerPreference { + + public ColorPickerWithOpacitySliderPreference(Context context) { + super(context); + init(); + } + + public ColorPickerWithOpacitySliderPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ColorPickerWithOpacitySliderPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + /** + * Initialize the preference with opacity slider enabled. + */ + private void init() { + // Enable the opacity slider for alpha channel support. + setOpacitySliderEnabled(true); + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/CustomDialogListPreference.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/CustomDialogListPreference.java new file mode 100644 index 0000000..8aedd11 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/CustomDialogListPreference.java @@ -0,0 +1,267 @@ +package app.morphe.extension.shared.settings.preference; + +import static app.morphe.extension.shared.ResourceUtils.getIdentifierOrThrow; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.preference.ListPreference; +import android.util.AttributeSet; +import android.util.Pair; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import app.morphe.extension.shared.ResourceType; +import app.morphe.extension.shared.Utils; +import app.morphe.extension.shared.ui.CustomDialog; + +/** + * A custom ListPreference that uses a styled custom dialog with a custom checkmark indicator, + * supports a static summary and highlighted entries for search functionality. + */ +@SuppressWarnings({"unused", "deprecation"}) +public class CustomDialogListPreference extends ListPreference { + + public static final int ID_MORPHE_CHECK_ICON = getIdentifierOrThrow( + ResourceType.ID, "morphe_check_icon"); + public static final int ID_MORPHE_CHECK_ICON_PLACEHOLDER = getIdentifierOrThrow( + ResourceType.ID, "morphe_check_icon_placeholder"); + public static final int ID_MORPHE_ITEM_TEXT = getIdentifierOrThrow( + ResourceType.ID, "morphe_item_text"); + public static final int LAYOUT_MORPHE_CUSTOM_LIST_ITEM_CHECKED = getIdentifierOrThrow( + ResourceType.LAYOUT, "morphe_custom_list_item_checked"); + public static final int DRAWABLE_CHECKMARK = getIdentifierOrThrow( + ResourceType.DRAWABLE, "morphe_settings_custom_checkmark"); + public static final int DRAWABLE_CHECKMARK_BOLD = getIdentifierOrThrow( + ResourceType.DRAWABLE, "morphe_settings_custom_checkmark_bold"); + + private String staticSummary = null; + private CharSequence[] highlightedEntriesForDialog = null; + + /** + * Set a static summary that will not be overwritten by value changes. + */ + public void setStaticSummary(String summary) { + this.staticSummary = summary; + } + + /** + * Returns the static summary if set, otherwise null. + */ + @Nullable + public String getStaticSummary() { + return staticSummary; + } + + /** + * Always return static summary if set. + */ + @Override + public CharSequence getSummary() { + if (staticSummary != null) { + return staticSummary; + } + return super.getSummary(); + } + + /** + * Sets highlighted entries for display in the dialog. + * These entries are used only for the current dialog and are automatically cleared. + */ + public void setHighlightedEntriesForDialog(CharSequence[] highlightedEntries) { + this.highlightedEntriesForDialog = highlightedEntries; + } + + /** + * Clears highlighted entries after the dialog is closed. + */ + public void clearHighlightedEntriesForDialog() { + this.highlightedEntriesForDialog = null; + } + + /** + * Returns entries for display in the dialog. + * If highlighted entries exist, they are used; otherwise, the original entries are returned. + */ + private CharSequence[] getEntriesForDialog() { + return highlightedEntriesForDialog != null ? highlightedEntriesForDialog : getEntries(); + } + + /** + * Custom ArrayAdapter to handle checkmark visibility. + */ + public static class ListPreferenceArrayAdapter extends ArrayAdapter { + private static class SubViewDataContainer { + ImageView checkIcon; + View placeholder; + TextView itemText; + } + + final int layoutResourceId; + final CharSequence[] entryValues; + String selectedValue; + + public ListPreferenceArrayAdapter(Context context, int resource, + CharSequence[] entries, + CharSequence[] entryValues, + String selectedValue) { + super(context, resource, entries); + this.layoutResourceId = resource; + this.entryValues = entryValues; + this.selectedValue = selectedValue; + } + + @NonNull + @Override + public View getView(int position, View convertView, @NonNull ViewGroup parent) { + View view = convertView; + SubViewDataContainer holder; + + if (view == null) { + LayoutInflater inflater = LayoutInflater.from(getContext()); + view = inflater.inflate(layoutResourceId, parent, false); + holder = new SubViewDataContainer(); + holder.placeholder = view.findViewById(ID_MORPHE_CHECK_ICON_PLACEHOLDER); + holder.itemText = view.findViewById(ID_MORPHE_ITEM_TEXT); + holder.checkIcon = view.findViewById(ID_MORPHE_CHECK_ICON); + holder.checkIcon.setImageResource(Utils.appIsUsingBoldIcons() + ? DRAWABLE_CHECKMARK_BOLD + : DRAWABLE_CHECKMARK + ); + view.setTag(holder); + } else { + holder = (SubViewDataContainer) view.getTag(); + } + + CharSequence itemText = getItem(position); + holder.itemText.setText(itemText); + holder.itemText.setTextColor(Utils.getAppForegroundColor()); + + // Show or hide checkmark and placeholder. + String currentValue = entryValues[position].toString(); + boolean isSelected = currentValue.equals(selectedValue); + holder.checkIcon.setVisibility(isSelected ? View.VISIBLE : View.GONE); + holder.checkIcon.setColorFilter(Utils.getAppForegroundColor()); + holder.placeholder.setVisibility(isSelected ? View.GONE : View.VISIBLE); + + return view; + } + + public void setSelectedValue(String value) { + this.selectedValue = value; + } + } + + public CustomDialogListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public CustomDialogListPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public CustomDialogListPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public CustomDialogListPreference(Context context) { + super(context); + } + + @Override + protected void showDialog(Bundle state) { + Context context = getContext(); + + CharSequence[] entriesToShow = getEntriesForDialog(); + CharSequence[] entryValues = getEntryValues(); + + // Create ListView. + ListView listView = new ListView(context); + listView.setId(android.R.id.list); + listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); + + // Create custom adapter for the ListView. + ListPreferenceArrayAdapter adapter = new ListPreferenceArrayAdapter( + context, + LAYOUT_MORPHE_CUSTOM_LIST_ITEM_CHECKED, + entriesToShow, + entryValues, + getValue() + ); + listView.setAdapter(adapter); + + // Set checked item. + String currentValue = getValue(); + if (currentValue != null) { + for (int i = 0, length = entryValues.length; i < length; i++) { + if (currentValue.equals(entryValues[i].toString())) { + listView.setItemChecked(i, true); + listView.setSelection(i); + break; + } + } + } + + // Create the custom dialog without OK button. + Pair dialogPair = CustomDialog.create( + context, + getTitle() != null ? getTitle().toString() : "", + null, + null, + null, + null, + this::clearHighlightedEntriesForDialog, // Cancel button action. + null, + null, + true + ); + + Dialog dialog = dialogPair.first; + // Add a listener to clear when the dialog is closed in any way. + dialog.setOnDismissListener(dialogInterface -> clearHighlightedEntriesForDialog()); + + // Add the ListView to the main layout. + LinearLayout mainLayout = dialogPair.second; + LinearLayout.LayoutParams listViewParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 0, + 1.0f + ); + mainLayout.addView(listView, mainLayout.getChildCount() - 1, listViewParams); + + // Handle item click to select value and dismiss dialog. + listView.setOnItemClickListener((parent, view, position, id) -> { + String selectedValue = entryValues[position].toString(); + if (callChangeListener(selectedValue)) { + setValue(selectedValue); + + // Update summaries from the original entries (without highlighting). + if (staticSummary == null) { + CharSequence[] originalEntries = getEntries(); + if (originalEntries != null && position < originalEntries.length) { + setSummary(originalEntries[position]); + } + } + + adapter.setSelectedValue(selectedValue); + adapter.notifyDataSetChanged(); + } + + // Clear highlighted entries before closing. + clearHighlightedEntriesForDialog(); + dialog.dismiss(); + }); + + // Show the dialog. + dialog.show(); + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ExportLogToClipboardPreference.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ExportLogToClipboardPreference.java new file mode 100644 index 0000000..e2772ca --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ExportLogToClipboardPreference.java @@ -0,0 +1,33 @@ +package app.morphe.extension.shared.settings.preference; + +import android.content.Context; +import android.preference.Preference; +import android.util.AttributeSet; + +/** + * A custom preference that triggers exporting Morphe debug logs to the clipboard when clicked. + * Invokes the {@link LogBufferManager#exportToClipboard} method. + */ +@SuppressWarnings({"deprecation", "unused"}) +public class ExportLogToClipboardPreference extends Preference { + + { + setOnPreferenceClickListener(pref -> { + LogBufferManager.exportToClipboard(); + return true; + }); + } + + public ExportLogToClipboardPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + public ExportLogToClipboardPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + public ExportLogToClipboardPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + public ExportLogToClipboardPreference(Context context) { + super(context); + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ImportExportPreference.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ImportExportPreference.java new file mode 100644 index 0000000..03109d0 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ImportExportPreference.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-patches + * + * Original hard forked code: + * https://github.com/ReVanced/revanced-patches/commit/724e6d61b2ecd868c1a9a37d465a688e83a74799 + * + * See the included NOTICE file for GPLv3 §7(b) and §7(c) terms that apply to Morphe contributions. + */ + +package app.morphe.extension.shared.settings.preference; + +import android.content.Context; +import android.preference.Preference; +import android.util.AttributeSet; + +import app.morphe.extension.shared.Logger; + +@SuppressWarnings({"unused", "deprecation"}) +public class ImportExportPreference extends Preference implements Preference.OnPreferenceClickListener { + + public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + public ImportExportPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + public ImportExportPreference(Context context) { + super(context); + init(); + } + + private void init() { + setOnPreferenceClickListener(this); + } + + @Override + public boolean onPreferenceClick(Preference preference) { + try { + if (AbstractPreferenceFragment.instance != null) { + AbstractPreferenceFragment.instance.showImportExportTextDialog(); + } + } catch (Exception ex) { + Logger.printException(() -> "onPreferenceClick failure", ex); + } + + return true; + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/LogBufferManager.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/LogBufferManager.java new file mode 100644 index 0000000..ed04be2 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/LogBufferManager.java @@ -0,0 +1,113 @@ +package app.morphe.extension.shared.settings.preference; + +import static app.morphe.extension.shared.StringRef.str; + +import java.util.Deque; +import java.util.Objects; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.atomic.AtomicInteger; + +import app.morphe.extension.shared.Logger; +import app.morphe.extension.shared.Utils; +import app.morphe.extension.shared.settings.BaseSettings; + +/** + * Manages a buffer for storing debug logs from {@link Logger}. + * Stores just under 1MB of the most recent log data. + *

+ * All methods are thread-safe. + */ +public final class LogBufferManager { + /** Maximum byte size of all buffer entries. Must be less than Android's 1 MB Binder transaction limit. */ + private static final int BUFFER_MAX_BYTES = 900_000; + /** Limit number of log lines. */ + private static final int BUFFER_MAX_SIZE = 10_000; + + private static final Deque logBuffer = new ConcurrentLinkedDeque<>(); + private static final AtomicInteger logBufferByteSize = new AtomicInteger(); + + /** + * Appends a log message to the internal buffer if debugging is enabled. + * The buffer is limited to approximately {@link #BUFFER_MAX_BYTES} or {@link #BUFFER_MAX_SIZE} + * to prevent excessive memory usage. + * + * @param message The log message to append. + */ + public static void appendToLogBuffer(String message) { + Objects.requireNonNull(message); + + // It's very important that no Settings are used in this method, + // as this code is used when a context is not set and thus referencing + // a setting will crash the app. + logBuffer.addLast(message); + int newSize = logBufferByteSize.addAndGet(message.length()); + + // Remove the oldest entries if over the log size limits. + while (newSize > BUFFER_MAX_BYTES || logBuffer.size() > BUFFER_MAX_SIZE) { + String removed = logBuffer.pollFirst(); + if (removed == null) { + // Thread race of two different calls to this method, and the other thread won. + return; + } + + newSize = logBufferByteSize.addAndGet(-removed.length()); + } + } + + /** + * Exports all logs from the internal buffer to the clipboard. + * Displays a toast with the result. + */ + public static void exportToClipboard() { + try { + if (!BaseSettings.DEBUG.get()) { + Utils.showToastShort(str("morphe_debug_logs_disabled")); + return; + } + + if (logBuffer.isEmpty()) { + Utils.showToastShort(str("morphe_debug_logs_none_found")); + clearLogBufferData(); // Clear toast log entry that was just created. + return; + } + + // Most (but not all) Android 13+ devices always show a "copied to clipboard" toast + // and there is no way to programmatically detect if a toast will show or not. + // Show a toast even if using Android 13+, but show Morphe toast first (before copying to clipboard). + Utils.showToastShort(str("morphe_debug_logs_copied_to_clipboard")); + + Utils.setClipboard(String.join("\n", logBuffer)); + } catch (Exception ex) { + // Handle security exception if clipboard access is denied. + String errorMessage = String.format(str("morphe_debug_logs_failed_to_export"), ex.getMessage()); + Utils.showToastLong(errorMessage); + Logger.printDebug(() -> errorMessage, ex); + } + } + + private static void clearLogBufferData() { + // Cannot simply clear the log buffer because there is no + // write lock for both the deque and the atomic int. + // Instead, pop off log entries and decrement the size one by one. + while (!logBuffer.isEmpty()) { + String removed = logBuffer.pollFirst(); + if (removed != null) { + logBufferByteSize.addAndGet(-removed.length()); + } + } + } + + /** + * Clears the internal log buffer and displays a toast with the result. + */ + public static void clearLogBuffer() { + if (!BaseSettings.DEBUG.get()) { + Utils.showToastShort(str("morphe_debug_logs_disabled")); + return; + } + + // Show toast before clearing, otherwise toast log will still remain. + Utils.showToastShort(str("morphe_debug_logs_clear_toast")); + clearLogBufferData(); + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/MorpheAboutPreference.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/MorpheAboutPreference.java new file mode 100644 index 0000000..731f293 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/MorpheAboutPreference.java @@ -0,0 +1,990 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-patches + * + * Original hard forked code: + * https://github.com/ReVanced/revanced-patches/commit/724e6d61b2ecd868c1a9a37d465a688e83a74799 + * + * See the included NOTICE file for GPLv3 §7(b) and §7(c) terms that apply to Morphe contributions. + */ + +package app.morphe.extension.shared.settings.preference; + +import static app.morphe.extension.shared.StringRef.str; +import static app.morphe.extension.shared.requests.Route.Method.GET; +import static app.morphe.extension.shared.settings.preference.MorpheAboutPreference.CREDITS_LINK; +import static app.morphe.extension.shared.settings.preference.MorpheAboutPreference.CREDITS_LINK_PLACEHOLDER_URL; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.Dialog; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.RoundRectShape; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.preference.Preference; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.View; +import android.view.Window; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.LinearLayout; + +import app.morphe.extension.shared.StringRef; +import app.morphe.extension.shared.settings.preference.MorpheAboutPreference.WebLink; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +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.requests.Requester; +import app.morphe.extension.shared.requests.Route; +import app.morphe.extension.shared.ui.Dim; + +/** + * Opens a dialog showing official links. + */ +@SuppressWarnings({"unused", "deprecation"}) +public class MorpheAboutPreference extends Preference { + + static class WebLink { + /** + * Localized name replacements for links. + */ + private static final Map webLinkNameReplacements = new HashMap<>() { + { + // Handle no string resources available, and use the original untranslated tet. + var websiteStringKey = "morphe_settings_about_links_website"; + if (ResourceUtils.getIdentifier(ResourceType.STRING, websiteStringKey) != 0) { + put("website", websiteStringKey); + put("donate", "morphe_settings_about_links_donate"); + put("translations", "morphe_settings_about_links_translations"); + put("credits", "morphe_settings_about_links_credits"); + } + } + }; + + final boolean preferred; + final String name; + @Nullable + final String subText; + final String url; + + WebLink(JSONObject json) throws JSONException { + this(json.getBoolean("preferred"), + json.getString("name"), + null, + json.getString("url") + ); + } + + WebLink(String name, @Nullable String subText, String url) { + this(false, name, url, subText); + } + + WebLink(boolean preferred, String name, @Nullable String subText, String url) { + this.preferred = preferred; + String localizedNameKey = webLinkNameReplacements.get(name.toLowerCase(Locale.US)); + this.name = (localizedNameKey != null) ? str(localizedNameKey) : name; + this.subText = subText; + this.url = url; + } + + @NonNull + @Override + public String toString() { + return "WebLink{" + + "preferred=" + preferred + + ", name='" + name + '\'' + + ", subText='" + subText + '\'' + + ", url='" + url + '\'' + + '}'; + } + } + + /** + * Returns an SVG icon string based on the link URL pattern. + * Matching by URL avoids issues with localized link names. + */ + private static String getLinkIcon(String url) { + // Globe - website / generic + final String iconGlobe = + "" + + "" + + ""; + // Heart - donate + final String iconHeart = + ""; + // Bubble + A - translations + final String iconTranslate = + "" + + "" + + "" + + ""; + // Person - credits + final String iconPerson = + "" + + ""; + // GitHub mark + final String iconGitHub = + ""; + // X / Twitter + final String iconX = + ""; + // Reddit - alien head + final String iconReddit = + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; + // External link - fallback + final String iconExternal = + ""; + + if (url == null) return iconExternal; + String u = url.toLowerCase(Locale.US); + if (u.contains("github.com")) return iconGitHub; + if (u.contains("reddit.com")) return iconReddit; + if (u.contains("twitter.com") || u.contains("x.com")) return iconX; + if (u.contains("crowdin") || u.contains("translate")) return iconTranslate; + if (u.contains("donate") || u.contains("donat")) return iconHeart; + if (u.equals("https://credits/")) return iconPerson; + return iconGlobe; + } + + // Dummy url + static final String CREDITS_LINK_PLACEHOLDER_URL = "https://morphe.software/credits/"; + + static final WebLink CREDITS_LINK = new WebLink("credits", CREDITS_LINK_PLACEHOLDER_URL, null); + + private static String useNonBreakingHyphens(String text) { + // Replace any dashes with non-breaking dashes, so the English text 'pre-release' + // and the dev release number does not break and cover two lines. + return text.replace("-", "‑"); // #8209 = non breaking hyphen. + } + + /** + * Apps that do not support bundling resources must override this. + * + * @return A localized string to display for the key. + */ + protected String getString(String key, Object... args) { + return str(key, args); + } + + private String createDialogHtml(WebLink[] aboutLinks, @Nullable String currentVersion) { + final boolean isNetworkConnected = Utils.isNetworkConnected(); + + // Get theme colors. + String foregroundColorHex = Utils.getColorHexString(Utils.getAppForegroundColor()); + String backgroundColorHex = Utils.getColorHexString(Utils.getDialogBackgroundColor()); + + // Morphe brand colors from logo. + String morpheBlue = "#1E5AA8"; + String morpheTeal = "#00AFAE"; + + StringBuilder html = new StringBuilder(String.format(""" + + + + + + + """, backgroundColorHex, foregroundColorHex, + morpheBlue, morpheTeal, + foregroundColorHex, + morpheBlue, foregroundColorHex, + foregroundColorHex, foregroundColorHex, foregroundColorHex + )); + + // Header section. + html.append("

"); + + // Logo with Morphe gradient border. + if (isNetworkConnected) { + html.append(String.format(""" +
+
+ +
+
+ """, AboutRoutes.aboutLogoUrl)); + } + + // App name. + html.append("
Morphe
"); + + String appPatchesVersion = Utils.getPatchesReleaseVersion(); + + // Version info card. + boolean isUpToDate = currentVersion == null || appPatchesVersion.equalsIgnoreCase(currentVersion); + String versionTitle = isUpToDate + ? getString("morphe_settings_about_links_dev_header_up_to_date") + : getString("morphe_settings_about_links_dev_header_update_available"); + html.append(String.format(""" +
+

%s

+

%s

+
+ """, + useNonBreakingHyphens(versionTitle), + useNonBreakingHyphens(isUpToDate + ? getString("morphe_settings_about_links_body_version_current", appPatchesVersion) + : getString("morphe_settings_about_links_body_version_outdated", appPatchesVersion, currentVersion) + ) + )); + + // Dev note card. + if (Utils.isPreReleasePatches()) { + html.append(String.format(""" +
+

%s

+

%s

+
+ """, useNonBreakingHyphens(getString("morphe_settings_about_links_dev_header")), + getString("morphe_settings_about_links_dev_body") + )); + } + + html.append("
"); // end .about-header + + // Links section. + html.append(String.format(""" + + + + """); + + return html.toString(); + } + + { + setOnPreferenceClickListener(pref -> { + Context context = pref.getContext(); + + // Show a progress spinner if the social links are not fetched yet. + if (Utils.isNetworkConnected() && !AboutRoutes.hasFetchedLinks() && !AboutRoutes.hasFetchedPatchersVersion()) { + // Show a progress spinner, but only if the api fetch takes more than a half a second. + final long delayToShowProgressSpinner = 500; + ProgressDialog progress = new ProgressDialog(getContext()); + progress.setProgressStyle(ProgressDialog.STYLE_SPINNER); + + Handler handler = new Handler(Looper.getMainLooper()); + Runnable showDialogRunnable = progress::show; + handler.postDelayed(showDialogRunnable, delayToShowProgressSpinner); + + Utils.runOnBackgroundThread(() -> + fetchLinksAndShowDialog(context, handler, showDialogRunnable, progress)); + } else { + // No network call required and can run now. + fetchLinksAndShowDialog(context, null, null, null); + } + + return false; + }); + } + + private void fetchLinksAndShowDialog(Context context, + @Nullable Handler handler, + Runnable showDialogRunnable, + @Nullable ProgressDialog progress) { + WebLink[] links = AboutRoutes.fetchAboutLinks(); + String currentVersion = AboutRoutes.getLatestPatchesVersion(); + String htmlDialog = createDialogHtml(links, currentVersion); + + // Enable to randomly force a delay to debug the spinner logic. + final boolean debugSpinnerDelayLogic = false; + //noinspection ConstantConditions + if (debugSpinnerDelayLogic && handler != null && Math.random() < 0.5f) { + Utils.doNothingForDuration((long) (Math.random() * 4000)); + } + + Utils.runOnMainThreadNowOrLater(() -> { + if (handler != null) { + handler.removeCallbacks(showDialogRunnable); + } + + // Don't continue if the activity is done. To test this tap the + // about dialog and immediately press back before the dialog can show. + if (context instanceof Activity activity) { + if (activity.isFinishing() || activity.isDestroyed()) { + Logger.printDebug(() -> "Not showing about dialog, activity is closed"); + return; + } + } + + if (progress != null && progress.isShowing()) { + progress.dismiss(); + } + new WebViewDialog(getContext(), htmlDialog).show(); + }); + } + + public MorpheAboutPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + public MorpheAboutPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + public MorpheAboutPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + public MorpheAboutPreference(Context context) { + super(context); + } +} + +/** + * Displays HTML content as a dialog. Any links a user taps on are opened in an external browser. + */ +class WebViewDialog extends Dialog { + + private final String htmlContent; + + public WebViewDialog(@NonNull Context context, @NonNull String htmlContent) { + super(context); + this.htmlContent = htmlContent; + } + + // JS required to hide any broken images. No remote JavaScript is ever loaded. + @SuppressLint("SetJavaScriptEnabled") + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); // Remove default title bar. + + // Create main layout. + LinearLayout mainLayout = new LinearLayout(getContext()); + mainLayout.setOrientation(LinearLayout.VERTICAL); + + mainLayout.setPadding(Dim.dp10, Dim.dp10, Dim.dp10, Dim.dp10); + // Set rounded rectangle background. + ShapeDrawable mainBackground = new ShapeDrawable(new RoundRectShape( + Dim.roundedCorners(28), null, null)); + mainBackground.getPaint().setColor(Utils.getDialogBackgroundColor()); + mainLayout.setBackground(mainBackground); + + // Create WebView. + WebView webView = new WebView(getContext()); + webView.setVerticalScrollBarEnabled(false); // Disable the vertical scrollbar. + webView.setOverScrollMode(View.OVER_SCROLL_NEVER); + webView.getSettings().setJavaScriptEnabled(true); + webView.setWebViewClient(new OpenAboutLinkWebClient()); + webView.loadDataWithBaseURL(null, htmlContent, "text/html", "utf-8", null); + + // Add WebView to layout. + mainLayout.addView(webView); + + setContentView(mainLayout); + + // Set dialog window attributes. + Window window = getWindow(); + if (window != null) { + Utils.setDialogWindowParameters(window, Gravity.CENTER, 0, 90, false); + } + } + + private class OpenAboutLinkWebClient extends OpenLinksExternallyWebClient { + public OpenAboutLinkWebClient() { + super(getContext(), WebViewDialog.this); + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + if (CREDITS_LINK_PLACEHOLDER_URL.equals(url)) { + new MorpheCreditsDialog(getContext()).show(); + dialog.dismiss(); + return true; + } + return super.shouldOverrideUrlLoading(view, url); + } + } +} + +class AboutRoutes { + /** + * Backup icon url if the API call fails. + */ + public static volatile String aboutLogoUrl = "https://morphe.software/favicon.svg"; + + /** + * Links to use if fetch links api call fails. + */ + private static final WebLink[] NO_CONNECTION_STATIC_LINKS = { + new WebLink(true, "Website", null, "https://morphe.software"), + CREDITS_LINK + }; + + private static final String API_URL = "https://api.morphe.software/v2"; + private static final Route.CompiledRoute API_ROUTE_ABOUT = new Route(GET, "/about").compile(); + + private static final String GITHUB_URL = "https://raw.githubusercontent.com"; + private static final Route.CompiledRoute GITHUB_ROUTE_PATCHES = new Route(GET, + (Utils.isPreReleasePatches() + ? "/MorpheApp/morphe-patches/refs/heads/dev/patches-bundle.json" + : "/MorpheApp/morphe-patches/refs/heads/main/patches-bundle.json") + ).compile(); + + @Nullable + private static volatile String latestPatchesVersion; + private static volatile long latestPatchesVersionLastCheckedTime; + + static boolean hasFetchedPatchersVersion() { + final long updateCheckFrequency = 5 * 60 * 1000; // 5 minutes. + final long now = System.currentTimeMillis(); + + return latestPatchesVersion != null && (now - latestPatchesVersionLastCheckedTime) < updateCheckFrequency; + } + + @Nullable + static String getLatestPatchesVersion() { + String version = latestPatchesVersion; + if (version != null) return version; + + if (!Utils.isNetworkConnected()) return null; + + try { + HttpURLConnection connection = Requester.getConnectionFromCompiledRoute( + GITHUB_URL, GITHUB_ROUTE_PATCHES); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + Logger.printDebug(() -> "Fetching latest patches version links from: " + connection.getURL()); + + // Do not show an exception toast if the server is down + final int responseCode = connection.getResponseCode(); + if (responseCode != 200) { + Logger.printDebug(() -> "Failed to get patches bundle. Response code: " + responseCode); + return null; + } + + JSONObject json = Requester.parseJSONObjectAndDisconnect(connection); + version = json.getString("version"); + if (version.startsWith("v")) { + version = version.substring(1); + } + latestPatchesVersion = version; + latestPatchesVersionLastCheckedTime = System.currentTimeMillis(); + + return version; + } catch (SocketTimeoutException ex) { + Logger.printInfo(() -> "Could not fetch patches version", ex); // No toast. + } catch (JSONException ex) { + Logger.printException(() -> "Could not parse about information", ex); + } catch (Exception ex) { + Logger.printException(() -> "Failed to get patches version", ex); + } + + return null; + } + + @Nullable + private static volatile WebLink[] fetchedLinks; + + static boolean hasFetchedLinks() { + return fetchedLinks != null; + } + + static WebLink[] fetchAboutLinks() { + try { + if (hasFetchedLinks()) return fetchedLinks; + + // Check if there is no internet connection. + if (!Utils.isNetworkConnected()) return NO_CONNECTION_STATIC_LINKS; + + HttpURLConnection connection = Requester.getConnectionFromCompiledRoute(API_URL, API_ROUTE_ABOUT); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + Logger.printDebug(() -> "Fetching social links from: " + connection.getURL()); + + + // Do not show an exception toast if the server is down + final int responseCode = connection.getResponseCode(); + if (responseCode != 200) { + Logger.printDebug(() -> "Failed to get about information. Response code: " + responseCode); + return NO_CONNECTION_STATIC_LINKS; + } + + JSONObject json = Requester.parseJSONObjectAndDisconnect(connection); + + aboutLogoUrl = json.getJSONObject("branding").getString("logo"); + + List links = new ArrayList<>(); + + JSONArray donations = json.getJSONObject("donations").getJSONArray("links"); + for (int i = 0, length = donations.length(); i < length; i++) { + WebLink link = new WebLink(donations.getJSONObject(i)); + if (link.preferred) { + links.add(link); + } + } + + JSONArray socials = json.getJSONArray("socials"); + for (int i = 0, length = socials.length(); i < length; i++) { + WebLink link = new WebLink(socials.getJSONObject(i)); + links.add(link); + } + + // Add credits link. + links.add(CREDITS_LINK); + + Logger.printDebug(() -> "links: " + links); + + return fetchedLinks = links.toArray(new WebLink[0]); + + } catch (SocketTimeoutException ex) { + Logger.printInfo(() -> "Could not fetch about information", ex); // No toast. + } catch (JSONException ex) { + Logger.printException(() -> "Could not parse about information", ex); + } catch (Exception ex) { + Logger.printException(() -> "Failed to get about information", ex); + } + + return NO_CONNECTION_STATIC_LINKS; + } +} + +class MorpheCreditsDialog extends Dialog { + + private static final MorpheAboutPreference.WebLink[] WORKS_LINKS_CURRENT = { + new WebLink("Morphe", "https://github.com/morpheapp/morphe-patches/graphs/contributors", str("morphe_settings_about_links_morphe") + ), + }; + + private static final MorpheAboutPreference.WebLink[] WORKS_LINKS_PRIOR = { + new WebLink("RVX", "https://github.com/inotia00/revanced-patches/graphs/contributors?from=3%2F1%2F2022&to=12%2F1%2F2025", str("morphe_settings_about_links_rvx") + ), + new WebLink("ReVanced", "https://github.com/ReVanced/revanced-patches/graphs/contributors?from=3%2F1%2F2022&to=12%2F1%2F2025", str("morphe_settings_about_links_rv") + ), + new WebLink("Vanced", "https://github.com/TeamVanced", str("morphe_settings_about_links_vanced") + ) + }; + + private String createDialogHtml() { + // Get theme colors. + String foregroundColorHex = Utils.getColorHexString(Utils.getAppForegroundColor()); + String backgroundColorHex = Utils.getColorHexString(Utils.getDialogBackgroundColor()); + + // Morphe brand colors from logo. + String morpheBlue = "#1E5AA8"; + String morpheTeal = "#00AFAE"; + + StringBuilder html = new StringBuilder(String.format(""" + + + + + + + """, + backgroundColorHex, foregroundColorHex, + foregroundColorHex, foregroundColorHex, + foregroundColorHex, morpheBlue, morpheTeal, + foregroundColorHex, foregroundColorHex, foregroundColorHex + )); + + // Header. + html.append(String.format(""" +
+
%s
+
+ """, + StringRef.str("morphe_settings_about_links_credits") + )); + + // Current contributors section. + html.append(String.format(""" +
+ + """, StringRef.str("morphe_settings_about_contributors_current"))); + for (MorpheAboutPreference.WebLink link : WORKS_LINKS_CURRENT) { + String initial = link.name.substring(0, 1).toUpperCase(); + html.append("") + .append("
").append(initial).append("
") + .append("
") + .append("
").append(link.name).append("
") + .append("
") + .append(link.subText).append("
") + .append("
") + .append("") + .append("
"); + } + html.append("
"); + + // Prior contributors section. + html.append(String.format(""" +
+ + """, StringRef.str("morphe_settings_about_contributors_prior"))); + for (MorpheAboutPreference.WebLink link : WORKS_LINKS_PRIOR) { + String initial = link.name.substring(0, 1).toUpperCase(); + html.append("") + .append("
").append(initial).append("
") + .append("
") + .append("
").append(link.name).append("
"); + if (link.subText != null) { + html.append("
").append(link.subText).append("
"); + } + html.append("
") + .append("") + .append("
"); + } + html.append("
"); + + html.append(""" + + + """); + + return html.toString(); + } + + private final String htmlContent; + + public MorpheCreditsDialog(Context context) { + super(context); + this.htmlContent = createDialogHtml(); + } + + // JS required to hide any broken images. No remote JavaScript is ever loaded. + @SuppressLint("SetJavaScriptEnabled") + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); // Remove default title bar. + + // Create main layout. + LinearLayout mainLayout = new LinearLayout(getContext()); + mainLayout.setOrientation(LinearLayout.VERTICAL); + + mainLayout.setPadding(Dim.dp10, Dim.dp10, Dim.dp10, Dim.dp10); + // Set rounded rectangle background. + ShapeDrawable mainBackground = new ShapeDrawable(new RoundRectShape( + Dim.roundedCorners(28), null, null)); + mainBackground.getPaint().setColor(Utils.getDialogBackgroundColor()); + mainLayout.setBackground(mainBackground); + + // Create WebView. + WebView webView = new WebView(getContext()); + webView.setVerticalScrollBarEnabled(false); // Disable the vertical scrollbar. + webView.setOverScrollMode(View.OVER_SCROLL_NEVER); + webView.getSettings().setJavaScriptEnabled(true); + webView.setWebViewClient(new OpenLinksExternallyWebClient(getContext(), this)); + webView.loadDataWithBaseURL(null, htmlContent, "text/html", "utf-8", null); + + // Add WebView to layout. + mainLayout.addView(webView); + + setContentView(mainLayout); + + // Set dialog window attributes. + Window window = getWindow(); + if (window != null) { + Utils.setDialogWindowParameters(window, Gravity.CENTER, 0, 90, false); + } + } +} + +class OpenLinksExternallyWebClient extends WebViewClient { + final Context context; + final Dialog dialog; + + public OpenLinksExternallyWebClient(Context context, Dialog dialog) { + this.context = context; + this.dialog = dialog; + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + try { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + context.startActivity(intent); + } catch (Exception ex) { + Logger.printException(() -> "Open link failure", ex); + } + // Dismiss the dialog using a delay, + // otherwise without a delay the UI looks hectic with the dialog dismissing + // to show the settings while simultaneously a web browser is opening. + Utils.runOnMainThreadDelayed(dialog::dismiss, 500); + return true; + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/NoTitlePreferenceCategory.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/NoTitlePreferenceCategory.java new file mode 100644 index 0000000..84222a6 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/NoTitlePreferenceCategory.java @@ -0,0 +1,58 @@ +package app.morphe.extension.shared.settings.preference; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.preference.PreferenceCategory; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; + +/** + * Empty preference category with no title, used to organize and group related preferences together. + */ +@SuppressWarnings({"unused", "deprecation"}) +public class NoTitlePreferenceCategory extends PreferenceCategory { + + public NoTitlePreferenceCategory(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public NoTitlePreferenceCategory(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public NoTitlePreferenceCategory(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public NoTitlePreferenceCategory(Context context) { + super(context); + } + + @Override + @SuppressLint("MissingSuperCall") + protected View onCreateView(ViewGroup parent) { + // Return a zero-height view to eliminate empty title space. + return new View(getContext()); + } + + @Override + public CharSequence getTitle() { + // Title can be used for sorting. Return the first sub preference title. + if (getPreferenceCount() > 0) { + return getPreference(0).getTitle(); + } + + return super.getTitle(); + } + + @Override + public int getTitleRes() { + if (getPreferenceCount() > 0) { + return getPreference(0).getTitleRes(); + } + + return super.getTitleRes(); + } +} + diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ResettableEditTextPreference.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ResettableEditTextPreference.java new file mode 100644 index 0000000..93e3ebc --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/ResettableEditTextPreference.java @@ -0,0 +1,105 @@ +package app.morphe.extension.shared.settings.preference; + +import static app.morphe.extension.shared.StringRef.str; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.preference.EditTextPreference; +import android.util.AttributeSet; +import android.util.Pair; +import android.widget.EditText; +import android.widget.LinearLayout; + +import androidx.annotation.Nullable; + +import java.util.Objects; + +import app.morphe.extension.shared.Logger; +import app.morphe.extension.shared.settings.Setting; +import app.morphe.extension.shared.ui.CustomDialog; + +@SuppressWarnings({"unused", "deprecation"}) +public class ResettableEditTextPreference extends EditTextPreference { + + /** + * Setting to reset. + */ + @Nullable + private Setting setting; + + public ResettableEditTextPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + public ResettableEditTextPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + public ResettableEditTextPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + public ResettableEditTextPreference(Context context) { + super(context); + } + + public void setSetting(@Nullable Setting setting) { + this.setting = setting; + } + + @Override + protected void showDialog(Bundle state) { + try { + Context context = getContext(); + EditText editText = getEditText(); + + // Resolve setting if not already set. + if (setting == null) { + String key = getKey(); + if (key != null) { + setting = Setting.getSettingFromPath(key); + } + } + + // Set initial EditText value to the current persisted value or empty string. + String initialValue = getText() != null ? getText() : ""; + editText.setText(initialValue); + editText.setSelection(initialValue.length()); // Move cursor to end. + + // Create custom dialog. + String neutralButtonText = (setting != null) ? str("morphe_settings_reset") : null; + Pair dialogPair = CustomDialog.create( + context, + getTitle() != null ? getTitle().toString() : "", // Title. + null, // Message is replaced by EditText. + editText, // Pass the EditText. + null, // OK button text. + () -> { + // OK button action. Persist the EditText value when OK is clicked. + String newValue = editText.getText().toString(); + if (callChangeListener(newValue)) { + setText(newValue); + } + }, + () -> {}, // Cancel button action (dismiss only). + neutralButtonText, // Neutral button text (Reset). + () -> { + // Neutral button action. + if (setting != null) { + try { + String defaultStringValue = Objects.requireNonNull(setting).defaultValue.toString(); + editText.setText(defaultStringValue); + editText.setSelection(defaultStringValue.length()); // Move cursor to end of text. + } catch (Exception ex) { + Logger.printException(() -> "reset failure", ex); + } + } + }, + false // Do not dismiss dialog when onNeutralClick. + ); + + // Show the dialog. + dialogPair.first.show(); + } catch (Exception ex) { + Logger.printException(() -> "showDialog failure", ex); + } + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/SharedPrefCategory.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/SharedPrefCategory.java new file mode 100644 index 0000000..11ad5b2 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/SharedPrefCategory.java @@ -0,0 +1,201 @@ +package app.morphe.extension.shared.settings.preference; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceFragment; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Objects; + +import app.morphe.extension.shared.Logger; +import app.morphe.extension.shared.Utils; + +/** + * Shared categories, and helper methods. + *

+ * The various save methods store numbers as Strings, + * which is required if using {@link PreferenceFragment}. + *

+ * If saved numbers will not be used with a preference fragment, + * then store the primitive numbers using the {@link #preferences} itself. + */ +public class SharedPrefCategory { + @NonNull + public final String name; + @NonNull + public final SharedPreferences preferences; + + public SharedPrefCategory(@NonNull String name) { + this.name = Objects.requireNonNull(name); + preferences = Objects.requireNonNull(Utils.getContext()).getSharedPreferences(name, Context.MODE_PRIVATE); + } + + private void removeConflictingPreferenceKeyValue(@NonNull String key) { + Logger.printException(() -> "Found conflicting preference: " + key); + removeKey(key); + } + + @SuppressLint("ApplySharedPref") // Must use commit to ensure default value is not saved to preferences. + private void saveObjectAsString(@NonNull String key, @Nullable Object value) { + preferences.edit().putString(key, (value == null ? null : value.toString())).commit(); + } + + @SuppressLint("ApplySharedPref") // Must use commit to ensure default value is not saved to preferences. + public void clear() { + preferences.edit().clear().commit(); + } + + /** + * Removes any preference data type that has the specified key. + */ + @SuppressLint("ApplySharedPref") // Must use commit to ensure default value is not saved to preferences. + public void removeKey(@NonNull String key) { + preferences.edit().remove(Objects.requireNonNull(key)).commit(); + } + + @SuppressLint("ApplySharedPref") // Must use commit to ensure default value is not saved to preferences. + public void saveBoolean(@NonNull String key, boolean value) { + preferences.edit().putBoolean(key, value).commit(); + } + + /** + * @param value a NULL parameter removes the value from the preferences + */ + public void saveEnumAsString(@NonNull String key, @Nullable Enum value) { + saveObjectAsString(key, value); + } + + /** + * @param value a NULL parameter removes the value from the preferences + */ + public void saveIntegerString(@NonNull String key, @Nullable Integer value) { + saveObjectAsString(key, value); + } + + /** + * @param value a NULL parameter removes the value from the preferences + */ + public void saveLongString(@NonNull String key, @Nullable Long value) { + saveObjectAsString(key, value); + } + + /** + * @param value a NULL parameter removes the value from the preferences + */ + public void saveFloatString(@NonNull String key, @Nullable Float value) { + saveObjectAsString(key, value); + } + + /** + * @param value a NULL parameter removes the value from the preferences + */ + public void saveString(@NonNull String key, @Nullable String value) { + saveObjectAsString(key, value); + } + + @NonNull + public String getString(@NonNull String key, @NonNull String _default) { + Objects.requireNonNull(_default); + try { + return preferences.getString(key, _default); + } catch (ClassCastException ex) { + // Value stored is a completely different type (should never happen). + removeConflictingPreferenceKeyValue(key); + return _default; + } + } + + @NonNull + public > T getEnum(@NonNull String key, @NonNull T _default) { + Objects.requireNonNull(_default); + try { + String enumName = preferences.getString(key, null); + if (enumName != null) { + try { + // noinspection unchecked + return (T) Enum.valueOf(_default.getClass(), enumName); + } catch (IllegalArgumentException ex) { + // Info level to allow removing enum values in the future without showing any user errors. + Logger.printInfo(() -> "Using default, and ignoring unknown enum value: " + enumName); + removeKey(key); + } + } + } catch (ClassCastException ex) { + // Value stored is a completely different type (should never happen). + removeConflictingPreferenceKeyValue(key); + } + return _default; + } + + public boolean getBoolean(@NonNull String key, boolean _default) { + try { + return preferences.getBoolean(key, _default); + } catch (ClassCastException ex) { + // Value stored is a completely different type (should never happen). + removeConflictingPreferenceKeyValue(key); + return _default; + } + } + + @NonNull + public Integer getIntegerString(@NonNull String key, @NonNull Integer _default) { + try { + String value = preferences.getString(key, null); + if (value != null) { + return Integer.valueOf(value); + } + } catch (ClassCastException | NumberFormatException ex) { + try { + // Old data previously stored as primitive. + return preferences.getInt(key, _default); + } catch (ClassCastException ex2) { + // Value stored is a completely different type (should never happen). + removeConflictingPreferenceKeyValue(key); + } + } + return _default; + } + + @NonNull + public Long getLongString(@NonNull String key, @NonNull Long _default) { + try { + String value = preferences.getString(key, null); + if (value != null) { + return Long.valueOf(value); + } + } catch (ClassCastException | NumberFormatException ex) { + try { + return preferences.getLong(key, _default); + } catch (ClassCastException ex2) { + removeConflictingPreferenceKeyValue(key); + } + } + return _default; + } + + @NonNull + public Float getFloatString(@NonNull String key, @NonNull Float _default) { + try { + String value = preferences.getString(key, null); + if (value != null) { + return Float.valueOf(value); + } + } catch (ClassCastException | NumberFormatException ex) { + try { + return preferences.getFloat(key, _default); + } catch (ClassCastException ex2) { + removeConflictingPreferenceKeyValue(key); + } + } + return _default; + } + + @NonNull + @Override + public String toString() { + return name; + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/SortedListPreference.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/SortedListPreference.java new file mode 100644 index 0000000..55fd505 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/SortedListPreference.java @@ -0,0 +1,138 @@ +package app.morphe.extension.shared.settings.preference; + +import static app.morphe.extension.shared.ResourceUtils.getStringArray; +import static app.morphe.extension.shared.StringRef.str; + +import android.content.Context; +import android.util.AttributeSet; +import android.util.Pair; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import app.morphe.extension.shared.Utils; +import app.morphe.extension.shared.settings.Setting; + +/** + * PreferenceList that sorts itself. + * By default, the first entry is preserved in its original position, + * and all other entries are sorted alphabetically. + *

+ * Ideally the 'keep first entries to preserve' is an XML parameter, + * but currently that's not so simple since Extensions code cannot use + * generated code from the Patches repo (which is required for custom XML parameters). + *

+ * If any class wants to use a different getFirstEntriesToPreserve value, + * it needs to subclass this preference and override {@link #getFirstEntriesToPreserve}. + */ +@SuppressWarnings({"unused", "deprecation"}) +public class SortedListPreference extends CustomDialogListPreference { + + /** + * Sorts the current list entries. + * + * @param firstEntriesToPreserve The number of entries to preserve in their original position, + * or a negative value to not sort and leave entries + * as they currently are. + */ + public void sortEntryAndValues(int firstEntriesToPreserve) { + CharSequence[] entries = getEntries(); + CharSequence[] entryValues = getEntryValues(); + if (entries == null || entryValues == null) { + return; + } + + final int entrySize = entries.length; + if (entrySize != entryValues.length) { + // XML array declaration has a missing/extra entry. + throw new IllegalStateException(); + } + + if (firstEntriesToPreserve < 0) { + return; // Nothing to do. + } + + List> firstEntries = new ArrayList<>(firstEntriesToPreserve); + + // Android does not have a triple class like Kotlin, So instead use a nested pair. + // Cannot easily use a SortedMap, because if two entries incorrectly have + // identical names then the duplicates entries are not preserved. + List>> lastEntries = new ArrayList<>(); + + for (int i = 0; i < entrySize; i++) { + Pair pair = new Pair<>(entries[i], entryValues[i]); + if (i < firstEntriesToPreserve) { + firstEntries.add(pair); + } else { + lastEntries.add(new Pair<>(Utils.removePunctuationToLowercase(pair.first), pair)); + } + } + + //noinspection ComparatorCombinators + Collections.sort(lastEntries, (pair1, pair2) + -> pair1.first.compareTo(pair2.first)); + + CharSequence[] sortedEntries = new CharSequence[entrySize]; + CharSequence[] sortedEntryValues = new CharSequence[entrySize]; + + int i = 0; + for (Pair pair : firstEntries) { + sortedEntries[i] = pair.first; + sortedEntryValues[i] = pair.second; + i++; + } + + for (Pair> outer : lastEntries) { + Pair inner = outer.second; + sortedEntries[i] = inner.first; + sortedEntryValues[i] = inner.second; + i++; + } + + super.setEntries(sortedEntries); + super.setEntryValues(sortedEntryValues); + } + + public SortedListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + sortEntryAndValues(getFirstEntriesToPreserve()); + } + + public SortedListPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + sortEntryAndValues(getFirstEntriesToPreserve()); + } + + public SortedListPreference(Context context, AttributeSet attrs) { + super(context, attrs); + + sortEntryAndValues(getFirstEntriesToPreserve()); + } + + public SortedListPreference(Context context) { + super(context); + + sortEntryAndValues(getFirstEntriesToPreserve()); + } + + public SortedListPreference(Context context, Setting setting) { + this(context); + + String key = setting.key; + setKey(key); + setTitle(str(key + "_title")); + setEntries(getStringArray(key + "_entries")); + setEntryValues(getStringArray(key + "_entry_values")); + } + + /** + * @return The number of first entries to leave exactly where they are, and do not sort them. + * A negative value indicates do not sort any entries. + */ + protected int getFirstEntriesToPreserve() { + return 1; + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/URLLinkPreference.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/URLLinkPreference.java new file mode 100644 index 0000000..96ac29f --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/settings/preference/URLLinkPreference.java @@ -0,0 +1,44 @@ +package app.morphe.extension.shared.settings.preference; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.preference.Preference; +import android.util.AttributeSet; + +import app.morphe.extension.shared.Logger; + +/** + * Simple preference that opens a URL when clicked. + */ +@SuppressWarnings("deprecation") +public class URLLinkPreference extends Preference { + + protected String externalURL; + + { + setOnPreferenceClickListener(pref -> { + if (externalURL == null) { + Logger.printException(() -> "URL not set " + getClass().getSimpleName()); + return false; + } + Intent i = new Intent(Intent.ACTION_VIEW); + i.setData(Uri.parse(externalURL)); + pref.getContext().startActivity(i); + return true; + }); + } + + public URLLinkPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + public URLLinkPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + public URLLinkPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + public URLLinkPreference(Context context) { + super(context); + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/ui/ColorDot.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/ui/ColorDot.java new file mode 100644 index 0000000..5b2b383 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/ui/ColorDot.java @@ -0,0 +1,60 @@ +package app.morphe.extension.shared.ui; + +import static app.morphe.extension.shared.Utils.adjustColorBrightness; +import static app.morphe.extension.shared.Utils.getAppBackgroundColor; +import static app.morphe.extension.shared.Utils.isDarkModeEnabled; +import static app.morphe.extension.shared.settings.preference.ColorPickerPreference.DISABLED_ALPHA; + +import android.graphics.Color; +import android.graphics.drawable.GradientDrawable; +import android.view.View; + +import androidx.annotation.ColorInt; + +public class ColorDot { + private static final int STROKE_WIDTH = Dim.dp(1.5f); + + /** + * Creates a circular drawable with a main fill and a stroke. + * Stroke adapts to dark/light theme and transparency, applied only when color is transparent or matches app background. + */ + public static GradientDrawable createColorDotDrawable(@ColorInt int color) { + final boolean isDarkTheme = isDarkModeEnabled(); + final boolean isTransparent = Color.alpha(color) == 0; + final int opaqueColor = color | 0xFF000000; + final int appBackground = getAppBackgroundColor(); + final int strokeColor; + final int strokeWidth; + + // Determine stroke color. + if (isTransparent || (opaqueColor == appBackground)) { + final int baseColor = isTransparent ? appBackground : opaqueColor; + strokeColor = adjustColorBrightness(baseColor, isDarkTheme ? 1.2f : 0.8f); + strokeWidth = STROKE_WIDTH; + } else { + strokeColor = 0; + strokeWidth = 0; + } + + // Create circular drawable with conditional stroke. + GradientDrawable circle = new GradientDrawable(); + circle.setShape(GradientDrawable.OVAL); + circle.setColor(color); + circle.setStroke(strokeWidth, strokeColor); + + return circle; + } + + /** + * Applies the color dot drawable to the target view. + */ + public static void applyColorDot(View targetView, @ColorInt int color, boolean enabled) { + if (targetView == null) return; + targetView.setBackground(createColorDotDrawable(color)); + targetView.setAlpha(enabled ? 1.0f : DISABLED_ALPHA); + if (!isDarkModeEnabled()) { + targetView.setClipToOutline(true); + targetView.setElevation(Dim.dp2); + } + } +} diff --git a/extensions/shared/library/src/main/java/app/morphe/extension/shared/ui/CustomDialog.java b/extensions/shared/library/src/main/java/app/morphe/extension/shared/ui/CustomDialog.java new file mode 100644 index 0000000..54aecf5 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/morphe/extension/shared/ui/CustomDialog.java @@ -0,0 +1,463 @@ +package app.morphe.extension.shared.ui; + +import android.app.Dialog; +import android.content.Context; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.RoundRectShape; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.method.LinkMovementMethod; +import android.util.Pair; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.List; + +import app.morphe.extension.shared.Logger; +import app.morphe.extension.shared.Utils; + +/** + * A utility class for creating a customizable dialog with a title, message or EditText, and up to three buttons (OK, Cancel, Neutral). + * The dialog supports themed colors, rounded corners, and dynamic button layout based on screen width. It is dismissible by default. + */ +public class CustomDialog { + private final Context context; + private final Dialog dialog; + private final LinearLayout mainLayout; + + /** + * Creates a custom dialog with a styled layout, including a title, message, buttons, and an optional EditText. + * The dialog's appearance adapts to the app's dark mode setting, with rounded corners and customizable button actions. + * Buttons adjust dynamically to their text content and are arranged in a single row if they fit within 80% of the + * screen width, with the Neutral button aligned to the left and OK/Cancel buttons centered on the right. + * If buttons do not fit, each is placed on a separate row, all aligned to the right. + * + * @param context Context used to create the dialog. + * @param title Title text of the dialog. + * @param message Message text of the dialog (supports Spanned for HTML), or null if replaced by EditText. + * @param editText EditText to include in the dialog, or null if no EditText is needed. + * @param okButtonText OK button text, or null to use the default "OK" string. + * @param onOkClick Action to perform when the OK button is clicked. + * @param onCancelClick Action to perform when the Cancel button is clicked, or null if no Cancel button is needed. + * @param neutralButtonText Neutral button text, or null if no Neutral button is needed. + * @param onNeutralClick Action to perform when the Neutral button is clicked, or null if no Neutral button is needed. + * @param dismissDialogOnNeutralClick If the dialog should be dismissed when the Neutral button is clicked. + * @return The Dialog and its main LinearLayout container. + */ + public static Pair create(Context context, CharSequence title, CharSequence message, + @Nullable EditText editText, CharSequence okButtonText, + Runnable onOkClick, Runnable onCancelClick, + @Nullable CharSequence neutralButtonText, + @Nullable Runnable onNeutralClick, + boolean dismissDialogOnNeutralClick) { + Logger.printDebug(() -> "Creating custom dialog with title: " + title); + CustomDialog customDialog = new CustomDialog(context, title, message, editText, + okButtonText, onOkClick, onCancelClick, + neutralButtonText, onNeutralClick, dismissDialogOnNeutralClick); + return new Pair<>(customDialog.dialog, customDialog.mainLayout); + } + + /** + * Initializes a custom dialog with the specified parameters. + * + * @param context Context used to create the dialog. + * @param title Title text of the dialog. + * @param message Message text of the dialog, or null if replaced by EditText. + * @param editText EditText to include in the dialog, or null if no EditText is needed. + * @param okButtonText OK button text, or null to use the default "OK" string. + * @param onOkClick Action to perform when the OK button is clicked. + * @param onCancelClick Action to perform when the Cancel button is clicked, or null if no Cancel button is needed. + * @param neutralButtonText Neutral button text, or null if no Neutral button is needed. + * @param onNeutralClick Action to perform when the Neutral button is clicked, or null if no Neutral button is needed. + * @param dismissDialogOnNeutralClick If the dialog should be dismissed when the Neutral button is clicked. + */ + private CustomDialog(Context context, CharSequence title, CharSequence message, @Nullable EditText editText, + CharSequence okButtonText, Runnable onOkClick, Runnable onCancelClick, + @Nullable CharSequence neutralButtonText, @Nullable Runnable onNeutralClick, + boolean dismissDialogOnNeutralClick) { + this.context = context; + this.dialog = new Dialog(context); + this.dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); // Remove default title bar. + + // Create main layout. + mainLayout = createMainLayout(); + addTitle(title); + addContent(message, editText); + addButtons(okButtonText, onOkClick, onCancelClick, neutralButtonText, onNeutralClick, dismissDialogOnNeutralClick); + + // Set dialog content and window attributes. + dialog.setContentView(mainLayout); + Window window = dialog.getWindow(); + if (window != null) { + Utils.setDialogWindowParameters(window, Gravity.CENTER, 0, 90, false); + } + } + + /** + * Creates the main layout for the dialog with vertical orientation and rounded corners. + * + * @return The configured LinearLayout for the dialog. + */ + private LinearLayout createMainLayout() { + LinearLayout layout = new LinearLayout(context); + layout.setOrientation(LinearLayout.VERTICAL); + layout.setPadding(Dim.dp24, Dim.dp16, Dim.dp24, Dim.dp24); + + // Set rounded rectangle background. + ShapeDrawable background = new ShapeDrawable(new RoundRectShape( + Dim.roundedCorners(28), null, null)); + // Dialog background. + background.getPaint().setColor(Utils.getDialogBackgroundColor()); + layout.setBackground(background); + + return layout; + } + + /** + * Adds a title to the dialog if provided. + * + * @param title The title text to display. + */ + private void addTitle(CharSequence title) { + if (TextUtils.isEmpty(title)) return; + + TextView titleView = new TextView(context); + titleView.setText(title); + titleView.setTypeface(Typeface.DEFAULT_BOLD); + titleView.setTextSize(18); + titleView.setTextColor(Utils.getAppForegroundColor()); + titleView.setGravity(Gravity.CENTER); + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + params.setMargins(0, 0, 0, Dim.dp16); + titleView.setLayoutParams(params); + + mainLayout.addView(titleView); + } + + /** + * Adds a message or EditText to the dialog within a ScrollView. + * + * @param message The message text to display (supports Spanned for HTML), or null if replaced by EditText. + * @param editText The EditText to include, or null if no EditText is needed. + */ + private void addContent(CharSequence message, @Nullable EditText editText) { + // Create content container (message/EditText) inside a ScrollView only if message or editText is provided. + if (message == null && editText == null) return; + + ScrollView scrollView = new ScrollView(context); + // Disable the vertical scrollbar. + scrollView.setVerticalScrollBarEnabled(false); + scrollView.setOverScrollMode(View.OVER_SCROLL_NEVER); + + LinearLayout contentContainer = new LinearLayout(context); + contentContainer.setOrientation(LinearLayout.VERTICAL); + scrollView.addView(contentContainer); + + // EditText (if provided). + if (editText != null) { + ShapeDrawable background = new ShapeDrawable(new RoundRectShape( + Dim.roundedCorners(10), null, null)); + background.getPaint().setColor(Utils.getEditTextBackground()); + scrollView.setPadding(Dim.dp8, Dim.dp8, Dim.dp8, Dim.dp8); + scrollView.setBackground(background); + scrollView.setClipToOutline(true); + + // Remove EditText from its current parent, if any. + ViewGroup parent = (ViewGroup) editText.getParent(); + if (parent != null) parent.removeView(editText); + // Style the EditText to match the dialog theme. + editText.setTextColor(Utils.getAppForegroundColor()); + editText.setBackgroundColor(Color.TRANSPARENT); + editText.setPadding(0, 0, 0, 0); + contentContainer.addView(editText, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + // Message (if not replaced by EditText). + } else { + TextView messageView = new TextView(context); + // Supports Spanned (HTML). + messageView.setText(message); + messageView.setTextSize(16); + messageView.setTextColor(Utils.getAppForegroundColor()); + // Enable HTML link clicking if the message contains links. + if (message instanceof Spanned) { + messageView.setMovementMethod(LinkMovementMethod.getInstance()); + } + contentContainer.addView(messageView, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + } + + // Weight to take available space. + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 0, + 1.0f); + scrollView.setLayoutParams(params); + // Add ScrollView to main layout only if content exist. + mainLayout.addView(scrollView); + } + + /** + * Adds buttons to the dialog, arranging them dynamically based on their widths. + * + * @param okButtonText OK button text, or null to use the default "OK" string. + * @param onOkClick Action for the OK button click. + * @param onCancelClick Action for the Cancel button click, or null if no Cancel button. + * @param neutralButtonText Neutral button text, or null if no Neutral button. + * @param onNeutralClick Action for the Neutral button click, or null if no Neutral button. + * @param dismissDialogOnNeutralClick If the dialog should dismiss on Neutral button click. + */ + private void addButtons(CharSequence okButtonText, Runnable onOkClick, Runnable onCancelClick, + @Nullable CharSequence neutralButtonText, @Nullable Runnable onNeutralClick, + boolean dismissDialogOnNeutralClick) { + // Button container. + LinearLayout buttonContainer = new LinearLayout(context); + buttonContainer.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams buttonContainerParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + buttonContainerParams.setMargins(0, Dim.dp16, 0, 0); + buttonContainer.setLayoutParams(buttonContainerParams); + + List