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" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Enable the sleep mode automatically when wearing the band during sleep
Auto export
+ Auto export database
+ Auto export zip
Auto export enabled
Export location
Export interval
@@ -936,6 +938,9 @@
%1$s battery full
%1$s battery low: %2$s
Export database failed! Please check your settings.
+ Export location not configured
+ Export zip failed! Please check your settings.
+ Unknown error
Charts tabs
Visible chart tabs
Sleep
@@ -1360,13 +1365,8 @@
Old activity data deleted.
Old Activity database deletion failed.
Overwrite
- Database autoexport location has been set to:
- Last AutoExport: %1$s
- AutoExport is enabled.
- AutoExport is not enabled.
AutoExport has (originally) been scheduled for %1$s
AutoExport has not been not scheduled.
- AutoExport
Location could not be understood. Likely an issue of newer Android permission system. Most likely, autoexport is not working now.
Export Data
Import Data
@@ -3452,8 +3452,13 @@
Allow triggering database export via Intent API
Broadcast on database export
Broadcast an intent when database export finishes
+ Allow zip export
+ Allow triggering zip export via Intent API
+ Broadcast on zip export
+ Broadcast an intent when zip export finishes
Broadcast on activity sync finish
Broadcast an intent when activity sync finishes for any device
+ Activity sync
Allow Debug Commands
Allow triggering debug menu commands via Intent API
Shell Racing
@@ -4265,4 +4270,15 @@
Connect
New sync protocol
Enable this if some information is not being fetched. Experimental and potentially unstable.
+ Last execution
+ Next execution
+ Running
+ Enqueued
+ Succeeded
+ Failed
+ Blocked
+ Cancelled
+ %1$s (%2$d%%)
+ %1$s (in %2$s)
+ %1$s (%2$s ago)
diff --git a/app/src/main/res/xml/auto_export_settings.xml b/app/src/main/res/xml/auto_export_settings.xml
new file mode 100644
index 0000000000..cc536e207c
--- /dev/null
+++ b/app/src/main/res/xml/auto_export_settings.xml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/automations_settings.xml b/app/src/main/res/xml/automations_settings.xml
new file mode 100644
index 0000000000..6b526ee773
--- /dev/null
+++ b/app/src/main/res/xml/automations_settings.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml
index 56f047ec3b..db2b66b5f9 100644
--- a/app/src/main/res/xml/preferences.xml
+++ b/app/src/main/res/xml/preferences.xml
@@ -246,55 +246,11 @@
android:title="@string/maps"
app:iconSpaceReserved="false" />
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:title="@string/pref_header_automations" />
-
-
+
-
+
+
-
+
-
+
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+