From be9cc348d1caa518221303cecc9d6fd212e3f1b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Mon, 9 Sep 2024 21:58:25 +0100 Subject: [PATCH] Allow full backup/restore from a zip file --- app/src/main/AndroidManifest.xml | 4 + .../RuntimeTypeAdapterFactory.java | 331 ++++++++++++++++++ .../gadgetbridge/GBApplication.java | 18 + .../activities/ActivitySummaryDetail.java | 19 +- .../BackupRestoreProgressActivity.java | 239 +++++++++++++ .../activities/DataManagementActivity.java | 62 +++- .../gadgetbridge/database/DBHelper.java | 8 +- .../devices/garmin/GarminWorkoutParser.java | 7 +- .../gadgetbridge/util/FileUtils.java | 50 +++ .../freeyourgadget/gadgetbridge/util/GB.java | 1 + .../util/ImportExportSharedPreferences.java | 11 +- .../util/backup/AbstractZipBackupJob.java | 100 ++++++ .../util/backup/JsonBackupPreferences.java | 202 +++++++++++ .../util/backup/ZipBackupCallback.java | 27 ++ .../util/backup/ZipBackupExportJob.java | 249 +++++++++++++ .../util/backup/ZipBackupImportJob.java | 237 +++++++++++++ .../util/backup/ZipBackupMetadata.java | 60 ++++ .../util/gson/GsonUtcDateAdapter.java | 56 +++ .../activity_backup_restore_progress.xml | 62 ++++ .../res/layout/activity_data_management.xml | 68 +++- app/src/main/res/values/strings.xml | 29 +- 21 files changed, 1804 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BackupRestoreProgressActivity.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/AbstractZipBackupJob.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/JsonBackupPreferences.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupCallback.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupExportJob.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupImportJob.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupMetadata.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/gson/GsonUtcDateAdapter.java create mode 100644 app/src/main/res/layout/activity_backup_restore_progress.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e792e65b9..70080701a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -174,6 +174,10 @@ android:name=".activities.files.FileManagerActivity" android:label="@string/activity_data_management_directory_content_title" android:parentActivityName=".activities.DataManagementActivity" /> + {@code + * abstract class Shape { + * int x; + * int y; + * } + * class Circle extends Shape { + * int radius; + * } + * class Rectangle extends Shape { + * int width; + * int height; + * } + * class Diamond extends Shape { + * int width; + * int height; + * } + * class Drawing { + * Shape bottomShape; + * Shape topShape; + * } + * } + * + *

Without additional type information, the serialized JSON is ambiguous. Is the bottom shape in + * this drawing a rectangle or a diamond? + * + *

{@code
+ * {
+ *   "bottomShape": {
+ *     "width": 10,
+ *     "height": 5,
+ *     "x": 0,
+ *     "y": 0
+ *   },
+ *   "topShape": {
+ *     "radius": 2,
+ *     "x": 4,
+ *     "y": 1
+ *   }
+ * }
+ * }
+ * + * This class addresses this problem by adding type information to the serialized JSON and honoring + * that type information when the JSON is deserialized: + * + *
{@code
+ * {
+ *   "bottomShape": {
+ *     "type": "Diamond",
+ *     "width": 10,
+ *     "height": 5,
+ *     "x": 0,
+ *     "y": 0
+ *   },
+ *   "topShape": {
+ *     "type": "Circle",
+ *     "radius": 2,
+ *     "x": 4,
+ *     "y": 1
+ *   }
+ * }
+ * }
+ * + * Both the type field name ({@code "type"}) and the type labels ({@code "Rectangle"}) are + * configurable. + * + *

Registering Types

