mirror of
https://github.com/revanced/revanced-patches.git
synced 2025-06-12 05:07:45 +02:00
chore: Separate extensions by app (#3905)
This commit is contained in:
21
extensions/shared/library/build.gradle.kts
Normal file
21
extensions/shared/library/build.gradle.kts
Normal file
@ -0,0 +1,21 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.revanced.extension"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 23
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(libs.annotation)
|
||||
}
|
@ -0,0 +1,158 @@
|
||||
package app.revanced.extension.shared;
|
||||
|
||||
import static app.revanced.extension.shared.StringRef.str;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.SearchManager;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.PowerManager;
|
||||
import android.provider.Settings;
|
||||
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
|
||||
/**
|
||||
* @noinspection unused
|
||||
*/
|
||||
public class GmsCoreSupport {
|
||||
public static final String ORIGINAL_UNPATCHED_PACKAGE_NAME = "com.google.android.youtube";
|
||||
private static final String GMS_CORE_PACKAGE_NAME
|
||||
= getGmsCoreVendorGroupId() + ".android.gms";
|
||||
private static final Uri GMS_CORE_PROVIDER
|
||||
= Uri.parse("content://" + getGmsCoreVendorGroupId() + ".android.gsf.gservices/prefix");
|
||||
private static final String DONT_KILL_MY_APP_LINK
|
||||
= "https://dontkillmyapp.com";
|
||||
|
||||
private static void open(String queryOrLink) {
|
||||
Intent intent;
|
||||
try {
|
||||
// Check if queryOrLink is a valid URL.
|
||||
new URL(queryOrLink);
|
||||
|
||||
intent = new Intent(Intent.ACTION_VIEW, Uri.parse(queryOrLink));
|
||||
} catch (MalformedURLException e) {
|
||||
intent = new Intent(Intent.ACTION_WEB_SEARCH);
|
||||
intent.putExtra(SearchManager.QUERY, queryOrLink);
|
||||
}
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
Utils.getContext().startActivity(intent);
|
||||
|
||||
// Gracefully exit, otherwise the broken app will continue to run.
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
private static void showBatteryOptimizationDialog(Activity context,
|
||||
String dialogMessageRef,
|
||||
String positiveButtonStringRef,
|
||||
DialogInterface.OnClickListener onPositiveClickListener) {
|
||||
// Do not set cancelable to false, to allow using back button to skip the action,
|
||||
// just in case the check can never be satisfied.
|
||||
var dialog = new AlertDialog.Builder(context)
|
||||
.setIconAttribute(android.R.attr.alertDialogIcon)
|
||||
.setTitle(str("gms_core_dialog_title"))
|
||||
.setMessage(str(dialogMessageRef))
|
||||
.setPositiveButton(str(positiveButtonStringRef), onPositiveClickListener)
|
||||
.create();
|
||||
Utils.showDialog(context, dialog);
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
@RequiresApi(api = Build.VERSION_CODES.N)
|
||||
public static void checkGmsCore(Activity context) {
|
||||
try {
|
||||
// Verify the user has not included GmsCore for a root installation.
|
||||
// GmsCore Support changes the package name, but with a mounted installation
|
||||
// all manifest changes are ignored and the original package name is used.
|
||||
if (context.getPackageName().equals(ORIGINAL_UNPATCHED_PACKAGE_NAME)) {
|
||||
Logger.printInfo(() -> "App is mounted with root, but GmsCore patch was included");
|
||||
// Cannot use localize text here, since the app will load
|
||||
// resources from the unpatched app and all patch strings are missing.
|
||||
Utils.showToastLong("The 'GmsCore support' patch breaks mount installations");
|
||||
|
||||
// Do not exit. If the app exits before launch completes (and without
|
||||
// opening another activity), then on some devices such as Pixel phone Android 10
|
||||
// no toast will be shown and the app will continually be relaunched
|
||||
// with the appearance of a hung app.
|
||||
}
|
||||
|
||||
// Verify GmsCore is installed.
|
||||
try {
|
||||
PackageManager manager = context.getPackageManager();
|
||||
manager.getPackageInfo(GMS_CORE_PACKAGE_NAME, PackageManager.GET_ACTIVITIES);
|
||||
} catch (PackageManager.NameNotFoundException exception) {
|
||||
Logger.printInfo(() -> "GmsCore was not found");
|
||||
// Cannot show a dialog and must show a toast,
|
||||
// because on some installations the app crashes before a dialog can be displayed.
|
||||
Utils.showToastLong(str("gms_core_toast_not_installed_message"));
|
||||
open(getGmsCoreDownload());
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if GmsCore is running in the background.
|
||||
try (var client = context.getContentResolver().acquireContentProviderClient(GMS_CORE_PROVIDER)) {
|
||||
if (client == null) {
|
||||
Logger.printInfo(() -> "GmsCore is not running in the background");
|
||||
|
||||
showBatteryOptimizationDialog(context,
|
||||
"gms_core_dialog_not_whitelisted_not_allowed_in_background_message",
|
||||
"gms_core_dialog_open_website_text",
|
||||
(dialog, id) -> open(DONT_KILL_MY_APP_LINK));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if GmsCore is whitelisted from battery optimizations.
|
||||
if (batteryOptimizationsEnabled(context)) {
|
||||
Logger.printInfo(() -> "GmsCore is not whitelisted from battery optimizations");
|
||||
showBatteryOptimizationDialog(context,
|
||||
"gms_core_dialog_not_whitelisted_using_battery_optimizations_message",
|
||||
"gms_core_dialog_continue_text",
|
||||
(dialog, id) -> openGmsCoreDisableBatteryOptimizationsIntent(context));
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "checkGmsCore failure", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife") // Permission is part of GmsCore
|
||||
private static void openGmsCoreDisableBatteryOptimizationsIntent(Activity activity) {
|
||||
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
|
||||
intent.setData(Uri.fromParts("package", GMS_CORE_PACKAGE_NAME, null));
|
||||
activity.startActivityForResult(intent, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return If GmsCore is not whitelisted from battery optimizations.
|
||||
*/
|
||||
private static boolean batteryOptimizationsEnabled(Context context) {
|
||||
var powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
|
||||
return !powerManager.isIgnoringBatteryOptimizations(GMS_CORE_PACKAGE_NAME);
|
||||
}
|
||||
|
||||
private static String getGmsCoreDownload() {
|
||||
final var vendorGroupId = getGmsCoreVendorGroupId();
|
||||
//noinspection SwitchStatementWithTooFewBranches
|
||||
switch (vendorGroupId) {
|
||||
case "app.revanced":
|
||||
return "https://github.com/revanced/gmscore/releases/latest";
|
||||
default:
|
||||
return vendorGroupId + ".android.gms";
|
||||
}
|
||||
}
|
||||
|
||||
// Modified by a patch. Do not touch.
|
||||
private static String getGmsCoreVendorGroupId() {
|
||||
return "app.revanced";
|
||||
}
|
||||
}
|
@ -0,0 +1,156 @@
|
||||
package app.revanced.extension.shared;
|
||||
|
||||
import android.util.Log;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import app.revanced.extension.shared.settings.BaseSettings;
|
||||
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
|
||||
import static app.revanced.extension.shared.settings.BaseSettings.*;
|
||||
|
||||
public class Logger {
|
||||
|
||||
/**
|
||||
* Log messages using lambdas.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface LogMessage {
|
||||
@NonNull
|
||||
String buildMessageString();
|
||||
|
||||
/**
|
||||
* @return For outer classes, this returns {@link Class#getSimpleName()}.
|
||||
* For static, inner, or anonymous classes, this returns the simple name of the enclosing class.
|
||||
* <br>
|
||||
* For example, each of these classes return 'SomethingView':
|
||||
* <code>
|
||||
* com.company.SomethingView
|
||||
* com.company.SomethingView$StaticClass
|
||||
* com.company.SomethingView$1
|
||||
* </code>
|
||||
*/
|
||||
private String findOuterClassSimpleName() {
|
||||
var selfClass = this.getClass();
|
||||
|
||||
String fullClassName = selfClass.getName();
|
||||
final int dollarSignIndex = fullClassName.indexOf('$');
|
||||
if (dollarSignIndex < 0) {
|
||||
return selfClass.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);
|
||||
}
|
||||
}
|
||||
|
||||
private static final String REVANCED_LOG_PREFIX = "revanced: ";
|
||||
|
||||
/**
|
||||
* Logs debug messages under the outer class name of the code calling this method.
|
||||
* Whenever possible, the log string should be constructed entirely inside {@link LogMessage#buildMessageString()}
|
||||
* so the performance cost of building strings is paid only if {@link BaseSettings#DEBUG} is enabled.
|
||||
*/
|
||||
public static void printDebug(@NonNull LogMessage message) {
|
||||
printDebug(message, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs debug messages under the outer class name of the code calling this method.
|
||||
* Whenever possible, the log string should be constructed entirely inside {@link LogMessage#buildMessageString()}
|
||||
* so the performance cost of building strings is paid only if {@link BaseSettings#DEBUG} is enabled.
|
||||
*/
|
||||
public static void printDebug(@NonNull LogMessage message, @Nullable Exception ex) {
|
||||
if (DEBUG.get()) {
|
||||
String logMessage = message.buildMessageString();
|
||||
String logTag = REVANCED_LOG_PREFIX + message.findOuterClassSimpleName();
|
||||
|
||||
if (DEBUG_STACKTRACE.get()) {
|
||||
var builder = new StringBuilder(logMessage);
|
||||
var sw = new StringWriter();
|
||||
new Throwable().printStackTrace(new PrintWriter(sw));
|
||||
|
||||
builder.append('\n').append(sw);
|
||||
logMessage = builder.toString();
|
||||
}
|
||||
|
||||
if (ex == null) {
|
||||
Log.d(logTag, logMessage);
|
||||
} else {
|
||||
Log.d(logTag, logMessage, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs information messages using the outer class name of the code calling this method.
|
||||
*/
|
||||
public static void printInfo(@NonNull LogMessage message) {
|
||||
printInfo(message, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs information messages using the outer class name of the code calling this method.
|
||||
*/
|
||||
public static void printInfo(@NonNull LogMessage message, @Nullable Exception ex) {
|
||||
String logTag = REVANCED_LOG_PREFIX + message.findOuterClassSimpleName();
|
||||
String logMessage = message.buildMessageString();
|
||||
if (ex == null) {
|
||||
Log.i(logTag, logMessage);
|
||||
} else {
|
||||
Log.i(logTag, logMessage, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs exceptions under the outer class name of the code calling this method.
|
||||
*/
|
||||
public static void printException(@NonNull LogMessage message) {
|
||||
printException(message, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs exceptions under the outer class name of the code calling this method.
|
||||
* <p>
|
||||
* If the calling code is showing it's own error toast,
|
||||
* instead use {@link #printInfo(LogMessage, Exception)}
|
||||
*
|
||||
* @param message log message
|
||||
* @param ex exception (optional)
|
||||
*/
|
||||
public static void printException(@NonNull LogMessage message, @Nullable Throwable ex) {
|
||||
String messageString = message.buildMessageString();
|
||||
String outerClassSimpleName = message.findOuterClassSimpleName();
|
||||
String logMessage = REVANCED_LOG_PREFIX + outerClassSimpleName;
|
||||
if (ex == null) {
|
||||
Log.e(logMessage, messageString);
|
||||
} else {
|
||||
Log.e(logMessage, messageString, ex);
|
||||
}
|
||||
if (DEBUG_TOAST_ON_ERROR.get()) {
|
||||
Utils.showToastLong(outerClassSimpleName + ": " + messageString);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logging to use if {@link BaseSettings#DEBUG} or {@link Utils#getContext()} may not be initialized.
|
||||
* Normally this method should not be used.
|
||||
*/
|
||||
public static void initializationInfo(@NonNull Class<?> callingClass, @NonNull String message) {
|
||||
Log.i(REVANCED_LOG_PREFIX + callingClass.getSimpleName(), message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logging to use if {@link BaseSettings#DEBUG} or {@link Utils#getContext()} may not be initialized.
|
||||
* Normally this method should not be used.
|
||||
*/
|
||||
public static void initializationException(@NonNull Class<?> callingClass, @NonNull String message,
|
||||
@Nullable Exception ex) {
|
||||
Log.e(REVANCED_LOG_PREFIX + callingClass.getSimpleName(), message, ex);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,122 @@
|
||||
package app.revanced.extension.shared;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.Resources;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class StringRef {
|
||||
private static Resources resources;
|
||||
private static String packageName;
|
||||
|
||||
// must use a thread safe map, as this class is used both on and off the main thread
|
||||
private static final Map<String, StringRef> 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 <code>sfc(id).toString()</code>
|
||||
*
|
||||
* @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 <code>sfc(id).toString()</code> and formats the string
|
||||
* with given args.
|
||||
*
|
||||
* @param id string resource name/id
|
||||
* @param args the args to format the string with
|
||||
* @return String value from string.xml formatted with given args
|
||||
*/
|
||||
@NonNull
|
||||
public static String str(@NonNull String id, Object... args) {
|
||||
return String.format(str(id), args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a StringRef object that'll not change it's value
|
||||
*
|
||||
* @param value value which toString() method returns when invoked on returned object
|
||||
* @return Unique StringRef instance, its value will never change
|
||||
*/
|
||||
@NonNull
|
||||
public static StringRef constant(@NonNull String value) {
|
||||
final StringRef ref = new StringRef(value);
|
||||
ref.resolved = true;
|
||||
return ref;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand for <code>constant("")</code>
|
||||
* Its value always resolves to empty string
|
||||
*/
|
||||
@NonNull
|
||||
public static final StringRef empty = constant("");
|
||||
|
||||
@NonNull
|
||||
private String value;
|
||||
private boolean resolved;
|
||||
|
||||
public StringRef(@NonNull String resName) {
|
||||
this.value = resName;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public String toString() {
|
||||
if (!resolved) {
|
||||
if (resources == null || packageName == null) {
|
||||
Context context = Utils.getContext();
|
||||
resources = context.getResources();
|
||||
packageName = context.getPackageName();
|
||||
}
|
||||
resolved = true;
|
||||
if (resources != null) {
|
||||
final int identifier = resources.getIdentifier(value, "string", packageName);
|
||||
if (identifier == 0)
|
||||
Logger.printException(() -> "Resource not found: " + value);
|
||||
else
|
||||
value = resources.getString(identifier);
|
||||
} else {
|
||||
Logger.printException(() -> "Could not resolve resources!");
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
@ -0,0 +1,770 @@
|
||||
package app.revanced.extension.shared;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.*;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.Resources;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.preference.Preference;
|
||||
import android.preference.PreferenceGroup;
|
||||
import android.preference.PreferenceScreen;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewParent;
|
||||
import android.view.animation.Animation;
|
||||
import android.view.animation.AnimationUtils;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.Toast;
|
||||
import android.widget.Toolbar;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.text.Bidi;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
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 app.revanced.extension.shared.settings.BooleanSetting;
|
||||
import app.revanced.extension.shared.settings.preference.ReVancedAboutPreference;
|
||||
|
||||
public class Utils {
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private static Context context;
|
||||
|
||||
private static String versionName;
|
||||
|
||||
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.
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The version name of the app, such as 19.11.43
|
||||
*/
|
||||
public static String getAppVersionName() {
|
||||
if (versionName == null) {
|
||||
try {
|
||||
final var packageName = Objects.requireNonNull(getContext()).getPackageName();
|
||||
|
||||
PackageManager packageManager = context.getPackageManager();
|
||||
PackageInfo packageInfo;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
packageInfo = packageManager.getPackageInfo(
|
||||
packageName,
|
||||
PackageManager.PackageInfoFlags.of(0)
|
||||
);
|
||||
} else {
|
||||
packageInfo = packageManager.getPackageInfo(
|
||||
packageName,
|
||||
0
|
||||
);
|
||||
}
|
||||
versionName = packageInfo.versionName;
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "Failed to get package info", ex);
|
||||
versionName = "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
return versionName;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Hide a view by setting its layout height and width to 1dp.
|
||||
*
|
||||
* @param condition The setting to check for hiding the view.
|
||||
* @param view The view to hide.
|
||||
*/
|
||||
public static void hideViewBy0dpUnderCondition(BooleanSetting condition, View view) {
|
||||
if (hideViewBy0dpUnderCondition(condition.get(), view)) {
|
||||
Logger.printDebug(() -> "View hidden by setting: " + condition);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a view by setting its layout height and width to 0dp.
|
||||
*
|
||||
* @param condition The setting to check for hiding the view.
|
||||
* @param view The view to hide.
|
||||
*/
|
||||
public static boolean hideViewBy0dpUnderCondition(boolean condition, View view) {
|
||||
if (condition) {
|
||||
hideViewByLayoutParams(view);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a view by setting its visibility to GONE.
|
||||
*
|
||||
* @param condition The setting to check for hiding the view.
|
||||
* @param view The view to hide.
|
||||
*/
|
||||
public static void hideViewUnderCondition(BooleanSetting condition, View view) {
|
||||
if (hideViewUnderCondition(condition.get(), view)) {
|
||||
Logger.printDebug(() -> "View hidden by setting: " + condition);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a view by setting its visibility to GONE.
|
||||
*
|
||||
* @param condition The setting to check for hiding the view.
|
||||
* @param view The view to hide.
|
||||
*/
|
||||
public static boolean hideViewUnderCondition(boolean condition, View view) {
|
||||
if (condition) {
|
||||
view.setVisibility(View.GONE);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void hideViewByRemovingFromParentUnderCondition(BooleanSetting condition, View view) {
|
||||
if (hideViewByRemovingFromParentUnderCondition(condition.get(), view)) {
|
||||
Logger.printDebug(() -> "View hidden by setting: " + condition);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean hideViewByRemovingFromParentUnderCondition(boolean setting, View view) {
|
||||
if (setting) {
|
||||
ViewParent parent = view.getParent();
|
||||
if (parent instanceof ViewGroup) {
|
||||
((ViewGroup) parent).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(@NonNull Runnable task) {
|
||||
backgroundThreadPool.execute(task);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static <T> Future<T> submitOnBackgroundThread(@NonNull Callable<T> 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(@NonNull String value, @NonNull String... targets) {
|
||||
return indexOfFirstFound(value, targets) >= 0;
|
||||
}
|
||||
|
||||
public static int indexOfFirstFound(@NonNull String value, @NonNull String... targets) {
|
||||
for (String string : targets) {
|
||||
if (!string.isEmpty()) {
|
||||
final int indexOf = value.indexOf(string);
|
||||
if (indexOf >= 0) return indexOf;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return zero, if the resource is not found
|
||||
*/
|
||||
@SuppressLint("DiscouragedApi")
|
||||
public static int getResourceIdentifier(@NonNull Context context, @NonNull String resourceIdentifierName, @NonNull String type) {
|
||||
return context.getResources().getIdentifier(resourceIdentifierName, type, context.getPackageName());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return zero, if the resource is not found
|
||||
*/
|
||||
public static int getResourceIdentifier(@NonNull String resourceIdentifierName, @NonNull String type) {
|
||||
return getResourceIdentifier(getContext(), resourceIdentifierName, type);
|
||||
}
|
||||
|
||||
public static int getResourceInteger(@NonNull String resourceIdentifierName) throws Resources.NotFoundException {
|
||||
return getContext().getResources().getInteger(getResourceIdentifier(resourceIdentifierName, "integer"));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static Animation getResourceAnimation(@NonNull String resourceIdentifierName) throws Resources.NotFoundException {
|
||||
return AnimationUtils.loadAnimation(getContext(), getResourceIdentifier(resourceIdentifierName, "anim"));
|
||||
}
|
||||
|
||||
public static int getResourceColor(@NonNull String resourceIdentifierName) throws Resources.NotFoundException {
|
||||
//noinspection deprecation
|
||||
return getContext().getResources().getColor(getResourceIdentifier(resourceIdentifierName, "color"));
|
||||
}
|
||||
|
||||
public static int getResourceDimensionPixelSize(@NonNull String resourceIdentifierName) throws Resources.NotFoundException {
|
||||
return getContext().getResources().getDimensionPixelSize(getResourceIdentifier(resourceIdentifierName, "dimen"));
|
||||
}
|
||||
|
||||
public static float getResourceDimension(@NonNull String resourceIdentifierName) throws Resources.NotFoundException {
|
||||
return getContext().getResources().getDimension(getResourceIdentifier(resourceIdentifierName, "dimen"));
|
||||
}
|
||||
|
||||
public interface MatchFilter<T> {
|
||||
boolean matches(T object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Includes sub children.
|
||||
*
|
||||
* @noinspection unchecked
|
||||
*/
|
||||
public static <R extends View> R getChildViewByResourceName(@NonNull View view, @NonNull String str) {
|
||||
var child = view.findViewById(Utils.getResourceIdentifier(str, "id"));
|
||||
if (child != null) {
|
||||
return (R) child;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("View with resource name '" + str + "' not found");
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 extends View> T getChildView(@NonNull ViewGroup viewGroup, boolean searchRecursively,
|
||||
@NonNull MatchFilter<View> 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(@NonNull 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(@NonNull Context context) {
|
||||
String packageName = context.getPackageName();
|
||||
Intent intent = context.getPackageManager().getLaunchIntentForPackage(packageName);
|
||||
Intent mainIntent = Intent.makeRestartActivityTask(intent.getComponent());
|
||||
// Required for API 34 and later
|
||||
// Ref: https://developer.android.com/about/versions/14/behavior-changes-14#safer-intents
|
||||
mainIntent.setPackage(packageName);
|
||||
context.startActivity(mainIntent);
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
public static Context getContext() {
|
||||
if (context == null) {
|
||||
Logger.initializationException(Utils.class, "Context is null, returning null!", null);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
public static void setContext(Context appContext) {
|
||||
context = appContext;
|
||||
// In some apps like TikTok, the Setting classes can load in weird orders due to cyclic class dependencies.
|
||||
// Calling the regular printDebug method here can cause a Settings context null pointer exception,
|
||||
// even though the context is already set before the call.
|
||||
//
|
||||
// The initialization logger methods do not directly or indirectly
|
||||
// reference the Context or any Settings and are unaffected by this problem.
|
||||
//
|
||||
// Info level also helps debug if a patch hook is called before
|
||||
// the context is set since debug logging is off by default.
|
||||
Logger.initializationInfo(Utils.class, "Set context: " + appContext);
|
||||
}
|
||||
|
||||
public static void setClipboard(@NonNull String text) {
|
||||
android.content.ClipboardManager clipboard = (android.content.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
android.content.ClipData clip = android.content.ClipData.newPlainText("ReVanced", text);
|
||||
clipboard.setPrimaryClip(clip);
|
||||
}
|
||||
|
||||
public static boolean isTablet() {
|
||||
return context.getResources().getConfiguration().smallestScreenWidthDp >= 600;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static Boolean isRightToLeftTextLayout;
|
||||
|
||||
/**
|
||||
* If the device language uses right to left text layout (hebrew, arabic, etc)
|
||||
*/
|
||||
public static boolean isRightToLeftTextLayout() {
|
||||
if (isRightToLeftTextLayout == null) {
|
||||
String displayLanguage = Locale.getDefault().getDisplayLanguage();
|
||||
isRightToLeftTextLayout = new Bidi(displayLanguage, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT).isRightToLeft();
|
||||
}
|
||||
return isRightToLeftTextLayout;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return if the text contains at least 1 number character,
|
||||
* including any unicode numbers such as Arabic.
|
||||
*/
|
||||
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
|
||||
public static boolean containsNumber(@NonNull CharSequence text) {
|
||||
for (int index = 0, length = text.length(); index < length;) {
|
||||
final int codePoint = Character.codePointAt(text, index);
|
||||
if (Character.isDigit(codePoint)) {
|
||||
return true;
|
||||
}
|
||||
index += Character.charCount(codePoint);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignore this class. It must be public to satisfy Android 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((AlertDialog) getDialog());
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "onStart failure: " + dialog.getClass().getSimpleName(), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for {@link #showDialog(Activity, AlertDialog, boolean, DialogFragmentOnStartAction)}.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface DialogFragmentOnStartAction {
|
||||
void onStart(AlertDialog dialog);
|
||||
}
|
||||
|
||||
public static void showDialog(Activity activity, AlertDialog dialog) {
|
||||
showDialog(activity, dialog, true, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to allow showing an AlertDialog on top of other alert dialogs.
|
||||
* Calling this will always display the dialog on top of all other dialogs
|
||||
* previously called using this method.
|
||||
* <br>
|
||||
* 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.
|
||||
*<br>
|
||||
* 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.
|
||||
*<br>
|
||||
* For all other situations it's better to not use this method and
|
||||
* call {@link AlertDialog#show()} on the dialog.
|
||||
*/
|
||||
@SuppressWarnings("deprecation")
|
||||
public static void showDialog(Activity activity,
|
||||
AlertDialog dialog,
|
||||
boolean isCancelable,
|
||||
@Nullable DialogFragmentOnStartAction onStartAction) {
|
||||
verifyOnMainThread();
|
||||
|
||||
DialogFragmentWrapper fragment = new DialogFragmentWrapper();
|
||||
fragment.dialog = dialog;
|
||||
fragment.onStartAction = onStartAction;
|
||||
fragment.setCancelable(isCancelable);
|
||||
|
||||
fragment.show(activity.getFragmentManager(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe to call from any thread
|
||||
*/
|
||||
public static void showToastShort(@NonNull String messageToToast) {
|
||||
showToast(messageToToast, Toast.LENGTH_SHORT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe to call from any thread
|
||||
*/
|
||||
public static void showToastLong(@NonNull String messageToToast) {
|
||||
showToast(messageToToast, Toast.LENGTH_LONG);
|
||||
}
|
||||
|
||||
private static void showToast(@NonNull String messageToToast, int toastDuration) {
|
||||
Objects.requireNonNull(messageToToast);
|
||||
runOnMainThreadNowOrLater(() -> {
|
||||
if (context == null) {
|
||||
Logger.initializationException(Utils.class, "Cannot show toast (context is null): " + messageToToast, null);
|
||||
} else {
|
||||
Logger.printDebug(() -> "Showing toast: " + messageToToast);
|
||||
Toast.makeText(context, messageToToast, toastDuration).show();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically logs any exceptions the runnable throws.
|
||||
*
|
||||
* @see #runOnMainThreadNowOrLater(Runnable)
|
||||
*/
|
||||
public static void runOnMainThread(@NonNull Runnable runnable) {
|
||||
runOnMainThreadDelayed(runnable, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically logs any exceptions the runnable throws
|
||||
*/
|
||||
public static void runOnMainThreadDelayed(@NonNull 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.<p>
|
||||
* If called off the main thread, this is the same as {@link #runOnMainThread(Runnable)}.
|
||||
*/
|
||||
public static void runOnMainThreadNowOrLater(@NonNull Runnable runnable) {
|
||||
if (isCurrentlyOnMainThread()) {
|
||||
runnable.run();
|
||||
} else {
|
||||
runOnMainThread(runnable);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return if the calling thread is on the main thread
|
||||
*/
|
||||
public static boolean isCurrentlyOnMainThread() {
|
||||
return Looper.getMainLooper().isCurrentThread();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws IllegalStateException if the calling thread is _off_ the main thread
|
||||
*/
|
||||
public static void verifyOnMainThread() throws IllegalStateException {
|
||||
if (!isCurrentlyOnMainThread()) {
|
||||
throw new IllegalStateException("Must call _on_ the main thread");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws IllegalStateException if the calling thread is _on_ the main thread
|
||||
*/
|
||||
public static void verifyOffMainThread() throws IllegalStateException {
|
||||
if (isCurrentlyOnMainThread()) {
|
||||
throw new IllegalStateException("Must call _off_ the main thread");
|
||||
}
|
||||
}
|
||||
|
||||
public enum NetworkType {
|
||||
NONE,
|
||||
MOBILE,
|
||||
OTHER,
|
||||
}
|
||||
|
||||
public static boolean isNetworkConnected() {
|
||||
NetworkType networkType = getNetworkType();
|
||||
return networkType == NetworkType.MOBILE
|
||||
|| networkType == NetworkType.OTHER;
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission") // permission already included in YouTube
|
||||
public static NetworkType getNetworkType() {
|
||||
Context networkContext = getContext();
|
||||
if (networkContext == null) {
|
||||
return NetworkType.NONE;
|
||||
}
|
||||
ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
var networkInfo = cm.getActiveNetworkInfo();
|
||||
|
||||
if (networkInfo == null || !networkInfo.isConnected()) {
|
||||
return NetworkType.NONE;
|
||||
}
|
||||
var type = networkInfo.getType();
|
||||
return (type == ConnectivityManager.TYPE_MOBILE)
|
||||
|| (type == ConnectivityManager.TYPE_BLUETOOTH) ? NetworkType.MOBILE : NetworkType.OTHER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a view by setting its layout params to 0x0
|
||||
* @param view The view to hide.
|
||||
*/
|
||||
public static void hideViewByLayoutParams(View view) {
|
||||
if (view instanceof LinearLayout) {
|
||||
LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(0, 0);
|
||||
view.setLayoutParams(layoutParams);
|
||||
} else if (view instanceof FrameLayout) {
|
||||
FrameLayout.LayoutParams layoutParams2 = new FrameLayout.LayoutParams(0, 0);
|
||||
view.setLayoutParams(layoutParams2);
|
||||
} else if (view instanceof RelativeLayout) {
|
||||
RelativeLayout.LayoutParams layoutParams3 = new RelativeLayout.LayoutParams(0, 0);
|
||||
view.setLayoutParams(layoutParams3);
|
||||
} else if (view instanceof Toolbar) {
|
||||
Toolbar.LayoutParams layoutParams4 = new Toolbar.LayoutParams(0, 0);
|
||||
view.setLayoutParams(layoutParams4);
|
||||
} else if (view instanceof ViewGroup) {
|
||||
ViewGroup.LayoutParams layoutParams5 = new ViewGroup.LayoutParams(0, 0);
|
||||
view.setLayoutParams(layoutParams5);
|
||||
} else {
|
||||
ViewGroup.LayoutParams params = view.getLayoutParams();
|
||||
params.width = 0;
|
||||
params.height = 0;
|
||||
view.setLayoutParams(params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@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;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
static Sort fromKey(@Nullable String key, @NonNull Sort defaultSort) {
|
||||
if (key != null) {
|
||||
for (Sort sort : values()) {
|
||||
if (key.endsWith(sort.keySuffix)) {
|
||||
return sort;
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultSort;
|
||||
}
|
||||
}
|
||||
|
||||
private static final Pattern punctuationPattern = Pattern.compile("\\p{P}+");
|
||||
|
||||
/**
|
||||
* Strips all punctuation and converts to lower case. A null parameter returns an empty string.
|
||||
*/
|
||||
public static String removePunctuationConvertToLowercase(@Nullable CharSequence original) {
|
||||
if (original == null) return "";
|
||||
return punctuationPattern.matcher(original).replaceAll("").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a PreferenceGroup and all it's sub groups by title or key.
|
||||
*
|
||||
* Sort order is determined by the preferences key {@link Sort} suffix.
|
||||
*
|
||||
* If a preference has no key or no {@link Sort} suffix,
|
||||
* then the preferences are left unsorted.
|
||||
*/
|
||||
@SuppressWarnings("deprecation")
|
||||
public static void sortPreferenceGroups(@NonNull PreferenceGroup group) {
|
||||
Sort groupSort = Sort.fromKey(group.getKey(), Sort.UNSORTED);
|
||||
SortedMap<String, Preference> preferences = new TreeMap<>();
|
||||
|
||||
for (int i = 0, prefCount = group.getPreferenceCount(); i < prefCount; i++) {
|
||||
Preference preference = group.getPreference(i);
|
||||
|
||||
final Sort preferenceSort;
|
||||
if (preference instanceof PreferenceGroup) {
|
||||
sortPreferenceGroups((PreferenceGroup) preference);
|
||||
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 = removePunctuationConvertToLowercase(preference.getTitle());
|
||||
break;
|
||||
case BY_KEY:
|
||||
sortValue = preference.getKey();
|
||||
break;
|
||||
case UNSORTED:
|
||||
continue; // Keep original sorting.
|
||||
default:
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
|
||||
preferences.put(sortValue, preference);
|
||||
}
|
||||
|
||||
int index = 0;
|
||||
for (Preference pref : preferences.values()) {
|
||||
int order = index++;
|
||||
|
||||
// Move any screens, intents, and the one off About preference to the top.
|
||||
if (pref instanceof PreferenceScreen || pref instanceof ReVancedAboutPreference
|
||||
|| pref.getIntent() != null) {
|
||||
// Arbitrary high 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 deviceLanguage = Utils.getContext().getResources().getConfiguration().locale.getLanguage();
|
||||
if (deviceLanguage.equals("en")) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0, prefCount = group.getPreferenceCount(); i < prefCount; i++) {
|
||||
Preference pref = group.getPreference(i);
|
||||
pref.setSingleLineTitle(false);
|
||||
|
||||
if (pref instanceof PreferenceGroup) {
|
||||
setPreferenceTitlesToMultiLineIfNeeded((PreferenceGroup) pref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If {@link Fragment} uses [Android library] rather than [AndroidX library],
|
||||
* the Dialog theme corresponding to [Android library] should be used.
|
||||
* <p>
|
||||
* If not, the following issues will occur:
|
||||
* <a href="https://github.com/ReVanced/revanced-patches/issues/3061">ReVanced/revanced-patches#3061</a>
|
||||
* <p>
|
||||
* To prevent these issues, apply the Dialog theme corresponding to [Android library].
|
||||
*/
|
||||
public static void setEditTextDialogTheme(AlertDialog.Builder builder) {
|
||||
final int editTextDialogStyle = getResourceIdentifier(
|
||||
"revanced_edit_text_dialog_style", "style");
|
||||
if (editTextDialogStyle != 0) {
|
||||
builder.getContext().setTheme(editTextDialogStyle);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,164 @@
|
||||
package app.revanced.extension.shared.checks;
|
||||
|
||||
import static android.text.Html.FROM_HTML_MODE_COMPACT;
|
||||
import static app.revanced.extension.shared.StringRef.str;
|
||||
import static app.revanced.extension.shared.Utils.DialogFragmentOnStartAction;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.text.Html;
|
||||
import android.widget.Button;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
import app.revanced.extension.shared.Logger;
|
||||
import app.revanced.extension.shared.Utils;
|
||||
import app.revanced.extension.shared.settings.BaseSettings;
|
||||
|
||||
abstract class Check {
|
||||
private static final int NUMBER_OF_TIMES_TO_IGNORE_WARNING_BEFORE_DISABLING = 2;
|
||||
|
||||
private static final int SECONDS_BEFORE_SHOWING_IGNORE_BUTTON = 15;
|
||||
private static final int SECONDS_BEFORE_SHOWING_WEBSITE_BUTTON = 10;
|
||||
|
||||
private static final Uri GOOD_SOURCE = Uri.parse("https://revanced.app");
|
||||
|
||||
/**
|
||||
* @return If the check conclusively passed or failed. A null value indicates it neither passed nor failed.
|
||||
*/
|
||||
@Nullable
|
||||
protected abstract Boolean check();
|
||||
|
||||
protected abstract String failureReason();
|
||||
|
||||
/**
|
||||
* Specifies a sorting order for displaying the checks that failed.
|
||||
* A lower value indicates to show first before other checks.
|
||||
*/
|
||||
public abstract int uiSortingValue();
|
||||
|
||||
/**
|
||||
* For debugging and development only.
|
||||
* Forces all checks to be performed and the check failed dialog to be shown.
|
||||
* Can be enabled by importing settings text with {@link BaseSettings#CHECK_ENVIRONMENT_WARNINGS_ISSUED}
|
||||
* set to -1.
|
||||
*/
|
||||
static boolean debugAlwaysShowWarning() {
|
||||
final boolean alwaysShowWarning = BaseSettings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get() < 0;
|
||||
if (alwaysShowWarning) {
|
||||
Logger.printInfo(() -> "Debug forcing environment check warning to show");
|
||||
}
|
||||
|
||||
return alwaysShowWarning;
|
||||
}
|
||||
|
||||
static boolean shouldRun() {
|
||||
return BaseSettings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get()
|
||||
< NUMBER_OF_TIMES_TO_IGNORE_WARNING_BEFORE_DISABLING;
|
||||
}
|
||||
|
||||
static void disableForever() {
|
||||
Logger.printInfo(() -> "Environment checks disabled forever");
|
||||
|
||||
BaseSettings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.save(Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
static void issueWarning(Activity activity, Collection<Check> failedChecks) {
|
||||
final var reasons = new StringBuilder();
|
||||
|
||||
reasons.append("<ul>");
|
||||
for (var check : failedChecks) {
|
||||
// Add a non breaking space to fix bullet points spacing issue.
|
||||
reasons.append("<li> ").append(check.failureReason());
|
||||
}
|
||||
reasons.append("</ul>");
|
||||
|
||||
var message = Html.fromHtml(
|
||||
str("revanced_check_environment_failed_message", reasons.toString()),
|
||||
FROM_HTML_MODE_COMPACT
|
||||
);
|
||||
|
||||
Utils.runOnMainThreadDelayed(() -> {
|
||||
AlertDialog alert = new AlertDialog.Builder(activity)
|
||||
.setCancelable(false)
|
||||
.setIconAttribute(android.R.attr.alertDialogIcon)
|
||||
.setTitle(str("revanced_check_environment_failed_title"))
|
||||
.setMessage(message)
|
||||
.setPositiveButton(
|
||||
" ",
|
||||
(dialog, which) -> {
|
||||
final var intent = new Intent(Intent.ACTION_VIEW, GOOD_SOURCE);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
activity.startActivity(intent);
|
||||
|
||||
// Shutdown to prevent the user from navigating back to this app,
|
||||
// which is no longer showing a warning dialog.
|
||||
activity.finishAffinity();
|
||||
System.exit(0);
|
||||
}
|
||||
).setNegativeButton(
|
||||
" ",
|
||||
(dialog, which) -> {
|
||||
// Cleanup data if the user incorrectly imported a huge negative number.
|
||||
final int current = Math.max(0, BaseSettings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get());
|
||||
BaseSettings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.save(current + 1);
|
||||
|
||||
dialog.dismiss();
|
||||
}
|
||||
).create();
|
||||
|
||||
Utils.showDialog(activity, alert, false, new DialogFragmentOnStartAction() {
|
||||
boolean hasRun;
|
||||
@Override
|
||||
public void onStart(AlertDialog dialog) {
|
||||
// Only run this once, otherwise if the user changes to a different app
|
||||
// then changes back, this handler will run again and disable the buttons.
|
||||
if (hasRun) {
|
||||
return;
|
||||
}
|
||||
hasRun = true;
|
||||
|
||||
var openWebsiteButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE);
|
||||
openWebsiteButton.setEnabled(false);
|
||||
|
||||
var dismissButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE);
|
||||
dismissButton.setEnabled(false);
|
||||
|
||||
getCountdownRunnable(dismissButton, openWebsiteButton).run();
|
||||
}
|
||||
});
|
||||
}, 1000); // Use a delay, so this dialog is shown on top of any other startup dialogs.
|
||||
}
|
||||
|
||||
private static Runnable getCountdownRunnable(Button dismissButton, Button openWebsiteButton) {
|
||||
return new Runnable() {
|
||||
private int secondsRemaining = SECONDS_BEFORE_SHOWING_IGNORE_BUTTON;
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
Utils.verifyOnMainThread();
|
||||
|
||||
if (secondsRemaining > 0) {
|
||||
if (secondsRemaining - SECONDS_BEFORE_SHOWING_WEBSITE_BUTTON == 0) {
|
||||
openWebsiteButton.setText(str("revanced_check_environment_dialog_open_official_source_button"));
|
||||
openWebsiteButton.setEnabled(true);
|
||||
}
|
||||
|
||||
secondsRemaining--;
|
||||
|
||||
Utils.runOnMainThreadDelayed(this, 1000);
|
||||
} else {
|
||||
dismissButton.setText(str("revanced_check_environment_dialog_ignore_button"));
|
||||
dismissButton.setEnabled(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,341 @@
|
||||
package app.revanced.extension.shared.checks;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.util.Base64;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import app.revanced.extension.shared.Logger;
|
||||
import app.revanced.extension.shared.Utils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.*;
|
||||
|
||||
import static app.revanced.extension.shared.StringRef.str;
|
||||
import static app.revanced.extension.shared.checks.Check.debugAlwaysShowWarning;
|
||||
import static app.revanced.extension.shared.checks.PatchInfo.Build.*;
|
||||
|
||||
/**
|
||||
* This class is used to check if the app was patched by the user
|
||||
* and not downloaded pre-patched, because pre-patched apps are difficult to trust.
|
||||
* <br>
|
||||
* Various indicators help to detect if the app was patched by the user.
|
||||
*/
|
||||
@SuppressWarnings("unused")
|
||||
public final class CheckEnvironmentPatch {
|
||||
private static final boolean DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG = debugAlwaysShowWarning();
|
||||
|
||||
private enum InstallationType {
|
||||
/**
|
||||
* CLI patching, manual installation of a previously patched using adb,
|
||||
* or root installation if stock app is first installed using adb.
|
||||
*/
|
||||
ADB((String) null),
|
||||
ROOT_MOUNT_ON_APP_STORE("com.android.vending"),
|
||||
MANAGER("app.revanced.manager.flutter",
|
||||
"app.revanced.manager",
|
||||
"app.revanced.manager.debug");
|
||||
|
||||
@Nullable
|
||||
static InstallationType installTypeFromPackageName(@Nullable String packageName) {
|
||||
for (InstallationType type : values()) {
|
||||
for (String installPackageName : type.packageNames) {
|
||||
if (Objects.equals(installPackageName, packageName)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Array elements can be null.
|
||||
*/
|
||||
final String[] packageNames;
|
||||
|
||||
InstallationType(String... packageNames) {
|
||||
this.packageNames = packageNames;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the app is installed by the manager, the app store, or through adb/CLI.
|
||||
* <br>
|
||||
* Does not conclusively
|
||||
* If the app is installed by the manager or the app store, it is likely, the app was patched using the manager,
|
||||
* or installed manually via ADB (in the case of ReVanced CLI for example).
|
||||
* <br>
|
||||
* If the app is not installed by the manager or the app store, then the app was likely downloaded pre-patched
|
||||
* and installed by the browser or another unknown app.
|
||||
*/
|
||||
private static class CheckExpectedInstaller extends Check {
|
||||
@Nullable
|
||||
InstallationType installerFound;
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
protected Boolean check() {
|
||||
final var context = Utils.getContext();
|
||||
|
||||
final var installerPackageName =
|
||||
context.getPackageManager().getInstallerPackageName(context.getPackageName());
|
||||
|
||||
Logger.printInfo(() -> "Installed by: " + installerPackageName);
|
||||
|
||||
installerFound = InstallationType.installTypeFromPackageName(installerPackageName);
|
||||
final boolean passed = (installerFound != null);
|
||||
|
||||
Logger.printInfo(() -> passed
|
||||
? "Apk was not installed from an unknown source"
|
||||
: "Apk was installed from an unknown source");
|
||||
|
||||
return passed;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String failureReason() {
|
||||
return str("revanced_check_environment_manager_not_expected_installer");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int uiSortingValue() {
|
||||
return -100; // Show first.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the build properties are the same as during the patch.
|
||||
* <br>
|
||||
* If the build properties are the same as during the patch, it is likely, the app was patched on the same device.
|
||||
* <br>
|
||||
* 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("revanced_check_environment_not_same_patching_device");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int uiSortingValue() {
|
||||
return 0; // Show in the middle.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the app was installed within the last 30 minutes after being patched.
|
||||
* <br>
|
||||
* If the app was installed within the last 30 minutes, it is likely, the app was patched by the user.
|
||||
* <br>
|
||||
* If the app was installed much later than the patch time, it is likely the app was
|
||||
* downloaded pre-patched or the user waited too long to install the app.
|
||||
*/
|
||||
private static class CheckIsNearPatchTime extends Check {
|
||||
/**
|
||||
* How soon after patching the app must be installed to pass.
|
||||
*/
|
||||
static final int INSTALL_AFTER_PATCHING_DURATION_THRESHOLD = 30 * 60 * 1000; // 30 minutes.
|
||||
|
||||
/**
|
||||
* Milliseconds between the time the app was patched, and when it was installed/updated.
|
||||
*/
|
||||
long durationBetweenPatchingAndInstallation;
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
protected Boolean check() {
|
||||
try {
|
||||
Context context = Utils.getContext();
|
||||
PackageManager packageManager = context.getPackageManager();
|
||||
PackageInfo packageInfo = packageManager.getPackageInfo(context.getPackageName(), 0);
|
||||
|
||||
// Duration since initial install or last update, which ever is sooner.
|
||||
durationBetweenPatchingAndInstallation = packageInfo.lastUpdateTime - PatchInfo.PATCH_TIME;
|
||||
Logger.printInfo(() -> "App was installed/updated: "
|
||||
+ (durationBetweenPatchingAndInstallation / (60 * 1000) + " minutes after patching"));
|
||||
|
||||
if (durationBetweenPatchingAndInstallation < 0) {
|
||||
// Patch time is in the future and clearly wrong.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (durationBetweenPatchingAndInstallation < INSTALL_AFTER_PATCHING_DURATION_THRESHOLD) {
|
||||
return true;
|
||||
}
|
||||
} catch (PackageManager.NameNotFoundException ex) {
|
||||
Logger.printException(() -> "Package name not found exception", ex); // Will never happen.
|
||||
}
|
||||
|
||||
// User installed more than 30 minutes after patching.
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String failureReason() {
|
||||
if (durationBetweenPatchingAndInstallation < 0) {
|
||||
// Could happen if the user has their device clock incorrectly set in the past,
|
||||
// but assume that isn't the case and the apk was patched on a device with the wrong system time.
|
||||
return str("revanced_check_environment_not_near_patch_time_invalid");
|
||||
}
|
||||
|
||||
// If patched over 1 day ago, show how old this pre-patched apk is.
|
||||
// Showing the age can help convey it's better to patch yourself and know it's the latest.
|
||||
final long oneDay = 24 * 60 * 60 * 1000;
|
||||
final long daysSincePatching = durationBetweenPatchingAndInstallation / oneDay;
|
||||
if (daysSincePatching > 1) { // Use over 1 day to avoid singular vs plural strings.
|
||||
return str("revanced_check_environment_not_near_patch_time_days", daysSincePatching);
|
||||
}
|
||||
|
||||
return str("revanced_check_environment_not_near_patch_time");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int uiSortingValue() {
|
||||
return 100; // Show last.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static void check(Activity context) {
|
||||
// If the warning was already issued twice, or if the check was successful in the past,
|
||||
// do not run the checks again.
|
||||
if (!Check.shouldRun() && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
|
||||
Logger.printDebug(() -> "Environment checks are disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
Utils.runOnBackgroundThread(() -> {
|
||||
try {
|
||||
Logger.printInfo(() -> "Running environment checks");
|
||||
List<Check> failedChecks = new ArrayList<>();
|
||||
|
||||
CheckWasPatchedOnSameDevice sameHardware = new CheckWasPatchedOnSameDevice();
|
||||
Boolean hardwareCheckPassed = sameHardware.check();
|
||||
if (hardwareCheckPassed != null) {
|
||||
if (hardwareCheckPassed && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
|
||||
// Patched on the same device using Manager,
|
||||
// and no further checks are needed.
|
||||
Check.disableForever();
|
||||
return;
|
||||
}
|
||||
|
||||
failedChecks.add(sameHardware);
|
||||
}
|
||||
|
||||
CheckExpectedInstaller installerCheck = new CheckExpectedInstaller();
|
||||
if (installerCheck.check() && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
|
||||
// If the installer package is Manager but this code is reached,
|
||||
// that means it must not be the right Manager otherwise the hardware hash
|
||||
// signatures would be present and this check would not have run.
|
||||
if (installerCheck.installerFound == InstallationType.MANAGER) {
|
||||
failedChecks.add(installerCheck);
|
||||
// Also could not have been patched on this device.
|
||||
failedChecks.add(sameHardware);
|
||||
} else if (failedChecks.isEmpty()) {
|
||||
// ADB install of CLI build. Allow even if patched a long time ago.
|
||||
Check.disableForever();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
failedChecks.add(installerCheck);
|
||||
}
|
||||
|
||||
CheckIsNearPatchTime nearPatchTime = new CheckIsNearPatchTime();
|
||||
Boolean timeCheckPassed = nearPatchTime.check();
|
||||
if (timeCheckPassed && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
|
||||
// Allow installing recently patched apks,
|
||||
// even if the install source is not Manager or ADB.
|
||||
Check.disableForever();
|
||||
return;
|
||||
} else {
|
||||
failedChecks.add(nearPatchTime);
|
||||
}
|
||||
|
||||
if (DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
|
||||
// Show all failures for debugging layout.
|
||||
failedChecks = Arrays.asList(
|
||||
sameHardware,
|
||||
nearPatchTime,
|
||||
installerCheck
|
||||
);
|
||||
}
|
||||
|
||||
//noinspection ComparatorCombinators
|
||||
Collections.sort(failedChecks, (o1, o2) -> o1.uiSortingValue() - o2.uiSortingValue());
|
||||
|
||||
Check.issueWarning(
|
||||
context,
|
||||
failedChecks
|
||||
);
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "check failure", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean buildFieldEqualsHash(String buildFieldName, String buildFieldValue, @Nullable String hash) {
|
||||
try {
|
||||
final var sha1 = MessageDigest.getInstance("SHA-1")
|
||||
.digest(buildFieldValue.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
// Must be careful to use same base64 encoding Kotlin uses.
|
||||
String runtimeHash = new String(Base64.encode(sha1, Base64.NO_WRAP), StandardCharsets.ISO_8859_1);
|
||||
final boolean equals = runtimeHash.equals(hash);
|
||||
if (!equals) {
|
||||
Logger.printInfo(() -> "Hashes do not match. " + buildFieldName + ": '" + buildFieldValue
|
||||
+ "' runtimeHash: '" + runtimeHash + "' patchTimeHash: '" + hash + "'");
|
||||
}
|
||||
|
||||
return equals;
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
Logger.printException(() -> "buildFieldEqualsHash failure", ex); // Will never happen.
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
package app.revanced.extension.shared.checks;
|
||||
|
||||
/**
|
||||
* Fields are set by the patch. Do not modify.
|
||||
* Fields are not final, because the compiler is inlining them.
|
||||
*
|
||||
* @noinspection CanBeFinal
|
||||
*/
|
||||
final class PatchInfo {
|
||||
static long PATCH_TIME = 0L;
|
||||
|
||||
final static class Build {
|
||||
static String PATCH_BOARD = "";
|
||||
static String PATCH_BOOTLOADER = "";
|
||||
static String PATCH_BRAND = "";
|
||||
static String PATCH_CPU_ABI = "";
|
||||
static String PATCH_CPU_ABI2 = "";
|
||||
static String PATCH_DEVICE = "";
|
||||
static String PATCH_DISPLAY = "";
|
||||
static String PATCH_FINGERPRINT = "";
|
||||
static String PATCH_HARDWARE = "";
|
||||
static String PATCH_HOST = "";
|
||||
static String PATCH_ID = "";
|
||||
static String PATCH_MANUFACTURER = "";
|
||||
static String PATCH_MODEL = "";
|
||||
static String PATCH_PRODUCT = "";
|
||||
static String PATCH_RADIO = "";
|
||||
static String PATCH_TAGS = "";
|
||||
static String PATCH_TYPE = "";
|
||||
static String PATCH_USER = "";
|
||||
}
|
||||
}
|
@ -0,0 +1,208 @@
|
||||
package app.revanced.extension.shared.fixes.slink;
|
||||
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import app.revanced.extension.shared.Logger;
|
||||
import app.revanced.extension.shared.Utils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URL;
|
||||
import java.util.Objects;
|
||||
|
||||
import static app.revanced.extension.shared.Utils.getContext;
|
||||
|
||||
|
||||
/**
|
||||
* Base class to implement /s/ link resolution in 3rd party Reddit apps.
|
||||
* <br>
|
||||
* <br>
|
||||
* Usage:
|
||||
* <br>
|
||||
* <br>
|
||||
* An implementation of this class must have two static methods that are called by the app:
|
||||
* <ul>
|
||||
* <li>public static boolean patchResolveSLink(String link)</li>
|
||||
* <li>public static void patchSetAccessToken(String accessToken)</li>
|
||||
* </ul>
|
||||
* The static methods must call the instance methods of the base class.
|
||||
* <br>
|
||||
* The singleton pattern can be used to access the instance of the class:
|
||||
* <pre>
|
||||
* {@code
|
||||
* {
|
||||
* INSTANCE = new FixSLinksPatch();
|
||||
* }
|
||||
* }
|
||||
* </pre>
|
||||
* Set the app's web view activity class as a fallback to open /s/ links if the resolution fails:
|
||||
* <pre>
|
||||
* {@code
|
||||
* private FixSLinksPatch() {
|
||||
* webViewActivityClass = WebViewActivity.class;
|
||||
* }
|
||||
* }
|
||||
* </pre>
|
||||
* Hook the app's navigation handler to call this method before doing any of its own resolution:
|
||||
* <pre>
|
||||
* {@code
|
||||
* public static boolean patchResolveSLink(Context context, String link) {
|
||||
* return INSTANCE.resolveSLink(context, link);
|
||||
* }
|
||||
* }
|
||||
* </pre>
|
||||
* If this method returns true, the app should early return and not do any of its own resolution.
|
||||
* <br>
|
||||
* <br>
|
||||
* Hook the app's access token so that this class can use it to resolve /s/ links:
|
||||
* <pre>
|
||||
* {@code
|
||||
* public static void patchSetAccessToken(String accessToken) {
|
||||
* INSTANCE.setAccessToken(access_token);
|
||||
* }
|
||||
* }
|
||||
* </pre>
|
||||
*/
|
||||
public abstract class BaseFixSLinksPatch {
|
||||
/**
|
||||
* The class of the activity used to open links in a web view if resolving them fails.
|
||||
*/
|
||||
protected Class<? extends Activity> webViewActivityClass;
|
||||
|
||||
/**
|
||||
* The access token used to resolve the /s/ link.
|
||||
*/
|
||||
protected String accessToken;
|
||||
|
||||
/**
|
||||
* The URL that was trying to be resolved before the access token was set.
|
||||
* If this is not null, the URL will be resolved right after the access token is set.
|
||||
*/
|
||||
protected String pendingUrl;
|
||||
|
||||
/**
|
||||
* The singleton instance of the class.
|
||||
*/
|
||||
protected static BaseFixSLinksPatch INSTANCE;
|
||||
|
||||
public boolean resolveSLink(String link) {
|
||||
switch (resolveLink(link)) {
|
||||
case ACCESS_TOKEN_START: {
|
||||
pendingUrl = link;
|
||||
return true;
|
||||
}
|
||||
case DO_NOTHING:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private ResolveResult resolveLink(String link) {
|
||||
Context context = getContext();
|
||||
if (link.matches(".*reddit\\.com/r/[^/]+/s/[^/]+")) {
|
||||
// A link ends with #bypass if it failed to resolve below.
|
||||
// resolveLink is called with the same link again but this time with #bypass
|
||||
// so that the link is opened in the app browser instead of trying to resolve it again.
|
||||
if (link.endsWith("#bypass")) {
|
||||
openInAppBrowser(context, link);
|
||||
|
||||
return ResolveResult.DO_NOTHING;
|
||||
}
|
||||
|
||||
Logger.printDebug(() -> "Resolving " + link);
|
||||
|
||||
if (accessToken == null) {
|
||||
// This is not optimal.
|
||||
// However, an accessToken is necessary to make an authenticated request to Reddit.
|
||||
// in case Reddit has banned the IP - e.g. VPN.
|
||||
Intent startIntent = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
|
||||
context.startActivity(startIntent);
|
||||
|
||||
return ResolveResult.ACCESS_TOKEN_START;
|
||||
}
|
||||
|
||||
|
||||
Utils.runOnBackgroundThread(() -> {
|
||||
String bypassLink = link + "#bypass";
|
||||
|
||||
String finalLocation = bypassLink;
|
||||
try {
|
||||
HttpURLConnection connection = getHttpURLConnection(link, accessToken);
|
||||
connection.connect();
|
||||
String location = connection.getHeaderField("location");
|
||||
connection.disconnect();
|
||||
|
||||
Objects.requireNonNull(location, "Location is null");
|
||||
|
||||
finalLocation = location;
|
||||
Logger.printDebug(() -> "Resolved " + link + " to " + location);
|
||||
} catch (SocketTimeoutException e) {
|
||||
Logger.printException(() -> "Timeout when trying to resolve " + link, e);
|
||||
finalLocation = bypassLink;
|
||||
} catch (Exception e) {
|
||||
Logger.printException(() -> "Failed to resolve " + link, e);
|
||||
finalLocation = bypassLink;
|
||||
} finally {
|
||||
Intent startIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(finalLocation));
|
||||
startIntent.setPackage(context.getPackageName());
|
||||
startIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
context.startActivity(startIntent);
|
||||
}
|
||||
});
|
||||
|
||||
return ResolveResult.DO_NOTHING;
|
||||
}
|
||||
|
||||
return ResolveResult.CONTINUE;
|
||||
}
|
||||
|
||||
public void setAccessToken(String accessToken) {
|
||||
Logger.printDebug(() -> "Setting access token");
|
||||
|
||||
this.accessToken = accessToken;
|
||||
|
||||
// In case a link was trying to be resolved before access token was set.
|
||||
// The link is resolved now, after the access token is set.
|
||||
if (pendingUrl != null) {
|
||||
String link = pendingUrl;
|
||||
pendingUrl = null;
|
||||
|
||||
Logger.printDebug(() -> "Opening pending URL");
|
||||
|
||||
resolveLink(link);
|
||||
}
|
||||
}
|
||||
|
||||
private void openInAppBrowser(Context context, String link) {
|
||||
Intent intent = new Intent(context, webViewActivityClass);
|
||||
intent.putExtra("url", link);
|
||||
context.startActivity(intent);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private HttpURLConnection getHttpURLConnection(String link, String accessToken) throws IOException {
|
||||
URL url = new URL(link);
|
||||
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
connection.setRequestMethod("HEAD");
|
||||
connection.setConnectTimeout(2000);
|
||||
connection.setReadTimeout(2000);
|
||||
|
||||
if (accessToken != null) {
|
||||
Logger.printDebug(() -> "Setting access token to make /s/ request");
|
||||
|
||||
connection.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||
} else {
|
||||
Logger.printDebug(() -> "Not setting access token to make /s/ request, because it is null");
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
package app.revanced.extension.shared.fixes.slink;
|
||||
|
||||
public enum ResolveResult {
|
||||
// Let app handle rest of stuff
|
||||
CONTINUE,
|
||||
// Start app, to make it cache its access_token
|
||||
ACCESS_TOKEN_START,
|
||||
// Don't do anything - we started resolving
|
||||
DO_NOTHING
|
||||
}
|
@ -0,0 +1,145 @@
|
||||
package app.revanced.extension.shared.requests;
|
||||
|
||||
import app.revanced.extension.shared.Utils;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
|
||||
public class Requester {
|
||||
private Requester() {
|
||||
}
|
||||
|
||||
public static HttpURLConnection getConnectionFromRoute(String apiUrl, Route route, String... params) throws IOException {
|
||||
return getConnectionFromCompiledRoute(apiUrl, route.compile(params));
|
||||
}
|
||||
|
||||
public static HttpURLConnection getConnectionFromCompiledRoute(String apiUrl, Route.CompiledRoute route) throws IOException {
|
||||
String url = apiUrl + route.getCompiledRoute();
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
|
||||
// Request data is in the URL parameters and no body is sent.
|
||||
// The calling code must set a length if using a request body.
|
||||
connection.setFixedLengthStreamingMode(0);
|
||||
connection.setRequestMethod(route.getMethod().name());
|
||||
String agentString = System.getProperty("http.agent")
|
||||
+ "; ReVanced/" + Utils.getAppVersionName()
|
||||
+ " (" + Utils.getPatchesReleaseVersion() + ")";
|
||||
connection.setRequestProperty("User-Agent", agentString);
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the {@link HttpURLConnection}, and closes the underlying InputStream.
|
||||
*/
|
||||
private static String parseInputStreamAndClose(InputStream inputStream) throws IOException {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
|
||||
StringBuilder jsonBuilder = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
jsonBuilder.append(line);
|
||||
jsonBuilder.append('\n');
|
||||
}
|
||||
return jsonBuilder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the {@link HttpURLConnection} response as a String.
|
||||
* This does not close the url connection. If further requests to this host are unlikely
|
||||
* in the near future, then instead use {@link #parseStringAndDisconnect(HttpURLConnection)}.
|
||||
*/
|
||||
public static String parseString(HttpURLConnection connection) throws IOException {
|
||||
return parseInputStreamAndClose(connection.getInputStream());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the {@link HttpURLConnection} response as a String, and disconnect.
|
||||
*
|
||||
* <b>Should only be used if other requests to the server in the near future are unlikely</b>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <b>Should only be used if other requests to the server in the near future are unlikely</b>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <b>Should only be used if other requests to the server in the near future are unlikely</b>
|
||||
*
|
||||
* @see #parseJSONArray(HttpURLConnection)
|
||||
*/
|
||||
public static JSONArray parseJSONArrayAndDisconnect(HttpURLConnection connection) throws JSONException, IOException {
|
||||
JSONArray array = parseJSONArray(connection);
|
||||
connection.disconnect();
|
||||
return array;
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
package app.revanced.extension.shared.requests;
|
||||
|
||||
public class Route {
|
||||
private final String route;
|
||||
private final Method method;
|
||||
private final int paramCount;
|
||||
|
||||
public Route(Method method, String route) {
|
||||
this.method = method;
|
||||
this.route = route;
|
||||
this.paramCount = countMatches(route, '{');
|
||||
|
||||
if (paramCount != countMatches(route, '}'))
|
||||
throw new IllegalArgumentException("Not enough parameters");
|
||||
}
|
||||
|
||||
public Method getMethod() {
|
||||
return method;
|
||||
}
|
||||
|
||||
public CompiledRoute compile(String... params) {
|
||||
if (params.length != paramCount)
|
||||
throw new IllegalArgumentException("Error compiling route [" + route + "], incorrect amount of parameters provided. " +
|
||||
"Expected: " + paramCount + ", provided: " + params.length);
|
||||
|
||||
StringBuilder compiledRoute = new StringBuilder(route);
|
||||
for (int i = 0; i < paramCount; i++) {
|
||||
int paramStart = compiledRoute.indexOf("{");
|
||||
int paramEnd = compiledRoute.indexOf("}");
|
||||
compiledRoute.replace(paramStart, paramEnd + 1, params[i]);
|
||||
}
|
||||
return new CompiledRoute(this, compiledRoute.toString());
|
||||
}
|
||||
|
||||
public static class CompiledRoute {
|
||||
private final Route baseRoute;
|
||||
private final String compiledRoute;
|
||||
|
||||
private CompiledRoute(Route baseRoute, String compiledRoute) {
|
||||
this.baseRoute = baseRoute;
|
||||
this.compiledRoute = compiledRoute;
|
||||
}
|
||||
|
||||
public String getCompiledRoute() {
|
||||
return compiledRoute;
|
||||
}
|
||||
|
||||
public Method getMethod() {
|
||||
return baseRoute.method;
|
||||
}
|
||||
}
|
||||
|
||||
private int countMatches(CharSequence seq, char c) {
|
||||
int count = 0;
|
||||
for (int i = 0; i < seq.length(); i++) {
|
||||
if (seq.charAt(i) == c)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public enum Method {
|
||||
GET,
|
||||
POST
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
package app.revanced.extension.shared.settings;
|
||||
|
||||
import static java.lang.Boolean.FALSE;
|
||||
import static java.lang.Boolean.TRUE;
|
||||
import static app.revanced.extension.shared.settings.Setting.parent;
|
||||
|
||||
/**
|
||||
* Settings shared across multiple apps.
|
||||
*
|
||||
* To ensure this class is loaded when the UI is created, app specific setting bundles should extend
|
||||
* or reference this class.
|
||||
*/
|
||||
public class BaseSettings {
|
||||
public static final BooleanSetting DEBUG = new BooleanSetting("revanced_debug", FALSE);
|
||||
public static final BooleanSetting DEBUG_STACKTRACE = new BooleanSetting("revanced_debug_stacktrace", FALSE, parent(DEBUG));
|
||||
public static final BooleanSetting DEBUG_TOAST_ON_ERROR = new BooleanSetting("revanced_debug_toast_on_error", TRUE, "revanced_debug_toast_on_error_user_dialog_message");
|
||||
|
||||
public static final IntegerSetting CHECK_ENVIRONMENT_WARNINGS_ISSUED = new IntegerSetting("revanced_check_environment_warnings_issued", 0, true, false);
|
||||
}
|
@ -0,0 +1,79 @@
|
||||
package app.revanced.extension.shared.settings;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public class BooleanSetting extends Setting<Boolean> {
|
||||
public BooleanSetting(String key, Boolean defaultValue) {
|
||||
super(key, defaultValue);
|
||||
}
|
||||
public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp) {
|
||||
super(key, defaultValue, rebootApp);
|
||||
}
|
||||
public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, boolean includeWithImportExport) {
|
||||
super(key, defaultValue, rebootApp, includeWithImportExport);
|
||||
}
|
||||
public BooleanSetting(String key, Boolean defaultValue, String userDialogMessage) {
|
||||
super(key, defaultValue, userDialogMessage);
|
||||
}
|
||||
public BooleanSetting(String key, Boolean defaultValue, Availability availability) {
|
||||
super(key, defaultValue, availability);
|
||||
}
|
||||
public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, String userDialogMessage) {
|
||||
super(key, defaultValue, rebootApp, userDialogMessage);
|
||||
}
|
||||
public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, Availability availability) {
|
||||
super(key, defaultValue, rebootApp, availability);
|
||||
}
|
||||
public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
|
||||
super(key, defaultValue, rebootApp, userDialogMessage, availability);
|
||||
}
|
||||
public BooleanSetting(@NonNull String key, @NonNull Boolean defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
|
||||
super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets, but does _not_ persistently save the value.
|
||||
* This method is only to be used by the Settings preference code.
|
||||
*
|
||||
* This intentionally is a static method to deter
|
||||
* accidental usage when {@link #save(Boolean)} was intnded.
|
||||
*/
|
||||
public static void privateSetValue(@NonNull BooleanSetting setting, @NonNull Boolean newValue) {
|
||||
setting.value = Objects.requireNonNull(newValue);
|
||||
}
|
||||
|
||||
@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 save(@NonNull Boolean newValue) {
|
||||
// Must set before saving to preferences (otherwise importing fails to update UI correctly).
|
||||
value = Objects.requireNonNull(newValue);
|
||||
preferences.saveBoolean(key, newValue);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Boolean get() {
|
||||
return value;
|
||||
}
|
||||
}
|
@ -0,0 +1,117 @@
|
||||
package app.revanced.extension.shared.settings;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
import app.revanced.extension.shared.Logger;
|
||||
|
||||
/**
|
||||
* If an Enum value is removed or changed, any saved or imported data using the
|
||||
* non-existent value will be reverted to the default value
|
||||
* (the event is logged, but no user error is displayed).
|
||||
*
|
||||
* All saved JSON text is converted to lowercase to keep the output less obnoxious.
|
||||
*/
|
||||
@SuppressWarnings("unused")
|
||||
public class EnumSetting<T extends Enum<?>> extends Setting<T> {
|
||||
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));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private 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 save(@NonNull T newValue) {
|
||||
// Must set before saving to preferences (otherwise importing fails to update UI correctly).
|
||||
value = Objects.requireNonNull(newValue);
|
||||
preferences.saveEnumAsString(key, newValue);
|
||||
}
|
||||
|
||||
@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(@NonNull T... types) {
|
||||
return () -> {
|
||||
T currentEnumType = get();
|
||||
for (T enumType : types) {
|
||||
if (currentEnumType == enumType) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
package app.revanced.extension.shared.settings;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public class FloatSetting extends Setting<Float> {
|
||||
|
||||
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 save(@NonNull Float newValue) {
|
||||
// Must set before saving to preferences (otherwise importing fails to update UI correctly).
|
||||
value = Objects.requireNonNull(newValue);
|
||||
preferences.saveFloatString(key, newValue);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Float get() {
|
||||
return value;
|
||||
}
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
package app.revanced.extension.shared.settings;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public class IntegerSetting extends Setting<Integer> {
|
||||
|
||||
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 save(@NonNull Integer newValue) {
|
||||
// Must set before saving to preferences (otherwise importing fails to update UI correctly).
|
||||
value = Objects.requireNonNull(newValue);
|
||||
preferences.saveIntegerString(key, newValue);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Integer get() {
|
||||
return value;
|
||||
}
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
package app.revanced.extension.shared.settings;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public class LongSetting extends Setting<Long> {
|
||||
|
||||
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 save(@NonNull Long newValue) {
|
||||
// Must set before saving to preferences (otherwise importing fails to update UI correctly).
|
||||
value = Objects.requireNonNull(newValue);
|
||||
preferences.saveLongString(key, newValue);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Long get() {
|
||||
return value;
|
||||
}
|
||||
}
|
@ -0,0 +1,460 @@
|
||||
package app.revanced.extension.shared.settings;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import app.revanced.extension.shared.Logger;
|
||||
import app.revanced.extension.shared.StringRef;
|
||||
import app.revanced.extension.shared.Utils;
|
||||
import app.revanced.extension.shared.settings.preference.SharedPrefCategory;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static app.revanced.extension.shared.StringRef.str;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public abstract class Setting<T> {
|
||||
|
||||
/**
|
||||
* Indicates if a {@link Setting} is available to edit and use.
|
||||
* Typically this is dependent upon other BooleanSetting(s) set to 'true',
|
||||
* but this can be used to call into extension code and check other conditions.
|
||||
*/
|
||||
public interface Availability {
|
||||
boolean isAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Availability based on a single parent setting being enabled.
|
||||
*/
|
||||
@NonNull
|
||||
public static Availability parent(@NonNull BooleanSetting parent) {
|
||||
return parent::get;
|
||||
}
|
||||
|
||||
/**
|
||||
* Availability based on all parents being enabled.
|
||||
*/
|
||||
@NonNull
|
||||
public static Availability parentsAll(@NonNull BooleanSetting... parents) {
|
||||
return () -> {
|
||||
for (BooleanSetting parent : parents) {
|
||||
if (!parent.get()) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Availability based on any parent being enabled.
|
||||
*/
|
||||
@NonNull
|
||||
public static Availability parentsAny(@NonNull BooleanSetting... parents) {
|
||||
return () -> {
|
||||
for (BooleanSetting parent : parents) {
|
||||
if (parent.get()) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for importing/exporting settings.
|
||||
*/
|
||||
public interface ImportExportCallback {
|
||||
/**
|
||||
* Called after all settings have been imported.
|
||||
*/
|
||||
void settingsImported(@Nullable Context context);
|
||||
|
||||
/**
|
||||
* Called after all settings have been exported.
|
||||
*/
|
||||
void settingsExported(@Nullable Context context);
|
||||
}
|
||||
|
||||
private static final List<ImportExportCallback> importExportCallbacks = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Adds a callback for {@link #importFromJSON(Context, String)} and {@link #exportToJson(Context)}.
|
||||
*/
|
||||
public static void addImportExportCallback(@NonNull 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<Setting<?>> SETTINGS = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Map of setting path to setting object.
|
||||
*/
|
||||
private static final Map<String, Setting<?>> PATH_TO_SETTINGS = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Preference all instances are saved to.
|
||||
*/
|
||||
public static final SharedPrefCategory preferences = new SharedPrefCategory("revanced_prefs");
|
||||
|
||||
@Nullable
|
||||
public static Setting<?> getSettingFromPath(@NonNull String str) {
|
||||
return PATH_TO_SETTINGS.get(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return All settings that have been created.
|
||||
*/
|
||||
@NonNull
|
||||
public static List<Setting<?>> allLoadedSettings() {
|
||||
return Collections.unmodifiableList(SETTINGS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return All settings that have been created, sorted by keys.
|
||||
*/
|
||||
@NonNull
|
||||
private static List<Setting<?>> allLoadedSettingsSorted() {
|
||||
Collections.sort(SETTINGS, (Setting<?> o1, Setting<?> o2) -> o1.key.compareTo(o2.key));
|
||||
return allLoadedSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* The key used to store the value in the shared preferences.
|
||||
*/
|
||||
@NonNull
|
||||
public final String key;
|
||||
|
||||
/**
|
||||
* The default value of the setting.
|
||||
*/
|
||||
@NonNull
|
||||
public final T defaultValue;
|
||||
|
||||
/**
|
||||
* If the app should be rebooted, if this setting is changed
|
||||
*/
|
||||
public final boolean rebootApp;
|
||||
|
||||
/**
|
||||
* If this setting should be included when importing/exporting settings.
|
||||
*/
|
||||
public final boolean includeWithImportExport;
|
||||
|
||||
/**
|
||||
* If this setting is available to edit and use.
|
||||
* Not to be confused with it's status returned from {@link #get()}.
|
||||
*/
|
||||
@Nullable
|
||||
private final Availability availability;
|
||||
|
||||
/**
|
||||
* Confirmation message to display, if the user tries to change the setting from the default value.
|
||||
* Currently this works only for Boolean setting types.
|
||||
*/
|
||||
@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.
|
||||
*/
|
||||
@NonNull
|
||||
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(@NonNull String key,
|
||||
@NonNull T defaultValue,
|
||||
boolean rebootApp,
|
||||
boolean includeWithImportExport,
|
||||
@Nullable String userDialogMessage,
|
||||
@Nullable Availability availability
|
||||
) {
|
||||
this.key = Objects.requireNonNull(key);
|
||||
this.value = this.defaultValue = Objects.requireNonNull(defaultValue);
|
||||
this.rebootApp = rebootApp;
|
||||
this.includeWithImportExport = includeWithImportExport;
|
||||
this.userDialogMessage = (userDialogMessage == null) ? null : new StringRef(userDialogMessage);
|
||||
this.availability = availability;
|
||||
|
||||
SETTINGS.add(this);
|
||||
if (PATH_TO_SETTINGS.put(key, this) != null) {
|
||||
// Debug setting may not be created yet so using Logger may cause an initialization crash.
|
||||
// Show a toast instead.
|
||||
Utils.showToastLong(this.getClass().getSimpleName()
|
||||
+ " error: Duplicate Setting key found: " + key);
|
||||
}
|
||||
|
||||
load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a setting value if the path is renamed but otherwise the old and new settings are identical.
|
||||
*/
|
||||
public static <T> void migrateOldSettingToNew(@NonNull Setting<T> oldSetting, @NonNull Setting<T> newSetting) {
|
||||
if (oldSetting == newSetting) throw new IllegalArgumentException();
|
||||
|
||||
if (!oldSetting.isSetToDefault()) {
|
||||
Logger.printInfo(() -> "Migrating old setting value: " + oldSetting + " into replacement setting: " + newSetting);
|
||||
newSetting.save(oldSetting.value);
|
||||
oldSetting.resetToDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate an old Setting value previously stored in a different SharedPreference.
|
||||
*
|
||||
* This method will be deleted in the future.
|
||||
*/
|
||||
public static void migrateFromOldPreferences(@NonNull SharedPrefCategory oldPrefs, @NonNull Setting setting, String settingKey) {
|
||||
if (!oldPrefs.preferences.contains(settingKey)) {
|
||||
return; // Nothing to do.
|
||||
}
|
||||
|
||||
Object newValue = setting.get();
|
||||
final Object migratedValue;
|
||||
if (setting instanceof BooleanSetting) {
|
||||
migratedValue = oldPrefs.getBoolean(settingKey, (Boolean) newValue);
|
||||
} else if (setting instanceof IntegerSetting) {
|
||||
migratedValue = oldPrefs.getIntegerString(settingKey, (Integer) newValue);
|
||||
} else if (setting instanceof LongSetting) {
|
||||
migratedValue = oldPrefs.getLongString(settingKey, (Long) newValue);
|
||||
} else if (setting instanceof FloatSetting) {
|
||||
migratedValue = oldPrefs.getFloatString(settingKey, (Float) newValue);
|
||||
} else if (setting instanceof StringSetting) {
|
||||
migratedValue = oldPrefs.getString(settingKey, (String) newValue);
|
||||
} else {
|
||||
Logger.printException(() -> "Unknown setting: " + setting);
|
||||
// Remove otherwise it'll show a toast on every launch
|
||||
oldPrefs.preferences.edit().remove(settingKey).apply();
|
||||
return;
|
||||
}
|
||||
|
||||
oldPrefs.preferences.edit().remove(settingKey).apply(); // Remove the old setting.
|
||||
if (migratedValue.equals(newValue)) {
|
||||
Logger.printDebug(() -> "Value does not need migrating: " + settingKey);
|
||||
return; // Old value is already equal to the new setting value.
|
||||
}
|
||||
|
||||
Logger.printDebug(() -> "Migrating old preference value into current preference: " + settingKey);
|
||||
//noinspection unchecked
|
||||
setting.save(migratedValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets, but does _not_ persistently save the value.
|
||||
* This method is only to be used by the Settings preference code.
|
||||
*
|
||||
* This intentionally is a static method to deter
|
||||
* accidental usage when {@link #save(Object)} was intended.
|
||||
*/
|
||||
public static void privateSetValueFromString(@NonNull Setting<?> setting, @NonNull String newValue) {
|
||||
setting.setValueFromString(newValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of {@link #value}, but do not save to {@link #preferences}.
|
||||
*/
|
||||
protected abstract void setValueFromString(@NonNull String newValue);
|
||||
|
||||
/**
|
||||
* Load and set the value of {@link #value}.
|
||||
*/
|
||||
protected abstract void load();
|
||||
|
||||
/**
|
||||
* Persistently saves the value.
|
||||
*/
|
||||
public abstract void save(@NonNull T newValue);
|
||||
|
||||
@NonNull
|
||||
public abstract T get();
|
||||
|
||||
/**
|
||||
* Identical to calling {@link #save(Object)} using {@link #defaultValue}.
|
||||
*/
|
||||
public void resetToDefault() {
|
||||
save(defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return if this setting can be configured and used.
|
||||
*/
|
||||
public boolean isAvailable() {
|
||||
return availability == null || availability.isAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return if the currently set value is the same as {@link #defaultValue}
|
||||
*/
|
||||
public boolean isSetToDefault() {
|
||||
return value.equals(defaultValue);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return key + "=" + get();
|
||||
}
|
||||
|
||||
// region Import / export
|
||||
|
||||
/**
|
||||
* If a setting path has this prefix, then remove it before importing/exporting.
|
||||
*/
|
||||
private static final String OPTIONAL_REVANCED_SETTINGS_PREFIX = "revanced_";
|
||||
|
||||
/**
|
||||
* The path, minus any 'revanced' prefix to keep json concise.
|
||||
*/
|
||||
private String getImportExportKey() {
|
||||
if (key.startsWith(OPTIONAL_REVANCED_SETTINGS_PREFIX)) {
|
||||
return key.substring(OPTIONAL_REVANCED_SETTINGS_PREFIX.length());
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param importExportKey The JSON key. The JSONObject parameter will contain data for this key.
|
||||
* @return the value stored using the import/export key. Do not set any values in this method.
|
||||
*/
|
||||
protected abstract T readFromJSON(JSONObject json, String importExportKey) throws JSONException;
|
||||
|
||||
/**
|
||||
* Saves this instance to JSON.
|
||||
* <p>
|
||||
* To keep the JSON simple and readable,
|
||||
* subclasses should not write out any embedded types (such as JSON Array or Dictionaries).
|
||||
* <p>
|
||||
* 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);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static String exportToJson(@Nullable Context alertDialogContext) {
|
||||
try {
|
||||
JSONObject json = new JSONObject();
|
||||
for (Setting<?> setting : allLoadedSettingsSorted()) {
|
||||
String importExportKey = setting.getImportExportKey();
|
||||
if (json.has(importExportKey)) {
|
||||
throw new IllegalArgumentException("duplicate key found: " + importExportKey);
|
||||
}
|
||||
|
||||
final boolean exportDefaultValues = false; // Enable to see what all settings looks like in the UI.
|
||||
//noinspection ConstantValue
|
||||
if (setting.includeWithImportExport && (!setting.isSetToDefault() || exportDefaultValues)) {
|
||||
setting.writeToJSON(json, importExportKey);
|
||||
}
|
||||
}
|
||||
|
||||
for (ImportExportCallback callback : importExportCallbacks) {
|
||||
callback.settingsExported(alertDialogContext);
|
||||
}
|
||||
|
||||
if (json.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String export = json.toString(0);
|
||||
|
||||
// Remove the outer JSON braces to make the output more compact,
|
||||
// and leave less chance of the user forgetting to copy it
|
||||
return export.substring(2, export.length() - 2);
|
||||
} catch (JSONException e) {
|
||||
Logger.printException(() -> "Export failure", e); // should never happen
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return if any settings that require a reboot were changed.
|
||||
*/
|
||||
public static boolean importFromJSON(@NonNull Context alertDialogContext, @NonNull String settingsJsonString) {
|
||||
try {
|
||||
if (!settingsJsonString.matches("[\\s\\S]*\\{")) {
|
||||
settingsJsonString = '{' + settingsJsonString + '}'; // Restore outer JSON braces
|
||||
}
|
||||
JSONObject json = new JSONObject(settingsJsonString);
|
||||
|
||||
boolean rebootSettingChanged = false;
|
||||
int numberOfSettingsImported = 0;
|
||||
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.settingsExported(alertDialogContext);
|
||||
}
|
||||
|
||||
Utils.showToastLong(numberOfSettingsImported == 0
|
||||
? str("revanced_settings_import_reset")
|
||||
: str("revanced_settings_import_success", numberOfSettingsImported));
|
||||
|
||||
return rebootSettingChanged;
|
||||
} catch (JSONException | IllegalArgumentException ex) {
|
||||
Utils.showToastLong(str("revanced_settings_import_failure_parse", ex.getMessage()));
|
||||
Logger.printInfo(() -> "", ex);
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "Import failure: " + ex.getMessage(), ex); // should never happen
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// End import / export
|
||||
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
package app.revanced.extension.shared.settings;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public class StringSetting extends Setting<String> {
|
||||
|
||||
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 save(@NonNull String newValue) {
|
||||
// Must set before saving to preferences (otherwise importing fails to update UI correctly).
|
||||
value = Objects.requireNonNull(newValue);
|
||||
preferences.saveString(key, newValue);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String get() {
|
||||
return value;
|
||||
}
|
||||
}
|
@ -0,0 +1,280 @@
|
||||
package app.revanced.extension.shared.settings.preference;
|
||||
|
||||
import static app.revanced.extension.shared.StringRef.str;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Bundle;
|
||||
import android.preference.*;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import app.revanced.extension.shared.Logger;
|
||||
import app.revanced.extension.shared.Utils;
|
||||
import app.revanced.extension.shared.settings.BaseSettings;
|
||||
import app.revanced.extension.shared.settings.BooleanSetting;
|
||||
import app.revanced.extension.shared.settings.Setting;
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
public abstract class AbstractPreferenceFragment extends PreferenceFragment {
|
||||
/**
|
||||
* Indicates that if a preference changes,
|
||||
* to apply the change from the Setting to the UI component.
|
||||
*/
|
||||
public static boolean settingImportInProgress;
|
||||
|
||||
/**
|
||||
* Confirm and restart dialog button text and title.
|
||||
* Set by subclasses if Strings cannot be added as a resource.
|
||||
*/
|
||||
@Nullable
|
||||
protected static String restartDialogButtonText, restartDialogTitle, confirmDialogTitle;
|
||||
|
||||
/**
|
||||
* Used to prevent showing reboot dialog, if user cancels a setting user dialog.
|
||||
*/
|
||||
private boolean showingUserDialogMessage;
|
||||
|
||||
private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> {
|
||||
try {
|
||||
Setting<?> setting = Setting.getSettingFromPath(str);
|
||||
if (setting == null) {
|
||||
return;
|
||||
}
|
||||
Preference pref = findPreference(str);
|
||||
if (pref == null) {
|
||||
return;
|
||||
}
|
||||
Logger.printDebug(() -> "Preference changed: " + setting.key);
|
||||
|
||||
// Apply 'Setting <- Preference', unless during importing when it needs to be 'Setting -> Preference'.
|
||||
updatePreference(pref, setting, true, settingImportInProgress);
|
||||
// Update any other preference availability that may now be different.
|
||||
updateUIAvailability();
|
||||
|
||||
if (settingImportInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showingUserDialogMessage) {
|
||||
if (setting.userDialogMessage != null && ((SwitchPreference) pref).isChecked() != (Boolean) setting.defaultValue) {
|
||||
showSettingUserDialogConfirmation((SwitchPreference) pref, (BooleanSetting) setting);
|
||||
} else if (setting.rebootApp) {
|
||||
showRestartDialog(getContext());
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "OnSharedPreferenceChangeListener failure", ex);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Initialize this instance, and do any custom behavior.
|
||||
* <p>
|
||||
* 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() {
|
||||
final var identifier = Utils.getResourceIdentifier("revanced_prefs", "xml");
|
||||
if (identifier == 0) return;
|
||||
addPreferencesFromResource(identifier);
|
||||
|
||||
PreferenceScreen screen = getPreferenceScreen();
|
||||
Utils.sortPreferenceGroups(screen);
|
||||
Utils.setPreferenceTitlesToMultiLineIfNeeded(screen);
|
||||
}
|
||||
|
||||
private void showSettingUserDialogConfirmation(SwitchPreference switchPref, BooleanSetting setting) {
|
||||
Utils.verifyOnMainThread();
|
||||
|
||||
final var context = getContext();
|
||||
if (confirmDialogTitle == null) {
|
||||
confirmDialogTitle = str("revanced_settings_confirm_user_dialog_title");
|
||||
}
|
||||
showingUserDialogMessage = true;
|
||||
new AlertDialog.Builder(context)
|
||||
.setTitle(confirmDialogTitle)
|
||||
.setMessage(Objects.requireNonNull(setting.userDialogMessage).toString())
|
||||
.setPositiveButton(android.R.string.ok, (dialog, id) -> {
|
||||
if (setting.rebootApp) {
|
||||
showRestartDialog(context);
|
||||
}
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, (dialog, id) -> {
|
||||
switchPref.setChecked(setting.defaultValue); // Recursive call that resets the Setting value.
|
||||
})
|
||||
.setOnDismissListener(dialog -> {
|
||||
showingUserDialogMessage = false;
|
||||
})
|
||||
.setCancelable(false)
|
||||
.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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs all UI Preferences to any {@link Setting} they represent.
|
||||
*/
|
||||
private void updatePreferenceScreen(@NonNull PreferenceScreen screen,
|
||||
boolean syncSettingValue,
|
||||
boolean applySettingToPreference) {
|
||||
// Alternatively this could iterate thru all Settings and check for any matching Preferences,
|
||||
// but there are many more Settings than UI preferences so it's more efficient to only check
|
||||
// the Preferences.
|
||||
for (int i = 0, prefCount = screen.getPreferenceCount(); i < prefCount; i++) {
|
||||
Preference pref = screen.getPreference(i);
|
||||
if (pref instanceof PreferenceScreen) {
|
||||
updatePreferenceScreen((PreferenceScreen) pref, 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) {
|
||||
SwitchPreference switchPref = (SwitchPreference) pref;
|
||||
BooleanSetting boolSetting = (BooleanSetting) setting;
|
||||
if (applySettingToPreference) {
|
||||
switchPref.setChecked(boolSetting.get());
|
||||
} else {
|
||||
BooleanSetting.privateSetValue(boolSetting, switchPref.isChecked());
|
||||
}
|
||||
} else if (pref instanceof EditTextPreference) {
|
||||
EditTextPreference editPreference = (EditTextPreference) pref;
|
||||
if (applySettingToPreference) {
|
||||
editPreference.setText(setting.get().toString());
|
||||
} else {
|
||||
Setting.privateSetValueFromString(setting, editPreference.getText());
|
||||
}
|
||||
} else if (pref instanceof ListPreference) {
|
||||
ListPreference listPref = (ListPreference) pref;
|
||||
if (applySettingToPreference) {
|
||||
listPref.setValue(setting.get().toString());
|
||||
} else {
|
||||
Setting.privateSetValueFromString(setting, listPref.getValue());
|
||||
}
|
||||
updateListPreferenceSummary(listPref, setting);
|
||||
} else {
|
||||
Logger.printException(() -> "Setting cannot be handled: " + pref.getClass() + ": " + pref);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a UI Preference with the {@link Setting} that backs it.
|
||||
*
|
||||
* @param syncSetting If the UI should be synced {@link Setting} <-> Preference
|
||||
* @param applySettingToPreference If true, then apply {@link Setting} -> Preference.
|
||||
* If false, then apply {@link Setting} <- Preference.
|
||||
*/
|
||||
private void updatePreference(@NonNull Preference pref, @NonNull Setting<?> setting,
|
||||
boolean syncSetting, boolean applySettingToPreference) {
|
||||
if (!syncSetting && applySettingToPreference) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
|
||||
if (syncSetting) {
|
||||
syncSettingWithPreference(pref, setting, applySettingToPreference);
|
||||
}
|
||||
|
||||
updatePreferenceAvailability(pref, setting);
|
||||
}
|
||||
|
||||
protected void updatePreferenceAvailability(@NonNull Preference pref, @NonNull Setting<?> setting) {
|
||||
pref.setEnabled(setting.isAvailable());
|
||||
}
|
||||
|
||||
protected void updateListPreferenceSummary(ListPreference listPreference, Setting<?> setting) {
|
||||
String objectStringValue = setting.get().toString();
|
||||
final int entryIndex = listPreference.findIndexOfValue(objectStringValue);
|
||||
if (entryIndex >= 0) {
|
||||
listPreference.setSummary(listPreference.getEntries()[entryIndex]);
|
||||
} else {
|
||||
// Value is not an available option.
|
||||
// User manually edited import data, or options changed and current selection is no longer available.
|
||||
// Still show the value in the summary, so it's clear that something is selected.
|
||||
listPreference.setSummary(objectStringValue);
|
||||
}
|
||||
}
|
||||
|
||||
public static void showRestartDialog(@NonNull final Context context) {
|
||||
Utils.verifyOnMainThread();
|
||||
if (restartDialogTitle == null) {
|
||||
restartDialogTitle = str("revanced_settings_restart_title");
|
||||
}
|
||||
if (restartDialogButtonText == null) {
|
||||
restartDialogButtonText = str("revanced_settings_restart");
|
||||
}
|
||||
new AlertDialog.Builder(context)
|
||||
.setMessage(restartDialogTitle)
|
||||
.setPositiveButton(restartDialogButtonText, (dialog, id)
|
||||
-> Utils.restartApp(context))
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setCancelable(false)
|
||||
.show();
|
||||
}
|
||||
|
||||
@SuppressLint("ResourceType")
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
try {
|
||||
PreferenceManager preferenceManager = getPreferenceManager();
|
||||
preferenceManager.setSharedPreferencesName(Setting.preferences.name);
|
||||
|
||||
// Must initialize before adding change listener,
|
||||
// otherwise the syncing of Setting -> UI
|
||||
// causes a callback to the listener even though nothing changed.
|
||||
initialize();
|
||||
updateUIToSettingValues();
|
||||
|
||||
preferenceManager.getSharedPreferences().registerOnSharedPreferenceChangeListener(listener);
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "onCreate() failure", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
getPreferenceManager().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(listener);
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
@ -0,0 +1,100 @@
|
||||
package app.revanced.extension.shared.settings.preference;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.preference.EditTextPreference;
|
||||
import android.preference.Preference;
|
||||
import android.text.InputType;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.TypedValue;
|
||||
import android.widget.EditText;
|
||||
import app.revanced.extension.shared.settings.Setting;
|
||||
import app.revanced.extension.shared.Logger;
|
||||
import app.revanced.extension.shared.Utils;
|
||||
|
||||
import static app.revanced.extension.shared.StringRef.str;
|
||||
|
||||
@SuppressWarnings({"unused", "deprecation"})
|
||||
public class ImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener {
|
||||
|
||||
private String existingSettings;
|
||||
|
||||
private void init() {
|
||||
setSelectable(true);
|
||||
|
||||
EditText editText = getEditText();
|
||||
editText.setTextIsSelectable(true);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
editText.setAutofillHints((String) null);
|
||||
}
|
||||
editText.setInputType(editText.getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
|
||||
editText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 7); // Use a smaller font to reduce text wrap.
|
||||
|
||||
setOnPreferenceClickListener(this);
|
||||
}
|
||||
|
||||
public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
init();
|
||||
}
|
||||
public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
init();
|
||||
}
|
||||
public ImportExportPreference(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
public ImportExportPreference(Context context) {
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPreferenceClick(Preference preference) {
|
||||
try {
|
||||
// Must set text before preparing dialog, otherwise text is non selectable if this preference is later reopened.
|
||||
existingSettings = Setting.exportToJson(getContext());
|
||||
getEditText().setText(existingSettings);
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "showDialog failure", ex);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
|
||||
try {
|
||||
Utils.setEditTextDialogTheme(builder);
|
||||
|
||||
// Show the user the settings in JSON format.
|
||||
builder.setNeutralButton(str("revanced_settings_import_copy"), (dialog, which) -> {
|
||||
Utils.setClipboard(getEditText().getText().toString());
|
||||
}).setPositiveButton(str("revanced_settings_import"), (dialog, which) -> {
|
||||
importSettings(getEditText().getText().toString());
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "onPrepareDialogBuilder failure", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void importSettings(String replacementSettings) {
|
||||
try {
|
||||
if (replacementSettings.equals(existingSettings)) {
|
||||
return;
|
||||
}
|
||||
AbstractPreferenceFragment.settingImportInProgress = true;
|
||||
|
||||
final boolean rebootNeeded = Setting.importFromJSON(Utils.getContext(), replacementSettings);
|
||||
if (rebootNeeded) {
|
||||
AbstractPreferenceFragment.showRestartDialog(getContext());
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "importSettings failure", ex);
|
||||
} finally {
|
||||
AbstractPreferenceFragment.settingImportInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,361 @@
|
||||
package app.revanced.extension.shared.settings.preference;
|
||||
|
||||
import static app.revanced.extension.shared.StringRef.str;
|
||||
import static app.revanced.extension.shared.requests.Route.Method.GET;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Dialog;
|
||||
import android.app.ProgressDialog;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Color;
|
||||
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.Window;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import app.revanced.extension.shared.Logger;
|
||||
import app.revanced.extension.shared.Utils;
|
||||
import app.revanced.extension.shared.requests.Requester;
|
||||
import app.revanced.extension.shared.requests.Route;
|
||||
|
||||
/**
|
||||
* Opens a dialog showing official links.
|
||||
*/
|
||||
@SuppressWarnings({"unused", "deprecation"})
|
||||
public class ReVancedAboutPreference extends Preference {
|
||||
|
||||
private static String useNonBreakingHyphens(String text) {
|
||||
// Replace any dashes with non breaking dashes, so the English text 'pre-release'
|
||||
// and the dev release number does not break and cover two lines.
|
||||
return text.replace("-", "‑"); // #8209 = non breaking hyphen.
|
||||
}
|
||||
|
||||
private static String getColorHexString(int color) {
|
||||
return String.format("#%06X", (0x00FFFFFF & color));
|
||||
}
|
||||
|
||||
protected boolean isDarkModeEnabled() {
|
||||
Configuration config = getContext().getResources().getConfiguration();
|
||||
final int currentNightMode = config.uiMode & Configuration.UI_MODE_NIGHT_MASK;
|
||||
return currentNightMode == Configuration.UI_MODE_NIGHT_YES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subclasses can override this and provide a themed color.
|
||||
*/
|
||||
protected int getLightColor() {
|
||||
return Color.WHITE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subclasses can override this and provide a themed color.
|
||||
*/
|
||||
protected int getDarkColor() {
|
||||
return Color.BLACK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apps that do not support bundling resources must override this.
|
||||
*
|
||||
* @return A localized string to display for the key.
|
||||
*/
|
||||
protected String getString(String key, Object ... args) {
|
||||
return str(key, args);
|
||||
}
|
||||
|
||||
private String createDialogHtml(WebLink[] aboutLinks) {
|
||||
final boolean isNetworkConnected = Utils.isNetworkConnected();
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("<html>");
|
||||
builder.append("<body style=\"text-align: center; padding: 10px;\">");
|
||||
|
||||
final boolean isDarkMode = isDarkModeEnabled();
|
||||
String backgroundColorHex = getColorHexString(isDarkMode ? getDarkColor() : getLightColor());
|
||||
String foregroundColorHex = getColorHexString(isDarkMode ? getLightColor() : getDarkColor());
|
||||
// Apply light/dark mode colors.
|
||||
builder.append(String.format(
|
||||
"<style> body { background-color: %s; color: %s; } a { color: %s; } </style>",
|
||||
backgroundColorHex, foregroundColorHex, foregroundColorHex));
|
||||
|
||||
if (isNetworkConnected) {
|
||||
builder.append("<img style=\"width: 100px; height: 100px;\" "
|
||||
// Hide the image if it does not load.
|
||||
+ "onerror=\"this.style.display='none';\" "
|
||||
+ "src=\"").append(AboutLinksRoutes.aboutLogoUrl).append("\" />");
|
||||
}
|
||||
|
||||
String patchesVersion = Utils.getPatchesReleaseVersion();
|
||||
|
||||
// Add the title.
|
||||
builder.append("<h1>")
|
||||
.append("ReVanced")
|
||||
.append("</h1>");
|
||||
|
||||
builder.append("<p>")
|
||||
// Replace hyphens with non breaking dashes so the version number does not break lines.
|
||||
.append(useNonBreakingHyphens(getString("revanced_settings_about_links_body", patchesVersion)))
|
||||
.append("</p>");
|
||||
|
||||
// Add a disclaimer if using a dev release.
|
||||
if (patchesVersion.contains("dev")) {
|
||||
builder.append("<h3>")
|
||||
// English text 'Pre-release' can break lines.
|
||||
.append(useNonBreakingHyphens(getString("revanced_settings_about_links_dev_header")))
|
||||
.append("</h3>");
|
||||
|
||||
builder.append("<p>")
|
||||
.append(getString("revanced_settings_about_links_dev_body"))
|
||||
.append("</p>");
|
||||
}
|
||||
|
||||
builder.append("<h2 style=\"margin-top: 30px;\">")
|
||||
.append(getString("revanced_settings_about_links_header"))
|
||||
.append("</h2>");
|
||||
|
||||
builder.append("<div>");
|
||||
for (WebLink link : aboutLinks) {
|
||||
builder.append("<div style=\"margin-bottom: 20px;\">");
|
||||
builder.append(String.format("<a href=\"%s\">%s</a>", link.url, link.name));
|
||||
builder.append("</div>");
|
||||
}
|
||||
builder.append("</div>");
|
||||
|
||||
builder.append("</body></html>");
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
{
|
||||
setOnPreferenceClickListener(pref -> {
|
||||
// Show a progress spinner if the social links are not fetched yet.
|
||||
if (!AboutLinksRoutes.hasFetchedLinks() && Utils.isNetworkConnected()) {
|
||||
// Show a progress spinner, but only if the api fetch takes more than a half a second.
|
||||
final long delayToShowProgressSpinner = 500;
|
||||
ProgressDialog progress = new ProgressDialog(getContext());
|
||||
progress.setProgressStyle(ProgressDialog.STYLE_SPINNER);
|
||||
|
||||
Handler handler = new Handler(Looper.getMainLooper());
|
||||
Runnable showDialogRunnable = progress::show;
|
||||
handler.postDelayed(showDialogRunnable, delayToShowProgressSpinner);
|
||||
|
||||
Utils.runOnBackgroundThread(() ->
|
||||
fetchLinksAndShowDialog(handler, showDialogRunnable, progress));
|
||||
} else {
|
||||
// No network call required and can run now.
|
||||
fetchLinksAndShowDialog(null, null, null);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private void fetchLinksAndShowDialog(@Nullable Handler handler,
|
||||
Runnable showDialogRunnable,
|
||||
@Nullable ProgressDialog progress) {
|
||||
WebLink[] links = AboutLinksRoutes.fetchAboutLinks();
|
||||
String htmlDialog = createDialogHtml(links);
|
||||
|
||||
// Enable to randomly force a delay to debug the spinner logic.
|
||||
final boolean debugSpinnerDelayLogic = false;
|
||||
//noinspection ConstantConditions
|
||||
if (debugSpinnerDelayLogic && handler != null && Math.random() < 0.5f) {
|
||||
Utils.doNothingForDuration((long) (Math.random() * 4000));
|
||||
}
|
||||
|
||||
Utils.runOnMainThreadNowOrLater(() -> {
|
||||
if (handler != null) {
|
||||
handler.removeCallbacks(showDialogRunnable);
|
||||
}
|
||||
if (progress != null) {
|
||||
progress.dismiss();
|
||||
}
|
||||
new WebViewDialog(getContext(), htmlDialog).show();
|
||||
});
|
||||
}
|
||||
|
||||
public ReVancedAboutPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
}
|
||||
public ReVancedAboutPreference(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
public ReVancedAboutPreference(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
public ReVancedAboutPreference(Context context) {
|
||||
super(context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays html content as a dialog. Any links a user taps on are opened in an external browser.
|
||||
*/
|
||||
class WebViewDialog extends Dialog {
|
||||
|
||||
private final String htmlContent;
|
||||
|
||||
public WebViewDialog(@NonNull Context context, @NonNull String htmlContent) {
|
||||
super(context);
|
||||
this.htmlContent = htmlContent;
|
||||
}
|
||||
|
||||
// JS required to hide any broken images. No remote javascript is ever loaded.
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
requestWindowFeature(Window.FEATURE_NO_TITLE);
|
||||
|
||||
WebView webView = new WebView(getContext());
|
||||
webView.getSettings().setJavaScriptEnabled(true);
|
||||
webView.setWebViewClient(new OpenLinksExternallyWebClient());
|
||||
webView.loadDataWithBaseURL(null, htmlContent, "text/html", "utf-8", null);
|
||||
|
||||
setContentView(webView);
|
||||
}
|
||||
|
||||
private class OpenLinksExternallyWebClient extends WebViewClient {
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(WebView view, String url) {
|
||||
try {
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
|
||||
getContext().startActivity(intent);
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "Open link failure", ex);
|
||||
}
|
||||
// Dismiss the about dialog using a delay,
|
||||
// otherwise without a delay the UI looks hectic with the dialog dismissing
|
||||
// to show the settings while simultaneously a web browser is opening.
|
||||
Utils.runOnMainThreadDelayed(WebViewDialog.this::dismiss, 500);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WebLink {
|
||||
final boolean preferred;
|
||||
String name;
|
||||
final String url;
|
||||
|
||||
WebLink(JSONObject json) throws JSONException {
|
||||
this(json.getBoolean("preferred"),
|
||||
json.getString("name"),
|
||||
json.getString("url")
|
||||
);
|
||||
}
|
||||
|
||||
WebLink(boolean preferred, String name, String url) {
|
||||
this.preferred = preferred;
|
||||
this.name = name;
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return "WebLink{" +
|
||||
"preferred=" + preferred +
|
||||
", name='" + name + '\'' +
|
||||
", url='" + url + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
||||
class AboutLinksRoutes {
|
||||
/**
|
||||
* Backup icon url if the API call fails.
|
||||
*/
|
||||
public static volatile String aboutLogoUrl = "https://revanced.app/favicon.ico";
|
||||
|
||||
/**
|
||||
* Links to use if fetch links api call fails.
|
||||
*/
|
||||
private static final WebLink[] NO_CONNECTION_STATIC_LINKS = {
|
||||
new WebLink(true, "ReVanced.app", "https://revanced.app")
|
||||
};
|
||||
|
||||
private static final String SOCIAL_LINKS_PROVIDER = "https://api.revanced.app/v4";
|
||||
private static final Route.CompiledRoute GET_SOCIAL = new Route(GET, "/about").compile();
|
||||
|
||||
@Nullable
|
||||
private static volatile WebLink[] fetchedLinks;
|
||||
|
||||
static boolean hasFetchedLinks() {
|
||||
return fetchedLinks != null;
|
||||
}
|
||||
|
||||
static WebLink[] fetchAboutLinks() {
|
||||
try {
|
||||
if (hasFetchedLinks()) return fetchedLinks;
|
||||
|
||||
// Check if there is no internet connection.
|
||||
if (!Utils.isNetworkConnected()) return NO_CONNECTION_STATIC_LINKS;
|
||||
|
||||
HttpURLConnection connection = Requester.getConnectionFromCompiledRoute(SOCIAL_LINKS_PROVIDER, GET_SOCIAL);
|
||||
connection.setConnectTimeout(5000);
|
||||
connection.setReadTimeout(5000);
|
||||
Logger.printDebug(() -> "Fetching social links from: " + connection.getURL());
|
||||
|
||||
// Do not show an exception toast if the server is down
|
||||
final int responseCode = connection.getResponseCode();
|
||||
if (responseCode != 200) {
|
||||
Logger.printDebug(() -> "Failed to get social links. Response code: " + responseCode);
|
||||
return NO_CONNECTION_STATIC_LINKS;
|
||||
}
|
||||
|
||||
JSONObject json = Requester.parseJSONObjectAndDisconnect(connection);
|
||||
aboutLogoUrl = json.getJSONObject("branding").getString("logo");
|
||||
|
||||
List<WebLink> links = new ArrayList<>();
|
||||
|
||||
JSONArray donations = json.getJSONObject("donations").getJSONArray("links");
|
||||
for (int i = 0, length = donations.length(); i < length; i++) {
|
||||
WebLink link = new WebLink(donations.getJSONObject(i));
|
||||
if (link.preferred) {
|
||||
// This could be localized, but TikTok does not support localized resources.
|
||||
// All link names returned by the api are also non localized.
|
||||
link.name = "Donate";
|
||||
links.add(link);
|
||||
}
|
||||
}
|
||||
|
||||
JSONArray socials = json.getJSONArray("socials");
|
||||
for (int i = 0, length = socials.length(); i < length; i++) {
|
||||
WebLink link = new WebLink(socials.getJSONObject(i));
|
||||
links.add(link);
|
||||
}
|
||||
|
||||
Logger.printDebug(() -> "links: " + links);
|
||||
|
||||
return fetchedLinks = links.toArray(new WebLink[0]);
|
||||
|
||||
} catch (SocketTimeoutException ex) {
|
||||
Logger.printInfo(() -> "Could not fetch social links", ex); // No toast.
|
||||
} catch (JSONException ex) {
|
||||
Logger.printException(() -> "Could not parse about information", ex);
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "Failed to get about information", ex);
|
||||
}
|
||||
|
||||
return NO_CONNECTION_STATIC_LINKS;
|
||||
}
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
package app.revanced.extension.shared.settings.preference;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.preference.EditTextPreference;
|
||||
import android.util.AttributeSet;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
|
||||
import app.revanced.extension.shared.Utils;
|
||||
import app.revanced.extension.shared.settings.Setting;
|
||||
import app.revanced.extension.shared.Logger;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import static app.revanced.extension.shared.StringRef.str;
|
||||
|
||||
@SuppressWarnings({"unused", "deprecation"})
|
||||
public class ResettableEditTextPreference extends EditTextPreference {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
|
||||
super.onPrepareDialogBuilder(builder);
|
||||
Utils.setEditTextDialogTheme(builder);
|
||||
|
||||
Setting<?> setting = Setting.getSettingFromPath(getKey());
|
||||
if (setting != null) {
|
||||
builder.setNeutralButton(str("revanced_settings_reset"), null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void showDialog(Bundle state) {
|
||||
super.showDialog(state);
|
||||
|
||||
// Override the button click listener to prevent dismissing the dialog.
|
||||
Button button = ((AlertDialog) getDialog()).getButton(AlertDialog.BUTTON_NEUTRAL);
|
||||
if (button == null) {
|
||||
return;
|
||||
}
|
||||
button.setOnClickListener(v -> {
|
||||
try {
|
||||
Setting<?> setting = Objects.requireNonNull(Setting.getSettingFromPath(getKey()));
|
||||
String defaultStringValue = setting.defaultValue.toString();
|
||||
EditText editText = getEditText();
|
||||
editText.setText(defaultStringValue);
|
||||
editText.setSelection(defaultStringValue.length()); // move cursor to end of text
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "reset failure", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,190 @@
|
||||
package app.revanced.extension.shared.settings.preference;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.preference.PreferenceFragment;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import app.revanced.extension.shared.Logger;
|
||||
import app.revanced.extension.shared.Utils;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Shared categories, and helper methods.
|
||||
*
|
||||
* The various save methods store numbers as Strings,
|
||||
* which is required if using {@link PreferenceFragment}.
|
||||
*
|
||||
* If saved numbers will not be used with a preference fragment,
|
||||
* then store the primitive numbers using the {@link #preferences} itself.
|
||||
*/
|
||||
public class SharedPrefCategory {
|
||||
@NonNull
|
||||
public final String name;
|
||||
@NonNull
|
||||
public final SharedPreferences preferences;
|
||||
|
||||
public SharedPrefCategory(@NonNull String name) {
|
||||
this.name = Objects.requireNonNull(name);
|
||||
preferences = Objects.requireNonNull(Utils.getContext()).getSharedPreferences(name, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
private void removeConflictingPreferenceKeyValue(@NonNull String key) {
|
||||
Logger.printException(() -> "Found conflicting preference: " + key);
|
||||
removeKey(key);
|
||||
}
|
||||
|
||||
private void saveObjectAsString(@NonNull String key, @Nullable Object value) {
|
||||
preferences.edit().putString(key, (value == null ? null : value.toString())).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes any preference data type that has the specified key.
|
||||
*/
|
||||
public void removeKey(@NonNull String key) {
|
||||
preferences.edit().remove(Objects.requireNonNull(key)).apply();
|
||||
}
|
||||
|
||||
public void saveBoolean(@NonNull String key, boolean value) {
|
||||
preferences.edit().putBoolean(key, value).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param value a NULL parameter removes the value from the preferences
|
||||
*/
|
||||
public void saveEnumAsString(@NonNull String key, @Nullable Enum<?> value) {
|
||||
saveObjectAsString(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param value a NULL parameter removes the value from the preferences
|
||||
*/
|
||||
public void saveIntegerString(@NonNull String key, @Nullable Integer value) {
|
||||
saveObjectAsString(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param value a NULL parameter removes the value from the preferences
|
||||
*/
|
||||
public void saveLongString(@NonNull String key, @Nullable Long value) {
|
||||
saveObjectAsString(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param value a NULL parameter removes the value from the preferences
|
||||
*/
|
||||
public void saveFloatString(@NonNull String key, @Nullable Float value) {
|
||||
saveObjectAsString(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param value a NULL parameter removes the value from the preferences
|
||||
*/
|
||||
public void saveString(@NonNull String key, @Nullable String value) {
|
||||
saveObjectAsString(key, value);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getString(@NonNull String key, @NonNull String _default) {
|
||||
Objects.requireNonNull(_default);
|
||||
try {
|
||||
return preferences.getString(key, _default);
|
||||
} catch (ClassCastException ex) {
|
||||
// Value stored is a completely different type (should never happen).
|
||||
removeConflictingPreferenceKeyValue(key);
|
||||
return _default;
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public <T extends Enum<?>> 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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user