From 2ecc845a740f066fbd73d8d5f59198d98c9c6c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Fri, 15 Aug 2025 19:34:15 +0100 Subject: [PATCH] Introduce automatic zip export --- app/src/main/AndroidManifest.xml | 17 + .../gadgetbridge/GBApplication.java | 26 +- .../AbstractPreferenceFragment.java | 28 +- .../BackupRestoreProgressActivity.java | 114 ++++--- .../activities/DataManagementActivity.java | 71 +---- .../activities/SettingsActivity.java | 129 +------- .../AbstractAutoExportSettingsFragment.kt | 301 ++++++++++++++++++ .../AutoExportDbSettingsActivity.kt | 31 ++ .../AutoExportZipSettingsActivity.kt | 31 ++ .../AutomationsSettingsActivity.kt | 62 ++++ .../database/DatabaseExportWorker.kt | 18 +- .../database/PeriodicDbExporter.kt | 42 +++ .../gadgetbridge/database/PeriodicExporter.kt | 85 ----- .../externalevents/AutoStartReceiver.java | 47 --- .../externalevents/AutoStartReceiver.kt | 55 ++++ .../BluetoothStateChangeReceiver.java | 2 +- .../externalevents/IntentApiReceiver.java | 30 +- .../service/DeviceCommunicationService.java | 3 +- .../receivers/GBAutoFetchReceiver.java | 6 +- .../gadgetbridge/util/AndroidUtils.java | 11 +- .../freeyourgadget/gadgetbridge/util/GB.java | 7 + .../gadgetbridge/util/GBPrefs.java | 32 +- .../gadgetbridge/util/PeriodicExporter.kt | 123 +++++++ .../gadgetbridge/util/Prefs.java | 2 +- .../gadgetbridge/util/XDatePreference.java | 4 +- .../gadgetbridge/util/XTimePreference.java | 52 +-- .../util/backup/AbstractZipBackupJob.java | 10 +- .../util/backup/PeriodicZipExporter.kt | 38 +++ .../util/backup/ZipExportWorker.kt | 163 ++++++++++ app/src/main/res/drawable/ic_database.xml | 11 + app/src/main/res/drawable/ic_zip.xml | 11 + .../res/layout/activity_data_management.xml | 52 --- app/src/main/res/values/strings.xml | 26 +- app/src/main/res/xml/auto_export_settings.xml | 77 +++++ app/src/main/res/xml/automations_settings.xml | 47 +++ app/src/main/res/xml/preferences.xml | 168 +++++----- 36 files changed, 1323 insertions(+), 609 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/automations/AbstractAutoExportSettingsFragment.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/automations/AutoExportDbSettingsActivity.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/automations/AutoExportZipSettingsActivity.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/automations/AutomationsSettingsActivity.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/PeriodicDbExporter.kt delete mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/PeriodicExporter.kt delete mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AutoStartReceiver.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AutoStartReceiver.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/PeriodicExporter.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/PeriodicZipExporter.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipExportWorker.kt create mode 100644 app/src/main/res/drawable/ic_database.xml create mode 100644 app/src/main/res/drawable/ic_zip.xml create mode 100644 app/src/main/res/xml/auto_export_settings.xml create mode 100644 app/src/main/res/xml/automations_settings.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1cfb50f464..13e0bdf44b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -69,6 +69,7 @@ + @@ -181,6 +182,18 @@ android:name=".activities.maps.MapsSettingsActivity" android:label="@string/maps_settings" android:parentActivityName=".activities.SettingsActivity" /> + + + + prefSetValue = prefs.getStringSet(key, Collections.emptySet()); if (prefSetValue.isEmpty()) { summary = requireContext().getString(R.string.not_set); } else { - final MultiSelectListPreference multiSelectListPreference = (MultiSelectListPreference) preference; final CharSequence[] entries = multiSelectListPreference.getEntries(); final CharSequence[] entryValues = multiSelectListPreference.getEntryValues(); final List translatedEntries = new ArrayList<>(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BackupRestoreProgressActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BackupRestoreProgressActivity.java index ae0e608bc7..29cb1e4fe9 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BackupRestoreProgressActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BackupRestoreProgressActivity.java @@ -86,77 +86,85 @@ public class BackupRestoreProgressActivity extends AbstractGBActivity { final TextView backupRestoreProgressText = binding.backupRestoreProgressText; final TextView backupRestoreProgressPercentage = binding.backupRestoreProgressPercentage; + final Handler mHandler = new Handler(getMainLooper()); + 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))); + mHandler.post(() -> { + 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")); + mHandler.post(() -> { + 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); + switch (action) { + case "import": + backupRestoreProgressText.setText(R.string.backup_restore_import_complete); - final StringBuilder message = new StringBuilder(); + final StringBuilder message = new StringBuilder(); - message.append(getString(R.string.backup_restore_restart_summary, getString(R.string.app_name))); + message.append(getString(R.string.backup_restore_restart_summary, getString(R.string.app_name))); - if (warnings != null) { - message.append("\n\n").append(warnings); - } + 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 -> { - finish(); - GBApplication.restart(); - })) - .setPositiveButton(R.string.ok, (dialog, which) -> { - finish(); - GBApplication.restart(); - }).show(); - break; - case "export": - backupRestoreProgressText.setText(R.string.backup_restore_export_complete); - break; - } + new MaterialAlertDialogBuilder(BackupRestoreProgressActivity.this) + .setCancelable(false) + .setIcon(R.drawable.ic_sync) + .setTitle(R.string.backup_restore_restart_title) + .setMessage(message.toString()) + .setOnCancelListener((dialog -> { + finish(); + GBApplication.restart(); + })) + .setPositiveButton(R.string.ok, (dialog, which) -> { + finish(); + 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; + mHandler.post(() -> { + 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(getString(R.string.error_message, errorMessage)); - backupRestoreProgressText.setTypeface(backupRestoreProgressText.getTypeface(), Typeface.BOLD); - 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(R.string.backup_restore_error_import); + break; + case "export": + backupRestoreHint.setText(R.string.backup_restore_error_export); + break; } - } + + backupRestoreProgressText.setText(getString(R.string.error_message, errorMessage)); + backupRestoreProgressText.setTypeface(backupRestoreProgressText.getTypeface(), Typeface.BOLD); + backupRestoreProgressPercentage.setVisibility(View.GONE); + + if ("export".equals(action)) { + final DocumentFile documentFile = DocumentFile.fromSingleUri(BackupRestoreProgressActivity.this, uri); + if (documentFile != null) { + documentFile.delete(); + } + } + }); } }; 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 510ea3aabf..533c87d75f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DataManagementActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DataManagementActivity.java @@ -16,7 +16,6 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.activities; -import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; @@ -53,15 +52,12 @@ import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.files.FileManagerActivity; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; -import nodomain.freeyourgadget.gadgetbridge.database.PeriodicExporter; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils; -import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; import nodomain.freeyourgadget.gadgetbridge.util.ImportExportSharedPreferences; -import nodomain.freeyourgadget.gadgetbridge.util.Prefs; public class DataManagementActivity extends AbstractGBActivity { @@ -181,69 +177,12 @@ public class DataManagementActivity extends AbstractGBActivity { cleanExportDirectory(); } }); - GBApplication gbApp = GBApplication.app(); - Prefs prefs = GBApplication.getPrefs(); - boolean autoExportEnabled = prefs.getBoolean(GBPrefs.AUTO_EXPORT_ENABLED, false); - int autoExportInterval = prefs.getInt(GBPrefs.AUTO_EXPORT_INTERVAL, 0); - //returns an ugly content://... - //String autoExportLocation = prefs.getString(GBPrefs.AUTO_EXPORT_LOCATION, ""); - - int testExportVisibility = (autoExportInterval > 0 && autoExportEnabled) ? View.VISIBLE : View.GONE; - boolean isExportEnabled = autoExportInterval > 0 && autoExportEnabled; - TextView autoExportLocation_label = findViewById(R.id.autoExportLocation_label); - autoExportLocation_label.setVisibility(testExportVisibility); - - TextView autoExportLocation_path = findViewById(R.id.autoExportLocation_path); - autoExportLocation_path.setVisibility(testExportVisibility); - autoExportLocation_path.setText(getAutoExportLocationUserString() + " (" + getAutoExportLocationPreferenceString() + ")" ); - - TextView autoExportEnabled_label = findViewById(R.id.autoExportEnabled); - if (isExportEnabled) { - autoExportEnabled_label.setText(getString(R.string.activity_db_management_autoexport_enabled_yes)); - } else { - autoExportEnabled_label.setText(getString(R.string.activity_db_management_autoexport_enabled_no)); - } - - TextView autoExportScheduled = findViewById(R.id.autoExportScheduled); - autoExportScheduled.setVisibility(testExportVisibility); - long setAutoExportScheduledTimestamp = gbApp.getAutoExportScheduledTimestamp(); - if (setAutoExportScheduledTimestamp > 0) { - autoExportScheduled.setText(getString(R.string.activity_db_management_autoexport_scheduled_yes, - DateTimeUtils.formatDateTime(new Date(setAutoExportScheduledTimestamp)))); - } else { - autoExportScheduled.setText(getResources().getString(R.string.activity_db_management_autoexport_scheduled_no)); - } - - TextView autoExport_lastTime_label = findViewById(R.id.autoExport_lastTime_label); - long lastAutoExportTimestamp = gbApp.getLastAutoExportTimestamp(); - - autoExport_lastTime_label.setVisibility(View.GONE); - autoExport_lastTime_label.setText(getString(R.string.autoExport_lastTime_label, - DateTimeUtils.formatDateTime(new Date(lastAutoExportTimestamp)))); - - if (lastAutoExportTimestamp > 0) { - autoExport_lastTime_label.setVisibility(testExportVisibility); - autoExport_lastTime_label.setVisibility(testExportVisibility); - } - - final Context context = getApplicationContext(); - Button testExportDBButton = findViewById(R.id.testExportDBButton); - testExportDBButton.setVisibility(testExportVisibility); - testExportDBButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - GB.toast(context, - context.getString(R.string.activity_DB_test_export_message), - Toast.LENGTH_SHORT, GB.INFO); - PeriodicExporter.trigger(); - } - }); sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this); } private String getAutoExportLocationPreferenceString() { - String autoExportLocation = GBApplication.getPrefs().getString(GBPrefs.AUTO_EXPORT_LOCATION, null); + String autoExportLocation = GBApplication.getPrefs().getString(GBPrefs.AUTO_EXPORT_DB_LOCATION, null); if (autoExportLocation == null) { return ""; } @@ -277,14 +216,6 @@ public class DataManagementActivity extends AbstractGBActivity { return ""; } - private String getAutoExportLocationUserString() { - String location = getAutoExportLocationUri(); - if (location == "") { - return getString(R.string.activity_db_management_autoexport_location); - } - return location; - } - private boolean hasOldActivityDatabase() { return new DBHelper(this).existsDB("ActivityDatabase"); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java index f1bf6b463a..c60cbf7bda 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java @@ -25,14 +25,11 @@ import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; -import android.database.Cursor; import android.location.Criteria; import android.location.Location; import android.location.LocationListener; import android.location.LocationManager; -import android.net.Uri; import android.os.Bundle; -import android.provider.DocumentsContract; import android.text.InputType; import android.view.View; import android.widget.AdapterView; @@ -64,17 +61,15 @@ import java.util.Set; import nodomain.freeyourgadget.gadgetbridge.BuildConfig; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.automations.AutomationsSettingsActivity; import nodomain.freeyourgadget.gadgetbridge.activities.charts.ChartsPreferencesActivity; import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryPairingPreferenceActivity; import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsSettingsActivity; -import nodomain.freeyourgadget.gadgetbridge.database.PeriodicExporter; import nodomain.freeyourgadget.gadgetbridge.externalevents.TimeChangeReceiver; import nodomain.freeyourgadget.gadgetbridge.model.weather.Weather; import nodomain.freeyourgadget.gadgetbridge.model.weather.WeatherCacheManager; -import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; -import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; public class SettingsActivity extends AbstractSettingsActivityV2 { @@ -101,6 +96,8 @@ public class SettingsActivity extends AbstractSettingsActivityV2 { open(NotificationManagementActivity.class, result); } else if (result.getResourceFile() == R.xml.map_settings) { open(MapsSettingsActivity.class, result); + } else if (result.getResourceFile() == R.xml.automations_settings) { + open(AutomationsSettingsActivity.class, result); } else { super.onSearchResultClicked(result); } @@ -109,7 +106,6 @@ public class SettingsActivity extends AbstractSettingsActivityV2 { public static class SettingsFragment extends AbstractPreferenceFragment { private static final Logger LOG = LoggerFactory.getLogger(SettingsActivity.class); - private static final int EXPORT_LOCATION_FILE_REQUEST_CODE = 4711; private EditText fitnessAppEditText = null; private int fitnessAppSelectionListSpinnerFirstRun = 0; @@ -124,12 +120,11 @@ public class SettingsActivity extends AbstractSettingsActivityV2 { index(R.xml.discovery_pairing_preferences, R.string.activity_prefs_discovery_pairing); index(R.xml.notifications_preferences, R.string.pref_header_notifications); index(R.xml.map_settings, R.string.maps_settings); + index(R.xml.automations_settings, R.string.pref_header_automations); setInputTypeFor("rtl_max_line_length", InputType.TYPE_CLASS_NUMBER); setInputTypeFor("location_latitude", InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED); setInputTypeFor("location_longitude", InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED); - setInputTypeFor("auto_export_interval", InputType.TYPE_CLASS_NUMBER); - setInputTypeFor("auto_fetch_interval_limit", InputType.TYPE_CLASS_NUMBER); Prefs prefs = GBApplication.getPrefs(); Preference pref = findPreference("pref_category_activity_personal"); @@ -296,66 +291,6 @@ public class SettingsActivity extends AbstractSettingsActivityV2 { }); } - - pref = findPreference(GBPrefs.AUTO_EXPORT_LOCATION); - if (pref != null) { - pref.setOnPreferenceClickListener(preference -> { - Intent i = new Intent(Intent.ACTION_CREATE_DOCUMENT); - i.setType("application/x-sqlite3"); - i.addCategory(Intent.CATEGORY_OPENABLE); - i.putExtra(Intent.EXTRA_TITLE, "Gadgetbridge.db"); - i.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - String title = requireContext().getApplicationContext().getString(R.string.choose_auto_export_location); - startActivityForResult(Intent.createChooser(i, title), EXPORT_LOCATION_FILE_REQUEST_CODE); - return true; - }); - pref.setSummary(getAutoExportLocationSummary()); - } - - pref = findPreference(GBPrefs.AUTO_EXPORT_INTERVAL); - if (pref != null) { - pref.setOnPreferenceChangeListener((preference, autoExportInterval) -> { - String summary = String.format( - requireContext().getApplicationContext().getString(R.string.pref_summary_auto_export_interval), - Integer.valueOf((String) autoExportInterval)); - preference.setSummary(summary); - boolean auto_export_enabled = GBApplication.getPrefs().getBoolean(GBPrefs.AUTO_EXPORT_ENABLED, false); - PeriodicExporter.scheduleAlarm(requireContext().getApplicationContext(), Integer.valueOf((String) autoExportInterval), auto_export_enabled); - return true; - }); - int autoExportInterval = GBApplication.getPrefs().getInt(GBPrefs.AUTO_EXPORT_INTERVAL, 0); - String summary = String.format( - requireContext().getApplicationContext().getString(R.string.pref_summary_auto_export_interval), - autoExportInterval); - pref.setSummary(summary); - } - - pref = findPreference(GBPrefs.AUTO_EXPORT_ENABLED); - if (pref != null) { - pref.setOnPreferenceChangeListener((preference, autoExportEnabled) -> { - int autoExportInterval = GBApplication.getPrefs().getInt(GBPrefs.AUTO_EXPORT_INTERVAL, 0); - PeriodicExporter.scheduleAlarm(requireContext().getApplicationContext(), autoExportInterval, (boolean) autoExportEnabled); - return true; - }); - } - - pref = findPreference("auto_fetch_interval_limit"); - if (pref != null) { - pref.setOnPreferenceChangeListener((preference, autoFetchInterval) -> { - String summary = String.format( - requireContext().getApplicationContext().getString(R.string.pref_auto_fetch_limit_fetches_summary), - Integer.valueOf((String) autoFetchInterval)); - preference.setSummary(summary); - return true; - }); - - int autoFetchInterval = GBApplication.getPrefs().getInt("auto_fetch_interval_limit", 0); - String summary = String.format( - requireContext().getApplicationContext().getString(R.string.pref_auto_fetch_limit_fetches_summary), - autoFetchInterval); - pref.setSummary(summary); - } - final ListPreference audioPlayer = findPreference("audio_player"); if (audioPlayer != null) { // Get all receivers of Media Buttons @@ -406,6 +341,15 @@ public class SettingsActivity extends AbstractSettingsActivityV2 { }); } + pref = findPreference("pref_screen_automations"); + if (pref != null) { + pref.setOnPreferenceClickListener(preference -> { + Intent enableIntent = new Intent(requireContext(), AutomationsSettingsActivity.class); + startActivity(enableIntent); + return true; + }); + } + pref = findPreference("pref_category_sleepasandroid"); if (pref != null) { pref.setOnPreferenceClickListener(preference -> { @@ -544,53 +488,6 @@ public class SettingsActivity extends AbstractSettingsActivityV2 { } } - @Override - public void onActivityResult(int requestCode, int resultCode, Intent intent) { - if (requestCode == EXPORT_LOCATION_FILE_REQUEST_CODE && intent != null) { - Uri uri = intent.getData(); - requireContext().getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - GBApplication.getPrefs().getPreferences() - .edit() - .putString(GBPrefs.AUTO_EXPORT_LOCATION, uri.toString()) - .apply(); - String summary = getAutoExportLocationSummary(); - findPreference(GBPrefs.AUTO_EXPORT_LOCATION).setSummary(summary); - boolean autoExportEnabled = GBApplication - .getPrefs().getBoolean(GBPrefs.AUTO_EXPORT_ENABLED, false); - int autoExportPeriod = GBApplication - .getPrefs().getInt(GBPrefs.AUTO_EXPORT_INTERVAL, 0); - PeriodicExporter.scheduleAlarm(requireContext().getApplicationContext(), autoExportPeriod, autoExportEnabled); - } - } - - /* - Either returns the file path of the selected document, or the display name, or an empty string - */ - public String getAutoExportLocationSummary() { - String autoExportLocation = GBApplication.getPrefs().getString(GBPrefs.AUTO_EXPORT_LOCATION, null); - if (autoExportLocation == null) { - return ""; - } - Uri uri = Uri.parse(autoExportLocation); - try { - return AndroidUtils.getFilePath(requireContext().getApplicationContext(), uri); - } catch (IllegalArgumentException e) { - try ( - Cursor cursor = requireContext().getContentResolver().query( - uri, - new String[]{DocumentsContract.Document.COLUMN_DISPLAY_NAME}, - null, null, null, null - )) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)); - } - } catch (Exception e2) { - LOG.warn("getAutoExportLocationSummary", e2); - } - } - return ""; - } - /* * delayed execution so that the preferences are applied first */ diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/automations/AbstractAutoExportSettingsFragment.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/automations/AbstractAutoExportSettingsFragment.kt new file mode 100644 index 0000000000..c5311d2cc1 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/automations/AbstractAutoExportSettingsFragment.kt @@ -0,0 +1,301 @@ +/* Copyright (C) 2025 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.automations + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.provider.DocumentsContract +import android.text.InputType +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.lifecycle.lifecycleScope +import androidx.preference.Preference +import androidx.preference.PreferenceGroup +import androidx.work.WorkInfo +import androidx.work.WorkManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import nodomain.freeyourgadget.gadgetbridge.GBApplication +import nodomain.freeyourgadget.gadgetbridge.R +import nodomain.freeyourgadget.gadgetbridge.activities.AbstractPreferenceFragment +import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils +import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils +import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs +import nodomain.freeyourgadget.gadgetbridge.util.PeriodicExporter +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.util.Date +import java.util.concurrent.TimeUnit + +abstract class AbstractAutoExportSettingsFragment( + val exporter: PeriodicExporter, +) : AbstractPreferenceFragment() { + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.auto_export_settings, rootKey) + + val keyPrefix = exporter.getKeyPrefix() + addPrefixToPreferenceKeys(preferenceScreen, keyPrefix) + + val prefKeyEnabled = keyPrefix + GBPrefs.AUTO_EXPORT_ENABLED + val prefKeyLocation = keyPrefix + GBPrefs.AUTO_EXPORT_LOCATION + val prefKeyInterval = keyPrefix + GBPrefs.AUTO_EXPORT_INTERVAL + val prefKeyStartTime = keyPrefix + "auto_export_start_time" + val prefKeyRunNow = keyPrefix + "auto_export_run_now" + + val gbPrefs = GBApplication.getPrefs() + + val exportLocationPicker = registerForActivityResult( + StartActivityForResult(), + ActivityResultCallback { result: ActivityResult? -> + if (result!!.resultCode != Activity.RESULT_OK) { + return@ActivityResultCallback + } + val uri = result.data?.data + if (uri == null) { + LOG.error("Got no uri") + return@ActivityResultCallback + } + requireContext().contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + gbPrefs.preferences.edit { + putString(prefKeyLocation, uri.toString()) + } + val summary = resolveLocationSummary( + requireContext(), + gbPrefs.getString(prefKeyLocation, "") + ) + findPreference(prefKeyLocation)?.setSummary(summary) + } + ) + + setInputTypeFor(prefKeyInterval, InputType.TYPE_CLASS_NUMBER) + + val prefExportLocation = findPreference(prefKeyLocation) + if (prefExportLocation != null) { + prefExportLocation.setOnPreferenceClickListener { + val i = Intent(Intent.ACTION_CREATE_DOCUMENT) + i.setType(exporter.getFileMimeType()) + i.addCategory(Intent.CATEGORY_OPENABLE) + i.putExtra(Intent.EXTRA_TITLE, "Gadgetbridge.${exporter.getFileExtension()}") + i.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + val title: String = requireContext().applicationContext.getString(R.string.choose_auto_export_location) + exportLocationPicker.launch(Intent.createChooser(i, title)) + + true + } + prefExportLocation.setSummary( + resolveLocationSummary( + requireContext(), + gbPrefs.getString(prefKeyLocation, "") + ) + ) + } + + val prefExportInterval = findPreference(prefKeyInterval) + if (prefExportInterval != null) { + prefExportInterval.setOnPreferenceChangeListener { preference: Preference?, autoExportInterval: Any? -> + val summary = String.format( + requireContext().applicationContext.getString(R.string.pref_summary_auto_export_interval), + (autoExportInterval as String?)!!.toInt() + ) + prefExportInterval.setSummary(summary) + scheduleNextExecutionDelayed() + true + } + val autoExportInterval = gbPrefs.getInt(prefKeyInterval, 0) + val summary = String.format( + requireContext().applicationContext.getString(R.string.pref_summary_auto_export_interval), + autoExportInterval + ) + prefExportInterval.setSummary(summary) + } + + val prefExportEnabled = findPreference(prefKeyEnabled) + prefExportEnabled?.setOnPreferenceChangeListener { preference: Preference?, autoExportEnabled: Any? -> + scheduleNextExecutionDelayed() + true + } + + findPreference(prefKeyRunNow)?.setOnPreferenceClickListener { + exporter.executeNow() + true + } + + val prefStartTime = findPreference(prefKeyStartTime) + prefStartTime?.setOnPreferenceChangeListener { preference: Preference?, startFrom: Any? -> + scheduleNextExecutionDelayed() + true + } + } + + /** + * Update the scheduled execution after a slight delay to allow for preferences to be persisted. + */ + private fun scheduleNextExecutionDelayed() { + lifecycleScope.launch(Dispatchers.Main) { + delay(200) + exporter.scheduleNextExecution(requireContext()) + } + } + + override fun onStart() { + super.onStart() + + val workManager = WorkManager.getInstance(requireContext()) + + val gbPrefs = GBApplication.getPrefs() + val prefKeyStatus = exporter.getKeyPrefix() + "auto_export_status" + val prefKeyLastExecution = exporter.getKeyPrefix() + GBPrefs.AUTO_EXPORT_LAST_EXECUTION + val prefKeyNextExecution = exporter.getKeyPrefix() + GBPrefs.AUTO_EXPORT_NEXT_EXECUTION + val prefStatus = findPreference(prefKeyStatus) + val prefLastExecution = findPreference(prefKeyLastExecution) + val prefNextExecution = findPreference(prefKeyNextExecution) + workManager.getWorkInfosByTagLiveData(exporter.getWorkTag()).observe(viewLifecycleOwner) { workInfos -> + LOG.debug("Got update for {} workInfos", workInfos.size) + + val sortedWorkInfos = workInfos.sortedBy { workInfo -> + workInfo.tags + .find { it.startsWith(PeriodicExporter.TAG_CREATED_AT) } + ?.removePrefix(PeriodicExporter.TAG_CREATED_AT) + ?.toLongOrNull() + ?: 0L + } + + val lastExecution = sortedWorkInfos.findLast { workInfo -> workInfo.state != WorkInfo.State.ENQUEUED } + val nextExecution = sortedWorkInfos.findLast { workInfo -> workInfo.state == WorkInfo.State.ENQUEUED } + + if (lastExecution != null) { + prefStatus?.summary = when (lastExecution.state) { + WorkInfo.State.RUNNING -> { + val progress = lastExecution.progress.getInt("progress", -1) + if (progress >= 0) { + requireContext().getString( + R.string.work_info_running_percentage, + getString(R.string.work_info_status_running), + progress + ) + } else { + getString(R.string.work_info_status_running) + } + } + + WorkInfo.State.ENQUEUED -> requireContext().getString(R.string.work_info_status_enqueued) + WorkInfo.State.SUCCEEDED -> requireContext().getString(R.string.work_info_status_succeeded) + WorkInfo.State.FAILED -> requireContext().getString(R.string.work_info_status_failed) + WorkInfo.State.BLOCKED -> requireContext().getString(R.string.work_info_status_blocked) + WorkInfo.State.CANCELLED -> requireContext().getString(R.string.work_info_status_cancelled) + } + } else { + prefStatus?.summary = requireContext().getString(R.string.unknown) + } + + // We need to persist and fetch the timestamp from preferences, since the WorkInfo will not contain it + val lastAutoExportTimestamp: Long = gbPrefs.getLong(prefKeyLastExecution, 0) + if (lastAutoExportTimestamp > 0) { + prefLastExecution?.summary = formatDateWithDiff(Date(lastAutoExportTimestamp)) + } else { + prefLastExecution?.summary = getString(R.string.unknown) + } + + if (nextExecution != null) { + prefNextExecution?.summary = formatDateWithDiff(Date(nextExecution.nextScheduleTimeMillis)) + } + } + } + + /** + * Add a prefix to the key and dependency of all preferences in a group. + */ + private fun addPrefixToPreferenceKeys(prefGroup: PreferenceGroup, prefix: String) { + for (i in 0 until prefGroup.preferenceCount) { + val pref = prefGroup.getPreference(i) + + if (pref is PreferenceGroup) { + addPrefixToPreferenceKeys(pref, prefix) + } + + if (!pref.key.isNullOrBlank() && !pref.key.startsWith(prefix)) { + pref.key = prefix + pref.key + } + if (!pref.dependency.isNullOrBlank() && !pref.dependency?.startsWith(prefix)!!) { + pref.dependency = prefix + pref.dependency + } + } + } + + /** + * Format a date, including the difference to the current time. + */ + private fun formatDateWithDiff(date: Date): String { + val diffMillis = System.currentTimeMillis() - date.time + return if (diffMillis > 0) { + requireContext().getString( + R.string.datetime_in_the_past, + DateTimeUtils.formatDateTime(date), + DateTimeUtils.formatDurationHoursMinutes(diffMillis, TimeUnit.MILLISECONDS) + ) + } else { + requireContext().getString( + R.string.datetime_in_the_future, + DateTimeUtils.formatDateTime(date), + DateTimeUtils.formatDurationHoursMinutes(-diffMillis, TimeUnit.MILLISECONDS) + ) + } + } + + companion object { + val LOG: Logger = LoggerFactory.getLogger(AbstractAutoExportSettingsFragment::class.java) + + /** + * Either returns the file path of the selected document, or the display name, or an error string + */ + fun resolveLocationSummary(context: Context, uriString: String): String { + if (uriString == "") { + return "" + } + val uri = uriString.toUri() + try { + return AndroidUtils.getFilePath(context.applicationContext, uri) + } catch (e: IllegalArgumentException) { + LOG.warn("getAutoExportLocationSummary 1", e) + try { + context.contentResolver.query( + uri, + arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), + null, null, null, null + ).use { cursor -> + if (cursor != null && cursor.moveToFirst()) { + return cursor.getString(cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)) + } + } + } catch (e2: Exception) { + LOG.warn("getAutoExportLocationSummary 2", e2) + } + } + return context.getString(R.string.activity_db_management_autoexport_location) + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/automations/AutoExportDbSettingsActivity.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/automations/AutoExportDbSettingsActivity.kt new file mode 100644 index 0000000000..a35c92e5ed --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/automations/AutoExportDbSettingsActivity.kt @@ -0,0 +1,31 @@ +/* Copyright (C) 2025 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.automations + +import androidx.preference.PreferenceFragmentCompat +import nodomain.freeyourgadget.gadgetbridge.activities.AbstractSettingsActivityV2 +import nodomain.freeyourgadget.gadgetbridge.database.PeriodicDbExporter + +class AutoExportDbSettingsActivity : AbstractSettingsActivityV2() { + override fun newFragment(): PreferenceFragmentCompat { + return AutoExportDbSettingsFragment() + } + + companion object { + class AutoExportDbSettingsFragment : AbstractAutoExportSettingsFragment(PeriodicDbExporter) + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/automations/AutoExportZipSettingsActivity.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/automations/AutoExportZipSettingsActivity.kt new file mode 100644 index 0000000000..d219243d7e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/automations/AutoExportZipSettingsActivity.kt @@ -0,0 +1,31 @@ +/* Copyright (C) 2025 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.automations + +import androidx.preference.PreferenceFragmentCompat +import nodomain.freeyourgadget.gadgetbridge.activities.AbstractSettingsActivityV2 +import nodomain.freeyourgadget.gadgetbridge.util.backup.PeriodicZipExporter + +class AutoExportZipSettingsActivity : AbstractSettingsActivityV2() { + override fun newFragment(): PreferenceFragmentCompat { + return AutoExportZipSettingsFragment() + } + + companion object { + class AutoExportZipSettingsFragment : AbstractAutoExportSettingsFragment(PeriodicZipExporter) + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/automations/AutomationsSettingsActivity.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/automations/AutomationsSettingsActivity.kt new file mode 100644 index 0000000000..2c07987854 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/automations/AutomationsSettingsActivity.kt @@ -0,0 +1,62 @@ +/* Copyright (C) 2025 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.automations + +import android.os.Bundle +import android.text.InputType +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import nodomain.freeyourgadget.gadgetbridge.GBApplication +import nodomain.freeyourgadget.gadgetbridge.R +import nodomain.freeyourgadget.gadgetbridge.activities.AbstractPreferenceFragment +import nodomain.freeyourgadget.gadgetbridge.activities.AbstractSettingsActivityV2 +import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs + +class AutomationsSettingsActivity : AbstractSettingsActivityV2() { + override fun newFragment(): PreferenceFragmentCompat { + return AutomationsSettingsFragment() + } + + companion object { + class AutomationsSettingsFragment : AbstractPreferenceFragment() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.automations_settings, rootKey) + + setInputTypeFor(GBPrefs.PREF_AUTO_FETCH_INTERVAL_LIMIT, InputType.TYPE_CLASS_NUMBER) + + val prefAutoFetchInterval = findPreference(GBPrefs.PREF_AUTO_FETCH_INTERVAL_LIMIT) + if (prefAutoFetchInterval != null) { + prefAutoFetchInterval.setOnPreferenceChangeListener { preference: Preference?, autoFetchInterval: Any? -> + val summary = String.format( + requireContext().applicationContext.getString(R.string.pref_auto_fetch_limit_fetches_summary), + (autoFetchInterval as String?)!!.toInt() + ) + preference!!.setSummary(summary) + true + } + + val autoFetchInterval = GBApplication.getPrefs().getInt(GBPrefs.PREF_AUTO_FETCH_INTERVAL_LIMIT, 0) + val summary = String.format( + requireContext().applicationContext.getString(R.string.pref_auto_fetch_limit_fetches_summary), + autoFetchInterval + ) + prefAutoFetchInterval.setSummary(summary) + } + } + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DatabaseExportWorker.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DatabaseExportWorker.kt index 81bc3b0ad6..56a1a27abc 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DatabaseExportWorker.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DatabaseExportWorker.kt @@ -2,6 +2,7 @@ package nodomain.freeyourgadget.gadgetbridge.database import android.content.Context import android.content.Intent +import androidx.core.content.edit import androidx.work.Worker import androidx.work.WorkerParameters import nodomain.freeyourgadget.gadgetbridge.GBApplication @@ -17,12 +18,21 @@ class DatabaseExportWorker( workerParams: WorkerParameters ) : Worker(mContext, workerParams) { override fun doWork(): Result { - val dst = GBApplication.getPrefs().getString(GBPrefs.AUTO_EXPORT_LOCATION, null) + val enabled = GBApplication.getPrefs().getBoolean(GBPrefs.AUTO_EXPORT_DB_ENABLED, false) + if (!enabled) { + LOG.warn("DB export started, but is disabled") + // Should not need i18n, this should never happen + GB.updateExportFailedNotification("DB export started, but is disabled", mContext) + return Result.failure() + } + + val dst = GBApplication.getPrefs().getString(GBPrefs.AUTO_EXPORT_DB_LOCATION, "") LOG.info("Starting DB export, dst={}", dst) if (dst == null) { LOG.warn("Unable to export DB, export location not set") + GB.updateExportFailedNotification(mContext.getString(R.string.notif_export_location_not_set), mContext) broadcastSuccess(false) return Result.failure() } @@ -35,7 +45,9 @@ class DatabaseExportWorker( } } - GBApplication.app().lastAutoExportTimestamp = System.currentTimeMillis() + GBApplication.getPrefs().preferences.edit { + putLong(GBPrefs.AUTO_EXPORT_DB_LAST_EXECUTION, System.currentTimeMillis()) + } } catch (e: Exception) { GB.updateExportFailedNotification(mContext.getString(R.string.notif_export_failed_title), mContext) LOG.error("Exception while exporting DB", e) @@ -51,7 +63,7 @@ class DatabaseExportWorker( } private fun broadcastSuccess(success: Boolean) { - if (!GBApplication.getPrefs().getBoolean("intent_api_broadcast_export", false)) { + if (!GBApplication.getPrefs().getBoolean(GBPrefs.INTENT_API_BROADCAST_EXPORT_DB, false)) { return } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/PeriodicDbExporter.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/PeriodicDbExporter.kt new file mode 100644 index 0000000000..8bbf108ffd --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/PeriodicDbExporter.kt @@ -0,0 +1,42 @@ +/* Copyright (C) 2018-2024 Carsten Pfeiffer, Felix Konstantin Maurer, + Ganblejs, José Rebelo, Petr Vaněk + + 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.database + +import androidx.work.ListenableWorker +import nodomain.freeyourgadget.gadgetbridge.util.PeriodicExporter + +object PeriodicDbExporter: PeriodicExporter() { + override fun getWorkerClass(): Class { + return DatabaseExportWorker::class.java + } + + /** + * DB export has no prefix for backwards compatibility + */ + override fun getKeyPrefix(): String { + return "" + } + + override fun getFileMimeType(): String { + return "application/x-sqlite3" + } + + override fun getFileExtension(): String { + return "db" + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/PeriodicExporter.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/PeriodicExporter.kt deleted file mode 100644 index c98a237f9c..0000000000 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/PeriodicExporter.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* Copyright (C) 2018-2024 Carsten Pfeiffer, Felix Konstantin Maurer, - Ganblejs, José Rebelo, Petr Vaněk - - 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.database - -import android.content.Context -import androidx.work.OneTimeWorkRequest -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import nodomain.freeyourgadget.gadgetbridge.GBApplication -import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs -import nodomain.freeyourgadget.gadgetbridge.util.Prefs -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import java.util.concurrent.TimeUnit - -object PeriodicExporter { - private val LOG: Logger = LoggerFactory.getLogger(PeriodicExporter::class.java) - - private const val TAG = "exporter_db" - - @JvmStatic - fun enablePeriodicExport(context: Context) { - val prefs: Prefs = GBApplication.getPrefs() - val autoExportScheduled = GBApplication.app().autoExportScheduledTimestamp - val autoExportEnabled = prefs.getBoolean(GBPrefs.AUTO_EXPORT_ENABLED, false) - val autoExportInterval = prefs.getInt(GBPrefs.AUTO_EXPORT_INTERVAL, 0) - scheduleAlarm(context, autoExportInterval, autoExportEnabled && autoExportScheduled == 0L) - } - - @JvmStatic - fun scheduleAlarm( - context: Context, - autoExportInterval: Int, - autoExportEnabled: Boolean - ) { - val workManager = WorkManager.getInstance(context) - workManager.cancelAllWorkByTag(TAG) - - if (!autoExportEnabled) { - LOG.info("Not scheduling periodic export, either already scheduled or not enabled") - return - } - - if (autoExportInterval == 0) { - LOG.info("Not scheduling periodic export, interval set to 0") - return - } - - LOG.info("Scheduling periodic export for {}h in the future", autoExportInterval) - - val exportPeriodMillis = autoExportInterval * 60 * 60 * 1000 - GBApplication.app().autoExportScheduledTimestamp = System.currentTimeMillis() + exportPeriodMillis - - val exportRequest = PeriodicWorkRequestBuilder( - autoExportInterval.toLong(), - TimeUnit.HOURS - ).addTag(TAG).build() - - workManager.enqueue(exportRequest) - } - - @JvmStatic - fun trigger() { - val workManager = WorkManager.getInstance(GBApplication.getContext()) - val exportRequest = OneTimeWorkRequest.Builder(DatabaseExportWorker::class.java) - .addTag(TAG) - .build() - workManager.enqueue(exportRequest) - } -} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AutoStartReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AutoStartReceiver.java deleted file mode 100644 index 5ce081ab79..0000000000 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AutoStartReceiver.java +++ /dev/null @@ -1,47 +0,0 @@ -/* Copyright (C) 2017-2024 Carsten Pfeiffer, Daniele Gobbetti, Felix - Konstantin Maurer, Petr Vaněk - - 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.externalevents; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.util.Log; - -import nodomain.freeyourgadget.gadgetbridge.GBApplication; -import nodomain.freeyourgadget.gadgetbridge.database.PeriodicExporter; - -public class AutoStartReceiver extends BroadcastReceiver { - private static final String TAG = AutoStartReceiver.class.getName(); - - @Override - public void onReceive(Context context, Intent intent) { - - if (GBApplication.getPrefs().getAutoStart() && - (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction()) || - Intent.ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction()) - )) { - Log.i(TAG, "Boot or reinstall completed, starting Gadgetbridge"); - if (GBApplication.getPrefs().getBoolean("general_autoconnectonbluetooth", false)) { - Log.i(TAG, "Autoconnect is enabled, attempting to connect"); - GBApplication.deviceService().connect(); - } - Log.i(TAG, "Going to enable periodic exporter"); - PeriodicExporter.enablePeriodicExport(context); - } - } -} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AutoStartReceiver.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AutoStartReceiver.kt new file mode 100644 index 0000000000..9c0f0ccac6 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AutoStartReceiver.kt @@ -0,0 +1,55 @@ +/* Copyright (C) 2017-2024 Carsten Pfeiffer, Daniele Gobbetti, Felix + Konstantin Maurer, Petr Vaněk + + 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.externalevents + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import nodomain.freeyourgadget.gadgetbridge.GBApplication +import nodomain.freeyourgadget.gadgetbridge.database.PeriodicDbExporter +import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs +import nodomain.freeyourgadget.gadgetbridge.util.backup.PeriodicZipExporter + +class AutoStartReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (!GBApplication.getPrefs().autoStart) { + return + } + if (Intent.ACTION_BOOT_COMPLETED != intent.action && Intent.ACTION_MY_PACKAGE_REPLACED != intent.action) { + return + } + + Log.i(TAG, "Boot or reinstall completed, starting Gadgetbridge") + + if (GBApplication.getPrefs().getBoolean(GBPrefs.AUTO_CONNECT_BLUETOOTH, false)) { + Log.i(TAG, "Auto-connect is enabled, attempting to connect") + GBApplication.deviceService().connect() + } + + Log.i(TAG, "Going to enable periodic db exporter") + PeriodicDbExporter.scheduleNextExecution(context) + + Log.i(TAG, "Going to enable periodic zip exporter") + PeriodicZipExporter.scheduleNextExecution(context) + } + + companion object { + private val TAG: String = AutoStartReceiver::class.java.getName() + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/BluetoothStateChangeReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/BluetoothStateChangeReceiver.java index 24495f0326..b3841284d0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/BluetoothStateChangeReceiver.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/BluetoothStateChangeReceiver.java @@ -65,7 +65,7 @@ public class BluetoothStateChangeReceiver extends BroadcastReceiver { return; } - if (!prefs.getBoolean("general_autoconnectonbluetooth", false)) { + if (!prefs.getBoolean(GBPrefs.AUTO_CONNECT_BLUETOOTH, false)) { return; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/IntentApiReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/IntentApiReceiver.java index c651ceb397..0a7cbea50a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/IntentApiReceiver.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/IntentApiReceiver.java @@ -36,7 +36,7 @@ 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.database.PeriodicExporter; +import nodomain.freeyourgadget.gadgetbridge.database.PeriodicDbExporter; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.CallSpec; @@ -45,6 +45,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; import nodomain.freeyourgadget.gadgetbridge.model.NotificationType; import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; +import nodomain.freeyourgadget.gadgetbridge.util.backup.PeriodicZipExporter; public class IntentApiReceiver extends BroadcastReceiver { private static final Logger LOG = LoggerFactory.getLogger(IntentApiReceiver.class); @@ -52,7 +53,10 @@ public class IntentApiReceiver extends BroadcastReceiver { private static final String msgDebugNotAllowed = "Intent API Allow Debug Commands not allowed"; public static final String COMMAND_ACTIVITY_SYNC = "nodomain.freeyourgadget.gadgetbridge.command.ACTIVITY_SYNC"; + @Deprecated public static final String COMMAND_TRIGGER_EXPORT = "nodomain.freeyourgadget.gadgetbridge.command.TRIGGER_EXPORT"; + public static final String TRIGGER_DATABASE_EXPORT = "nodomain.freeyourgadget.gadgetbridge.command.TRIGGER_DATABASE_EXPORT"; + public static final String COMMAND_TRIGGER_ZIP_EXPORT = "nodomain.freeyourgadget.gadgetbridge.command.TRIGGER_ZIP_EXPORT"; public static final String COMMAND_DEBUG_SEND_NOTIFICATION = "nodomain.freeyourgadget.gadgetbridge.command.DEBUG_SEND_NOTIFICATION"; public static final String COMMAND_DEBUG_INCOMING_CALL = "nodomain.freeyourgadget.gadgetbridge.command.DEBUG_INCOMING_CALL"; public static final String COMMAND_DEBUG_END_CALL = "nodomain.freeyourgadget.gadgetbridge.command.DEBUG_END_CALL"; @@ -97,15 +101,31 @@ public class IntentApiReceiver extends BroadcastReceiver { break; case COMMAND_TRIGGER_EXPORT: + LOG.warn( + "The action {} is deprecated, please use {}", + COMMAND_TRIGGER_EXPORT, + TRIGGER_DATABASE_EXPORT + ); + case TRIGGER_DATABASE_EXPORT: if (!prefs.getBoolean("intent_api_allow_trigger_export", false)) { - LOG.warn("Intent API export trigger not allowed"); + LOG.warn("Intent API db export trigger not allowed"); return; } - LOG.info("Triggering export"); + LOG.info("Triggering db export"); - final Intent exportIntent = new Intent(context, PeriodicExporter.class); - context.sendBroadcast(exportIntent); + PeriodicDbExporter.INSTANCE.executeNow(); + break; + + case COMMAND_TRIGGER_ZIP_EXPORT: + if (!prefs.getBoolean("intent_api_allow_trigger_zip_export", false)) { + LOG.warn("Intent API zip export trigger not allowed"); + return; + } + + LOG.info("Triggering zip export"); + + PeriodicZipExporter.INSTANCE.executeNow(); break; case COMMAND_DEBUG_SEND_NOTIFICATION: diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java index 34f1d38521..afb7817fe1 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java @@ -1468,8 +1468,7 @@ public class DeviceCommunicationService extends Service implements SharedPrefere } } - if (GBApplication.getPrefs().getBoolean("auto_fetch_enabled", false) && - features.supportsActivityDataFetching() && mGBAutoFetchReceiver == null) { + if (features.supportsActivityDataFetching() && mGBAutoFetchReceiver == null) { mGBAutoFetchReceiver = new GBAutoFetchReceiver(); ContextCompat.registerReceiver(this, mGBAutoFetchReceiver, new IntentFilter("android.intent.action.USER_PRESENT"), ContextCompat.RECEIVER_EXPORTED); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/receivers/GBAutoFetchReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/receivers/GBAutoFetchReceiver.java index 5d191d7f78..f66fb96fb0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/receivers/GBAutoFetchReceiver.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/receivers/GBAutoFetchReceiver.java @@ -1,4 +1,4 @@ -/* Copyright (C) 2018-2024 Daniele Gobbetti, José Rebelo, Martin +/* Copyright (C) 2018-2025 Daniele Gobbetti, José Rebelo, Martin This file is part of Gadgetbridge. @@ -28,6 +28,7 @@ import java.util.Date; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes; +import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; public class GBAutoFetchReceiver extends BroadcastReceiver { @@ -37,6 +38,9 @@ public class GBAutoFetchReceiver extends BroadcastReceiver { @Override public void onReceive(final Context context, final Intent intent) { + if (!GBApplication.getPrefs().getBoolean(GBPrefs.PREF_AUTO_FETCH_ENABLED, false)) { + return; + } synchronized (this) { final Date now = new Date(); final long timeSinceLast = now.getTime() - lastSync.getTime(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/AndroidUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/AndroidUtils.java index afa7797c86..2ec9c9837d 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/AndroidUtils.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/AndroidUtils.java @@ -256,11 +256,12 @@ public class AndroidUtils { String[] projection = { MediaStore.Images.Media.DATA }; - Cursor cursor = context.getContentResolver() - .query(uri, projection, selection, selectionArgs, null); - int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA); - if (cursor.moveToFirst()) { - return cursor.getString(column_index); + try (Cursor cursor = context.getContentResolver() + .query(uri, projection, selection, selectionArgs, null)) { + int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA); + if (cursor.moveToFirst()) { + return cursor.getString(column_index); + } } } else if ("file".equalsIgnoreCase(uri.getScheme())) { return uri.getPath(); 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 da7a098d16..63f5400924 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java @@ -76,6 +76,7 @@ public class GB { public static final String NOTIFICATION_CHANNEL_ID_SCAN_SERVICE = "gadgetbridge_scan_service"; public static final String NOTIFICATION_CHANNEL_HIGH_PRIORITY_ID = "gadgetbridge_high_priority"; public static final String NOTIFICATION_CHANNEL_ID_TRANSFER = "gadgetbridge transfer"; + public static final String NOTIFICATION_CHANNEL_ID_EXPORT = "gadgetbridge export"; public static final String NOTIFICATION_CHANNEL_ID_LOW_BATTERY = "low_battery"; public static final String NOTIFICATION_CHANNEL_ID_FULL_BATTERY = "full_battery"; public static final String NOTIFICATION_CHANNEL_ID_GPS = "gps"; @@ -147,6 +148,12 @@ public class GB { NotificationManager.IMPORTANCE_LOW); notificationManager.createNotificationChannel(channelTransfer); + NotificationChannel channelExport = new NotificationChannel( + NOTIFICATION_CHANNEL_ID_EXPORT, + context.getString(R.string.pref_header_auto_export), + NotificationManager.IMPORTANCE_LOW); + notificationManager.createNotificationChannel(channelExport); + NotificationChannel channelLowBattery = new NotificationChannel( NOTIFICATION_CHANNEL_ID_LOW_BATTERY, context.getString(R.string.notification_channel_low_battery_name), diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBPrefs.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBPrefs.java index 0623616a5c..9f4ee1ffc3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBPrefs.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBPrefs.java @@ -48,16 +48,42 @@ public class GBPrefs extends Prefs { public static final String DEVICE_AUTO_RECONNECT = "prefs_key_device_auto_reconnect"; public static final String DEVICE_CONNECT_BACK = "prefs_key_device_reconnect_on_acl"; private static final String AUTO_START = "general_autostartonboot"; - public static final String AUTO_EXPORT_ENABLED = "auto_export_enabled"; - public static final String AUTO_EXPORT_LOCATION = "auto_export_location"; + public static final String AUTO_CONNECT_BLUETOOTH = "general_autoconnectonbluetooth"; public static final String PING_TONE = "ping_tone"; - public static final String AUTO_EXPORT_INTERVAL = "auto_export_interval"; private static final boolean AUTO_START_DEFAULT = true; public static final String RTL_SUPPORT = "rtl"; public static final String RTL_CONTEXTUAL_ARABIC = "contextualArabic"; public static boolean AUTO_RECONNECT_DEFAULT = true; public static final String PREF_ALLOW_INTENT_API = "prefs_key_allow_bluetooth_intent_api"; + public static final String PREF_AUTO_FETCH_ENABLED = "auto_fetch_enabled"; + public static final String PREF_AUTO_FETCH_INTERVAL_LIMIT = "auto_fetch_interval_limit"; + + // These should get the prefix appended - see below + public static final String AUTO_EXPORT_ENABLED = "auto_export_enabled"; + public static final String AUTO_EXPORT_LOCATION = "auto_export_location"; + public static final String AUTO_EXPORT_INTERVAL = "auto_export_interval"; + public static final String AUTO_EXPORT_LAST_EXECUTION = "auto_export_last_execution"; + public static final String AUTO_EXPORT_NEXT_EXECUTION = "auto_export_next_execution"; + + // DB export has no prefix + public static final String AUTO_EXPORT_DB_ENABLED = AUTO_EXPORT_ENABLED; + public static final String AUTO_EXPORT_DB_LOCATION = AUTO_EXPORT_LOCATION; + public static final String AUTO_EXPORT_DB_INTERVAL = AUTO_EXPORT_INTERVAL; + public static final String AUTO_EXPORT_DB_LAST_EXECUTION = AUTO_EXPORT_LAST_EXECUTION; + public static final String AUTO_EXPORT_DB_NEXT_EXECUTION = AUTO_EXPORT_NEXT_EXECUTION; + + // Zip export with "zip_" prefix + public static final String AUTO_EXPORT_ZIP_ENABLED = "zip_auto_export_enabled"; + public static final String AUTO_EXPORT_ZIP_LOCATION = "zip_auto_export_location"; + public static final String AUTO_EXPORT_ZIP_INTERVAL = "zip_auto_export_interval"; + public static final String AUTO_EXPORT_ZIP_LAST_EXECUTION = "zip_auto_export_last_execution"; + public static final String AUTO_EXPORT_ZIP_NEXT_EXECUTION = "zip_auto_export_next_execution"; + + // Intent API + public static final String INTENT_API_BROADCAST_EXPORT_DB = "intent_api_broadcast_export"; + public static final String INTENT_API_BROADCAST_EXPORT_ZIP = "intent_api_broadcast_zip_export"; + public static final String RECONNECT_SCAN_KEY = "prefs_general_key_auto_reconnect_scan"; public static final boolean RECONNECT_SCAN_DEFAULT = false; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/PeriodicExporter.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/PeriodicExporter.kt new file mode 100644 index 0000000000..5b183a8694 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/PeriodicExporter.kt @@ -0,0 +1,123 @@ +package nodomain.freeyourgadget.gadgetbridge.util + +import android.content.Context +import androidx.core.content.edit +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ListenableWorker +import androidx.work.OneTimeWorkRequest +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import nodomain.freeyourgadget.gadgetbridge.GBApplication +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.time.Duration +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit +import java.util.concurrent.TimeUnit + + +abstract class PeriodicExporter { + abstract fun getWorkerClass(): Class + abstract fun getKeyPrefix(): String + abstract fun getFileMimeType(): String + abstract fun getFileExtension(): String + + fun scheduleNextExecution(context: Context) { + try { + val keyPrefix = getKeyPrefix() + val prefKeyEnabled = keyPrefix + GBPrefs.AUTO_EXPORT_ENABLED + val prefKeyInterval = keyPrefix + GBPrefs.AUTO_EXPORT_INTERVAL + val prefKeyNextExecution = keyPrefix + GBPrefs.AUTO_EXPORT_NEXT_EXECUTION + val prefKeyStartTime = keyPrefix + "auto_export_start_time" + + val workManager = WorkManager.getInstance(context) + workManager.cancelAllWorkByTag(getWorkTag()) + + val prefs: Prefs = GBApplication.getPrefs() + + val autoExportEnabled = prefs.getBoolean(prefKeyEnabled, false) + if (!autoExportEnabled) { + LOG.info("Not scheduling {} - not enabled", getWorkerClass().simpleName) + return + } + + val autoExportInterval = prefs.getInt(prefKeyInterval, 0) + if (autoExportInterval == 0) { + LOG.info("Not scheduling {}, interval set to 0", getWorkerClass().simpleName) + return + } + val exportPeriodMillis = autoExportInterval * 60 * 60 * 1000L + + val startTime = prefs.getLocalTime(prefKeyStartTime, "00:00") + val nextExecution = nextExecution(startTime, autoExportInterval.toLong()) + val initialDelayMillis = nextExecution.toInstant().toEpochMilli() - System.currentTimeMillis() + LOG.info( + "Scheduling {} for {}h in the future from {} ({}ms)", + getWorkerClass().simpleName, + autoExportInterval, + startTime, + initialDelayMillis + ) + + prefs.preferences.edit { + putLong(prefKeyNextExecution, System.currentTimeMillis() + exportPeriodMillis) + } + + val exportRequest = PeriodicWorkRequest.Builder( + getWorkerClass(), + autoExportInterval.toLong(), + TimeUnit.HOURS + ).setInitialDelay(initialDelayMillis, TimeUnit.MILLISECONDS) + .addTag(getWorkTag()) + .addTag("$TAG_CREATED_AT${System.currentTimeMillis()}") + .build() + + workManager.enqueueUniquePeriodicWork( + getWorkTag(), + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + exportRequest + ) + } catch (e: Exception) { + LOG.error("Failed to schedule next execution for {}", getWorkerClass().simpleName, e) + } + } + + private fun nextExecution(anchor: LocalTime, periodHours: Long): ZonedDateTime { + val now = ZonedDateTime.now(ZoneId.systemDefault()) + + val candidate = now.with(anchor) + + // If today's anchor is after now, it's the first execution + if (candidate.isAfter(now)) { + return candidate + } + + // Otherwise, find how many periods have passed since anchor + val elapsedMillis = ChronoUnit.MILLIS.between(candidate, now) + val periodMillis = Duration.ofHours(periodHours).toMillis() + + val periodsPassed = (elapsedMillis / periodMillis) + 1 // +1 = next occurrence + return candidate.plus(periodsPassed * periodHours, ChronoUnit.HOURS) + } + + fun executeNow() { + val workManager = WorkManager.getInstance(GBApplication.getContext()) + val exportRequest = OneTimeWorkRequest.Builder(getWorkerClass()) + .addTag(getWorkTag()) + .addTag("$TAG_CREATED_AT${System.currentTimeMillis()}") + .build() + workManager.enqueue(exportRequest) + } + + fun getWorkTag(): String { + return "${getKeyPrefix()}exporter_worker" + } + + companion object { + private val LOG: Logger = LoggerFactory.getLogger(PeriodicExporter::class.java) + + const val TAG_CREATED_AT = "createdAt-" + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/Prefs.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/Prefs.java index d55a689be9..835c0e2374 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/Prefs.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/Prefs.java @@ -48,7 +48,7 @@ public class Prefs { public String getString(String key, String defaultValue) { String value = preferences.getString(key, defaultValue); - if (value == null || "".equals(value)) { + if (value == null || value.isEmpty()) { return defaultValue; } return value; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XDatePreference.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XDatePreference.java index 0797ae148a..e357a93a9a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XDatePreference.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XDatePreference.java @@ -92,7 +92,7 @@ public class XDatePreference extends DialogPreference { return maxDate; } - String getPrefValue() { + public String getPrefValue() { return String.format(Locale.ROOT, "%04d-%02d-%02d", year, month, day); } @@ -102,6 +102,8 @@ public class XDatePreference extends DialogPreference { this.day = day; persistStringValue(getPrefValue()); + + updateSummary(); } void updateSummary() { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XTimePreference.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XTimePreference.java index ac3349d2ce..726b805023 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XTimePreference.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XTimePreference.java @@ -31,34 +31,19 @@ public class XTimePreference extends DialogPreference { protected Format format = Format.AUTO; - public XTimePreference(Context context, AttributeSet attrs) { + public XTimePreference(final Context context, final AttributeSet attrs) { super(context, attrs); } @Override - protected Object onGetDefaultValue(TypedArray a, int index) { + protected Object onGetDefaultValue(final TypedArray a, final int index) { return a.getString(index); } @Override - protected void onSetInitialValue(boolean restoreValue, Object defaultValue) { - String time; - - if (restoreValue) { - if (defaultValue == null) { - time = getPersistedString("00:00"); - } else { - time = getPersistedString(defaultValue.toString()); - } - } else { - if (defaultValue != null) { - time = defaultValue.toString(); - } else { - time = "00:00"; - } - } - - String[] pieces = time.split(":"); + protected void onSetInitialValue(final Object defaultValue) { + final String time = getPersistedString((String) defaultValue); + final String[] pieces = time.split(":"); hour = Integer.parseInt(pieces[0]); minute = Integer.parseInt(pieces[1]); @@ -75,6 +60,8 @@ public class XTimePreference extends DialogPreference { this.minute = minute; persistStringValue(getPrefValue()); + + updateSummary(); } void updateSummary() { @@ -85,14 +72,14 @@ public class XTimePreference extends DialogPreference { } String getTime24h() { - return String.format("%02d", hour) + ":" + String.format("%02d", minute); + return String.format(Locale.ROOT, "%02d:%02d", hour, minute); } private String getTime12h() { - String suffix = hour < 12 ? " AM" : " PM"; - int h = hour > 12 ? hour - 12 : hour; + final String suffix = hour < 12 ? "AM" : "PM"; + final int h = hour > 12 ? hour - 12 : hour; - return h + ":" + String.format("%02d", minute) + suffix; + return String.format(Locale.ROOT, "%d:%02d %s",h, minute, suffix); } public void setFormat(final Format format) { @@ -103,26 +90,21 @@ public class XTimePreference extends DialogPreference { return format; } - void persistStringValue(String value) { + void persistStringValue(final String value) { persistString(value); } public boolean is24HourFormat() { - switch (format) { - case FORMAT_24H: - return true; - case FORMAT_12H: - return false; - case AUTO: - default: - return DateFormat.is24HourFormat(getContext()); - } + return switch (format) { + case FORMAT_24H -> true; + case FORMAT_12H -> false; + default -> DateFormat.is24HourFormat(getContext()); + }; } public enum Format { AUTO, FORMAT_24H, FORMAT_12H, - ; } } 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 index acd1a97f90..a1e8640ef2 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/AbstractZipBackupJob.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/AbstractZipBackupJob.java @@ -46,7 +46,6 @@ public abstract class AbstractZipBackupJob implements Runnable { .create(); private final Context mContext; - private final Handler mHandler; private final ZipBackupCallback mCallback; private final AtomicBoolean aborted = new AtomicBoolean(false); @@ -56,7 +55,6 @@ public abstract class AbstractZipBackupJob implements Runnable { public AbstractZipBackupJob(final Context context, final ZipBackupCallback callback) { this.mContext = context; - this.mHandler = new Handler(context.getMainLooper()); this.mCallback = callback; } @@ -83,18 +81,16 @@ public abstract class AbstractZipBackupJob implements Runnable { } lastProgressUpdateTs = now; lastProgressUpdateMessage = message; - mHandler.post(() -> { - mCallback.onProgress(percentage, getContext().getString(message, formatArgs)); - }); + mCallback.onProgress(percentage, getContext().getString(message, formatArgs)); } @WorkerThread protected void onSuccess(final String warnings) { - mHandler.post(() -> mCallback.onSuccess(warnings)); + mCallback.onSuccess(warnings); } @WorkerThread protected void onFailure(final String errorMessage) { - mHandler.post(() -> mCallback.onFailure(errorMessage)); + mCallback.onFailure(errorMessage); } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/PeriodicZipExporter.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/PeriodicZipExporter.kt new file mode 100644 index 0000000000..6e5266d38c --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/PeriodicZipExporter.kt @@ -0,0 +1,38 @@ +/* Copyright (C) 2025 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.work.ListenableWorker +import nodomain.freeyourgadget.gadgetbridge.util.PeriodicExporter + +object PeriodicZipExporter: PeriodicExporter() { + override fun getWorkerClass(): Class { + return ZipExportWorker::class.java + } + + override fun getKeyPrefix(): String { + return "zip_" + } + + override fun getFileMimeType(): String { + return "application/zip" + } + + override fun getFileExtension(): String { + return "zip" + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipExportWorker.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipExportWorker.kt new file mode 100644 index 0000000000..7057818ceb --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipExportWorker.kt @@ -0,0 +1,163 @@ +package nodomain.freeyourgadget.gadgetbridge.util.backup + +import android.Manifest +import android.app.Notification +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.Worker +import androidx.work.WorkerParameters +import nodomain.freeyourgadget.gadgetbridge.GBApplication +import nodomain.freeyourgadget.gadgetbridge.R +import nodomain.freeyourgadget.gadgetbridge.util.GB +import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs +import org.slf4j.Logger +import org.slf4j.LoggerFactory + + +class ZipExportWorker( + private val mContext: Context, + workerParams: WorkerParameters +) : Worker(mContext, workerParams), ZipBackupCallback { + private var success = false + + override fun doWork(): Result { + val enabled = GBApplication.getPrefs().getBoolean(GBPrefs.AUTO_EXPORT_ZIP_ENABLED, false) + if (!enabled) { + LOG.warn("Zip export started, but is disabled") + // Should not need i18n, this should never happen + onFailure("Zip export started, but is disabled"); + return Result.failure() + } + + val dst = GBApplication.getPrefs().getString(GBPrefs.AUTO_EXPORT_ZIP_LOCATION, null) + + LOG.info("Starting zip export, dst={}", dst) + + if (dst == null) { + LOG.warn("Unable to export zip, export location not set") + onFailure(mContext.getString(R.string.notif_export_location_not_set)); + broadcastSuccess(false) + return Result.failure() + } + + setForegroundAsync(createForegroundInfo()) + + ZipBackupExportJob(mContext, this, dst.toUri()).run() + + LOG.info("Zip export completed, success={}", success) + + broadcastSuccess(success) + + return if (success) Result.success() else Result.failure() + } + + override fun onProgress(progress: Int, message: String?) { + setProgressAsync(Data.Builder().putInt("progress", progress).build()) + + val updatedNotification: Notification = + NotificationCompat.Builder(mContext, GB.NOTIFICATION_CHANNEL_ID_EXPORT) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle(mContext.getString(R.string.backup_restore_exporting)) + .setContentText(message) + .setProgress(100, progress, false) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + notify(NOTIF_ID_PROGRESS, updatedNotification) + } + + override fun onSuccess(warnings: String?) { + success = true + GBApplication.getPrefs().preferences.edit { + putLong(GBPrefs.AUTO_EXPORT_ZIP_LAST_EXECUTION, System.currentTimeMillis()) + } + if (warnings != null) { + val builder: NotificationCompat.Builder = + NotificationCompat.Builder(mContext, GB.NOTIFICATION_CHANNEL_ID_EXPORT) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setContentTitle(mContext.getString(R.string.backup_restore_export_complete)) + .setContentText(warnings) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + + notify(NOTIF_ID_WARNING, builder.build()) + } + } + + override fun onFailure(errorMessage: String?) { + val builder: NotificationCompat.Builder = + NotificationCompat.Builder(mContext, GB.NOTIFICATION_CHANNEL_ID_EXPORT) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setContentTitle(mContext.getString(R.string.notif_export_zip_failed_title)) + .setContentText(errorMessage ?: mContext.getString(R.string.unknown_error)) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + + notify(NOTIF_ID_FAILURE, builder.build()) + } + + private fun createForegroundInfo(): ForegroundInfo { + val notification: Notification = + NotificationCompat.Builder(mContext, GB.NOTIFICATION_CHANNEL_ID_EXPORT) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle(mContext.getString(R.string.backup_restore_exporting)) + .setProgress(100, 0, false) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(NOTIF_ID_PROGRESS, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(NOTIF_ID_PROGRESS, notification) + } + } + + private fun broadcastSuccess(success: Boolean) { + if (!GBApplication.getPrefs().getBoolean(GBPrefs.INTENT_API_BROADCAST_EXPORT_ZIP, false)) { + return + } + + LOG.info("Broadcasting zip export success={}", success) + + val action: String = if (success) ACTION_ZIP_EXPORT_SUCCESS else ACTION_ZIP_EXPORT_FAIL + val exportedNotifyIntent = Intent(action) + mContext.sendBroadcast(exportedNotifyIntent) + } + + private fun notify(id: Int, notification: Notification) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ActivityCompat.checkSelfPermission( + mContext, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + + NotificationManagerCompat.from(mContext).notify(id, notification) + } + + companion object { + private val LOG: Logger = LoggerFactory.getLogger(ZipExportWorker::class.java) + + private const val NOTIF_ID_PROGRESS: Int = 1001 + private const val NOTIF_ID_FAILURE: Int = 1002 + private const val NOTIF_ID_WARNING: Int = 1003 + + const val ACTION_ZIP_EXPORT_SUCCESS: String = + "nodomain.freeyourgadget.gadgetbridge.action.ZIP_EXPORT_SUCCESS" + const val ACTION_ZIP_EXPORT_FAIL: String = + "nodomain.freeyourgadget.gadgetbridge.action.ZIP_EXPORT_FAIL" + } +} diff --git a/app/src/main/res/drawable/ic_database.xml b/app/src/main/res/drawable/ic_database.xml new file mode 100644 index 0000000000..464900e255 --- /dev/null +++ b/app/src/main/res/drawable/ic_database.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_zip.xml b/app/src/main/res/drawable/ic_zip.xml new file mode 100644 index 0000000000..6f54936f56 --- /dev/null +++ b/app/src/main/res/drawable/ic_zip.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/layout/activity_data_management.xml b/app/src/main/res/layout/activity_data_management.xml index 1d92049704..7dc33d6faf 100644 --- a/app/src/main/res/layout/activity_data_management.xml +++ b/app/src/main/res/layout/activity_data_management.xml @@ -155,58 +155,6 @@ android:layout_weight="1" android:text="@string/activity_db_management_clean_export_directory_label" /> - - - - - - - - - - - - - -