+ * + * Create a {@code RuntimeTypeAdapterFactory} by passing the base type and type field name to the + * {@link #of} factory method. If you don't supply an explicit type field name, {@code "type"} will + * be used. + * + *
{@code
+ * RuntimeTypeAdapterFactory shapeAdapterFactory
+ *     = RuntimeTypeAdapterFactory.of(Shape.class, "type");
+ * }
+ * + * Next register all of your subtypes. Every subtype must be explicitly registered. This protects + * your application from injection attacks. If you don't supply an explicit type label, the type's + * simple name will be used. + * + *
{@code
+ * shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
+ * shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
+ * shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
+ * }
+ * + * Finally, register the type adapter factory in your application's GSON builder: + * + *
{@code
+ * Gson gson = new GsonBuilder()
+ *     .registerTypeAdapterFactory(shapeAdapterFactory)
+ *     .create();
+ * }
+ * + * Like {@code GsonBuilder}, this API supports chaining: + * + *
{@code
+ * RuntimeTypeAdapterFactory shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
+ *     .registerSubtype(Rectangle.class)
+ *     .registerSubtype(Circle.class)
+ *     .registerSubtype(Diamond.class);
+ * }
+ * + *

Serialization and deserialization

+ * + * In order to serialize and deserialize a polymorphic object, you must specify the base type + * explicitly. + * + *
{@code
+ * Diamond diamond = new Diamond();
+ * String json = gson.toJson(diamond, Shape.class);
+ * }
+ * + * And then: + * + *
{@code
+ * Shape shape = gson.fromJson(json, Shape.class);
+ * }
+ */ +public final class RuntimeTypeAdapterFactory implements TypeAdapterFactory { + private final Class baseType; + private final String typeFieldName; + private final Map> labelToSubtype = new LinkedHashMap<>(); + private final Map, String> subtypeToLabel = new LinkedHashMap<>(); + private final boolean maintainType; + private boolean recognizeSubtypes; + + private RuntimeTypeAdapterFactory(Class baseType, String typeFieldName, boolean maintainType) { + if (typeFieldName == null || baseType == null) { + throw new NullPointerException(); + } + this.baseType = baseType; + this.typeFieldName = typeFieldName; + this.maintainType = maintainType; + } + + /** + * Creates a new runtime type adapter for {@code baseType} using {@code typeFieldName} as the type + * field name. Type field names are case sensitive. + * + * @param maintainType true if the type field should be included in deserialized objects + */ + public static RuntimeTypeAdapterFactory of( + Class baseType, String typeFieldName, boolean maintainType) { + return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, maintainType); + } + + /** + * Creates a new runtime type adapter for {@code baseType} using {@code typeFieldName} as the type + * field name. Type field names are case sensitive. + */ + public static RuntimeTypeAdapterFactory of(Class baseType, String typeFieldName) { + return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, false); + } + + /** + * Creates a new runtime type adapter for {@code baseType} using {@code "type"} as the type field + * name. + */ + public static RuntimeTypeAdapterFactory of(Class baseType) { + return new RuntimeTypeAdapterFactory<>(baseType, "type", false); + } + + /** + * Ensures that this factory will handle not just the given {@code baseType}, but any subtype of + * that type. + */ + @CanIgnoreReturnValue + public RuntimeTypeAdapterFactory recognizeSubtypes() { + this.recognizeSubtypes = true; + return this; + } + + /** + * Registers {@code type} identified by {@code label}. Labels are case sensitive. + * + * @throws IllegalArgumentException if either {@code type} or {@code label} have already been + * registered on this type adapter. + */ + @CanIgnoreReturnValue + public RuntimeTypeAdapterFactory registerSubtype(Class type, String label) { + if (type == null || label == null) { + throw new NullPointerException(); + } + if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) { + throw new IllegalArgumentException("types and labels must be unique"); + } + labelToSubtype.put(label, type); + subtypeToLabel.put(type, label); + return this; + } + + /** + * Registers {@code type} identified by its {@link Class#getSimpleName simple name}. Labels are + * case sensitive. + * + * @throws IllegalArgumentException if either {@code type} or its simple name have already been + * registered on this type adapter. + */ + @CanIgnoreReturnValue + public RuntimeTypeAdapterFactory registerSubtype(Class type) { + return registerSubtype(type, type.getSimpleName()); + } + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + if (type == null) { + return null; + } + Class rawType = type.getRawType(); + boolean handle = + recognizeSubtypes ? baseType.isAssignableFrom(rawType) : baseType.equals(rawType); + if (!handle) { + return null; + } + + final TypeAdapter jsonElementAdapter = gson.getAdapter(JsonElement.class); + final Map> labelToDelegate = new LinkedHashMap<>(); + final Map, TypeAdapter> subtypeToDelegate = new LinkedHashMap<>(); + for (Map.Entry> entry : labelToSubtype.entrySet()) { + TypeAdapter delegate = gson.getDelegateAdapter(this, TypeToken.get(entry.getValue())); + labelToDelegate.put(entry.getKey(), delegate); + subtypeToDelegate.put(entry.getValue(), delegate); + } + + return new TypeAdapter() { + @Override + public R read(JsonReader in) throws IOException { + JsonElement jsonElement = jsonElementAdapter.read(in); + JsonElement labelJsonElement; + if (maintainType) { + labelJsonElement = jsonElement.getAsJsonObject().get(typeFieldName); + } else { + labelJsonElement = jsonElement.getAsJsonObject().remove(typeFieldName); + } + + if (labelJsonElement == null) { + throw new JsonParseException( + "cannot deserialize " + + baseType + + " because it does not define a field named " + + typeFieldName); + } + String label = labelJsonElement.getAsString(); + @SuppressWarnings("unchecked") // registration requires that subtype extends T + TypeAdapter delegate = (TypeAdapter) labelToDelegate.get(label); + if (delegate == null) { + throw new JsonParseException( + "cannot deserialize " + + baseType + + " subtype named " + + label + + "; did you forget to register a subtype?"); + } + return delegate.fromJsonTree(jsonElement); + } + + @Override + public void write(JsonWriter out, R value) throws IOException { + Class srcType = value.getClass(); + String label = subtypeToLabel.get(srcType); + @SuppressWarnings("unchecked") // registration requires that subtype extends T + TypeAdapter delegate = (TypeAdapter) subtypeToDelegate.get(srcType); + if (delegate == null) { + throw new JsonParseException( + "cannot serialize " + srcType.getName() + "; did you forget to register a subtype?"); + } + JsonObject jsonObject = delegate.toJsonTree(value).getAsJsonObject(); + + if (maintainType) { + jsonElementAdapter.write(out, jsonObject); + return; + } + + JsonObject clone = new JsonObject(); + + if (jsonObject.has(typeFieldName)) { + throw new JsonParseException( + "cannot serialize " + + srcType.getName() + + " because it already defines a field named " + + typeFieldName); + } + clone.add(typeFieldName, new JsonPrimitive(label)); + + for (Map.Entry e : jsonObject.entrySet()) { + clone.add(e.getKey(), e.getValue()); + } + jsonElementAdapter.write(out, clone); + } + }.nullSafe(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java index 923289d60..0a1997ee3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java @@ -21,9 +21,11 @@ package nodomain.freeyourgadget.gadgetbridge; import android.annotation.TargetApi; +import android.app.AlarmManager; import android.app.Application; import android.app.NotificationManager; import android.app.NotificationManager.Policy; +import android.app.PendingIntent; import android.bluetooth.BluetoothAdapter; import android.content.Context; import android.content.Intent; @@ -62,6 +64,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; @@ -86,6 +89,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue; +import nodomain.freeyourgadget.gadgetbridge.util.PendingIntentUtils; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; import nodomain.freeyourgadget.gadgetbridge.util.preferences.DevicePrefs; @@ -170,6 +174,20 @@ public class GBApplication extends Application { System.exit(0); } + public static void restart() { + GB.log("Restarting Gadgetbridge...", GB.INFO, null); + final Intent quitIntent = new Intent(GBApplication.ACTION_QUIT); + LocalBroadcastManager.getInstance(context).sendBroadcast(quitIntent); + GBApplication.deviceService().quit(); + + final Intent startActivity = new Intent(context, ControlCenterv2.class); + final PendingIntent pendingIntent = PendingIntentUtils.getActivity(context, 1337, startActivity, PendingIntent.FLAG_CANCEL_CURRENT, false); + final AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + alarmManager.set(AlarmManager.RTC, System.currentTimeMillis() + 2000, pendingIntent); + + System.exit(0); + } + public GBApplication() { context = this; // don't do anything here, add it to onCreate instead diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java index ba9832a52..7003b730d 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java @@ -762,7 +762,10 @@ public class ActivitySummaryDetail extends AbstractGBActivity { private boolean itemHasGps() { if (currentItem.getGpxTrack() != null) { - return new File(currentItem.getGpxTrack()).canRead(); + final File existing = FileUtils.tryFixPath(new File(currentItem.getGpxTrack())); + if (existing != null && existing.canRead()) { + return true; + } } final String summaryData = currentItem.getSummaryData(); if (summaryData.contains(INTERNAL_HAS_GPS)) { @@ -783,21 +786,11 @@ public class ActivitySummaryDetail extends AbstractGBActivity { private File getTrackFile() { final String gpxTrack = currentItem.getGpxTrack(); if (gpxTrack != null) { - File file = new File(gpxTrack); - if (file.exists()) { - return file; - } else { - return null; - } + return FileUtils.tryFixPath(new File(gpxTrack)); } final String rawDetails = currentItem.getRawDetailsPath(); if (rawDetails != null && rawDetails.endsWith(".fit")) { - File file = new File(rawDetails); - if (file.exists()) { - return file; - } else { - return null; - } + return FileUtils.tryFixPath(new File(rawDetails)); } return null; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BackupRestoreProgressActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BackupRestoreProgressActivity.java new file mode 100644 index 000000000..9a9d0e9bb --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BackupRestoreProgressActivity.java @@ -0,0 +1,239 @@ +/* Copyright (C) 2024 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.activities; + +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.view.MenuItem; +import android.view.View; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.util.backup.AbstractZipBackupJob; +import nodomain.freeyourgadget.gadgetbridge.util.backup.ZipBackupCallback; +import nodomain.freeyourgadget.gadgetbridge.util.backup.ZipBackupExportJob; +import nodomain.freeyourgadget.gadgetbridge.util.backup.ZipBackupImportJob; + +public class BackupRestoreProgressActivity extends AbstractGBActivity { + private static final Logger LOG = LoggerFactory.getLogger(BackupRestoreProgressActivity.class); + + public static final String EXTRA_URI = "uri"; + public static final String EXTRA_ACTION = "action"; // import/export + + private boolean jobFinished = false; + private Uri uri; + private String action; + private Thread mThread; + private AbstractZipBackupJob mZipBackupJob; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_backup_restore_progress); + + final Bundle extras = getIntent().getExtras(); + if (extras == null) { + LOG.error("No extras"); + finish(); + return; + } + + uri = extras.getParcelable(EXTRA_URI); + if (uri == null) { + LOG.error("No uri"); + finish(); + return; + } + + action = extras.getString(EXTRA_ACTION); + if (action == null) { + LOG.error("No action"); + finish(); + return; + } + + final TextView backupRestoreHint = findViewById(R.id.backupRestoreHint); + final ProgressBar backupRestoreProgressBar = findViewById(R.id.backupRestoreProgressBar); + final TextView backupRestoreProgressText = findViewById(R.id.backupRestoreProgressText); + final TextView backupRestoreProgressPercentage = findViewById(R.id.backupRestoreProgressPercentage); + + final ZipBackupCallback zipBackupCallback = new ZipBackupCallback() { + @Override + public void onProgress(final int progress, final String message) { + backupRestoreProgressBar.setIndeterminate(progress == 0); + backupRestoreProgressBar.setProgress(progress); + backupRestoreProgressText.setText(message); + backupRestoreProgressPercentage.setText(getString(R.string.battery_percentage_str, String.valueOf(progress))); + } + + @Override + public void onSuccess(final String warnings) { + jobFinished = true; + backupRestoreHint.setVisibility(View.GONE); + backupRestoreProgressBar.setProgress(100); + backupRestoreProgressPercentage.setText(getString(R.string.battery_percentage_str, "100")); + + switch (action) { + case "import": + backupRestoreProgressText.setText(R.string.backup_restore_import_complete); + + final StringBuilder message = new StringBuilder(); + + message.append(getString(R.string.backup_restore_restart_summary, getString(R.string.app_name))); + + if (warnings != null) { + message.append("\n\n").append(warnings); + } + + new MaterialAlertDialogBuilder(BackupRestoreProgressActivity.this) + .setCancelable(false) + .setIcon(R.drawable.ic_sync) + .setTitle(R.string.backup_restore_restart_title) + .setMessage(message.toString()) + .setOnCancelListener((dialog -> { + GBApplication.restart(); + })) + .setPositiveButton(R.string.ok, (dialog, which) -> { + GBApplication.restart(); + }).show(); + break; + case "export": + backupRestoreProgressText.setText(R.string.backup_restore_export_complete); + break; + } + } + + @Override + public void onFailure(@Nullable final String errorMessage) { + jobFinished = true; + + switch (action) { + case "import": + backupRestoreHint.setText(R.string.backup_restore_error_import); + break; + case "export": + backupRestoreHint.setText(R.string.backup_restore_error_export); + break; + } + + backupRestoreProgressText.setText(errorMessage); + backupRestoreProgressPercentage.setVisibility(View.GONE); + + if ("export".equals(action)) { + final DocumentFile documentFile = DocumentFile.fromSingleUri(BackupRestoreProgressActivity.this, uri); + if (documentFile != null) { + documentFile.delete(); + } + } + } + }; + + switch (action) { + case "import": + backupRestoreHint.setText(getString(R.string.backup_restore_do_not_exit, getString(R.string.backup_restore_importing))); + mZipBackupJob = new ZipBackupImportJob(GBApplication.getContext(), zipBackupCallback, uri); + break; + case "export": + backupRestoreHint.setText(getString(R.string.backup_restore_do_not_exit, getString(R.string.backup_restore_exporting))); + mZipBackupJob = new ZipBackupExportJob(GBApplication.getContext(), zipBackupCallback, uri); + break; + default: + LOG.error("Unknown action {}", action); + finish(); + return; + } + + mThread = new Thread(mZipBackupJob, "gb-backup-restore"); + mThread.start(); + + getOnBackPressedDispatcher().addCallback(new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + confirmExit(); + } + }); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + final int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + // back button + confirmExit(); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void confirmExit() { + if (jobFinished) { + finish(); + return; + } + + final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this) + .setCancelable(true) + .setIcon(R.drawable.ic_warning) + .setTitle(R.string.backup_restore_abort_title) + .setPositiveButton(R.string.backup_restore_abort_title, (dialog, which) -> { + if (mZipBackupJob != null) { + LOG.info("Aborting {}", action); + final Handler handler = new Handler(getMainLooper()); + mZipBackupJob.abort(); + new Thread(() -> { + try { + mThread.join(60_000); + } catch (final InterruptedException ignored) { + } + handler.post(() -> { + LOG.info("Aborted {}", action); + if ("export".equals(action)) { + // Delete the incomplete export file + final DocumentFile documentFile = DocumentFile.fromSingleUri(BackupRestoreProgressActivity.this, uri); + if (documentFile != null) { + documentFile.delete(); + } + } + finish(); + }); + }).start(); + } + }) + .setNegativeButton(R.string.Cancel, (dialog, which) -> { + }); + + if ("import".equals(action)) { + builder.setMessage(R.string.backup_restore_abort_import_confirmation); + } else { + builder.setMessage(R.string.backup_restore_abort_export_confirmation); + } + + builder.show(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DataManagementActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DataManagementActivity.java index 99c6a3bb6..593b1dd32 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DataManagementActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DataManagementActivity.java @@ -28,12 +28,12 @@ import android.preference.PreferenceManager; import android.provider.DocumentsContract; import android.view.MenuItem; import android.view.View; -import android.widget.ArrayAdapter; import android.widget.Button; import android.widget.TextView; import android.widget.Toast; -import androidx.appcompat.app.AlertDialog; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.core.app.NavUtils; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -43,8 +43,10 @@ import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; +import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; +import java.util.Locale; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; @@ -71,6 +73,58 @@ public class DataManagementActivity extends AbstractGBActivity { super.onCreate(savedInstanceState); setContentView(R.layout.activity_data_management); + final ActivityResultLauncher backupZipFileChooser = registerForActivityResult( + new ActivityResultContracts.CreateDocument("application/zip"), + uri -> { + LOG.info("Got target backup file: {}", uri); + if (uri != null) { + final Intent startBackupIntent = new Intent(DataManagementActivity.this, BackupRestoreProgressActivity.class); + startBackupIntent.putExtra(BackupRestoreProgressActivity.EXTRA_URI, uri); + startBackupIntent.putExtra(BackupRestoreProgressActivity.EXTRA_ACTION, "export"); + startActivity(startBackupIntent); + } + } + ); + + final Button backupToZipButton = findViewById(R.id.backupToZipButton); + backupToZipButton.setOnClickListener(v -> { + final SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault()); + final String defaultFilename = String.format(Locale.ROOT, "gadgetbridge_%s.zip", sdf.format(new Date())); + backupZipFileChooser.launch(defaultFilename); + }); + + final ActivityResultLauncher restoreFileChooser = registerForActivityResult( + new ActivityResultContracts.OpenDocument(), + uri -> { + LOG.info("Got restore file: {}", uri); + + if (uri == null) { + return; + } + + new MaterialAlertDialogBuilder(this) + .setCancelable(true) + .setIcon(R.drawable.ic_warning) + .setTitle(R.string.dbmanagementactivity_import_data_title) + .setMessage(R.string.dbmanagementactivity_overwrite_database_confirmation) + .setPositiveButton(R.string.dbmanagementactivity_overwrite, (dialog, which) -> { + // Disconnect from all devices right away + GBApplication.deviceService().disconnect(); + + final Intent startBackupIntent = new Intent(DataManagementActivity.this, BackupRestoreProgressActivity.class); + startBackupIntent.putExtra(BackupRestoreProgressActivity.EXTRA_URI, uri); + startBackupIntent.putExtra(BackupRestoreProgressActivity.EXTRA_ACTION, "import"); + startActivity(startBackupIntent); + }) + .setNegativeButton(R.string.Cancel, (dialog, which) -> { + }) + .show(); + } + ); + + final Button restoreFromZipButton = findViewById(R.id.restoreFromZipButton); + restoreFromZipButton.setOnClickListener(v -> restoreFileChooser.launch(new String[]{"application/zip"})); + TextView dbPath = findViewById(R.id.activity_data_management_path); dbPath.setText(getExternalPath()); @@ -248,7 +302,7 @@ public class DataManagementActivity extends AbstractGBActivity { try { File myPath = FileUtils.getExternalFilesDir(); File myFile = new File(myPath, "Export_preference"); - ImportExportSharedPreferences.exportToFile(sharedPrefs, myFile, null); + ImportExportSharedPreferences.exportToFile(sharedPrefs, myFile); } catch (IOException ex) { GB.toast(this, getString(R.string.dbmanagementactivity_error_exporting_shared, ex.getMessage()), Toast.LENGTH_LONG, GB.ERROR, ex); } @@ -260,7 +314,7 @@ public class DataManagementActivity extends AbstractGBActivity { File myPath = FileUtils.getExternalFilesDir(); File myFile = new File(myPath, "Export_preference_" + FileUtils.makeValidFileName(dbDevice.getIdentifier())); try { - ImportExportSharedPreferences.exportToFile(deviceSharedPrefs, myFile, null); + ImportExportSharedPreferences.exportToFile(deviceSharedPrefs, myFile); } catch (Exception ignore) { // some devices no not have device specific preferences } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java index bba3ac517..833345693 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java @@ -30,7 +30,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; +import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.text.SimpleDateFormat; import java.util.Calendar; @@ -148,10 +150,14 @@ public class DBHelper { } public void importDB(DBHandler dbHandler, File fromFile) throws IllegalStateException, IOException { + importDB(dbHandler, new FileInputStream(fromFile)); + } + + public void importDB(DBHandler dbHandler, InputStream inputStream) throws IllegalStateException, IOException { String dbPath = getClosedDBPath(dbHandler); try { File toFile = new File(dbPath); - FileUtils.copyFile(fromFile, toFile); + FileUtils.copyStreamToFile(inputStream, toFile); } finally { dbHandler.openDb(); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminWorkoutParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminWorkoutParser.java index 197020bf5..be815fa17 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminWorkoutParser.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminWorkoutParser.java @@ -32,6 +32,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages. import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSport; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitTimeInZone; import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; +import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; public class GarminWorkoutParser implements ActivitySummaryParser { private static final Logger LOG = LoggerFactory.getLogger(GarminWorkoutParser.class); @@ -65,9 +66,9 @@ public class GarminWorkoutParser implements ActivitySummaryParser { LOG.warn("No rawDetailsPath"); return summary; } - final File file = new File(rawDetailsPath); - if (!file.isFile() || !file.canRead()) { - LOG.warn("Unable to read {}", file); + final File file = FileUtils.tryFixPath(new File(rawDetailsPath)); + if (file == null || !file.isFile() || !file.canRead()) { + LOG.warn("Unable to read {}", rawDetailsPath); return summary; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/FileUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/FileUtils.java index 8ad4adf27..ea8ab1eb5 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/FileUtils.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/FileUtils.java @@ -25,6 +25,7 @@ import android.os.Environment; import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; @@ -43,6 +44,7 @@ import java.io.OutputStream; import java.nio.channels.FileChannel; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Objects; @@ -53,6 +55,14 @@ public class FileUtils { // Don't use slf4j here -- would be a bootstrapping problem private static final String TAG = "FileUtils"; + private static final List KNOWN_PACKAGES = Arrays.asList( + "nodomain.freeyourgadget.gadgetbridge", + "nodomain.freeyourgadget.gadgetbridge.nightly", + "nodomain.freeyourgadget.gadgetbridge.nightly_nopebble", + "com.espruino.gadgetbridge.banglejs", + "com.espruino.gadgetbridge.banglejs.nightly" + ); + /** * Copies the the given sourceFile to destFile, overwriting it, in case it exists. * @@ -392,4 +402,44 @@ public class FileUtils { fos.close(); return Uri.fromFile(tempFile); } + + /** + * When migrating the database between Gadgetbridge versions or phones, we may end up with the + * wrong path persisted in the database. Attempt to find the file in the current external data. + * + * @return the fixed file path, if it exists, null otherwise + */ + @Nullable + public static File tryFixPath(final File file) { + if (file == null || (file.isFile() && file.canRead())) { + return file; + } + + File externalFilesDir; + try { + externalFilesDir = getExternalFilesDir(); + } catch (final IOException e) { + return null; + } + + final String absolutePath = file.getAbsolutePath(); + for (final String knownPackage : KNOWN_PACKAGES) { + final int i = absolutePath.indexOf(knownPackage); + if (i < 0) { + continue; + } + + // We found the gadgetbridge package in the path! + String relativePath = absolutePath.substring(i + knownPackage.length() + 1); + if (relativePath.startsWith("files/")) { + relativePath = relativePath.substring(6); + } + final File fixedFile = new File(externalFilesDir, relativePath); + if (fixedFile.exists()) { + return fixedFile; + } + } + + return null; + } } \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java index 10f840977..f9c76f896 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java @@ -520,6 +520,7 @@ public class GB { .setStyle(new NotificationCompat.BigTextStyle().bigText(text)) .setContentText(text) .setContentIntent(pendingIntent) + .setOnlyAlertOnce(percentage > 0 && percentage < 100) .setOngoing(ongoing); if (ongoing) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/ImportExportSharedPreferences.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/ImportExportSharedPreferences.java index 05f186a60..6bc373bc3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/ImportExportSharedPreferences.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/ImportExportSharedPreferences.java @@ -48,15 +48,13 @@ public class ImportExportSharedPreferences { private static final String NAME = "name"; private static final String PREFERENCES = "preferences"; - public static void exportToFile(SharedPreferences sharedPreferences, File outFile, - Set doNotExport) throws IOException { + public static void exportToFile(SharedPreferences sharedPreferences, File outFile) throws IOException { try (FileWriter outputWriter = new FileWriter(outFile)) { - export(sharedPreferences, outputWriter, doNotExport); + export(sharedPreferences, outputWriter); } } - private static void export(SharedPreferences sharedPreferences, Writer writer, - Set doNotExport) throws IOException { + public static void export(SharedPreferences sharedPreferences, Writer writer) throws IOException { XmlSerializer serializer = Xml.newSerializer(); serializer.setOutput(writer); serializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true); @@ -64,7 +62,6 @@ public class ImportExportSharedPreferences { serializer.startTag("", PREFERENCES); for (Map.Entry entry : sharedPreferences.getAll().entrySet()) { String key = entry.getKey(); - if (doNotExport != null && doNotExport.contains(key)) continue; Object valueObject = entry.getValue(); // Skip this entry if the value is null; @@ -86,7 +83,7 @@ public class ImportExportSharedPreferences { return importFromReader(sharedPreferences, new FileReader(inFile)); } - private static boolean importFromReader(SharedPreferences sharedPreferences, Reader in) + public static boolean importFromReader(SharedPreferences sharedPreferences, Reader in) throws Exception { SharedPreferences.Editor editor = sharedPreferences.edit(); editor.clear(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/AbstractZipBackupJob.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/AbstractZipBackupJob.java new file mode 100644 index 000000000..acd1a97f9 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/AbstractZipBackupJob.java @@ -0,0 +1,100 @@ +/* Copyright (C) 2024 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.util.backup; + +import android.content.Context; +import android.os.Handler; + +import androidx.annotation.StringRes; +import androidx.annotation.WorkerThread; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.util.Date; +import java.util.concurrent.atomic.AtomicBoolean; + +import nodomain.freeyourgadget.gadgetbridge.util.gson.GsonUtcDateAdapter; + +public abstract class AbstractZipBackupJob implements Runnable { + public static final String METADATA_FILENAME = "gadgetbridge.json"; + public static final String DATABASE_FILENAME = "database/Gadgetbridge"; + public static final String PREFS_GLOBAL_FILENAME = "preferences/global.json"; + public static final String PREFS_DEVICE_FILENAME = "preferences/device_%s.json"; + public static final String EXTERNAL_FILES_FOLDER = "files"; + + public static final int VERSION = 1; + + protected static final Gson GSON = new GsonBuilder() + .registerTypeAdapter(Date.class, new GsonUtcDateAdapter()) + .setPrettyPrinting() + .serializeNulls() + .create(); + + private final Context mContext; + private final Handler mHandler; + private final ZipBackupCallback mCallback; + + private final AtomicBoolean aborted = new AtomicBoolean(false); + + private long lastProgressUpdateTs; + private long lastProgressUpdateMessage; + + public AbstractZipBackupJob(final Context context, final ZipBackupCallback callback) { + this.mContext = context; + this.mHandler = new Handler(context.getMainLooper()); + this.mCallback = callback; + } + + public Context getContext() { + return mContext; + } + + public void abort() { + aborted.set(true); + } + + public boolean isAborted() { + return aborted.get(); + } + + @WorkerThread + protected void updateProgress(final int percentage, @StringRes final int message, final Object... formatArgs) { + final long now = System.currentTimeMillis(); + if (percentage != 100 && now - lastProgressUpdateTs < 1000L) { + // Avoid updating the notification too frequently, but still do if the message changed + if (lastProgressUpdateMessage == message) { + return; + } + } + lastProgressUpdateTs = now; + lastProgressUpdateMessage = message; + mHandler.post(() -> { + mCallback.onProgress(percentage, getContext().getString(message, formatArgs)); + }); + } + + @WorkerThread + protected void onSuccess(final String warnings) { + mHandler.post(() -> mCallback.onSuccess(warnings)); + } + + @WorkerThread + protected void onFailure(final String errorMessage) { + mHandler.post(() -> mCallback.onFailure(errorMessage)); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/JsonBackupPreferences.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/JsonBackupPreferences.java new file mode 100644 index 000000000..3b1862792 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/JsonBackupPreferences.java @@ -0,0 +1,202 @@ +/* Copyright (C) 2024 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.util.backup; + +import android.content.SharedPreferences; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.TypeAdapterFactory; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import com.google.gson.typeadapters.RuntimeTypeAdapterFactory; + +public class JsonBackupPreferences { + private static final Gson GSON = new GsonBuilder() + .registerTypeAdapterFactory(getTypeAdapterFactory()) + .setPrettyPrinting() + .serializeNulls() + .create(); + + private final Map preferences; + + private static final String BOOLEAN = "Boolean"; + private static final String FLOAT = "Float"; + private static final String INTEGER = "Integer"; + private static final String LONG = "Long"; + private static final String STRING = "String"; + private static final String HASHSET = "HashSet"; + + public JsonBackupPreferences(final Map preferences) { + this.preferences = preferences; + } + + public static JsonBackupPreferences fromJson(final InputStream inputStream) { + return GSON.fromJson( + new InputStreamReader(inputStream, StandardCharsets.UTF_8), + JsonBackupPreferences.class + ); + } + + public String toJson() { + return GSON.toJson(this); + } + + /** + * @noinspection BooleanMethodIsAlwaysInverted + */ + public boolean importInto(final SharedPreferences sharedPreferences) { + final SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.clear(); + for (final Map.Entry e : preferences.entrySet()) { + e.getValue().put(editor, e.getKey()); + } + return editor.commit(); + } + + public static JsonBackupPreferences exportFrom(final SharedPreferences sharedPreferences) { + final Map values = new HashMap<>(); + + for (final Map.Entry entry : sharedPreferences.getAll().entrySet()) { + final String key = entry.getKey(); + + final Object valueObject = entry.getValue(); + // Skip this entry if the value is null; + if (valueObject == null) continue; + + final String valueType = valueObject.getClass().getSimpleName(); + + if (BOOLEAN.equals(valueType)) { + values.put(key, new BooleanPreferenceValue((Boolean) valueObject)); + } else if (FLOAT.equals(valueType)) { + values.put(key, new FloatPreferenceValue((Float) valueObject)); + } else if (INTEGER.equals(valueType)) { + values.put(key, new IntegerPreferenceValue((Integer) valueObject)); + } else if (LONG.equals(valueType)) { + values.put(key, new LongPreferenceValue((Long) valueObject)); + } else if (STRING.equals(valueType)) { + values.put(key, new StringPreferenceValue((String) valueObject)); + } else if (HASHSET.equals(valueType)) { + values.put(key, new StringSetPreferenceValue((HashSet) valueObject)); + } else { + throw new IllegalArgumentException("Unknown preference type " + valueType); + } + } + + return new JsonBackupPreferences(values); + } + + public interface PreferenceValue { + void put(final SharedPreferences.Editor editor, final String key); + } + + public static class BooleanPreferenceValue implements PreferenceValue { + private final boolean value; + + public BooleanPreferenceValue(final boolean value) { + this.value = value; + } + + @Override + public void put(final SharedPreferences.Editor editor, final String key) { + editor.putBoolean(key, value); + } + } + + public static class FloatPreferenceValue implements PreferenceValue { + private final float value; + + public FloatPreferenceValue(final float value) { + this.value = value; + } + + @Override + public void put(final SharedPreferences.Editor editor, final String key) { + editor.putFloat(key, value); + } + } + + public static class IntegerPreferenceValue implements PreferenceValue { + private final int value; + + public IntegerPreferenceValue(final int value) { + this.value = value; + } + + @Override + public void put(final SharedPreferences.Editor editor, final String key) { + editor.putInt(key, value); + } + } + + public static class LongPreferenceValue implements PreferenceValue { + private final long value; + + public LongPreferenceValue(final long value) { + this.value = value; + } + + @Override + public void put(final SharedPreferences.Editor editor, final String key) { + editor.putLong(key, value); + } + } + + public static class StringPreferenceValue implements PreferenceValue { + private final String value; + + public StringPreferenceValue(final String value) { + this.value = value; + } + + @Override + public void put(final SharedPreferences.Editor editor, final String key) { + editor.putString(key, value); + } + } + + public static class StringSetPreferenceValue implements PreferenceValue { + private final Set value; + + public StringSetPreferenceValue(final Set value) { + this.value = value; + } + + @Override + public void put(final SharedPreferences.Editor editor, final String key) { + editor.putStringSet(key, new HashSet<>(value)); + } + } + + public static TypeAdapterFactory getTypeAdapterFactory() { + return RuntimeTypeAdapterFactory + .of(PreferenceValue.class, "type") + .registerSubtype(BooleanPreferenceValue.class, BOOLEAN) + .registerSubtype(FloatPreferenceValue.class, FLOAT) + .registerSubtype(IntegerPreferenceValue.class, INTEGER) + .registerSubtype(LongPreferenceValue.class, LONG) + .registerSubtype(StringPreferenceValue.class, STRING) + .registerSubtype(StringSetPreferenceValue.class, HASHSET); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupCallback.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupCallback.java new file mode 100644 index 000000000..0cfbcdae7 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupCallback.java @@ -0,0 +1,27 @@ +/* Copyright (C) 2024 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.util.backup; + +import androidx.annotation.Nullable; + +public interface ZipBackupCallback { + void onProgress(final int progress, final String message); + + void onSuccess(final String warnings); + + void onFailure(@Nullable final String errorMessage); +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupExportJob.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupExportJob.java new file mode 100644 index 000000000..912c1243e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupExportJob.java @@ -0,0 +1,249 @@ +/* Copyright (C) 2024 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.util.backup; + +import android.content.Context; +import android.content.SharedPreferences; +import android.net.Uri; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import nodomain.freeyourgadget.gadgetbridge.BuildConfig; +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; + +public class ZipBackupExportJob extends AbstractZipBackupJob { + private static final Logger LOG = LoggerFactory.getLogger(ZipBackupExportJob.class); + + private final Uri mUri; + + private final byte[] copyBuffer = new byte[8192]; + + public ZipBackupExportJob(final Context context, final ZipBackupCallback callback, final Uri uri) { + super(context, callback); + this.mUri = uri; + } + + @Override + public void run() { + try (final OutputStream outputStream = getContext().getContentResolver().openOutputStream(mUri); + final ZipOutputStream zipOut = new ZipOutputStream(outputStream)) { + + if (isAborted()) return; + + // Preferences + updateProgress(0, R.string.backup_restore_exporting_preferences); + exportPreferences(zipOut); + + if (isAborted()) return; + + // Database + updateProgress(10, R.string.backup_restore_exporting_database); + exportDatabase(zipOut, getContext()); + + if (isAborted()) return; + + // External files + updateProgress(25, R.string.backup_restore_exporting_files); + + final File externalFilesDir = FileUtils.getExternalFilesDir(); + LOG.debug("Exporting external files from {}", externalFilesDir); + + final List allExternalFiles = getAllRelativeFiles(externalFilesDir); + LOG.debug("Got {} files to export", allExternalFiles.size()); + + for (int i = 0; i < allExternalFiles.size() && !isAborted(); i++) { + final String child = allExternalFiles.get(i); + exportSingleExternalFile(zipOut, externalFilesDir, child); + + final int progress = (int) Math.min(99, 50 + 49 * (i / (float) allExternalFiles.size())); + updateProgress(progress, R.string.backup_restore_exporting_files_i_of_n, i + 1, allExternalFiles.size()); + } + + // Metadata + updateProgress(99, R.string.backup_restore_exporting_finishing); + + if (isAborted()) return; + + addMetadata(zipOut); + + zipOut.finish(); + zipOut.flush(); + + if (isAborted()) return; + + LOG.info("Export complete"); + + onSuccess(null); + } catch (final Exception e) { + LOG.error("Export failed", e); + + if (!isAborted()) { + onFailure(e.getLocalizedMessage()); + } + } + } + + private static void exportPreferences(final ZipOutputStream zipOut) throws IOException { + LOG.debug("Exporting global preferences"); + + final SharedPreferences globalPreferences = GBApplication.getPrefs().getPreferences(); + exportPreferences(zipOut, globalPreferences, PREFS_GLOBAL_FILENAME); + + try (DBHandler dbHandler = GBApplication.acquireDB()) { + final List activeDevices = DBHelper.getActiveDevices(dbHandler.getDaoSession()); + for (Device dbDevice : activeDevices) { + LOG.debug("Exporting device preferences for {}", dbDevice.getIdentifier()); + final SharedPreferences devicePrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier()); + if (devicePrefs != null) { + exportPreferences(zipOut, devicePrefs, String.format(Locale.ROOT, PREFS_DEVICE_FILENAME, dbDevice.getIdentifier())); + } + } + } catch (final Exception e) { + throw new IOException("Failed to export device preferences", e); + } + } + + private static void exportPreferences(final ZipOutputStream zipOut, + final SharedPreferences sharedPreferences, + final String zipEntryName) throws IOException { + LOG.debug("Exporting preferences to {}", zipEntryName); + + final JsonBackupPreferences jsonBackupPreferences = JsonBackupPreferences.exportFrom(sharedPreferences); + final String preferencesJson = jsonBackupPreferences.toJson(); + + final ZipEntry zipEntry = new ZipEntry(zipEntryName); + zipOut.putNextEntry(zipEntry); + zipOut.write(preferencesJson.getBytes(StandardCharsets.UTF_8)); + } + + private static void exportDatabase(final ZipOutputStream zipOut, final Context context) throws IOException { + LOG.debug("Exporting database"); + + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (DBHandler dbHandler = GBApplication.acquireDB()) { + final DBHelper helper = new DBHelper(context); + helper.exportDB(dbHandler, baos); + } catch (final Exception e) { + throw new IOException("Failed to export database", e); + } + + final ZipEntry zipEntry = new ZipEntry(DATABASE_FILENAME); + zipOut.putNextEntry(zipEntry); + zipOut.write(baos.toByteArray()); + } + + /** + * Gets a list of the relative path of all files from a directory, recursively. + */ + private static List getAllRelativeFiles(final File dir) { + final List ret = new ArrayList<>(); + + final String[] childEntries = dir.list(); + if (childEntries == null) { + LOG.warn("Files in external dir are null"); + return ret; + } + + for (final String child : childEntries) { + getAllRelativeFilesAux(ret, dir, child); + } + + return ret; + } + + private static void getAllRelativeFilesAux(final List currentList, + final File externalFilesDir, + final String relativePath) { + final File file = new File(externalFilesDir, relativePath); + if (file.isDirectory()) { + final String[] childEntries = file.list(); + if (childEntries == null) { + LOG.warn("Files in {} are null", file); + return; + } + + for (final String child : childEntries) { + getAllRelativeFilesAux(currentList, externalFilesDir, relativePath + "/" + child); + } + } else if (file.isFile()) { + currentList.add(relativePath); + } else { + // Should never happen? + LOG.error("Unknown file type for {}", file); + } + } + + private void exportSingleExternalFile(final ZipOutputStream zipOut, + final File externalFilesDir, + final String relativePath) throws IOException { + final File file = new File(externalFilesDir, relativePath); + if (!file.isFile()) { + throw new IOException("Not a file: " + file); + } + + LOG.trace("Exporting file: {}", relativePath); + + final ZipEntry zipEntry = new ZipEntry(EXTERNAL_FILES_FOLDER + "/" + relativePath); + zipOut.putNextEntry(zipEntry); + + try (final InputStream in = new FileInputStream(new File(externalFilesDir, relativePath))) { + int read; + while ((read = in.read(copyBuffer)) > 0) { + zipOut.write(copyBuffer, 0, read); + } + } catch (final Exception e) { + throw new IOException("Failed to write " + relativePath, e); + } + } + + private static void addMetadata(final ZipOutputStream zipOut) throws IOException { + LOG.debug("Adding metadata"); + + final ZipBackupMetadata metadata = new ZipBackupMetadata( + BuildConfig.APPLICATION_ID, + BuildConfig.VERSION_NAME, + BuildConfig.VERSION_CODE, + VERSION, + new Date() + ); + final String metadataJson = GSON.toJson(metadata); + + final ZipEntry zipEntry = new ZipEntry(METADATA_FILENAME); + zipOut.putNextEntry(zipEntry); + zipOut.write(metadataJson.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupImportJob.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupImportJob.java new file mode 100644 index 000000000..a398fde00 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupImportJob.java @@ -0,0 +1,237 @@ +/* Copyright (C) 2024 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.util.backup; + +import android.content.Context; +import android.content.SharedPreferences; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.Locale; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; + +public class ZipBackupImportJob extends AbstractZipBackupJob { + private static final Logger LOG = LoggerFactory.getLogger(ZipBackupImportJob.class); + + private final Uri mUri; + private final byte[] copyBuffer = new byte[8192]; + + public ZipBackupImportJob(final Context context, final ZipBackupCallback callback, final Uri uri) { + super(context, callback); + this.mUri = uri; + } + + @Override + public void run() { + try { + updateProgress(0, R.string.backup_restore_importing_loading); + + // Load zip to temporary file so we can seek + LOG.debug("Getting zip file from {}", mUri); + final ZipFile zipFile = getZipFromUri(getContext(), mUri); + + if (isAborted()) return; + + // Validate file + updateProgress(10, R.string.backup_restore_importing_validating); + validateBackupFile(zipFile); + + LOG.debug("Valid zip file: {}", mUri); + + if (isAborted()) return; + + final List externalFiles = new ArrayList<>(); + final List devicePreferences = new ArrayList<>(); + + final Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements() && !isAborted()) { + final ZipEntry zipEntry = entries.nextElement(); + + if (zipEntry.getName().startsWith(EXTERNAL_FILES_FOLDER + "/")) { + if (zipEntry.getName().endsWith(".log") || zipEntry.getName().endsWith(".log.zip")) { + continue; + } + + externalFiles.add(zipEntry); + } else if (zipEntry.getName().startsWith("preferences/device_")) { + devicePreferences.add(zipEntry); + } + } + + LOG.debug("Got {} external files, {} device preferences", externalFiles.size(), devicePreferences.size()); + + // Restore external files + final File externalFilesDir = FileUtils.getExternalFilesDir(); + final List failedFiles = new ArrayList<>(); + + for (int i = 0; i < externalFiles.size() && !isAborted(); i++) { + final ZipEntry externalFile = externalFiles.get(i); + final File targetExternalFile = new File(externalFilesDir, externalFile.getName().replaceFirst(EXTERNAL_FILES_FOLDER + "/", "")); + final File parentFile = targetExternalFile.getParentFile(); + if (parentFile == null) { + LOG.warn("Parent file for {} is null", targetExternalFile); + } else { + if (!parentFile.exists()) { + if (!parentFile.mkdirs()) { + LOG.warn("Failed to create parent dirs for {}", targetExternalFile); + } + } + } + + try (InputStream inputStream = zipFile.getInputStream(externalFile); + FileOutputStream fout = new FileOutputStream(targetExternalFile)) { + while (inputStream.available() > 0) { + final int bytes = inputStream.read(copyBuffer); + fout.write(copyBuffer, 0, bytes); + } + } catch (final Exception e) { + LOG.error("Failed to restore file {}", externalFile); + failedFiles.add(externalFile.getName()); + } + + // 10% to 75% + final int progress = (int) (10 + 65 * (i / (float) externalFiles.size())); + updateProgress(progress, R.string.backup_restore_importing_files_i_of_n, i + 1, externalFiles.size()); + } + + if (isAborted()) return; + + // Restore database + LOG.debug("Importing database"); + updateProgress(75, R.string.backup_restore_importing_database); + try (DBHandler dbHandler = GBApplication.acquireDB()) { + final DBHelper helper = new DBHelper(getContext()); + final SQLiteOpenHelper sqLiteOpenHelper = dbHandler.getHelper(); + try (InputStream databaseInputStream = zipFile.getInputStream(zipFile.getEntry(DATABASE_FILENAME))) { + helper.importDB(dbHandler, databaseInputStream); + helper.validateDB(sqLiteOpenHelper); + } + } + + if (isAborted()) return; + + // Restore preferences + LOG.debug("Importing global preferences"); + updateProgress(85, R.string.backup_restore_importing_preferences); + try (InputStream globalPrefsInputStream = zipFile.getInputStream(zipFile.getEntry(PREFS_GLOBAL_FILENAME))) { + final SharedPreferences globalPreferences = GBApplication.getPrefs().getPreferences(); + + final JsonBackupPreferences jsonBackupPreferences = JsonBackupPreferences.fromJson(globalPrefsInputStream); + if (!jsonBackupPreferences.importInto(globalPreferences)) { + LOG.warn("Global preferences were not commited"); + } + } + + if (isAborted()) return; + + // At this point we already restored the db, so we can list the devices from there + try (DBHandler dbHandler = GBApplication.acquireDB()) { + final List activeDevices = DBHelper.getActiveDevices(dbHandler.getDaoSession()); + for (Device dbDevice : activeDevices) { + LOG.debug("Importing device preferences for {}", dbDevice.getIdentifier()); + final SharedPreferences devicePrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier()); + if (devicePrefs != null && !isAborted()) { + final ZipEntry devicePrefsZipEntry = zipFile.getEntry(String.format(Locale.ROOT, PREFS_DEVICE_FILENAME, dbDevice.getIdentifier())); + if (devicePrefsZipEntry == null) { + continue; + } + try (InputStream devicePrefsInputStream = zipFile.getInputStream(devicePrefsZipEntry)) { + final JsonBackupPreferences jsonBackupPreferences = JsonBackupPreferences.fromJson(devicePrefsInputStream); + if (!jsonBackupPreferences.importInto(devicePrefs)) { + LOG.warn("Device preferences for {} were not commited", dbDevice.getIdentifier()); + } + } + } + } + } + + if (isAborted()) return; + + LOG.info("Import complete"); + + if (!failedFiles.isEmpty()) { + final String failedFilesListMessage = "- " + String.join("\n- ", failedFiles); + onSuccess(getContext().getString(R.string.backup_restore_warning_files, failedFiles.size(), failedFilesListMessage)); + } else { + onSuccess(null); + } + } catch (final Exception e) { + LOG.error("Import failed", e); + onFailure(e.getLocalizedMessage()); + } + } + + private ZipFile getZipFromUri(final Context context, final Uri uri) throws IOException { + final File tmpFile = File.createTempFile("gb-backup-zip-import", "zip", context.getCacheDir()); + tmpFile.deleteOnExit(); + + try (InputStream inputStream = context.getContentResolver().openInputStream(uri); + FileOutputStream outputStream = new FileOutputStream(tmpFile)) { + if (inputStream == null) { + throw new IOException("Failed to get input stream"); + } + + int len; + while ((len = inputStream.read(copyBuffer)) != -1) { + outputStream.write(copyBuffer, 0, len); + } + } + + return new ZipFile(tmpFile); + } + + private static void validateBackupFile(final ZipFile zipFile) throws IOException { + final ZipEntry metadataEntry = zipFile.getEntry(METADATA_FILENAME); + if (metadataEntry == null) { + throw new IOException("Zip file has no metadata"); + } + final InputStream inputStream = zipFile.getInputStream(metadataEntry); + final ZipBackupMetadata zipBackupMetadata = GSON.fromJson( + new InputStreamReader(inputStream, StandardCharsets.UTF_8), + ZipBackupMetadata.class + ); + + if (zipBackupMetadata.getBackupVersion() > VERSION) { + throw new IOException("Unsupported backup version " + zipBackupMetadata.getBackupVersion()); + } + + final ZipEntry databaseEntry = zipFile.getEntry(DATABASE_FILENAME); + if (databaseEntry == null) { + throw new IOException("Zip file has no database"); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupMetadata.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupMetadata.java new file mode 100644 index 000000000..3563e6c3b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupMetadata.java @@ -0,0 +1,60 @@ +/* Copyright (C) 2024 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.util.backup; + +import java.util.Date; + +public class ZipBackupMetadata { + private final String appId; + private final String appVersionName; + private final int appVersionCode; + + private final int backupVersion; + private final Date backupDate; + + public ZipBackupMetadata(final String appId, + final String appVersionName, + final int appVersionCode, + final int backupVersion, + final Date backupDate) { + this.appId = appId; + this.appVersionName = appVersionName; + this.appVersionCode = appVersionCode; + this.backupVersion = backupVersion; + this.backupDate = backupDate; + } + + public String getAppId() { + return appId; + } + + public String getAppVersionName() { + return appVersionName; + } + + public int getAppVersionCode() { + return appVersionCode; + } + + public int getBackupVersion() { + return backupVersion; + } + + public Date getBackupDate() { + return backupDate; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/gson/GsonUtcDateAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/gson/GsonUtcDateAdapter.java new file mode 100644 index 000000000..7cf814702 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/gson/GsonUtcDateAdapter.java @@ -0,0 +1,56 @@ +/* Copyright (C) 2024 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.util.gson; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +import java.lang.reflect.Type; +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +public class GsonUtcDateAdapter implements JsonSerializer, JsonDeserializer { + private final DateFormat dateFormat; + + public GsonUtcDateAdapter() { + dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT); + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + @Override + public Date deserialize(final JsonElement json, final Type typeOfT, final JsonDeserializationContext context) throws JsonParseException { + try { + return dateFormat.parse(json.getAsString()); + } catch (final ParseException e) { + throw new JsonParseException(e); + } + } + + @Override + public JsonElement serialize(final Date date, final Type typeOfSrc, final JsonSerializationContext context) { + return new JsonPrimitive(dateFormat.format(date)); + } +} diff --git a/app/src/main/res/layout/activity_backup_restore_progress.xml b/app/src/main/res/layout/activity_backup_restore_progress.xml new file mode 100644 index 000000000..f021950ab --- /dev/null +++ b/app/src/main/res/layout/activity_backup_restore_progress.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_data_management.xml b/app/src/main/res/layout/activity_data_management.xml index d04c07c24..1d9204970 100644 --- a/app/src/main/res/layout/activity_data_management.xml +++ b/app/src/main/res/layout/activity_data_management.xml @@ -1,14 +1,67 @@ - + tools:context="nodomain.freeyourgadget.gadgetbridge.activities.DataManagementActivity"> - + android:layout_height="wrap_content" + android:orientation="vertical" + android:paddingLeft="@dimen/activity_horizontal_margin" + android:paddingTop="@dimen/activity_vertical_margin" + android:paddingRight="@dimen/activity_horizontal_margin" + android:paddingBottom="@dimen/activity_vertical_margin"> + + + + + + + + +