diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 881eac1..a028363 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -116,6 +116,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/backup_readme.txt b/app/src/main/assets/backup_readme.txt
new file mode 100644
index 0000000..f69a5a5
--- /dev/null
+++ b/app/src/main/assets/backup_readme.txt
@@ -0,0 +1,8 @@
+Hi!
+
+Yes, the backup is just a simple zip file, and the files themselves are plain text.
+You can edit it if you want, and the app will try to load it anyway, but be careful because it may become unusable.
+If it does, you'll need to delete the corrupted data (for example by loading the original backup).
+
+For more information, and to know how it is created/loaded, you can just check the source code:
+https://github.com/TrianguloY/UrlChecker/blob/master/app/src/main/java/com/trianguloy/urlchecker/activities/BackupActivity.java
\ No newline at end of file
diff --git a/app/src/main/java/com/trianguloy/urlchecker/activities/BackupActivity.java b/app/src/main/java/com/trianguloy/urlchecker/activities/BackupActivity.java
new file mode 100644
index 0000000..7439da5
--- /dev/null
+++ b/app/src/main/java/com/trianguloy/urlchecker/activities/BackupActivity.java
@@ -0,0 +1,472 @@
+package com.trianguloy.urlchecker.activities;
+
+import static com.trianguloy.urlchecker.utilities.methods.JavaUtils.negate;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.storage.StorageManager;
+import android.provider.DocumentsContract;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.Button;
+import android.widget.Switch;
+import android.widget.Toast;
+
+import com.trianguloy.urlchecker.BuildConfig;
+import com.trianguloy.urlchecker.R;
+import com.trianguloy.urlchecker.fragments.ResultCodeInjector;
+import com.trianguloy.urlchecker.modules.companions.Hosts;
+import com.trianguloy.urlchecker.modules.companions.VersionManager;
+import com.trianguloy.urlchecker.modules.list.LogModule;
+import com.trianguloy.urlchecker.modules.list.VirusTotalModule;
+import com.trianguloy.urlchecker.utilities.AndroidSettings;
+import com.trianguloy.urlchecker.utilities.generics.GenericPref;
+import com.trianguloy.urlchecker.utilities.methods.AndroidUtils;
+import com.trianguloy.urlchecker.utilities.methods.JavaUtils;
+import com.trianguloy.urlchecker.utilities.methods.JavaUtils.Function;
+import com.trianguloy.urlchecker.utilities.methods.PackageUtils;
+import com.trianguloy.urlchecker.utilities.wrappers.ProgressDialog;
+import com.trianguloy.urlchecker.utilities.wrappers.ZipReader;
+import com.trianguloy.urlchecker.utilities.wrappers.ZipWriter;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+public class BackupActivity extends Activity {
+
+ private final ResultCodeInjector resultCodeInjector = new ResultCodeInjector();
+
+ private Switch chk_data;
+ private Switch chk_data_prefs;
+ private Switch chk_data_files;
+ private Switch chk_secrets;
+ private Switch chk_cache;
+ private Switch chk_delete;
+ private Switch chk_ignoreNewer;
+ private Button btn_backup;
+ private Button btn_restore;
+ private Button btn_delete;
+ private SharedPreferences prefs;
+
+ /* ------------------- activity ------------------- */
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ AndroidSettings.setTheme(this, false);
+ AndroidSettings.setLocale(this);
+ setContentView(R.layout.activity_backup);
+ setTitle(R.string.btn_backupRestore);
+ AndroidUtils.configureUp(this);
+
+ prefs = GenericPref.getPrefs(this);
+ chk_data = findViewById(R.id.chk_data);
+ chk_data_prefs = findViewById(R.id.chk_data_prefs);
+ chk_data_files = findViewById(R.id.chk_data_files);
+ chk_secrets = findViewById(R.id.chk_secrets);
+ chk_cache = findViewById(R.id.chk_cache);
+ chk_delete = findViewById(R.id.chk_delete);
+ chk_ignoreNewer = findViewById(R.id.chk_ignoreNewer);
+ btn_backup = findViewById(R.id.btn_backup);
+ btn_restore = findViewById(R.id.btn_restore);
+ btn_delete = findViewById(R.id.btn_delete);
+
+ // if this app was reloaded, some settings may have changed, so reload previous one too
+ if (AndroidSettings.wasReloaded(this)) AndroidSettings.markForReloading(this);
+
+ // sync data switches
+ chk_data.setOnCheckedChangeListener((v, checked) -> {
+ chk_data_prefs.setChecked(checked);
+ chk_data_files.setChecked(checked);
+ });
+
+ // sync button enabled status
+ var chks = List.of(chk_cache, chk_secrets, chk_data_files, chk_data_prefs);
+ for (var chk : chks)
+ chk.setOnCheckedChangeListener((b, c) -> {
+ var enabled = false;
+ for (var chkk : chks) if (chkk.isChecked()) enabled = true;
+ btn_backup.setEnabled(enabled);
+ btn_restore.setEnabled(enabled);
+ btn_delete.setEnabled(enabled);
+ });
+
+ // restore advanced status
+ if (getIntent().getBooleanExtra(ADVANCED_EXTRA, false)) {
+ showAdvanced();
+ }
+
+ // ask to restore if a file was opened
+ var data = getIntent().getData();
+ if (data != null) {
+ getIntent().setData(null); // avoid restoring again after reload
+ askRestore(data);
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ getMenuInflater().inflate(R.menu.activity_backup, menu);
+
+ // restore advanced status
+ if (getIntent().getBooleanExtra(ADVANCED_EXTRA, false)) {
+ menu.findItem(R.id.menu_advanced).setVisible(false);
+ }
+
+ return super.onCreateOptionsMenu(menu);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home -> {
+ // press the 'back' button in the action bar to go back
+ onBackPressed();
+ return true;
+ }
+ case R.id.menu_advanced -> {
+ // show advanced
+ item.setVisible(false);
+ getIntent().putExtra(ADVANCED_EXTRA, true);
+ showAdvanced();
+ }
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ if (!resultCodeInjector.onActivityResult(requestCode, resultCode, data))
+ super.onActivityResult(requestCode, resultCode, data);
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+ if (!resultCodeInjector.onRequestPermissionsResult(requestCode, permissions, grantResults))
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ }
+
+ /* ------------------- backup ------------------- */
+
+ public void backup(View ignored) {
+ chooseFile(Intent.ACTION_CREATE_DOCUMENT, this::backup);
+ }
+
+ /**
+ * Creates a backup and saves it to [uri]
+ */
+ private void backup(Uri uri) {
+ ProgressDialog.run(this, R.string.btn_backup, progress -> {
+
+ progress.setMax(7);
+ progress.setMessage("Initializing backup");
+ try (var zip = new ZipWriter(uri, this)) {
+ zip.setComment(getString(R.string.app_name) + " (" + getPackageName() + ") " + getString(R.string.btn_backup));
+
+ // version
+ progress.setMessage("Adding version");
+ progress.increaseProgress();
+ zip.addStringFile(FILE_VERSION, BuildConfig.VERSION_NAME);
+
+ // readme
+ progress.setMessage("Adding readme");
+ progress.increaseProgress();
+ try (var readme = getAssets().open("backup_readme.txt")) {
+ zip.addStreamFile("readme.txt", readme);
+ }
+
+ // rest of preferences
+ progress.setMessage("Adding preferences");
+ progress.increaseProgress();
+ if (chk_data_prefs.isChecked()) backupPreferencesMatching(FILE_PREFERENCES, negate(IS_PREF_SECRET), zip);
+
+ // secret preferences
+ progress.setMessage("Adding secrets");
+ progress.increaseProgress();
+ if (chk_secrets.isChecked()) backupPreferencesMatching(FILE_SECRETS, IS_PREF_SECRET, zip);
+
+ // rest of files
+ progress.setMessage("Adding files");
+ progress.increaseProgress();
+ if (chk_data_files.isChecked()) backupFilesMatching(FILES_FOLDER, negate(IS_FILE_CACHE), zip);
+
+ // cache files
+ progress.setMessage("Adding cache");
+ progress.increaseProgress();
+ if (chk_cache.isChecked()) backupFilesMatching(CACHE_FOLDER, IS_FILE_CACHE, zip);
+
+ runOnUiThread(() -> Toast.makeText(this, R.string.bck_backupOk, Toast.LENGTH_SHORT).show());
+
+ } catch (Exception e) {
+ e.printStackTrace();
+ runOnUiThread(() -> Toast.makeText(this, R.string.bck_backupError, Toast.LENGTH_LONG).show());
+ }
+ });
+ }
+
+ private void backupPreferencesMatching(String fileName, Function predicate, ZipWriter zip) throws IOException, JSONException {
+ var jsonPrefs = new JSONObject();
+ for (var entry : prefs.getAll().entrySet()) {
+ if (!predicate.apply(entry.getKey())) continue;
+ jsonPrefs.put(entry.getKey(), new JSONObject()
+ .put(PREF_VALUE, entry.getValue())
+ .put(PREF_TYPE, entry.getValue().getClass().getSimpleName()));
+ }
+ zip.addStringFile(fileName, jsonPrefs.toString());
+ }
+
+ private void backupFilesMatching(String folder, Function predicate, ZipWriter zip) throws IOException {
+ var empty = true;
+ for (var file : fileList()) {
+ if (!predicate.apply(file)) continue;
+ try (var in = openFileInput(file)) {
+ zip.addStreamFile(folder + file, in);
+ empty = false;
+ }
+ }
+ if (empty) zip.addStringFile(folder + EMPTY, "");
+ }
+
+ /* ------------------- restore ------------------- */
+
+ public void restore(View ignored) {
+ chooseFile(Intent.ACTION_OPEN_DOCUMENT, this::askRestore);
+ }
+
+ /**
+ * Asks to restore a backup from [uri]
+ */
+ private void askRestore(Uri uri) {
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.bck_restoreTitle)
+ .setMessage(R.string.bck_restoreMessage)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(R.string.bck_restoreConfirm, (d, w) -> restore(uri))
+ .show();
+ }
+
+ /**
+ * Restores a backup from [uri]
+ */
+ private void restore(Uri uri) {
+ ProgressDialog.run(this, R.string.btn_restore, progress -> {
+ progress.setMax(5);
+ progress.setMessage("Loading backup");
+ try (var zip = new ZipReader(uri, this)) {
+
+ // check version
+ if (!chk_ignoreNewer.isChecked() && VersionManager.isNewerThanCurrent(zip.getFileString(FILE_VERSION))) {
+ runOnUiThread(() -> Toast.makeText(this, R.string.bck_newer, Toast.LENGTH_LONG).show());
+ return;
+ }
+
+ // rest of preferences
+ progress.setMessage("Restoring preferences");
+ progress.increaseProgress();
+ if (chk_data_prefs.isChecked()) restorePreferencesMatching(FILE_PREFERENCES, negate(IS_PREF_SECRET), zip);
+
+ // secret preferences
+ progress.setMessage("Restoring secrets");
+ progress.increaseProgress();
+ if (chk_secrets.isChecked()) restorePreferencesMatching(FILE_SECRETS, IS_PREF_SECRET, zip);
+
+ // rest of files
+ progress.setMessage("Restoring files");
+ progress.increaseProgress();
+ if (chk_data_files.isChecked()) restoreFilesMatching(FILES_FOLDER, negate(IS_FILE_CACHE), zip);
+
+ // cache files
+ progress.setMessage("Restoring cache");
+ progress.increaseProgress();
+ if (chk_cache.isChecked()) restoreFilesMatching(CACHE_FOLDER, IS_FILE_CACHE, zip);
+
+ runOnUiThread(() -> Toast.makeText(this, R.string.bck_restoreOk, Toast.LENGTH_LONG).show());
+
+ } catch (Exception e) {
+ e.printStackTrace();
+ runOnUiThread(() -> Toast.makeText(this, R.string.bck_restoreError, Toast.LENGTH_LONG).show());
+ }
+
+ runOnUiThread(() -> AndroidSettings.reload(this));
+ });
+ }
+
+ private void restorePreferencesMatching(String fileName, Function predicate, ZipReader zip) throws IOException, JSONException {
+ var preferences = zip.getFileString(fileName);
+ if (preferences == null) return;
+
+ var jsonPrefs = new JSONObject(preferences);
+ var editor = prefs.edit();
+
+ // remove
+ if (chk_delete.isChecked()) {
+ for (var key : prefs.getAll().keySet()) {
+ if (predicate.apply(key)) editor.remove(key);
+ }
+ }
+
+ // add
+ for (var key : JavaUtils.toList(jsonPrefs.keys())) {
+ var ent = jsonPrefs.getJSONObject(key);
+ switch (ent.getString(PREF_TYPE)) {
+ case "String" -> editor.putString(key, ent.getString(PREF_VALUE));
+ case "Integer" -> editor.putInt(key, ent.getInt(PREF_VALUE));
+ case "Long" -> editor.putLong(key, ent.getLong(PREF_VALUE));
+ case "Boolean" -> editor.putBoolean(key, ent.getBoolean(PREF_VALUE));
+ default -> AndroidUtils.assertError("Unknown type: " + ent.getString(PREF_TYPE));
+ }
+ }
+
+ editor.apply();
+ }
+
+ private void restoreFilesMatching(String folder, Function predicate, ZipReader zip) throws IOException {
+ var fileNames = zip.fileNames(folder);
+ if (fileNames.isEmpty()) return;
+
+ // delete
+ if (chk_delete.isChecked()) {
+ for (var file : fileList()) {
+ if (predicate.apply(file)) deleteFile(file);
+ }
+ }
+
+ // create
+ for (var fileName : fileNames) {
+ if ((folder + EMPTY).equals(fileName)) continue; // ignore marker
+ try (var out = openFileOutput(fileName.substring(folder.length()), MODE_PRIVATE)) {
+ zip.getFileStream(fileName, out);
+ }
+ }
+ }
+
+ /* ------------------- delete ------------------- */
+
+ public void delete(View ignored) {
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.bck_deleteTitle)
+ .setMessage(R.string.bck_deleteMessage)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(R.string.bck_deleteConfirm, (d, w) -> delete())
+ .show();
+ }
+
+ private void delete() {
+ ProgressDialog.run(this, R.string.btn_delete, progress -> {
+ progress.setMax(4);
+ try {
+
+ // rest of preferences
+ progress.setMessage("Deleting preferences");
+ if (chk_data_prefs.isChecked()) deletePreferencesMatching(negate(IS_PREF_SECRET));
+
+ // secret preferences
+ progress.setMessage("Deleting secrets");
+ progress.increaseProgress();
+ if (chk_secrets.isChecked()) deletePreferencesMatching(IS_PREF_SECRET);
+
+ // rest of files
+ progress.setMessage("Deleting files");
+ progress.increaseProgress();
+ if (chk_data_files.isChecked()) deleteFilesMatching(negate(IS_FILE_CACHE));
+
+ // cache files
+ progress.setMessage("Deleting cache");
+ progress.increaseProgress();
+ if (chk_cache.isChecked()) deleteFilesMatching(IS_FILE_CACHE);
+
+ runOnUiThread(() -> Toast.makeText(this, R.string.bck_deleteOk, Toast.LENGTH_SHORT).show());
+
+ } catch (Exception e) {
+ e.printStackTrace();
+ runOnUiThread(() -> Toast.makeText(this, R.string.bck_deleteError, Toast.LENGTH_SHORT).show());
+ }
+
+ runOnUiThread(() -> AndroidSettings.reload(this));
+ });
+ }
+
+ private void deletePreferencesMatching(Function predicate) {
+ var editor = prefs.edit();
+ for (var key : prefs.getAll().keySet()) {
+ if (predicate.apply(key)) editor.remove(key);
+ }
+ editor.apply();
+ }
+
+ private void deleteFilesMatching(Function predicate) {
+ for (var file : fileList()) {
+ if (predicate.apply(file)) deleteFile(file);
+ }
+ }
+
+ /* ------------------- common ------------------- */
+
+ private static final String FILE_VERSION = "version";
+ private static final String FILE_PREFERENCES = "preferences";
+ private static final String FILE_SECRETS = "secrets";
+ private static final String FILES_FOLDER = "files/";
+ private static final String CACHE_FOLDER = "cache/";
+ private static final String PREF_VALUE = "value";
+ private static final String PREF_TYPE = "type";
+ private static final String EMPTY = ".empty";
+ private static final String ADVANCED_EXTRA = "advanced";
+
+ private static final Function IS_PREF_SECRET = List.of(VirusTotalModule.PREF, LogModule.PREF)::contains;
+ private static final Function IS_FILE_CACHE = s -> s.startsWith(Hosts.PREFIX);
+
+ private void chooseFile(String action, JavaUtils.Consumer listener) {
+ // choose backup file
+ var intent = new Intent(action);
+ intent.putExtra(Intent.EXTRA_TITLE, getInitialFile());
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, getInitialFolder());
+
+ intent.setType("*/*");
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+
+ PackageUtils.startActivityForResult(
+ Intent.createChooser(intent, getString(R.string.bck_chooseFile)),
+ resultCodeInjector.registerActivityResult((resultCode, data) -> {
+ // file selected?
+ if (resultCode == Activity.RESULT_OK && data != null && data.getData() != null) listener.accept(data.getData());
+ else Toast.makeText(this, R.string.canceled, Toast.LENGTH_SHORT).show();
+ }),
+ R.string.toast_noApp,
+ this);
+ }
+
+ private File getInitialFolder() {
+ var folder = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
+ ? new File(getSystemService(StorageManager.class).getPrimaryStorageVolume().getDirectory(), Environment.DIRECTORY_DOWNLOADS)
+ : Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
+ folder.mkdirs();
+ return folder;
+ }
+
+ private String getInitialFile() {
+ return "UrlChecker_" + new SimpleDateFormat("yyyyMMddHHmmss", Locale.US).format(new Date()) + ".ucbckp";
+ }
+
+ private void showAdvanced() {
+ chk_data.setThumbResource(android.R.color.transparent);
+ chk_data_prefs.setVisibility(View.VISIBLE);
+ chk_data_files.setVisibility(View.VISIBLE);
+ chk_delete.setVisibility(View.VISIBLE);
+ chk_ignoreNewer.setVisibility(View.VISIBLE);
+ btn_delete.setVisibility(View.VISIBLE);
+ }
+}
diff --git a/app/src/main/java/com/trianguloy/urlchecker/activities/SettingsActivity.java b/app/src/main/java/com/trianguloy/urlchecker/activities/SettingsActivity.java
index 0e2057b..554ca98 100644
--- a/app/src/main/java/com/trianguloy/urlchecker/activities/SettingsActivity.java
+++ b/app/src/main/java/com/trianguloy/urlchecker/activities/SettingsActivity.java
@@ -125,4 +125,13 @@ public class SettingsActivity extends Activity {
PackageUtils.startActivity(new Intent(this, TutorialActivity.class), R.string.toast_noApp, this);
}
+ /* ------------------- backup ------------------- */
+
+ public void openBackup(View view) {
+ PackageUtils.startActivityForResult(new Intent(this, BackupActivity.class),
+ AndroidSettings.registerForReloading(resultCodeInjector, this),
+ R.string.toast_noApp,
+ this
+ );
+ }
}
diff --git a/app/src/main/java/com/trianguloy/urlchecker/modules/companions/Hosts.java b/app/src/main/java/com/trianguloy/urlchecker/modules/companions/Hosts.java
index 1a0cd78..7bfe95a 100644
--- a/app/src/main/java/com/trianguloy/urlchecker/modules/companions/Hosts.java
+++ b/app/src/main/java/com/trianguloy/urlchecker/modules/companions/Hosts.java
@@ -27,7 +27,7 @@ public class Hosts {
private static final char SEPARATOR = '\t';
private static final int FILES = 128;
- private static final String PREFIX = "hosts_";
+ public static final String PREFIX = "hosts_";
// A custom mapping from a given hash with queryable buckets
private final HashMap>> buckets = new HashMap<>();
diff --git a/app/src/main/java/com/trianguloy/urlchecker/modules/companions/VersionManager.java b/app/src/main/java/com/trianguloy/urlchecker/modules/companions/VersionManager.java
index 9fad4d2..eb316db 100644
--- a/app/src/main/java/com/trianguloy/urlchecker/modules/companions/VersionManager.java
+++ b/app/src/main/java/com/trianguloy/urlchecker/modules/companions/VersionManager.java
@@ -6,6 +6,11 @@ import com.trianguloy.urlchecker.BuildConfig;
import com.trianguloy.urlchecker.activities.TutorialActivity;
import com.trianguloy.urlchecker.utilities.generics.GenericPref;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Pattern;
+
/**
* Manages the app version, to notify of updates
*/
@@ -17,6 +22,8 @@ public class VersionManager {
return new GenericPref.Str("changelog_lastVersion", null, cntx);
}
+ /* ------------------- static ------------------- */
+
/**
* Check if the version must be updated
*/
@@ -25,6 +32,32 @@ public class VersionManager {
new VersionManager(cntx);
}
+ /** Returns true iff [version] is newer than the current one */
+ public static boolean isNewerThanCurrent(String version) {
+ // shortcut to check own version
+ if (BuildConfig.VERSION_NAME.equals(version)) return false;
+
+ var versionSplit = split(version);
+ // invalid version, consider new just in case
+ if (versionSplit.isEmpty()) return true;
+ // compare: "1" < "2", "1" < "1.1"
+ var currentSplit = split(BuildConfig.VERSION_NAME);
+ var i = 0;
+ while (true) {
+ // end of version, version is older (or equal)
+ if (versionSplit.size() <= i) return false;
+ // end of current, version is newer
+ if (currentSplit.size() <= i) return true;
+ // version is older
+ if (versionSplit.get(i) < currentSplit.get(i)) return false;
+ // version is newer
+ if (versionSplit.get(i) > currentSplit.get(i)) return true;
+ i++;
+ }
+ }
+
+ /* ------------------- instance ------------------- */
+
public VersionManager(Context cntx) {
lastVersion = LASTVERSION_PREF(cntx);
if (lastVersion.get() == null) {
@@ -50,4 +83,17 @@ public class VersionManager {
public void markSeen() {
lastVersion.set(BuildConfig.VERSION_NAME);
}
+
+ /* ------------------- private ------------------- */
+
+ static private final Pattern INTEGER_PATTERN = Pattern.compile("\\d+");
+
+ /** Extracts all numbers from the string: "1.2.34d" -> [1, 2, 34] */
+ private static List split(String version) {
+ if (version == null) return Collections.emptyList();
+ var matcher = INTEGER_PATTERN.matcher(version);
+ var parts = new ArrayList();
+ while (matcher.find()) parts.add(Integer.parseInt(matcher.group()));
+ return parts;
+ }
}
diff --git a/app/src/main/java/com/trianguloy/urlchecker/modules/list/LogModule.java b/app/src/main/java/com/trianguloy/urlchecker/modules/list/LogModule.java
index 3e03113..7d27d07 100644
--- a/app/src/main/java/com/trianguloy/urlchecker/modules/list/LogModule.java
+++ b/app/src/main/java/com/trianguloy/urlchecker/modules/list/LogModule.java
@@ -26,8 +26,10 @@ import java.util.Date;
*/
public class LogModule extends AModuleData {
+ public static final String PREF = "log_data";
+
public static GenericPref.Str LOG_DATA(Context cntx) {
- return new GenericPref.Str("log_data", "", cntx);
+ return new GenericPref.Str(PREF, "", cntx);
}
public static GenericPref.Int LOG_LIMIT(Context cntx) {
diff --git a/app/src/main/java/com/trianguloy/urlchecker/modules/list/VirusTotalModule.java b/app/src/main/java/com/trianguloy/urlchecker/modules/list/VirusTotalModule.java
index b43fe4c..98481f6 100644
--- a/app/src/main/java/com/trianguloy/urlchecker/modules/list/VirusTotalModule.java
+++ b/app/src/main/java/com/trianguloy/urlchecker/modules/list/VirusTotalModule.java
@@ -26,8 +26,10 @@ import com.trianguloy.urlchecker.utilities.wrappers.DefaultTextWatcher;
*/
public class VirusTotalModule extends AModuleData {
+ public static final String PREF = "api_key";
+
static GenericPref.Str API_PREF(Context cntx) {
- return new GenericPref.Str("api_key", "", cntx);
+ return new GenericPref.Str(PREF, "", cntx);
}
@Override
diff --git a/app/src/main/java/com/trianguloy/urlchecker/utilities/generics/GenericPref.java b/app/src/main/java/com/trianguloy/urlchecker/utilities/generics/GenericPref.java
index 151cf94..e3f29c2 100644
--- a/app/src/main/java/com/trianguloy/urlchecker/utilities/generics/GenericPref.java
+++ b/app/src/main/java/com/trianguloy/urlchecker/utilities/generics/GenericPref.java
@@ -26,6 +26,11 @@ import java.util.Objects;
*/
public abstract class GenericPref {
+ /** Returns the sharedPrefs used by everything in the app */
+ public static SharedPreferences getPrefs(Context cntx) {
+ return cntx.getSharedPreferences(cntx.getPackageName(), Context.MODE_PRIVATE);
+ }
+
/**
* android sharedprefs
*/
@@ -50,7 +55,7 @@ public abstract class GenericPref {
public GenericPref(String prefName, T defaultValue, Context cntx) {
this.prefName = prefName;
this.defaultValue = defaultValue;
- prefs = cntx.getSharedPreferences(cntx.getPackageName(), Context.MODE_PRIVATE);
+ prefs = getPrefs(cntx);
}
/**
diff --git a/app/src/main/java/com/trianguloy/urlchecker/utilities/methods/AndroidUtils.java b/app/src/main/java/com/trianguloy/urlchecker/utilities/methods/AndroidUtils.java
index 3bb7e06..2afefa2 100644
--- a/app/src/main/java/com/trianguloy/urlchecker/utilities/methods/AndroidUtils.java
+++ b/app/src/main/java/com/trianguloy/urlchecker/utilities/methods/AndroidUtils.java
@@ -21,6 +21,8 @@ import android.widget.Toast;
import com.trianguloy.urlchecker.BuildConfig;
import com.trianguloy.urlchecker.R;
+import java.io.File;
+import java.io.IOException;
import java.text.DateFormat;
import java.util.Date;
import java.util.HashSet;
@@ -205,4 +207,11 @@ public interface AndroidUtils {
while (matcher.find()) links.add(matcher.group());
return links;
}
+
+ /** Copies a Uri to a file. */
+ static void copyUri2File(Uri uri, File file, Context cntx) throws IOException {
+ try (var in = cntx.getContentResolver().openInputStream(uri)) {
+ StreamUtils.inputStream2File(in, file);
+ }
+ }
}
diff --git a/app/src/main/java/com/trianguloy/urlchecker/utilities/methods/JavaUtils.java b/app/src/main/java/com/trianguloy/urlchecker/utilities/methods/JavaUtils.java
index 06d74e2..7af8015 100644
--- a/app/src/main/java/com/trianguloy/urlchecker/utilities/methods/JavaUtils.java
+++ b/app/src/main/java/com/trianguloy/urlchecker/utilities/methods/JavaUtils.java
@@ -130,6 +130,11 @@ public interface JavaUtils {
R apply(T t);
}
+ /** Negates a boolean Function */
+ static Function negate(Function function) {
+ return t -> !function.apply(t);
+ }
+
/**
* java.util.function.UnaryOperator requires api 24
*/
diff --git a/app/src/main/java/com/trianguloy/urlchecker/utilities/methods/StreamUtils.java b/app/src/main/java/com/trianguloy/urlchecker/utilities/methods/StreamUtils.java
index 126faf3..99266c0 100644
--- a/app/src/main/java/com/trianguloy/urlchecker/utilities/methods/StreamUtils.java
+++ b/app/src/main/java/com/trianguloy/urlchecker/utilities/methods/StreamUtils.java
@@ -1,9 +1,12 @@
package com.trianguloy.urlchecker.utilities.methods;
import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
+import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.net.URL;
import java.net.URLConnection;
@@ -79,6 +82,22 @@ public interface StreamUtils {
}
}
+ /** Reads an inputStream and transfers its content to a file. The stream is NOT closed */
+ static void inputStream2File(InputStream in, File file) throws IOException {
+ try (var out = new FileOutputStream(file)) {
+ inputStream2OutputStream(in, out);
+ }
+ }
+
+ /** Reads an inputStream and transfers its content to an output stream. The streams are NOT closed */
+ static void inputStream2OutputStream(InputStream in, OutputStream out) throws IOException {
+ var buffer = new byte[10240];
+ int read;
+ while ((read = in.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ }
+ }
+
/**
* Reads an input stream and streams its lines
*/
diff --git a/app/src/main/java/com/trianguloy/urlchecker/utilities/wrappers/ZipReader.java b/app/src/main/java/com/trianguloy/urlchecker/utilities/wrappers/ZipReader.java
new file mode 100644
index 0000000..90e5b77
--- /dev/null
+++ b/app/src/main/java/com/trianguloy/urlchecker/utilities/wrappers/ZipReader.java
@@ -0,0 +1,65 @@
+package com.trianguloy.urlchecker.utilities.wrappers;
+
+import android.content.Context;
+import android.net.Uri;
+
+import com.trianguloy.urlchecker.utilities.methods.AndroidUtils;
+import com.trianguloy.urlchecker.utilities.methods.StreamUtils;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.zip.ZipFile;
+
+/** Utility class to manage loading zips */
+public class ZipReader implements Closeable {
+ private final ZipFile zip;
+ private File cacheFile;
+
+ /** Opens a zip from a uri */
+ public ZipReader(Uri uri, Context cntx) throws IOException {
+ // copy to temporal file to allow using ZipFile
+ cacheFile = new File(cntx.getCacheDir(), "ZipReader");
+ try {
+ AndroidUtils.copyUri2File(uri, cacheFile, cntx);
+ zip = new ZipFile(cacheFile);
+ } catch (IOException e) {
+ cacheFile.delete();
+ cacheFile = null;
+ throw e;
+ }
+ }
+
+ /** Returns the content of file as string */
+ public String getFileString(String name) throws IOException {
+ var preferences = zip.getEntry(name);
+ return preferences != null ? StreamUtils.inputStream2String(zip.getInputStream(preferences)) : null;
+ }
+
+ /** Return the content of a file as stream */
+ public void getFileStream(String name, OutputStream out) throws IOException {
+ try (var in = zip.getInputStream(zip.getEntry(name))) {
+ StreamUtils.inputStream2OutputStream(in, out);
+ }
+ }
+
+ /** Returns the list of files from a particular folder */
+ public List fileNames(String folder) {
+ var fileNames = new ArrayList();
+ var zipEntries = zip.entries();
+ while (zipEntries.hasMoreElements()) {
+ var name = zipEntries.nextElement().getName();
+ if (name.startsWith(folder)) fileNames.add(name);
+ }
+ return fileNames;
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (zip != null) zip.close();
+ if (cacheFile != null) cacheFile.delete();
+ }
+}
diff --git a/app/src/main/java/com/trianguloy/urlchecker/utilities/wrappers/ZipWriter.java b/app/src/main/java/com/trianguloy/urlchecker/utilities/wrappers/ZipWriter.java
new file mode 100644
index 0000000..83b0a61
--- /dev/null
+++ b/app/src/main/java/com/trianguloy/urlchecker/utilities/wrappers/ZipWriter.java
@@ -0,0 +1,49 @@
+package com.trianguloy.urlchecker.utilities.wrappers;
+
+import static com.trianguloy.urlchecker.utilities.methods.StreamUtils.UTF_8;
+
+import android.content.Context;
+import android.net.Uri;
+
+import com.trianguloy.urlchecker.utilities.methods.StreamUtils;
+
+import java.io.Closeable;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+/** Utility class to manage creating zips */
+public class ZipWriter implements Closeable {
+
+ private final ZipOutputStream zip;
+
+ /** Creates a zip from a uri */
+ public ZipWriter(Uri uri, Context cntx) throws FileNotFoundException {
+ zip = new ZipOutputStream(cntx.getContentResolver().openOutputStream(uri));
+ }
+
+ /** Sets the zip comment */
+ public void setComment(String comment) {
+ zip.setComment(comment);
+ }
+
+ /** Adds a string as a file */
+ public void addStringFile(String name, String content) throws IOException {
+ var entry = new ZipEntry(name);
+ zip.putNextEntry(entry);
+ zip.write(content.getBytes(UTF_8));
+ }
+
+ /** Adds the content of a stream as a file. The stream is NOT closed */
+ public void addStreamFile(String name, InputStream stream) throws IOException {
+ zip.putNextEntry(new ZipEntry(name));
+ StreamUtils.inputStream2OutputStream(stream, zip);
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (zip != null) zip.close();
+ }
+}
diff --git a/app/src/main/res/layout/activity_backup.xml b/app/src/main/res/layout/activity_backup.xml
new file mode 100644
index 0000000..d1fd727
--- /dev/null
+++ b/app/src/main/res/layout/activity_backup.xml
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml
index e62f605..18d31ba 100644
--- a/app/src/main/res/layout/activity_settings.xml
+++ b/app/src/main/res/layout/activity_settings.xml
@@ -17,17 +17,21 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:onClick="openTutorial"
- android:text="@string/btn_tutorialSettings" />
+ android:text="@string/btn_tutorialSettings" />
+
+
-
+
+
+
@@ -36,37 +40,46 @@
android:layout_height="wrap_content"
android:paddingTop="@dimen/padding"
android:paddingBottom="@dimen/padding">
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/activity_backup.xml b/app/src/main/res/menu/activity_backup.xml
new file mode 100644
index 0000000..6f79d2e
--- /dev/null
+++ b/app/src/main/res/menu/activity_backup.xml
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 3e7c1dc..bf759f4 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -336,4 +336,32 @@ Hay algunos enlaces cuyo único propósito es redirigirte a otro enlace. Si el e
La aplicación se ha actualizado.
Versión actual: %s
Ver los cambios
+ Copia de seguridad y restauración
+ Mostrar opciones avanzadas
+ Hacer copia
+ Reemplazar
+ Restaurar copia
+ Borrar
+ Elige los elementos para hacer copia/restaurar.\nAl restaurar, si la copia se creó sin ellos, los existentes se mantendrán.
+ Ajustes: Ajustes generales de la aplicación. Por ejemplo módulos habilitados, configuración json, etc. No incluye secretos ni caché.
+ Preferencias: Ajustes guardados como preferencias (preferences).
+ Archivos: Ajustes guardados como ficheros.
+ Secretos: Ajustes considerados \'secretos\', como la clave de VirusTotal o el registro (log).
+ Caché: Datos que pueden ser fácilmente descargados/generados, como la base de datos del etiquetador de hosts. Activar esta opción puede aumentar el tamaño de la copia notablemente.
+ Borrar elementos antes de restaurar. Ten en cuenta que las preferencias y algunos ficheros con valores por defecto pueden no existir, con lo que deshabilitando esta opción combinarán los elementos existentes con los de la copia de formas inesperadas. Deshabilitalo sólo si sabes lo que estás haciendo.
+ Permitir copias creadas en versiones más recientes de la aplicación.
+ Cancelado. La copia fue creada con una versión más reciente de la aplicación.
+ Elige fichero de la copia
+ Copia de seguridad creada
+ No se ha podido crear la copia, prueba a elegir otra ubicación para el fichero.
+ Restaurar copia
+ ¡CUIDADO! Esto va a reemplazar los elementos seleccionados con los de la copia de seguridad.\n¿Realmente deseas continuar?
+ Reemplazar elementos
+ Copia restaurada
+ No se ha podido restaurar la copia. ¿Es un fichero válido?
+ Borrar elementos
+ ¡CUIDADO! Esto borrará todos los elementos seleccionados.\n¿Realmente deseas continuar?
+ Borrar elementos
+ Elementos borrados
+ No se han podido borrar los elementos
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a71d64c..f59343d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -53,6 +53,40 @@ Translations: %s."
Light
Locale:
Repeat tutorial
+ Backup / Restore
+
+ Show advanced options
+ Backup
+ Replace
+ Restore
+ Delete
+ "Choose which elements to backup/restore.
+When restoring, if the backup was created without it, existing ones will be kept."
+ Data: Standard application data. Things like enabled modules, json configurations, etc. Will not include secrets nor cache.
+ Preferences: Data saved as preferences.
+ Files: Data saved as files.
+ "Secrets: Data considered 'secret', like the VirusTotal api key or the log."
+ Cache: Data that can be easily downloaded/generated, like the hosts database. Enabling this option may increase the backup size notably.
+ Delete elements before restoring. Note that preferences and some files with default values may not exist, so disabling this option will merge the existing elements with the backup ones in unexpected ways. Disable if you know what you are doing.
+ Allow backups created from newer app versions.
+ Canceled. The backup was created with a newer app version.
+ Choose backup file
+ Backup created
+ "Unable to create backup, try to choose a different location for the file."
+ Restore backup
+ "WARNING! This will replace the selected elements from the backup.
+Do you really want to continue?"
+ Replace elements
+ Backup restored
+ Unable to restore the backup. Is it a valid file?
+ Delete elements
+ "WARNING! This will delete all the selected elements.
+Do you really want to continue?"
+ Delete elements
+ Elements deleted
+ Unable to delete elements