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 @@ + + + + + + + + + + + + + + + + + + + +