From 1ebd990051a6fc53916b4bb57c9c6974948b7dfc Mon Sep 17 00:00:00 2001 From: oSumAtrIX Date: Sat, 21 Mar 2026 19:52:43 +0100 Subject: [PATCH] feat: Add import from & export settings to a file Co-authored-by: ILoveOpenSourceApplications <117499019+iloveopensourceapplications@users.noreply.github.com> --- .../extension/shared/settings/Setting.java | 36 ++- .../AbstractPreferenceFragment.java | 244 +++++++++++++++++- .../preference/ImportExportPreference.java | 105 +------- .../sponsorblock/SponsorBlockSettings.java | 18 +- .../resources/addresources/values/strings.xml | 7 + 5 files changed, 290 insertions(+), 120 deletions(-) diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/Setting.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/Setting.java index 53a980e3c2..e3cb9b5b78 100644 --- a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/Setting.java +++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/Setting.java @@ -2,7 +2,7 @@ package app.revanced.extension.shared.settings; import static app.revanced.extension.shared.StringRef.str; -import android.content.Context; +import android.app.Activity; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -122,18 +122,18 @@ public abstract class Setting { /** * Called after all settings have been imported. */ - void settingsImported(@Nullable Context context); + void settingsImported(@Nullable Activity context); /** * Called after all settings have been exported. */ - void settingsExported(@Nullable Context context); + void settingsExported(@Nullable Activity context); } private static final List importExportCallbacks = new ArrayList<>(); /** - * Adds a callback for {@link #importFromJSON(Context, String)} and {@link #exportToJson(Context)}. + * Adds a callback for {@link #importFromJSON(Activity, String)} and {@link #exportToJson(Activity)}. */ public static void addImportExportCallback(ImportExportCallback callback) { importExportCallbacks.add(Objects.requireNonNull(callback)); @@ -413,7 +413,7 @@ public abstract class Setting { json.put(importExportKey, value); } - public static String exportToJson(@Nullable Context alertDialogContext) { + public static String exportToJson(@Nullable Activity alertDialogContext) { try { JSONObject json = new JSONObject(); for (Setting setting : allLoadedSettingsSorted()) { @@ -439,11 +439,17 @@ public abstract class Setting { 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); + if (export.startsWith("{") && export.endsWith("}")) { + // Remove the outer JSON braces to make the output more compact, + // and leave less chance of the user forgetting to copy it + export = export.substring(1, export.length() - 1); + } + + export = export.replaceAll("^\\n+", "").replaceAll("\\n+$", ""); + + return export + ","; } catch (JSONException e) { - Logger.printException(() -> "Export failure", e); // should never happen + Logger.printException(() -> "Export failure", e); // Should never happen return ""; } } @@ -451,10 +457,16 @@ public abstract class Setting { /** * @return if any settings that require a reboot were changed. */ - public static boolean importFromJSON(Context alertDialogContext, String settingsJsonString) { + public static boolean importFromJSON(Activity alertDialogContext, String settingsJsonString) { try { - if (!settingsJsonString.matches("[\\s\\S]*\\{")) { - settingsJsonString = '{' + settingsJsonString + '}'; // Restore outer JSON braces + settingsJsonString = settingsJsonString.trim(); + + if (settingsJsonString.endsWith(",")) { + settingsJsonString = settingsJsonString.substring(0, settingsJsonString.length() - 1); + } + + if (!settingsJsonString.trim().startsWith("{")) { + settingsJsonString = "{\n" + settingsJsonString + "\n}"; // Restore outer JSON braces } JSONObject json = new JSONObject(settingsJsonString); diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java index a515471a00..5d8a6bd2dd 100644 --- a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java +++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java @@ -3,8 +3,10 @@ package app.revanced.extension.shared.settings.preference; import static app.revanced.extension.shared.StringRef.str; import android.annotation.SuppressLint; +import android.app.Activity; import android.app.Dialog; import android.content.Context; +import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; import android.preference.Preference; @@ -16,11 +18,18 @@ import android.preference.SwitchPreference; import android.preference.EditTextPreference; import android.preference.ListPreference; import android.util.Pair; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; import android.widget.LinearLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; import java.util.Objects; +import java.util.Scanner; import app.revanced.extension.shared.Logger; import app.revanced.extension.shared.ResourceType; @@ -33,6 +42,9 @@ import app.revanced.extension.shared.ui.CustomDialog; @SuppressWarnings("deprecation") public abstract class AbstractPreferenceFragment extends PreferenceFragment { + @SuppressLint("StaticFieldLeak") + public static AbstractPreferenceFragment instance; + /** * Indicates that if a preference changes, * to apply the change from the Setting to the UI component. @@ -56,6 +68,12 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment { @Nullable protected static CharSequence restartDialogTitle, restartDialogMessage, restartDialogButtonText, confirmDialogTitle; + private static final int READ_REQUEST_CODE = 42; + private static final int WRITE_REQUEST_CODE = 43; + private String existingSettings = ""; + + private EditText currentImportExportEditText; + private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> { try { if (updatingPreference) { @@ -198,8 +216,7 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment { return listPref.getValue().equals(defaultValueString); } - throw new IllegalStateException("Must override method to handle " - + "preference type: " + pref.getClass()); + throw new IllegalStateException("Must override method to handle preference type: " + pref.getClass()); } /** @@ -332,10 +349,230 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment { dialogPair.first.show(); } + /** + * Import / Export Subroutines + */ + @NonNull + private Button createDialogButton(Context context, String text, int marginLeft, int marginRight, View.OnClickListener listener) { + int height = (int) android.util.TypedValue.applyDimension(android.util.TypedValue.COMPLEX_UNIT_DIP, 36f, context.getResources().getDisplayMetrics()); + int paddingHorizontal = (int) android.util.TypedValue.applyDimension(android.util.TypedValue.COMPLEX_UNIT_DIP, 16f, context.getResources().getDisplayMetrics()); + float radius = android.util.TypedValue.applyDimension(android.util.TypedValue.COMPLEX_UNIT_DIP, 20f, context.getResources().getDisplayMetrics()); + + Button btn = new Button(context, null, 0); + btn.setText(text); + btn.setAllCaps(false); + btn.setTextSize(14); + btn.setSingleLine(true); + btn.setEllipsize(android.text.TextUtils.TruncateAt.END); + btn.setGravity(android.view.Gravity.CENTER); + btn.setPadding(paddingHorizontal, 0, paddingHorizontal, 0); + btn.setTextColor(Utils.isDarkModeEnabled() ? android.graphics.Color.WHITE : android.graphics.Color.BLACK); + + android.graphics.drawable.GradientDrawable bg = new android.graphics.drawable.GradientDrawable(); + bg.setCornerRadius(radius); + bg.setColor(Utils.getCancelOrNeutralButtonBackgroundColor()); + btn.setBackground(bg); + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, height, 1.0f); + params.setMargins(marginLeft, 0, marginRight, 0); + btn.setLayoutParams(params); + btn.setOnClickListener(listener); + + return btn; + } + public void showImportExportTextDialog() { + try { + Activity context = getActivity(); + // Must set text before showing dialog, + // otherwise text is non-selectable if this preference is later reopened. + existingSettings = Setting.exportToJson(context); + currentImportExportEditText = getEditText(context); + + // Create a custom dialog with the EditText. + Pair dialogPair = CustomDialog.create( + context, + str("revanced_pref_import_export_title"), // Title. + null, // No message (EditText replaces it). + currentImportExportEditText, // Pass the EditText. + str("revanced_settings_save"), // OK button text. + () -> importSettingsText(context, currentImportExportEditText.getText().toString()), // OK button action. + () -> {}, // Cancel button action (dismiss only). + str("revanced_settings_import_copy"), // Neutral button (Copy) text. + () -> Utils.setClipboard(currentImportExportEditText.getText().toString()), // Neutral button (Copy) action. Show the user the settings in JSON format. + true // Dismiss dialog when onNeutralClick. + ); + + LinearLayout fileButtonsContainer = getLinearLayout(context); + int margin = (int) android.util.TypedValue.applyDimension(android.util.TypedValue.COMPLEX_UNIT_DIP, 4f, context.getResources().getDisplayMetrics()); + + Button btnExport = createDialogButton(context, str("revanced_settings_export_file"), 0, margin, v -> exportActivity()); + Button btnImport = createDialogButton(context, str("revanced_settings_import_file"), margin, 0, v -> importActivity()); + + fileButtonsContainer.addView(btnExport); + fileButtonsContainer.addView(btnImport); + + dialogPair.second.addView(fileButtonsContainer, 2); + + dialogPair.first.setOnDismissListener(d -> currentImportExportEditText = null); + + // If there are no settings yet, then show the on-screen keyboard and bring focus to + // the edit text. This makes it easier to paste saved settings after a reinstallation. + dialogPair.first.setOnShowListener(dialogInterface -> { + if (existingSettings.isEmpty() && currentImportExportEditText != null) { + currentImportExportEditText.postDelayed(() -> { + if (currentImportExportEditText != null) { + currentImportExportEditText.requestFocus(); + android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) imm.showSoftInput(currentImportExportEditText, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT); + } + }, 100); + } + }); + + // Show the dialog. + dialogPair.first.show(); + } catch (Exception ex) { + Logger.printException(() -> "showImportExportTextDialog failure", ex); + } + } + + @NonNull + private static LinearLayout getLinearLayout(Context context) { + LinearLayout fileButtonsContainer = new LinearLayout(context); + fileButtonsContainer.setOrientation(LinearLayout.HORIZONTAL); + LinearLayout.LayoutParams fbParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); + + int marginTop = (int) android.util.TypedValue.applyDimension(android.util.TypedValue.COMPLEX_UNIT_DIP, 16f, context.getResources().getDisplayMetrics()); + fbParams.setMargins(0, marginTop, 0, 0); + fileButtonsContainer.setLayoutParams(fbParams); + return fileButtonsContainer; + } + + @NonNull + private EditText getEditText(Context context) { + EditText editText = new EditText(context); + editText.setText(existingSettings); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + editText.setAutofillHints((String) null); + } + editText.setInputType(android.text.InputType.TYPE_CLASS_TEXT | + android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS | + android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE); + editText.setSingleLine(false); + editText.setTextSize(14); + return editText; + } + + public void exportActivity() { + try { + Setting.exportToJson(getActivity()); + + String formatDate = new java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US).format(new java.util.Date()); + String fileName = "revanced_Settings_" + formatDate + ".txt"; + + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_TITLE, fileName); + startActivityForResult(intent, WRITE_REQUEST_CODE); + } catch (Exception ex) { + Logger.printException(() -> "exportActivity failure", ex); + } + } + + public void importActivity() { + try { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + startActivityForResult(intent, READ_REQUEST_CODE); + } catch (Exception ex) { + Logger.printException(() -> "importActivity failure", ex); + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == WRITE_REQUEST_CODE && resultCode == android.app.Activity.RESULT_OK && data != null) { + exportTextToFile(data.getData()); + } else if (requestCode == READ_REQUEST_CODE && resultCode == android.app.Activity.RESULT_OK && data != null) { + importTextFromFile(data.getData()); + } + } + + protected static void showLocalizedToast(String resourceKey, String fallbackMessage) { + if (ResourceUtils.getIdentifier(ResourceType.STRING, resourceKey) != 0) { + Utils.showToastLong(str(resourceKey)); + } else { + Utils.showToastLong(fallbackMessage); + } + } + + private void exportTextToFile(android.net.Uri uri) { + try { + OutputStream out = getContext().getContentResolver().openOutputStream(uri); + if (out != null) { + String textToExport = existingSettings; + if (currentImportExportEditText != null) { + textToExport = currentImportExportEditText.getText().toString(); + } + out.write(textToExport.getBytes(StandardCharsets.UTF_8)); + out.close(); + + showLocalizedToast("revanced_settings_export_file_success", "Settings exported successfully"); + } + } catch (Exception e) { + showLocalizedToast("revanced_settings_export_file_failed", "Failed to export settings"); + Logger.printException(() -> "exportTextToFile failure", e); + } + } + + @SuppressWarnings("CharsetObjectCanBeUsed") + private void importTextFromFile(android.net.Uri uri) { + try { + InputStream in = getContext().getContentResolver().openInputStream(uri); + if (in != null) { + Scanner scanner = new Scanner(in, StandardCharsets.UTF_8.name()).useDelimiter("\\A"); + String result = scanner.hasNext() ? scanner.next() : ""; + in.close(); + + if (currentImportExportEditText != null) { + currentImportExportEditText.setText(result); + showLocalizedToast("revanced_settings_import_file_success", "Settings imported successfully, tap Save to apply"); + } else { + importSettingsText(getContext(), result); + } + } + } catch (Exception e) { + showLocalizedToast("revanced_settings_import_file_failed", "Failed to import settings"); + Logger.printException(() -> "importTextFromFile failure", e); + } + } + + private void importSettingsText(Context context, String replacementSettings) { + try { + existingSettings = Setting.exportToJson(null); + if (replacementSettings.equals(existingSettings)) { + return; + } + settingImportInProgress = true; + final boolean rebootNeeded = Setting.importFromJSON(getActivity(), replacementSettings); + if (rebootNeeded) { + showRestartDialog(context); + } + } catch (Exception ex) { + Logger.printException(() -> "importSettingsText failure", ex); + } finally { + settingImportInProgress = false; + } + } + @SuppressLint("ResourceType") @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + instance = this; try { PreferenceManager preferenceManager = getPreferenceManager(); preferenceManager.setSharedPreferencesName(Setting.preferences.name); @@ -354,6 +591,9 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment { @Override public void onDestroy() { + if (instance == this) { + instance = null; + } getPreferenceManager().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(listener); super.onDestroy(); } diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java index 1044ba424e..c187acf608 100644 --- a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java +++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java @@ -4,40 +4,13 @@ import static app.revanced.extension.shared.StringRef.str; import android.app.Dialog; import android.content.Context; -import android.os.Build; -import android.os.Bundle; -import android.preference.EditTextPreference; import android.preference.Preference; -import android.text.InputType; import android.util.AttributeSet; -import android.util.Pair; -import android.view.inputmethod.InputMethodManager; -import android.widget.EditText; -import android.widget.LinearLayout; import app.revanced.extension.shared.Logger; -import app.revanced.extension.shared.Utils; -import app.revanced.extension.shared.settings.Setting; -import app.revanced.extension.shared.ui.CustomDialog; @SuppressWarnings({"unused", "deprecation"}) -public class ImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener { - - private String existingSettings; - - private void init() { - setSelectable(true); - - EditText editText = getEditText(); - editText.setTextIsSelectable(true); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - editText.setAutofillHints((String) null); - } - editText.setInputType(editText.getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); - editText.setTextSize(14); - - setOnPreferenceClickListener(this); - } +public class ImportExportPreference extends Preference implements Preference.OnPreferenceClickListener { public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); @@ -56,78 +29,20 @@ public class ImportExportPreference extends EditTextPreference implements Prefer init(); } + private void init() { + setOnPreferenceClickListener(this); + } + @Override public boolean onPreferenceClick(Preference preference) { try { - // Must set text before showing dialog, - // otherwise text is non-selectable if this preference is later reopened. - existingSettings = Setting.exportToJson(getContext()); - getEditText().setText(existingSettings); + if (AbstractPreferenceFragment.instance != null) { + AbstractPreferenceFragment.instance.showImportExportTextDialog(); + } } catch (Exception ex) { - Logger.printException(() -> "showDialog failure", ex); + Logger.printException(() -> "onPreferenceClick failure", ex); } + return true; } - - @Override - protected void showDialog(Bundle state) { - try { - Context context = getContext(); - EditText editText = getEditText(); - - // Create a custom dialog with the EditText. - Pair dialogPair = CustomDialog.create( - context, - str("revanced_pref_import_export_title"), // Title. - null, // No message (EditText replaces it). - editText, // Pass the EditText. - str("revanced_settings_import"), // OK button text. - () -> importSettings(context, editText.getText().toString()), // OK button action. - () -> {}, // Cancel button action (dismiss only). - str("revanced_settings_import_copy"), // Neutral button (Copy) text. - () -> { - // Neutral button (Copy) action. Show the user the settings in JSON format. - Utils.setClipboard(editText.getText()); - }, - true // Dismiss dialog when onNeutralClick. - ); - - // If there are no settings yet, then show the on screen keyboard and bring focus to - // the edit text. This makes it easier to paste saved settings after a reinstall. - dialogPair.first.setOnShowListener(dialogInterface -> { - if (existingSettings.isEmpty()) { - editText.postDelayed(() -> { - editText.requestFocus(); - - InputMethodManager inputMethodManager = (InputMethodManager) - editText.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); - inputMethodManager.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT); - }, 100); - } - }); - - // Show the dialog. - dialogPair.first.show(); - } catch (Exception ex) { - Logger.printException(() -> "showDialog failure", ex); - } - } - - private void importSettings(Context context, String replacementSettings) { - try { - if (replacementSettings.equals(existingSettings)) { - return; - } - AbstractPreferenceFragment.settingImportInProgress = true; - - final boolean rebootNeeded = Setting.importFromJSON(context, replacementSettings); - if (rebootNeeded) { - AbstractPreferenceFragment.showRestartDialog(context); - } - } catch (Exception ex) { - Logger.printException(() -> "importSettings failure", ex); - } finally { - AbstractPreferenceFragment.settingImportInProgress = false; - } - } } diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockSettings.java b/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockSettings.java index e3a4c31ad9..26d8afa715 100644 --- a/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockSettings.java +++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockSettings.java @@ -2,8 +2,8 @@ package app.revanced.extension.youtube.sponsorblock; import static app.revanced.extension.shared.StringRef.str; +import android.app.Activity; import android.app.Dialog; -import android.content.Context; import android.util.Pair; import android.util.Patterns; import android.widget.LinearLayout; @@ -34,12 +34,12 @@ public class SponsorBlockSettings { public static final Setting.ImportExportCallback SB_IMPORT_EXPORT_CALLBACK = new Setting.ImportExportCallback() { @Override - public void settingsImported(@Nullable Context context) { + public void settingsImported(@Nullable Activity context) { SegmentCategory.loadAllCategoriesFromSettings(); SponsorBlockPreferenceGroup.settingsImported = true; } @Override - public void settingsExported(@Nullable Context context) { + public void settingsExported(@Nullable Activity context) { showExportWarningIfNeeded(context); } }; @@ -184,16 +184,16 @@ public class SponsorBlockSettings { /** * Export the categories using flatten JSON (no embedded dictionaries or arrays). */ - private static void showExportWarningIfNeeded(@Nullable Context dialogContext) { + private static void showExportWarningIfNeeded(@Nullable Activity activity) { Utils.verifyOnMainThread(); initialize(); // If user has a SponsorBlock user ID then show a warning. - if (dialogContext != null && SponsorBlockSettings.userHasSBPrivateID() + if (activity != null && SponsorBlockSettings.userHasSBPrivateID() && !Settings.SB_HIDE_EXPORT_WARNING.get()) { // Create the custom dialog. Pair dialogPair = CustomDialog.create( - dialogContext, + activity, null, // No title. str("revanced_sb_settings_revanced_export_user_id_warning"), // Message. null, // No EditText. @@ -205,11 +205,7 @@ public class SponsorBlockSettings { true // Dismiss dialog when onNeutralClick. ); - // Set dialog as non-cancelable. - dialogPair.first.setCancelable(false); - - // Show the dialog. - dialogPair.first.show(); + Utils.showDialog(activity, dialogPair.first, false, null); } } diff --git a/patches/src/main/resources/addresources/values/strings.xml b/patches/src/main/resources/addresources/values/strings.xml index ce5c2dd5fc..becd1a11ef 100644 --- a/patches/src/main/resources/addresources/values/strings.xml +++ b/patches/src/main/resources/addresources/values/strings.xml @@ -95,6 +95,13 @@ To translate new languages or improve the existing translations, visit translate App language Import / Export Import / Export ReVanced settings + Import from file + Settings imported successfully, save to apply + Failed to import settings + Export to file + Settings exported successfully + Failed to export settings + You are using ReVanced Patches version <i>%s</i> Note