Introduce automatic zip export

This commit is contained in:
José Rebelo
2025-08-15 20:35:01 +02:00
committed by José Rebelo
parent c45374fd12
commit 2ecc845a74
36 changed files with 1323 additions and 609 deletions
+17
View File
@@ -69,6 +69,7 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND" /> <uses-permission android:name="android.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND" />
<uses-permission android:name="android.permission.REQUEST_OBSERVE_COMPANION_DEVICE_PRESENCE" /> <uses-permission android:name="android.permission.REQUEST_OBSERVE_COMPANION_DEVICE_PRESENCE" />
@@ -181,6 +182,18 @@
android:name=".activities.maps.MapsSettingsActivity" android:name=".activities.maps.MapsSettingsActivity"
android:label="@string/maps_settings" android:label="@string/maps_settings"
android:parentActivityName=".activities.SettingsActivity" /> android:parentActivityName=".activities.SettingsActivity" />
<activity
android:name=".activities.automations.AutomationsSettingsActivity"
android:label="@string/pref_header_automations"
android:parentActivityName=".activities.SettingsActivity" />
<activity
android:name=".activities.automations.AutoExportDbSettingsActivity"
android:label="@string/pref_header_auto_export_db"
android:parentActivityName=".activities.SettingsActivity" />
<activity
android:name=".activities.automations.AutoExportZipSettingsActivity"
android:label="@string/pref_header_auto_export_zip"
android:parentActivityName=".activities.SettingsActivity" />
<activity <activity
android:name=".activities.maps.MapsTrackActivity" android:name=".activities.maps.MapsTrackActivity"
android:label="@string/maps" android:label="@string/maps"
@@ -516,6 +529,10 @@
android:parentActivityName=".activities.app_specific_notifications.AppSpecificNotificationSettingsActivity" android:parentActivityName=".activities.app_specific_notifications.AppSpecificNotificationSettingsActivity"
/> />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<service <service
android:name=".externalevents.NotificationListener" android:name=".externalevents.NotificationListener"
android:label="@string/app_name" android:label="@string/app_name"
@@ -79,7 +79,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSett
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.database.DBOpenHelper; import nodomain.freeyourgadget.gadgetbridge.database.DBOpenHelper;
import nodomain.freeyourgadget.gadgetbridge.database.PeriodicExporter; import nodomain.freeyourgadget.gadgetbridge.database.PeriodicDbExporter;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager;
import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoMaster; import nodomain.freeyourgadget.gadgetbridge.entities.DaoMaster;
@@ -103,6 +103,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue; import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue;
import nodomain.freeyourgadget.gadgetbridge.util.PendingIntentUtils; import nodomain.freeyourgadget.gadgetbridge.util.PendingIntentUtils;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs; import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
import nodomain.freeyourgadget.gadgetbridge.util.backup.PeriodicZipExporter;
import nodomain.freeyourgadget.gadgetbridge.util.preferences.DevicePrefs; import nodomain.freeyourgadget.gadgetbridge.util.preferences.DevicePrefs;
import static nodomain.freeyourgadget.gadgetbridge.model.DeviceType.AMAZFITBIP; import static nodomain.freeyourgadget.gadgetbridge.model.DeviceType.AMAZFITBIP;
@@ -177,10 +178,6 @@ public class GBApplication extends Application {
private OpenTracksContentObserver openTracksObserver; private OpenTracksContentObserver openTracksObserver;
private long lastAutoExportTimestamp = 0;
private long autoExportScheduledTimestamp = 0;
/// flush the log buffer and stop file logging /// flush the log buffer and stop file logging
private static final class ShutdownHook implements Runnable { private static final class ShutdownHook implements Runnable {
@Override @Override
@@ -330,7 +327,8 @@ public class GBApplication extends Application {
loadAppsPebbleBlackList(); loadAppsPebbleBlackList();
if (!GBEnvironment.env().isTest()) { if (!GBEnvironment.env().isTest()) {
PeriodicExporter.enablePeriodicExport(context); PeriodicDbExporter.INSTANCE.scheduleNextExecution(context);
PeriodicZipExporter.INSTANCE.scheduleNextExecution(context);
} }
if (isRunningMarshmallowOrLater()) { if (isRunningMarshmallowOrLater()) {
@@ -2296,22 +2294,6 @@ public class GBApplication extends Application {
return openTracksObserver; return openTracksObserver;
} }
public long getLastAutoExportTimestamp() {
return lastAutoExportTimestamp;
}
public void setLastAutoExportTimestamp(long lastAutoExportTimestamp) {
this.lastAutoExportTimestamp = lastAutoExportTimestamp;
}
public long getAutoExportScheduledTimestamp() {
return autoExportScheduledTimestamp;
}
public void setAutoExportScheduledTimestamp(long autoExportScheduledTimestamp) {
this.autoExportScheduledTimestamp = autoExportScheduledTimestamp;
}
private static class GBActivityLifecycleCallbacks implements ActivityLifecycleCallbacks{ private static class GBActivityLifecycleCallbacks implements ActivityLifecycleCallbacks{
@Override @Override
public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
@@ -52,12 +52,15 @@ import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
import nodomain.freeyourgadget.gadgetbridge.util.XDatePreference; import nodomain.freeyourgadget.gadgetbridge.util.XDatePreference;
import nodomain.freeyourgadget.gadgetbridge.util.XDatePreferenceFragment; import nodomain.freeyourgadget.gadgetbridge.util.XDatePreferenceFragment;
import nodomain.freeyourgadget.gadgetbridge.util.XTimePreference; import nodomain.freeyourgadget.gadgetbridge.util.XTimePreference;
@@ -266,9 +269,11 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragmentCompa
*/ */
private class SharedPreferencesChangeHandler implements SharedPreferences.OnSharedPreferenceChangeListener { private class SharedPreferencesChangeHandler implements SharedPreferences.OnSharedPreferenceChangeListener {
@Override @Override
public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) { public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) {
LOG.trace("Preference changed: {}", key); LOG.trace("Preference changed: {}", key);
final Prefs prefs = new Prefs(sharedPreferences);
if (key == null) { if (key == null) {
LOG.warn("Preference null, ignoring"); LOG.warn("Preference null, ignoring");
return; return;
@@ -281,18 +286,20 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragmentCompa
return; return;
} }
if (preference instanceof SeekBarPreference) { if (preference instanceof SeekBarPreference seekBarPreference) {
final SeekBarPreference seekBarPreference = (SeekBarPreference) preference;
seekBarPreference.setValue(prefs.getInt(key, seekBarPreference.getValue())); seekBarPreference.setValue(prefs.getInt(key, seekBarPreference.getValue()));
} else if (preference instanceof SwitchPreferenceCompat) { } else if (preference instanceof SwitchPreferenceCompat switchPreference) {
final SwitchPreferenceCompat switchPreference = (SwitchPreferenceCompat) preference;
switchPreference.setChecked(prefs.getBoolean(key, switchPreference.isChecked())); switchPreference.setChecked(prefs.getBoolean(key, switchPreference.isChecked()));
} else if (preference instanceof ListPreference) { } else if (preference instanceof ListPreference listPreference) {
final ListPreference listPreference = (ListPreference) preference;
listPreference.setValue(prefs.getString(key, listPreference.getValue())); listPreference.setValue(prefs.getString(key, listPreference.getValue()));
} else if (preference instanceof EditTextPreference) { } else if (preference instanceof EditTextPreference editTextPreference) {
final EditTextPreference editTextPreference = (EditTextPreference) preference;
editTextPreference.setText(prefs.getString(key, editTextPreference.getText())); editTextPreference.setText(prefs.getString(key, editTextPreference.getText()));
} else if (preference instanceof XTimePreference xTimePreference) {
final LocalTime localTime = prefs.getLocalTime(key, xTimePreference.getPrefValue());
xTimePreference.setValue(localTime.getHour(), localTime.getMinute());
} else if (preference instanceof XDatePreference xDatePreference) {
final LocalDate localDate = prefs.getLocalDate(key, xDatePreference.getPrefValue());
xDatePreference.setValue(localDate.getYear(), localDate.getMonthValue(), localDate.getDayOfMonth());
} else if (preference instanceof PreferenceScreen || Preference.class.equals(preference.getClass())) { } else if (preference instanceof PreferenceScreen || Preference.class.equals(preference.getClass())) {
LOG.trace("Unknown preference class {} for {}, ignoring", preference.getClass(), key); LOG.trace("Unknown preference class {} for {}, ignoring", preference.getClass(), key);
} else { } else {
@@ -303,12 +310,11 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragmentCompa
final String summary; final String summary;
// For multi select preferences, let's set the summary to the values, comma-delimited // For multi select preferences, let's set the summary to the values, comma-delimited
if (preference instanceof MultiSelectListPreference) { if (preference instanceof MultiSelectListPreference multiSelectListPreference) {
final Set<String> prefSetValue = prefs.getStringSet(key, Collections.emptySet()); final Set<String> prefSetValue = prefs.getStringSet(key, Collections.emptySet());
if (prefSetValue.isEmpty()) { if (prefSetValue.isEmpty()) {
summary = requireContext().getString(R.string.not_set); summary = requireContext().getString(R.string.not_set);
} else { } else {
final MultiSelectListPreference multiSelectListPreference = (MultiSelectListPreference) preference;
final CharSequence[] entries = multiSelectListPreference.getEntries(); final CharSequence[] entries = multiSelectListPreference.getEntries();
final CharSequence[] entryValues = multiSelectListPreference.getEntryValues(); final CharSequence[] entryValues = multiSelectListPreference.getEntryValues();
final List<String> translatedEntries = new ArrayList<>(); final List<String> translatedEntries = new ArrayList<>();
@@ -86,77 +86,85 @@ public class BackupRestoreProgressActivity extends AbstractGBActivity {
final TextView backupRestoreProgressText = binding.backupRestoreProgressText; final TextView backupRestoreProgressText = binding.backupRestoreProgressText;
final TextView backupRestoreProgressPercentage = binding.backupRestoreProgressPercentage; final TextView backupRestoreProgressPercentage = binding.backupRestoreProgressPercentage;
final Handler mHandler = new Handler(getMainLooper());
final ZipBackupCallback zipBackupCallback = new ZipBackupCallback() { final ZipBackupCallback zipBackupCallback = new ZipBackupCallback() {
@Override @Override
public void onProgress(final int progress, final String message) { public void onProgress(final int progress, final String message) {
backupRestoreProgressBar.setIndeterminate(progress == 0); mHandler.post(() -> {
backupRestoreProgressBar.setProgress(progress); backupRestoreProgressBar.setIndeterminate(progress == 0);
backupRestoreProgressText.setText(message); backupRestoreProgressBar.setProgress(progress);
backupRestoreProgressPercentage.setText(getString(R.string.battery_percentage_str, String.valueOf(progress))); backupRestoreProgressText.setText(message);
backupRestoreProgressPercentage.setText(getString(R.string.battery_percentage_str, String.valueOf(progress)));
});
} }
@Override @Override
public void onSuccess(final String warnings) { public void onSuccess(final String warnings) {
jobFinished = true; mHandler.post(() -> {
backupRestoreHint.setVisibility(View.GONE); jobFinished = true;
backupRestoreProgressBar.setProgress(100); backupRestoreHint.setVisibility(View.GONE);
backupRestoreProgressPercentage.setText(getString(R.string.battery_percentage_str, "100")); backupRestoreProgressBar.setProgress(100);
backupRestoreProgressPercentage.setText(getString(R.string.battery_percentage_str, "100"));
switch (action) { switch (action) {
case "import": case "import":
backupRestoreProgressText.setText(R.string.backup_restore_import_complete); 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) { if (warnings != null) {
message.append("\n\n").append(warnings); message.append("\n\n").append(warnings);
} }
new MaterialAlertDialogBuilder(BackupRestoreProgressActivity.this) new MaterialAlertDialogBuilder(BackupRestoreProgressActivity.this)
.setCancelable(false) .setCancelable(false)
.setIcon(R.drawable.ic_sync) .setIcon(R.drawable.ic_sync)
.setTitle(R.string.backup_restore_restart_title) .setTitle(R.string.backup_restore_restart_title)
.setMessage(message.toString()) .setMessage(message.toString())
.setOnCancelListener((dialog -> { .setOnCancelListener((dialog -> {
finish(); finish();
GBApplication.restart(); GBApplication.restart();
})) }))
.setPositiveButton(R.string.ok, (dialog, which) -> { .setPositiveButton(R.string.ok, (dialog, which) -> {
finish(); finish();
GBApplication.restart(); GBApplication.restart();
}).show(); }).show();
break; break;
case "export": case "export":
backupRestoreProgressText.setText(R.string.backup_restore_export_complete); backupRestoreProgressText.setText(R.string.backup_restore_export_complete);
break; break;
} }
});
} }
@Override @Override
public void onFailure(@Nullable final String errorMessage) { public void onFailure(@Nullable final String errorMessage) {
jobFinished = true; mHandler.post(() -> {
jobFinished = true;
switch (action) { switch (action) {
case "import": case "import":
backupRestoreHint.setText(R.string.backup_restore_error_import); backupRestoreHint.setText(R.string.backup_restore_error_import);
break; break;
case "export": case "export":
backupRestoreHint.setText(R.string.backup_restore_error_export); backupRestoreHint.setText(R.string.backup_restore_error_export);
break; 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();
} }
}
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();
}
}
});
} }
}; };
@@ -16,7 +16,6 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */ along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities; package nodomain.freeyourgadget.gadgetbridge.activities;
import android.content.Context;
import android.content.DialogInterface; import android.content.DialogInterface;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
@@ -53,15 +52,12 @@ import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.files.FileManagerActivity; import nodomain.freeyourgadget.gadgetbridge.activities.files.FileManagerActivity;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.database.PeriodicExporter;
import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils; import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
import nodomain.freeyourgadget.gadgetbridge.util.ImportExportSharedPreferences; import nodomain.freeyourgadget.gadgetbridge.util.ImportExportSharedPreferences;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public class DataManagementActivity extends AbstractGBActivity { public class DataManagementActivity extends AbstractGBActivity {
@@ -181,69 +177,12 @@ public class DataManagementActivity extends AbstractGBActivity {
cleanExportDirectory(); 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); sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this);
} }
private String getAutoExportLocationPreferenceString() { 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) { if (autoExportLocation == null) {
return ""; return "";
} }
@@ -277,14 +216,6 @@ public class DataManagementActivity extends AbstractGBActivity {
return ""; return "";
} }
private String getAutoExportLocationUserString() {
String location = getAutoExportLocationUri();
if (location == "") {
return getString(R.string.activity_db_management_autoexport_location);
}
return location;
}
private boolean hasOldActivityDatabase() { private boolean hasOldActivityDatabase() {
return new DBHelper(this).existsDB("ActivityDatabase"); return new DBHelper(this).existsDB("ActivityDatabase");
} }
@@ -25,14 +25,11 @@ import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo; import android.content.pm.ResolveInfo;
import android.database.Cursor;
import android.location.Criteria; import android.location.Criteria;
import android.location.Location; import android.location.Location;
import android.location.LocationListener; import android.location.LocationListener;
import android.location.LocationManager; import android.location.LocationManager;
import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.provider.DocumentsContract;
import android.text.InputType; import android.text.InputType;
import android.view.View; import android.view.View;
import android.widget.AdapterView; import android.widget.AdapterView;
@@ -64,17 +61,15 @@ import java.util.Set;
import nodomain.freeyourgadget.gadgetbridge.BuildConfig; import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.automations.AutomationsSettingsActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.ChartsPreferencesActivity; import nodomain.freeyourgadget.gadgetbridge.activities.charts.ChartsPreferencesActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryPairingPreferenceActivity; import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryPairingPreferenceActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsSettingsActivity; import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsSettingsActivity;
import nodomain.freeyourgadget.gadgetbridge.database.PeriodicExporter;
import nodomain.freeyourgadget.gadgetbridge.externalevents.TimeChangeReceiver; import nodomain.freeyourgadget.gadgetbridge.externalevents.TimeChangeReceiver;
import nodomain.freeyourgadget.gadgetbridge.model.weather.Weather; import nodomain.freeyourgadget.gadgetbridge.model.weather.Weather;
import nodomain.freeyourgadget.gadgetbridge.model.weather.WeatherCacheManager; import nodomain.freeyourgadget.gadgetbridge.model.weather.WeatherCacheManager;
import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs; import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public class SettingsActivity extends AbstractSettingsActivityV2 { public class SettingsActivity extends AbstractSettingsActivityV2 {
@@ -101,6 +96,8 @@ public class SettingsActivity extends AbstractSettingsActivityV2 {
open(NotificationManagementActivity.class, result); open(NotificationManagementActivity.class, result);
} else if (result.getResourceFile() == R.xml.map_settings) { } else if (result.getResourceFile() == R.xml.map_settings) {
open(MapsSettingsActivity.class, result); open(MapsSettingsActivity.class, result);
} else if (result.getResourceFile() == R.xml.automations_settings) {
open(AutomationsSettingsActivity.class, result);
} else { } else {
super.onSearchResultClicked(result); super.onSearchResultClicked(result);
} }
@@ -109,7 +106,6 @@ public class SettingsActivity extends AbstractSettingsActivityV2 {
public static class SettingsFragment extends AbstractPreferenceFragment { public static class SettingsFragment extends AbstractPreferenceFragment {
private static final Logger LOG = LoggerFactory.getLogger(SettingsActivity.class); private static final Logger LOG = LoggerFactory.getLogger(SettingsActivity.class);
private static final int EXPORT_LOCATION_FILE_REQUEST_CODE = 4711;
private EditText fitnessAppEditText = null; private EditText fitnessAppEditText = null;
private int fitnessAppSelectionListSpinnerFirstRun = 0; 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.discovery_pairing_preferences, R.string.activity_prefs_discovery_pairing);
index(R.xml.notifications_preferences, R.string.pref_header_notifications); index(R.xml.notifications_preferences, R.string.pref_header_notifications);
index(R.xml.map_settings, R.string.maps_settings); 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("rtl_max_line_length", InputType.TYPE_CLASS_NUMBER);
setInputTypeFor("location_latitude", InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED); 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("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(); Prefs prefs = GBApplication.getPrefs();
Preference pref = findPreference("pref_category_activity_personal"); 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"); final ListPreference audioPlayer = findPreference("audio_player");
if (audioPlayer != null) { if (audioPlayer != null) {
// Get all receivers of Media Buttons // 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"); pref = findPreference("pref_category_sleepasandroid");
if (pref != null) { if (pref != null) {
pref.setOnPreferenceClickListener(preference -> { 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 * delayed execution so that the preferences are applied first
*/ */
@@ -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 <https://www.gnu.org/licenses/>. */
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<Intent?, ActivityResult?>(
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<Preference>(prefKeyLocation)?.setSummary(summary)
}
)
setInputTypeFor(prefKeyInterval, InputType.TYPE_CLASS_NUMBER)
val prefExportLocation = findPreference<Preference>(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<Preference>(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<Preference>(prefKeyEnabled)
prefExportEnabled?.setOnPreferenceChangeListener { preference: Preference?, autoExportEnabled: Any? ->
scheduleNextExecutionDelayed()
true
}
findPreference<Preference>(prefKeyRunNow)?.setOnPreferenceClickListener {
exporter.executeNow()
true
}
val prefStartTime = findPreference<Preference>(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<Preference>(prefKeyStatus)
val prefLastExecution = findPreference<Preference>(prefKeyLastExecution)
val prefNextExecution = findPreference<Preference>(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)
}
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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)
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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)
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<Preference>(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)
}
}
}
}
}
@@ -2,6 +2,7 @@ package nodomain.freeyourgadget.gadgetbridge.database
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.content.edit
import androidx.work.Worker import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import nodomain.freeyourgadget.gadgetbridge.GBApplication import nodomain.freeyourgadget.gadgetbridge.GBApplication
@@ -17,12 +18,21 @@ class DatabaseExportWorker(
workerParams: WorkerParameters workerParams: WorkerParameters
) : Worker(mContext, workerParams) { ) : Worker(mContext, workerParams) {
override fun doWork(): Result { 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) LOG.info("Starting DB export, dst={}", dst)
if (dst == null) { if (dst == null) {
LOG.warn("Unable to export DB, export location not set") LOG.warn("Unable to export DB, export location not set")
GB.updateExportFailedNotification(mContext.getString(R.string.notif_export_location_not_set), mContext)
broadcastSuccess(false) broadcastSuccess(false)
return Result.failure() 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) { } catch (e: Exception) {
GB.updateExportFailedNotification(mContext.getString(R.string.notif_export_failed_title), mContext) GB.updateExportFailedNotification(mContext.getString(R.string.notif_export_failed_title), mContext)
LOG.error("Exception while exporting DB", e) LOG.error("Exception while exporting DB", e)
@@ -51,7 +63,7 @@ class DatabaseExportWorker(
} }
private fun broadcastSuccess(success: Boolean) { 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 return
} }
@@ -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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.database
import androidx.work.ListenableWorker
import nodomain.freeyourgadget.gadgetbridge.util.PeriodicExporter
object PeriodicDbExporter: PeriodicExporter() {
override fun getWorkerClass(): Class<out ListenableWorker> {
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"
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<DatabaseExportWorker>(
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)
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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);
}
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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()
}
}
@@ -65,7 +65,7 @@ public class BluetoothStateChangeReceiver extends BroadcastReceiver {
return; return;
} }
if (!prefs.getBoolean("general_autoconnectonbluetooth", false)) { if (!prefs.getBoolean(GBPrefs.AUTO_CONNECT_BLUETOOTH, false)) {
return; return;
} }
@@ -36,7 +36,7 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; 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.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.CallSpec; 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.NotificationType;
import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes; import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs; import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
import nodomain.freeyourgadget.gadgetbridge.util.backup.PeriodicZipExporter;
public class IntentApiReceiver extends BroadcastReceiver { public class IntentApiReceiver extends BroadcastReceiver {
private static final Logger LOG = LoggerFactory.getLogger(IntentApiReceiver.class); 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"; 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"; 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 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_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_INCOMING_CALL = "nodomain.freeyourgadget.gadgetbridge.command.DEBUG_INCOMING_CALL";
public static final String COMMAND_DEBUG_END_CALL = "nodomain.freeyourgadget.gadgetbridge.command.DEBUG_END_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; break;
case COMMAND_TRIGGER_EXPORT: 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)) { 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; return;
} }
LOG.info("Triggering export"); LOG.info("Triggering db export");
final Intent exportIntent = new Intent(context, PeriodicExporter.class); PeriodicDbExporter.INSTANCE.executeNow();
context.sendBroadcast(exportIntent); 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; break;
case COMMAND_DEBUG_SEND_NOTIFICATION: case COMMAND_DEBUG_SEND_NOTIFICATION:
@@ -1468,8 +1468,7 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
} }
} }
if (GBApplication.getPrefs().getBoolean("auto_fetch_enabled", false) && if (features.supportsActivityDataFetching() && mGBAutoFetchReceiver == null) {
features.supportsActivityDataFetching() && mGBAutoFetchReceiver == null) {
mGBAutoFetchReceiver = new GBAutoFetchReceiver(); mGBAutoFetchReceiver = new GBAutoFetchReceiver();
ContextCompat.registerReceiver(this, mGBAutoFetchReceiver, new IntentFilter("android.intent.action.USER_PRESENT"), ContextCompat.RECEIVER_EXPORTED); ContextCompat.registerReceiver(this, mGBAutoFetchReceiver, new IntentFilter("android.intent.action.USER_PRESENT"), ContextCompat.RECEIVER_EXPORTED);
} }
@@ -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. This file is part of Gadgetbridge.
@@ -28,6 +28,7 @@ import java.util.Date;
import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes; import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes;
import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
public class GBAutoFetchReceiver extends BroadcastReceiver { public class GBAutoFetchReceiver extends BroadcastReceiver {
@@ -37,6 +38,9 @@ public class GBAutoFetchReceiver extends BroadcastReceiver {
@Override @Override
public void onReceive(final Context context, final Intent intent) { public void onReceive(final Context context, final Intent intent) {
if (!GBApplication.getPrefs().getBoolean(GBPrefs.PREF_AUTO_FETCH_ENABLED, false)) {
return;
}
synchronized (this) { synchronized (this) {
final Date now = new Date(); final Date now = new Date();
final long timeSinceLast = now.getTime() - lastSync.getTime(); final long timeSinceLast = now.getTime() - lastSync.getTime();
@@ -256,11 +256,12 @@ public class AndroidUtils {
String[] projection = { String[] projection = {
MediaStore.Images.Media.DATA MediaStore.Images.Media.DATA
}; };
Cursor cursor = context.getContentResolver() try (Cursor cursor = context.getContentResolver()
.query(uri, projection, selection, selectionArgs, null); .query(uri, projection, selection, selectionArgs, null)) {
int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA); int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
return cursor.getString(column_index); return cursor.getString(column_index);
}
} }
} else if ("file".equalsIgnoreCase(uri.getScheme())) { } else if ("file".equalsIgnoreCase(uri.getScheme())) {
return uri.getPath(); return uri.getPath();
@@ -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_ID_SCAN_SERVICE = "gadgetbridge_scan_service";
public static final String NOTIFICATION_CHANNEL_HIGH_PRIORITY_ID = "gadgetbridge_high_priority"; 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_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_LOW_BATTERY = "low_battery";
public static final String NOTIFICATION_CHANNEL_ID_FULL_BATTERY = "full_battery"; public static final String NOTIFICATION_CHANNEL_ID_FULL_BATTERY = "full_battery";
public static final String NOTIFICATION_CHANNEL_ID_GPS = "gps"; public static final String NOTIFICATION_CHANNEL_ID_GPS = "gps";
@@ -147,6 +148,12 @@ public class GB {
NotificationManager.IMPORTANCE_LOW); NotificationManager.IMPORTANCE_LOW);
notificationManager.createNotificationChannel(channelTransfer); 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( NotificationChannel channelLowBattery = new NotificationChannel(
NOTIFICATION_CHANNEL_ID_LOW_BATTERY, NOTIFICATION_CHANNEL_ID_LOW_BATTERY,
context.getString(R.string.notification_channel_low_battery_name), context.getString(R.string.notification_channel_low_battery_name),
@@ -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_AUTO_RECONNECT = "prefs_key_device_auto_reconnect";
public static final String DEVICE_CONNECT_BACK = "prefs_key_device_reconnect_on_acl"; public static final String DEVICE_CONNECT_BACK = "prefs_key_device_reconnect_on_acl";
private static final String AUTO_START = "general_autostartonboot"; private static final String AUTO_START = "general_autostartonboot";
public static final String AUTO_EXPORT_ENABLED = "auto_export_enabled"; public static final String AUTO_CONNECT_BLUETOOTH = "general_autoconnectonbluetooth";
public static final String AUTO_EXPORT_LOCATION = "auto_export_location";
public static final String PING_TONE = "ping_tone"; 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; private static final boolean AUTO_START_DEFAULT = true;
public static final String RTL_SUPPORT = "rtl"; public static final String RTL_SUPPORT = "rtl";
public static final String RTL_CONTEXTUAL_ARABIC = "contextualArabic"; public static final String RTL_CONTEXTUAL_ARABIC = "contextualArabic";
public static boolean AUTO_RECONNECT_DEFAULT = true; 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_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 String RECONNECT_SCAN_KEY = "prefs_general_key_auto_reconnect_scan";
public static final boolean RECONNECT_SCAN_DEFAULT = false; public static final boolean RECONNECT_SCAN_DEFAULT = false;
@@ -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<out ListenableWorker>
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-"
}
}
@@ -48,7 +48,7 @@ public class Prefs {
public String getString(String key, String defaultValue) { public String getString(String key, String defaultValue) {
String value = preferences.getString(key, defaultValue); String value = preferences.getString(key, defaultValue);
if (value == null || "".equals(value)) { if (value == null || value.isEmpty()) {
return defaultValue; return defaultValue;
} }
return value; return value;
@@ -92,7 +92,7 @@ public class XDatePreference extends DialogPreference {
return maxDate; return maxDate;
} }
String getPrefValue() { public String getPrefValue() {
return String.format(Locale.ROOT, "%04d-%02d-%02d", year, month, day); return String.format(Locale.ROOT, "%04d-%02d-%02d", year, month, day);
} }
@@ -102,6 +102,8 @@ public class XDatePreference extends DialogPreference {
this.day = day; this.day = day;
persistStringValue(getPrefValue()); persistStringValue(getPrefValue());
updateSummary();
} }
void updateSummary() { void updateSummary() {
@@ -31,34 +31,19 @@ public class XTimePreference extends DialogPreference {
protected Format format = Format.AUTO; protected Format format = Format.AUTO;
public XTimePreference(Context context, AttributeSet attrs) { public XTimePreference(final Context context, final AttributeSet attrs) {
super(context, attrs); super(context, attrs);
} }
@Override @Override
protected Object onGetDefaultValue(TypedArray a, int index) { protected Object onGetDefaultValue(final TypedArray a, final int index) {
return a.getString(index); return a.getString(index);
} }
@Override @Override
protected void onSetInitialValue(boolean restoreValue, Object defaultValue) { protected void onSetInitialValue(final Object defaultValue) {
String time; final String time = getPersistedString((String) defaultValue);
final String[] pieces = time.split(":");
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(":");
hour = Integer.parseInt(pieces[0]); hour = Integer.parseInt(pieces[0]);
minute = Integer.parseInt(pieces[1]); minute = Integer.parseInt(pieces[1]);
@@ -75,6 +60,8 @@ public class XTimePreference extends DialogPreference {
this.minute = minute; this.minute = minute;
persistStringValue(getPrefValue()); persistStringValue(getPrefValue());
updateSummary();
} }
void updateSummary() { void updateSummary() {
@@ -85,14 +72,14 @@ public class XTimePreference extends DialogPreference {
} }
String getTime24h() { String getTime24h() {
return String.format("%02d", hour) + ":" + String.format("%02d", minute); return String.format(Locale.ROOT, "%02d:%02d", hour, minute);
} }
private String getTime12h() { private String getTime12h() {
String suffix = hour < 12 ? " AM" : " PM"; final String suffix = hour < 12 ? "AM" : "PM";
int h = hour > 12 ? hour - 12 : hour; 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) { public void setFormat(final Format format) {
@@ -103,26 +90,21 @@ public class XTimePreference extends DialogPreference {
return format; return format;
} }
void persistStringValue(String value) { void persistStringValue(final String value) {
persistString(value); persistString(value);
} }
public boolean is24HourFormat() { public boolean is24HourFormat() {
switch (format) { return switch (format) {
case FORMAT_24H: case FORMAT_24H -> true;
return true; case FORMAT_12H -> false;
case FORMAT_12H: default -> DateFormat.is24HourFormat(getContext());
return false; };
case AUTO:
default:
return DateFormat.is24HourFormat(getContext());
}
} }
public enum Format { public enum Format {
AUTO, AUTO,
FORMAT_24H, FORMAT_24H,
FORMAT_12H, FORMAT_12H,
;
} }
} }
@@ -46,7 +46,6 @@ public abstract class AbstractZipBackupJob implements Runnable {
.create(); .create();
private final Context mContext; private final Context mContext;
private final Handler mHandler;
private final ZipBackupCallback mCallback; private final ZipBackupCallback mCallback;
private final AtomicBoolean aborted = new AtomicBoolean(false); 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) { public AbstractZipBackupJob(final Context context, final ZipBackupCallback callback) {
this.mContext = context; this.mContext = context;
this.mHandler = new Handler(context.getMainLooper());
this.mCallback = callback; this.mCallback = callback;
} }
@@ -83,18 +81,16 @@ public abstract class AbstractZipBackupJob implements Runnable {
} }
lastProgressUpdateTs = now; lastProgressUpdateTs = now;
lastProgressUpdateMessage = message; lastProgressUpdateMessage = message;
mHandler.post(() -> { mCallback.onProgress(percentage, getContext().getString(message, formatArgs));
mCallback.onProgress(percentage, getContext().getString(message, formatArgs));
});
} }
@WorkerThread @WorkerThread
protected void onSuccess(final String warnings) { protected void onSuccess(final String warnings) {
mHandler.post(() -> mCallback.onSuccess(warnings)); mCallback.onSuccess(warnings);
} }
@WorkerThread @WorkerThread
protected void onFailure(final String errorMessage) { protected void onFailure(final String errorMessage) {
mHandler.post(() -> mCallback.onFailure(errorMessage)); mCallback.onFailure(errorMessage);
} }
} }
@@ -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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.util.backup
import androidx.work.ListenableWorker
import nodomain.freeyourgadget.gadgetbridge.util.PeriodicExporter
object PeriodicZipExporter: PeriodicExporter() {
override fun getWorkerClass(): Class<out ListenableWorker> {
return ZipExportWorker::class.java
}
override fun getKeyPrefix(): String {
return "zip_"
}
override fun getFileMimeType(): String {
return "application/zip"
}
override fun getFileExtension(): String {
return "zip"
}
}
@@ -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"
}
}
+11
View File
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,840q-151,0 -255.5,-46.5T120,680v-400q0,-66 105.5,-113T480,120q149,0 254.5,47T840,280v400q0,67 -104.5,113.5T480,840ZM480,361q89,0 179,-25.5T760,281q-11,-29 -100.5,-55T480,200q-91,0 -178.5,25.5T200,281q14,30 101.5,55T480,361ZM480,560q42,0 81,-4t74.5,-11.5q35.5,-7.5 67,-18.5t57.5,-25v-120q-26,14 -57.5,25t-67,18.5Q600,432 561,436t-81,4q-42,0 -82,-4t-75.5,-11.5Q287,417 256,406t-56,-25v120q25,14 56,25t66.5,18.5Q358,552 398,556t82,4ZM480,760q46,0 93.5,-7t87.5,-18.5q40,-11.5 67,-26t32,-29.5v-98q-26,14 -57.5,25t-67,18.5Q600,632 561,636t-81,4q-42,0 -82,-4t-75.5,-11.5Q287,617 256,606t-56,-25v99q5,15 31.5,29t66.5,25.5q40,11.5 88,18.5t94,7Z" />
</vector>
+11
View File
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M640,480v-80h80v80h-80ZM640,560h-80v-80h80v80ZM640,640v-80h80v80h-80ZM447,320l-80,-80L160,240v480h400v-80h80v80h160v-400L640,320v80h-80v-80L447,320ZM160,800q-33,0 -56.5,-23.5T80,720v-480q0,-33 23.5,-56.5T160,160h240l80,80h320q33,0 56.5,23.5T880,320v400q0,33 -23.5,56.5T800,800L160,800ZM160,720v-480,480Z" />
</vector>
@@ -155,58 +155,6 @@
android:layout_weight="1" android:layout_weight="1"
android:text="@string/activity_db_management_clean_export_directory_label" /> android:text="@string/activity_db_management_clean_export_directory_label" />
<TextView
android:id="@+id/autoExportTitleLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/activity_db_management_autoexport_label"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="@color/accent" />
<TextView
android:id="@+id/autoExportEnabled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textAppearance="?android:attr/textAppearanceSmall" />
<TextView
android:id="@+id/autoExportScheduled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/activity_db_management_autoexport_scheduled_no"
android:textAppearance="?android:attr/textAppearanceSmall" />
<TextView
android:id="@+id/autoExport_lastTime_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/autoExport_lastTime_label"
android:textAppearance="?android:attr/textAppearanceSmall" />
<TextView
android:id="@+id/autoExportLocation_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/activity_db_management_autoexport_explanation"
android:textAppearance="?android:attr/textAppearanceSmall" />
<TextView
android:id="@+id/autoExportLocation_path"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textStyle="bold" />
<Button
android:id="@+id/testExportDBButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/activity_DB_test_export_button"
grid:layout_gravity="center" />
<TextView <TextView
android:id="@+id/mergeOldActivityDataTitle" android:id="@+id/mergeOldActivityDataTitle"
android:layout_width="match_parent" android:layout_width="match_parent"
+21 -5
View File
@@ -625,6 +625,8 @@
<string name="pref_sleep_mode_smart_enable_summary">Enable the sleep mode automatically when wearing the band during sleep</string> <string name="pref_sleep_mode_smart_enable_summary">Enable the sleep mode automatically when wearing the band during sleep</string>
<!-- Auto export preferences --> <!-- Auto export preferences -->
<string name="pref_header_auto_export">Auto export</string> <string name="pref_header_auto_export">Auto export</string>
<string name="pref_header_auto_export_db">Auto export database</string>
<string name="pref_header_auto_export_zip">Auto export zip</string>
<string name="pref_title_auto_export_enabled">Auto export enabled</string> <string name="pref_title_auto_export_enabled">Auto export enabled</string>
<string name="pref_title_auto_export_location">Export location</string> <string name="pref_title_auto_export_location">Export location</string>
<string name="pref_title_auto_export_interval">Export interval</string> <string name="pref_title_auto_export_interval">Export interval</string>
@@ -936,6 +938,9 @@
<string name="notif_battery_full">%1$s battery full</string> <string name="notif_battery_full">%1$s battery full</string>
<string name="notif_battery_low_extended">%1$s battery low: %2$s</string> <string name="notif_battery_low_extended">%1$s battery low: %2$s</string>
<string name="notif_export_failed_title">Export database failed! Please check your settings.</string> <string name="notif_export_failed_title">Export database failed! Please check your settings.</string>
<string name="notif_export_location_not_set">Export location not configured</string>
<string name="notif_export_zip_failed_title">Export zip failed! Please check your settings.</string>
<string name="unknown_error">Unknown error</string>
<string name="prefs_charts_tabs">Charts tabs</string> <string name="prefs_charts_tabs">Charts tabs</string>
<string name="prefs_charts_tabs_summary">Visible chart tabs</string> <string name="prefs_charts_tabs_summary">Visible chart tabs</string>
<string name="sleepchart_your_sleep">Sleep</string> <string name="sleepchart_your_sleep">Sleep</string>
@@ -1360,13 +1365,8 @@
<string name="dbmanagementactivity_old_activity_db_successfully_deleted">Old activity data deleted.</string> <string name="dbmanagementactivity_old_activity_db_successfully_deleted">Old activity data deleted.</string>
<string name="dbmanagementactivity_old_activity_db_deletion_failed">Old Activity database deletion failed.</string> <string name="dbmanagementactivity_old_activity_db_deletion_failed">Old Activity database deletion failed.</string>
<string name="dbmanagementactivity_overwrite">Overwrite</string> <string name="dbmanagementactivity_overwrite">Overwrite</string>
<string name="activity_db_management_autoexport_explanation">Database autoexport location has been set to:</string>
<string name="autoExport_lastTime_label">Last AutoExport: %1$s</string>
<string name="activity_db_management_autoexport_enabled_yes">AutoExport is enabled.</string>
<string name="activity_db_management_autoexport_enabled_no">AutoExport is not enabled.</string>
<string name="activity_db_management_autoexport_scheduled_yes">AutoExport has (originally) been scheduled for %1$s</string> <string name="activity_db_management_autoexport_scheduled_yes">AutoExport has (originally) been scheduled for %1$s</string>
<string name="activity_db_management_autoexport_scheduled_no">AutoExport has not been not scheduled.</string> <string name="activity_db_management_autoexport_scheduled_no">AutoExport has not been not scheduled.</string>
<string name="activity_db_management_autoexport_label">AutoExport</string>
<string name="activity_db_management_autoexport_location">Location could not be understood. Likely an issue of newer Android permission system. Most likely, autoexport is not working now.</string> <string name="activity_db_management_autoexport_location">Location could not be understood. Likely an issue of newer Android permission system. Most likely, autoexport is not working now.</string>
<string name="activity_DB_ExportButton">Export Data</string> <string name="activity_DB_ExportButton">Export Data</string>
<string name="activity_DB_import_button">Import Data</string> <string name="activity_DB_import_button">Import Data</string>
@@ -3452,8 +3452,13 @@
<string name="intent_api_allow_trigger_export_summary">Allow triggering database export via Intent API</string> <string name="intent_api_allow_trigger_export_summary">Allow triggering database export via Intent API</string>
<string name="intent_api_broadcast_export_title">Broadcast on database export</string> <string name="intent_api_broadcast_export_title">Broadcast on database export</string>
<string name="intent_api_broadcast_export_summary">Broadcast an intent when database export finishes</string> <string name="intent_api_broadcast_export_summary">Broadcast an intent when database export finishes</string>
<string name="intent_api_allow_trigger_zip_export_title">Allow zip export</string>
<string name="intent_api_allow_trigger_zip_export_summary">Allow triggering zip export via Intent API</string>
<string name="intent_api_broadcast_zip_export_title">Broadcast on zip export</string>
<string name="intent_api_broadcast_zip_export_summary">Broadcast an intent when zip export finishes</string>
<string name="intent_api_broadcast_activity_sync_title">Broadcast on activity sync finish</string> <string name="intent_api_broadcast_activity_sync_title">Broadcast on activity sync finish</string>
<string name="intent_api_broadcast_activity_sync_summary">Broadcast an intent when activity sync finishes for any device</string> <string name="intent_api_broadcast_activity_sync_summary">Broadcast an intent when activity sync finishes for any device</string>
<string name="intent_activity_sync_title">Activity sync</string>
<string name="intent_api_allow_debug_commands_title">Allow Debug Commands</string> <string name="intent_api_allow_debug_commands_title">Allow Debug Commands</string>
<string name="intent_api_allow_debug_commands_summary">Allow triggering debug menu commands via Intent API</string> <string name="intent_api_allow_debug_commands_summary">Allow triggering debug menu commands via Intent API</string>
<string name="devicetype_super_cars" translatable="false">Shell Racing</string> <string name="devicetype_super_cars" translatable="false">Shell Racing</string>
@@ -4265,4 +4270,15 @@
<string name="connect">Connect</string> <string name="connect">Connect</string>
<string name="pref_new_sync_protocol_title">New sync protocol</string> <string name="pref_new_sync_protocol_title">New sync protocol</string>
<string name="pref_new_sync_protocol_summary">Enable this if some information is not being fetched. Experimental and potentially unstable.</string> <string name="pref_new_sync_protocol_summary">Enable this if some information is not being fetched. Experimental and potentially unstable.</string>
<string name="auto_export_last_execution">Last execution</string>
<string name="auto_export_next_execution">Next execution</string>
<string name="work_info_status_running">Running</string>
<string name="work_info_status_enqueued">Enqueued</string>
<string name="work_info_status_succeeded">Succeeded</string>
<string name="work_info_status_failed">Failed</string>
<string name="work_info_status_blocked">Blocked</string>
<string name="work_info_status_cancelled">Cancelled</string>
<string name="work_info_running_percentage">%1$s (%2$d%%)</string>
<string name="datetime_in_the_future">%1$s (in %2$s)</string>
<string name="datetime_in_the_past">%1$s (%2$s ago)</string>
</resources> </resources>
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<!--
WARNING: All the preferences in this page get a prefix added programmatically
by AbstractAutoExportSettingsFragment
-->
<SwitchPreferenceCompat
android:defaultValue="false"
android:icon="@drawable/ic_toggle_on"
android:key="auto_export_enabled"
android:layout="@layout/preference_checkbox"
android:title="@string/pref_title_auto_export_enabled"
app:iconSpaceReserved="false" />
<Preference
android:dependency="auto_export_enabled"
android:icon="@drawable/ic_file_export"
android:key="auto_export_location"
android:title="@string/pref_title_auto_export_location"
app:iconSpaceReserved="false" />
<EditTextPreference
android:defaultValue="3"
android:dependency="auto_export_enabled"
android:icon="@drawable/ic_timer"
android:inputType="number"
android:key="auto_export_interval"
android:maxLength="3"
android:summary="@string/pref_summary_auto_export_interval"
android:title="@string/pref_title_auto_export_interval"
app:iconSpaceReserved="false" />
<nodomain.freeyourgadget.gadgetbridge.util.XTimePreference
android:defaultValue="00:00"
android:dependency="auto_export_enabled"
android:icon="@drawable/ic_access_time"
android:key="auto_export_start_time"
android:title="@string/mi2_prefs_do_not_disturb_start" />
<PreferenceCategory
android:key="pref_header_status"
android:title="@string/status">
<Preference
android:icon="@drawable/ic_info"
android:key="auto_export_status"
android:summary="@string/unknown"
android:title="@string/status" />
<Preference
android:icon="@drawable/ic_access_time"
android:key="auto_export_last_execution"
android:summary="@string/unknown"
android:title="@string/auto_export_last_execution" />
<Preference
android:icon="@drawable/ic_access_time"
android:key="auto_export_next_execution"
android:summary="@string/unknown"
android:title="@string/auto_export_next_execution" />
</PreferenceCategory>
<PreferenceCategory
android:key="pref_header_test"
android:title="@string/test">
<Preference
android:dependency="auto_export_enabled"
android:icon="@drawable/ic_play"
android:key="auto_export_run_now"
android:title="@string/activity_DB_test_export_button" />
</PreferenceCategory>
</PreferenceScreen>
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory android:title="@string/pref_header_auto_export">
<Preference
android:icon="@drawable/ic_database"
android:title="@string/pref_header_auto_export_db">
<intent
android:targetClass="nodomain.freeyourgadget.gadgetbridge.activities.automations.AutoExportDbSettingsActivity"
android:targetPackage="@string/applicationId" />
</Preference>
<Preference
android:icon="@drawable/ic_zip"
android:title="@string/pref_header_auto_export_zip">
<intent
android:targetClass="nodomain.freeyourgadget.gadgetbridge.activities.automations.AutoExportZipSettingsActivity"
android:targetPackage="@string/applicationId" />
</Preference>
</PreferenceCategory>
<PreferenceCategory android:title="@string/pref_header_auto_fetch">
<SwitchPreferenceCompat
android:defaultValue="false"
android:icon="@drawable/ic_refresh"
android:key="auto_fetch_enabled"
android:layout="@layout/preference_checkbox"
android:summary="@string/pref_auto_fetch_summary"
android:title="@string/pref_auto_fetch"
app:iconSpaceReserved="false" />
<EditTextPreference
android:defaultValue="0"
android:dependency="auto_fetch_enabled"
android:icon="@drawable/ic_timer"
android:inputType="number"
android:key="auto_fetch_interval_limit"
android:maxLength="3"
android:summary="@string/pref_auto_fetch_limit_fetches_summary"
android:title="@string/pref_auto_fetch_limit_fetches"
app:iconSpaceReserved="false" />
</PreferenceCategory>
</PreferenceScreen>
+84 -84
View File
@@ -246,55 +246,11 @@
android:title="@string/maps" android:title="@string/maps"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
<PreferenceScreen <Preference
android:icon="@drawable/ic_auto_awesome" android:icon="@drawable/ic_auto_awesome"
android:key="pref_screen_automations" android:key="pref_screen_automations"
android:summary="@string/pref_description_automations" android:summary="@string/pref_description_automations"
android:title="@string/pref_header_automations"> android:title="@string/pref_header_automations" />
<PreferenceCategory
android:title="@string/pref_header_auto_export">
<Preference
android:key="auto_export_location"
android:title="@string/pref_title_auto_export_location"
android:icon="@drawable/ic_file_export" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="auto_export_enabled"
android:layout="@layout/preference_checkbox"
android:title="@string/pref_title_auto_export_enabled"
android:icon="@drawable/ic_toggle_on" />
<EditTextPreference
android:defaultValue="3"
android:dependency="auto_export_enabled"
android:inputType="number"
android:key="auto_export_interval"
android:maxLength="3"
android:summary="@string/pref_summary_auto_export_interval"
android:title="@string/pref_title_auto_export_interval"
android:icon="@drawable/ic_timer" />
</PreferenceCategory>
<PreferenceCategory
android:title="@string/pref_header_auto_fetch">
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="auto_fetch_enabled"
android:layout="@layout/preference_checkbox"
android:summary="@string/pref_auto_fetch_summary"
android:title="@string/pref_auto_fetch"
android:icon="@drawable/ic_refresh" />
<EditTextPreference
android:defaultValue="0"
android:dependency="auto_fetch_enabled"
android:inputType="number"
android:key="auto_fetch_interval_limit"
android:maxLength="3"
android:summary="@string/pref_auto_fetch_limit_fetches_summary"
android:title="@string/pref_auto_fetch_limit_fetches"
android:icon="@drawable/ic_timer" />
</PreferenceCategory>
</PreferenceScreen>
<PreferenceScreen <PreferenceScreen
android:icon="@drawable/ic_extension" android:icon="@drawable/ic_extension"
@@ -408,53 +364,97 @@
android:title="@string/pref_cache_weather" android:title="@string/pref_cache_weather"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
<PreferenceCategory <PreferenceScreen
android:key="pref_screen_intent_api" android:key="pref_screen_intent_api"
android:title="@string/pref_header_intent_api" android:title="@string/pref_header_intent_api"
app:iconSpaceReserved="false"> app:iconSpaceReserved="false">
<SwitchPreferenceCompat <PreferenceCategory
android:key="prefs_key_allow_bluetooth_intent_api" android:key="pref_header_bluetooth"
android:layout="@layout/preference_checkbox" android:title="@string/menuitem_bluetooth"
android:summary="@string/activity_prefs_summary_allow_bluetooth_intent_api" app:iconSpaceReserved="false">
android:title="@string/activity_prefs_allow_bluetooth_intent_api"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:key="intent_api_allow_activity_sync" android:key="prefs_key_allow_bluetooth_intent_api"
android:layout="@layout/preference_checkbox" android:layout="@layout/preference_checkbox"
android:summary="@string/intent_api_allow_activity_sync_summary" android:summary="@string/activity_prefs_summary_allow_bluetooth_intent_api"
android:title="@string/intent_api_allow_activity_sync_title" android:title="@string/activity_prefs_allow_bluetooth_intent_api"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
</PreferenceCategory>
<SwitchPreferenceCompat <PreferenceCategory
android:key="intent_api_broadcast_activity_sync" android:key="pref_header_activity_sync"
android:layout="@layout/preference_checkbox" android:title="@string/intent_activity_sync_title"
android:summary="@string/intent_api_broadcast_activity_sync_summary" app:iconSpaceReserved="false">
android:title="@string/intent_api_broadcast_activity_sync_title"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:key="intent_api_allow_trigger_export" android:key="intent_api_allow_activity_sync"
android:layout="@layout/preference_checkbox" android:layout="@layout/preference_checkbox"
android:summary="@string/intent_api_allow_trigger_export_summary" android:summary="@string/intent_api_allow_activity_sync_summary"
android:title="@string/intent_api_allow_trigger_export_title" android:title="@string/intent_api_allow_activity_sync_title"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:key="intent_api_broadcast_export" android:key="intent_api_broadcast_activity_sync"
android:layout="@layout/preference_checkbox" android:layout="@layout/preference_checkbox"
android:summary="@string/intent_api_broadcast_export_summary" android:summary="@string/intent_api_broadcast_activity_sync_summary"
android:title="@string/intent_api_broadcast_export_title" android:title="@string/intent_api_broadcast_activity_sync_title"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
</PreferenceCategory>
<SwitchPreferenceCompat <PreferenceCategory
android:key="intent_api_allow_debug_commands" android:key="pref_header_auto_export_db"
android:layout="@layout/preference_checkbox" android:title="@string/pref_header_auto_export_db"
android:summary="@string/intent_api_allow_debug_commands_summary" app:iconSpaceReserved="false">
android:title="@string/intent_api_allow_debug_commands_title"
app:iconSpaceReserved="false" /> <SwitchPreferenceCompat
</PreferenceCategory> android:key="intent_api_allow_trigger_export"
android:layout="@layout/preference_checkbox"
android:summary="@string/intent_api_allow_trigger_export_summary"
android:title="@string/intent_api_allow_trigger_export_title"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
android:key="intent_api_broadcast_export"
android:layout="@layout/preference_checkbox"
android:summary="@string/intent_api_broadcast_export_summary"
android:title="@string/intent_api_broadcast_export_title"
app:iconSpaceReserved="false" />
</PreferenceCategory>
<PreferenceCategory
android:key="pref_header_auto_export_zip"
android:title="@string/pref_header_auto_export_zip"
app:iconSpaceReserved="false">
<SwitchPreferenceCompat
android:key="intent_api_allow_trigger_zip_export"
android:layout="@layout/preference_checkbox"
android:summary="@string/intent_api_allow_trigger_zip_export_summary"
android:title="@string/intent_api_allow_trigger_zip_export_title"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
android:key="intent_api_broadcast_zip_export"
android:layout="@layout/preference_checkbox"
android:summary="@string/intent_api_broadcast_zip_export_summary"
android:title="@string/intent_api_broadcast_zip_export_title"
app:iconSpaceReserved="false" />
</PreferenceCategory>
<PreferenceCategory
android:key="pref_header_debug"
android:title="@string/action_debug"
app:iconSpaceReserved="false">
<SwitchPreferenceCompat
android:key="intent_api_allow_debug_commands"
android:layout="@layout/preference_checkbox"
android:summary="@string/intent_api_allow_debug_commands_summary"
android:title="@string/intent_api_allow_debug_commands_title"
app:iconSpaceReserved="false" />
</PreferenceCategory>
</PreferenceScreen>
</PreferenceScreen> </PreferenceScreen>
<PreferenceScreen <PreferenceScreen