diff --git a/app/build.gradle b/app/build.gradle
index a31afa69fc..f67dc5c00f 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -291,6 +291,10 @@ dependencies {
// Needed for Armenian transliteration
implementation 'org.ahocorasick:ahocorasick:0.6.3'
+
+ // Android Health Connect
+ implementation 'com.google.guava:guava:33.4.8-android' // This is needed because CameraActivity.java line 41 doesn't have a ListenableFuture package
+ implementation 'androidx.health.connect:connect-client:1.1.0'
}
preBuild.dependsOn(":GBDaoGenerator:genSources")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4a30095334..8ffd8ab3ca 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -217,6 +237,10 @@
android:name=".activities.InternetHelperPreferencesActivity"
android:label="@string/prefs_internet_helper_title"
android:parentActivityName=".activities.SettingsActivity" />
+
+ tools:node="merge"
+ android:exported="false">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java
index f6e9e6852f..5b010a187c 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java
@@ -126,6 +126,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue;
import nodomain.freeyourgadget.gadgetbridge.util.PermissionsUtils;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
import nodomain.freeyourgadget.gadgetbridge.util.backup.PeriodicZipExporter;
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectPermissionManager;
import nodomain.freeyourgadget.gadgetbridge.util.preferences.DevicePrefs;
/**
@@ -357,6 +358,10 @@ public class GBApplication extends Application {
startNotificationCollectorMonitorService();
BondingUtil.StartObservingAll(getBaseContext());
+
+ if (prefs.getBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false)) {
+ HealthConnectPermissionManager.checkAndRectifyPermissions(this);
+ }
}
private void startNotificationCollectorMonitorService() {
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractPreferenceFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractPreferenceFragment.java
index ae1c666c4a..d7acbb774b 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractPreferenceFragment.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractPreferenceFragment.java
@@ -279,10 +279,19 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragmentCompa
return;
}
+ // Skip internal programmatic preferences that aren't in XML
+ final Set internalPreferences = Set.of(
+ "health_connect_last_granted_permissions",
+ "health_connect_prompt_for_full_dao_reset"
+ );
+ if (internalPreferences.contains(key)) {
+ LOG.trace("Internal preference {} changed, ignoring in UI update", key);
+ return;
+ }
+
final Preference preference = findPreference(key);
if (preference == null) {
LOG.warn("Preference {} not found", key);
-
return;
}
@@ -292,6 +301,11 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragmentCompa
switchPreference.setChecked(prefs.getBoolean(key, switchPreference.isChecked()));
} else if (preference instanceof ListPreference listPreference) {
listPreference.setValue(prefs.getString(key, listPreference.getValue()));
+ } else if (preference instanceof MultiSelectListPreference multiSelectListPreference) {
+ Set values = prefs.getStringSet(key, multiSelectListPreference.getValues());
+ if (values != null) {
+ multiSelectListPreference.setValues(values);
+ }
} else if (preference instanceof EditTextPreference editTextPreference) {
editTextPreference.setText(prefs.getString(key, editTextPreference.getText()));
} else if (preference instanceof XTimePreference xTimePreference) {
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HealthConnectInitialSyncDialog.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HealthConnectInitialSyncDialog.java
new file mode 100644
index 0000000000..fb3d41a7dc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HealthConnectInitialSyncDialog.java
@@ -0,0 +1,201 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.activities;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+import android.widget.Spinner;
+
+import androidx.annotation.NonNull;
+
+import com.google.android.material.datepicker.MaterialDatePicker;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+import nodomain.freeyourgadget.gadgetbridge.util.dialogs.MaterialDialogFragment;
+
+public class HealthConnectInitialSyncDialog extends MaterialDialogFragment {
+ private static final Logger LOG = LoggerFactory.getLogger(HealthConnectInitialSyncDialog.class);
+
+ private RadioGroup radioGroup;
+ private RadioButton radioPreset;
+ private RadioButton radioCustomDate;
+ private Spinner spinnerPreset;
+ private Button buttonSelectDate;
+ private LocalDate customSelectedDate = null;
+
+ public interface InitialSyncDialogListener {
+ void onSyncPeriodSelected();
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ Context context = requireContext();
+ LayoutInflater inflater = requireActivity().getLayoutInflater();
+ View dialogView = inflater.inflate(R.layout.dialog_health_connect_initial_sync, null);
+
+ radioGroup = dialogView.findViewById(R.id.radio_group_sync_period);
+ radioPreset = dialogView.findViewById(R.id.radio_preset);
+ radioCustomDate = dialogView.findViewById(R.id.radio_custom_date);
+ spinnerPreset = dialogView.findViewById(R.id.spinner_sync_period);
+ buttonSelectDate = dialogView.findViewById(R.id.button_select_date);
+
+ // Setup preset spinner
+ ArrayAdapter adapter = ArrayAdapter.createFromResource(context,
+ R.array.health_connect_initial_sync_periods, android.R.layout.simple_spinner_item);
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ spinnerPreset.setAdapter(adapter);
+ spinnerPreset.setSelection(1); // Default to 7 days
+
+ // Radio button listeners to enable/disable controls
+ radioGroup.setOnCheckedChangeListener((group, checkedId) -> {
+ if (checkedId == R.id.radio_preset) {
+ spinnerPreset.setEnabled(true);
+ buttonSelectDate.setEnabled(false);
+ } else if (checkedId == R.id.radio_custom_date) {
+ spinnerPreset.setEnabled(false);
+ buttonSelectDate.setEnabled(true);
+ }
+ });
+
+ // Initially select preset option
+ radioPreset.setChecked(true);
+ spinnerPreset.setEnabled(true);
+ buttonSelectDate.setEnabled(false);
+
+ // Date picker button
+ buttonSelectDate.setOnClickListener(v -> showDatePicker());
+
+ MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context);
+ builder.setView(dialogView)
+ .setTitle(R.string.health_connect_initial_sync_dialog_title)
+ .setPositiveButton(R.string.ok, (dialog, id) -> {
+ handleSyncPeriodSelection();
+ })
+ .setNegativeButton(R.string.Cancel, (dialog, id) -> dismiss());
+
+ return builder.create();
+ }
+
+ private void showDatePicker() {
+ MaterialDatePicker datePicker = MaterialDatePicker.Builder.datePicker()
+ .setTitleText(R.string.health_connect_initial_sync_select_date)
+ .setSelection(MaterialDatePicker.todayInUtcMilliseconds())
+ .build();
+
+ datePicker.addOnPositiveButtonClickListener(selection -> {
+ // Convert UTC milliseconds to LocalDate in system timezone
+ customSelectedDate = Instant.ofEpochMilli(selection)
+ .atZone(ZoneId.systemDefault())
+ .toLocalDate();
+ buttonSelectDate.setText(DateTimeFormatter.ofPattern("yyyy-MM-dd").format(customSelectedDate));
+ });
+
+ datePicker.show(getParentFragmentManager(), "DATE_PICKER");
+ }
+
+ private void handleSyncPeriodSelection() {
+ long startTimestampSeconds;
+
+ if (radioPreset.isChecked()) {
+ int selectedPosition = spinnerPreset.getSelectedItemPosition();
+ int daysToSync;
+ switch (selectedPosition) {
+ case 0: // 3 days
+ daysToSync = 3;
+ break;
+ case 1: // 7 days
+ daysToSync = 7;
+ break;
+ case 2: // 30 days
+ daysToSync = 30;
+ break;
+ case 3: // Sync all
+ daysToSync = -1; // Special value to indicate sync all
+ break;
+ default:
+ daysToSync = 7; // Default fallback
+ }
+
+ if (daysToSync == -1) {
+ startTimestampSeconds = -1;
+ LOG.info("Initial sync period selected: Sync all data");
+ } else {
+ Instant now = Instant.now();
+ startTimestampSeconds = now.minusSeconds(daysToSync * 24L * 60L * 60L).getEpochSecond();
+ LOG.info("Initial sync period selected: {} days (from preset)", daysToSync);
+ }
+
+ } else if (radioCustomDate.isChecked() && customSelectedDate != null) {
+ startTimestampSeconds = customSelectedDate.atStartOfDay(ZoneId.systemDefault())
+ .toInstant()
+ .getEpochSecond();
+ LOG.info("Initial sync period selected: custom date {}", customSelectedDate);
+
+ } else {
+ // Fallback to 7 days if nothing selected properly
+ Instant now = Instant.now();
+ startTimestampSeconds = now.minusSeconds(7 * 24L * 60L * 60L).getEpochSecond();
+ LOG.warn("No valid sync period selected, defaulting to 7 days");
+ }
+
+ GBApplication.getContext()
+ .getSharedPreferences(GBPrefs.HEALTH_CONNECT_SETTINGS, Context.MODE_PRIVATE)
+ .edit()
+ .putLong(GBPrefs.HEALTH_CONNECT_INITIAL_SYNC_START_TS, startTimestampSeconds)
+ .commit();
+
+ dismiss();
+ showSyncNowDialog();
+ }
+
+ private void showSyncNowDialog() {
+ new MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.health_connect_sync_now_title)
+ .setMessage(R.string.health_connect_sync_now_message)
+ .setPositiveButton(R.string.health_connect_sync_now, (dialog, which) -> {
+ if (getTargetFragment() instanceof InitialSyncDialogListener) {
+ ((InitialSyncDialogListener) getTargetFragment()).onSyncPeriodSelected();
+ } else if (getParentFragment() instanceof InitialSyncDialogListener) {
+ ((InitialSyncDialogListener) getParentFragment()).onSyncPeriodSelected();
+ }
+ })
+ .setNegativeButton(R.string.health_connect_sync_later, (dialog, which) -> {
+ })
+ .setCancelable(false)
+ .show();
+ }
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HealthConnectResetDialogFragment.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HealthConnectResetDialogFragment.kt
new file mode 100644
index 0000000000..b5152d2982
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HealthConnectResetDialogFragment.kt
@@ -0,0 +1,62 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.activities
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import nodomain.freeyourgadget.gadgetbridge.R
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectPermissionManager
+import org.slf4j.LoggerFactory
+
+class HealthConnectResetDialogFragment : DialogFragment() {
+
+ companion object {
+ const val TAG = "HealthConnectResetDialog"
+ private val LOG = LoggerFactory.getLogger(HealthConnectResetDialogFragment::class.java)
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ return MaterialAlertDialogBuilder(requireActivity())
+ .setTitle(R.string.healthconnect_settings)
+ .setMessage(R.string.health_connect_prompt_full_dao_reset)
+ .setPositiveButton(R.string.health_connect_reset_sync_history) { _, _ ->
+ LOG.info("User chose to reset Health Connect sync states.")
+ HealthConnectPermissionManager.clearAllSyncStates(requireContext().applicationContext)
+ // The flag is now cleared by clearAllSyncStates via setHealthConnectPermissionResetNeeded
+ }
+ .setNegativeButton(R.string.health_connect_keep_sync_history) { _, _ ->
+ LOG.info("User cancelled Health Connect sync state reset.")
+ clearPromptFlag() // Call to clear the flag
+ }
+ .create()
+ }
+
+ override fun onCancel(dialog: DialogInterface) {
+ super.onCancel(dialog)
+ LOG.info("Health Connect sync state reset dialog was cancelled/dismissed.")
+ clearPromptFlag() // Call to clear the flag
+ }
+
+ private fun clearPromptFlag() {
+ // requireContext().applicationContext is used because the operation involves SharedPreferences
+ // and should ideally use the application context if the original context's lifecycle is a concern,
+ HealthConnectPermissionManager.setHealthConnectPermissionResetNeeded(requireContext().applicationContext, false)
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/PermissionsRationaleActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/PermissionsRationaleActivity.java
new file mode 100644
index 0000000000..f60b62a422
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/PermissionsRationaleActivity.java
@@ -0,0 +1,32 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.activities;
+
+import android.os.Bundle;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+/**
+ * Activity to show the rationale for Health Connect permissions.
+ */
+public class PermissionsRationaleActivity extends AbstractGBActivity {
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.permissions_rationale);
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java
index 0bf82c78c0..f325ee2874 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java
@@ -65,6 +65,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.automations.AutomationsSe
import nodomain.freeyourgadget.gadgetbridge.activities.charts.ChartsPreferencesActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryPairingPreferenceActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsSettingsActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.preferences.HealthConnectPreferencesActivity;
import nodomain.freeyourgadget.gadgetbridge.externalevents.TimeChangeReceiver;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
@@ -363,6 +364,15 @@ public class SettingsActivity extends AbstractSettingsActivityV2 {
}
}
+ pref = findPreference("pref_category_healthconnect");
+ if (pref != null) {
+ pref.setOnPreferenceClickListener(preference -> {
+ Intent enableIntent = new Intent(requireContext(), HealthConnectPreferencesActivity.class);
+ startActivity(enableIntent);
+ return true;
+ });
+ }
+
pref = findPreference("pref_category_notifications");
if (pref != null) {
pref.setOnPreferenceClickListener(preference -> {
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/debug/OtherDebugFragment.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/debug/OtherDebugFragment.kt
index fad6c87b04..d380af5709 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/debug/OtherDebugFragment.kt
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/debug/OtherDebugFragment.kt
@@ -96,6 +96,28 @@ class OtherDebugFragment : AbstractDebugFragment() {
}
}
+ onClick(PREF_DEBUG_RESET_HC_SYNC_STATE) {
+ MaterialAlertDialogBuilder(requireActivity())
+ .setCancelable(true)
+ .setTitle("Reset HC sync state")
+ .setMessage("Reset Health Connect sync state? This will allow all data to be re-synced.")
+ .setPositiveButton(R.string.ok) { _, _ ->
+ try {
+ val db = GBApplication.acquireDB()
+ try {
+ db.daoSession.healthConnectSyncStateDao.deleteAll()
+ GB.toast("Health Connect sync state reset successfully", Toast.LENGTH_SHORT, GB.INFO)
+ } finally {
+ GBApplication.releaseDB()
+ }
+ } catch (e: Exception) {
+ GB.toast("Failed to reset Health Connect sync state", Toast.LENGTH_LONG, GB.ERROR, e)
+ }
+ }
+ .setNegativeButton(R.string.Cancel) { _, _ -> }
+ .show()
+ }
+
onClick(PREF_DEBUG_FACTORY_RESET) {
MaterialAlertDialogBuilder(requireActivity())
.setCancelable(true)
@@ -119,6 +141,7 @@ class OtherDebugFragment : AbstractDebugFragment() {
private const val PREF_DEBUG_FETCH_DEBUG_LOGS = "pref_debug_fetch_debug_logs"
private const val PREF_DEBUG_HEADER_RESET = "pref_debug_header_reset"
private const val PREF_DEBUG_REBOOT = "pref_debug_reboot"
+ private const val PREF_DEBUG_RESET_HC_SYNC_STATE = "pref_debug_reset_hc_sync_state"
private const val PREF_DEBUG_FACTORY_RESET = "pref_debug_factory_reset"
}
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/preferences/HealthConnectPreferencesActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/preferences/HealthConnectPreferencesActivity.java
new file mode 100644
index 0000000000..447ae8ac75
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/preferences/HealthConnectPreferencesActivity.java
@@ -0,0 +1,764 @@
+/* Copyright (C) 2025 LLan, Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.activities.preferences;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.core.content.ContextCompat;
+import androidx.fragment.app.DialogFragment;
+import androidx.health.connect.client.HealthConnectClient;
+import androidx.health.connect.client.PermissionController;
+import androidx.lifecycle.LifecycleKt;
+import androidx.preference.MultiSelectListPreference;
+import androidx.preference.MultiSelectListPreferenceDialogFragmentCompat;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.SwitchPreferenceCompat;
+import androidx.work.Data;
+import androidx.work.ExistingWorkPolicy;
+import androidx.work.OneTimeWorkRequest;
+import androidx.work.WorkInfo;
+import androidx.work.WorkManager;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import kotlin.Unit;
+import kotlin.coroutines.Continuation;
+import kotlin.coroutines.intrinsics.IntrinsicsKt;
+import kotlinx.coroutines.BuildersKt;
+import kotlinx.coroutines.CoroutineStart;
+import kotlinx.coroutines.Dispatchers;
+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.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectClientProvider;
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectPermissionManager;
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectSyncWorker;
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.PermissionsResultOutcome;
+import nodomain.freeyourgadget.gadgetbridge.activities.HealthConnectInitialSyncDialog;
+
+
+public class HealthConnectPreferencesActivity extends AbstractSettingsActivityV2 {
+ @Override
+ protected PreferenceFragmentCompat newFragment() {
+ return new HealthConnectPreferencesFragment();
+ }
+
+ public static class HealthConnectPreferencesFragment extends AbstractPreferenceFragment implements HealthConnectInitialSyncDialog.InitialSyncDialogListener {
+ protected static final Logger LOG = LoggerFactory.getLogger(HealthConnectPreferencesFragment.class);
+
+ private static final String HEALTH_CONNECT_SYNC_WORKER_TAG = "HealthConnectSyncWorker";
+ private static final String HEALTH_CONNECT_ONETIME_WORK_NAME = "HealthConnectSyncWorker_OneTime";
+ private static final String HC_DEVICE_SELECT_DIALOG_TAG = "HC_DEVICE_SELECT_DIALOG";
+ private static final String HC_INITIAL_SYNC_DIALOG_TAG = "HC_INITIAL_SYNC_DIALOG_TAG";
+
+ private HealthConnectUtils healthConnectUtils;
+ private ActivityResultLauncher> requestPermissionLauncher;
+
+ private SwitchPreferenceCompat healthConnectEnabledPref;
+ private Preference healthConnectSyncStatus;
+ private Preference healthConnectDisableNotice;
+ private SwitchPreferenceCompat syncOnEventPref;
+ private MultiSelectListPreference selectedDevicesPref;
+ private Preference healthConnectManualSettings;
+ private Preference healthConnectSettings;
+ private boolean pendingHealthConnectPermissionRequest = false;
+ private Runnable permissionCheckRunnable;
+ private final android.os.Handler permissionCheckHandler = new android.os.Handler(android.os.Looper.getMainLooper());
+ private String lastDisplayedStatus = null;
+
+
+ @Override
+ public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
+ setPreferencesFromResource(R.xml.health_connect_preferences, rootKey);
+
+ healthConnectUtils = new HealthConnectUtils();
+
+ healthConnectEnabledPref = findPreference(GBPrefs.HEALTH_CONNECT_ENABLED);
+ healthConnectSyncStatus = findPreference(GBPrefs.HEALTH_CONNECT_SYNC_STATUS);
+ healthConnectDisableNotice = findPreference(GBPrefs.HEALTH_CONNECT_DISABLE_NOTICE);
+ syncOnEventPref = findPreference(GBPrefs.HEALTH_CONNECT_SYNC_ON_EVENT);
+ selectedDevicesPref = findPreference(GBPrefs.HEALTH_CONNECT_DEVICE_SELECTION);
+ healthConnectManualSettings = findPreference(GBPrefs.HEALTH_CONNECT_MANUAL_SETTINGS);
+ healthConnectSettings = findPreference(GBPrefs.HEALTH_CONNECT_SETTINGS);
+
+ requestPermissionLauncher = registerForActivityResult(
+ PermissionController.createRequestPermissionResultContract(),
+ grantedPermissions -> {
+ PermissionsResultOutcome outcome = HealthConnectPermissionManager.handlePermissionsResult(requireContext(), grantedPermissions);
+
+ if (outcome.getMessage() != null && getContext() != null) {
+ GB.toast(getContext(), outcome.getMessage(), Toast.LENGTH_LONG, outcome.getMessageType());
+ }
+
+ GBApplication.getPrefs().getPreferences().edit().putBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, outcome.getSuccess()).apply();
+ updateHealthConnectUIState(outcome.getSuccess());
+
+ if (outcome.getSuccess()) {
+ if (outcome.getStartSync()) {
+ HealthConnectInitialSyncDialog dialog = new HealthConnectInitialSyncDialog();
+ dialog.setTargetFragment(HealthConnectPreferencesFragment.this, 0);
+ dialog.show(getParentFragmentManager(), HC_INITIAL_SYNC_DIALOG_TAG);
+ }
+ } else {
+ WorkManager.getInstance(requireContext()).cancelAllWorkByTag(HEALTH_CONNECT_SYNC_WORKER_TAG);
+ }
+ });
+
+ setupHealthConnectSwitch();
+ setupManualSettingsLink();
+ setupHealthConnectSettingsLink();
+ setupDeviceMultiSelectList();
+
+ checkInitialPermissionsAndUpdateUI();
+ }
+
+ @Override
+ public void onViewCreated(@androidx.annotation.NonNull android.view.View view, @androidx.annotation.Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ WorkManager.getInstance(requireContext()).getWorkInfosByTagLiveData(HEALTH_CONNECT_SYNC_WORKER_TAG).observe(getViewLifecycleOwner(), workInfos -> {
+ if (workInfos == null || workInfos.isEmpty()) {
+ updateSyncStatusFromPreferences();
+ return;
+ }
+
+ WorkInfo runningWork = findRunningWork(workInfos);
+ handleWorkInfoUpdate(runningWork);
+ });
+ }
+
+ private WorkInfo findRunningWork(List workInfos) {
+ for (WorkInfo info : workInfos) {
+ if (info.getState() == WorkInfo.State.RUNNING) {
+ return info;
+ }
+ }
+ return null;
+ }
+
+ private void handleWorkInfoUpdate(WorkInfo runningWork) {
+ if (runningWork == null) {
+ updateSyncStatusFromPreferences();
+ return;
+ }
+
+ String storedStatus = GBApplication.getPrefs().getPreferences().getString(GBPrefs.HEALTH_CONNECT_SYNC_STATUS, "");
+ if (!storedStatus.contains("Finished")) {
+ updateSyncStatus(runningWork);
+ return;
+ }
+
+ String runningProgress = runningWork.getProgress().getString("progress");
+ if (runningProgress != null && runningProgress.contains("Finished")) {
+ updateSyncStatus(runningWork);
+ } else {
+ updateSyncStatusFromPreferences();
+ }
+ }
+
+ @Override
+ public void onSyncPeriodSelected() {
+ checkIfSyncIsRunningAndStartInitialSync();
+ }
+
+ private void checkIfSyncIsRunningAndStartInitialSync() {
+ WorkManager workManager = WorkManager.getInstance(requireContext());
+ ListenableFuture> future = workManager.getWorkInfosByTag(HEALTH_CONNECT_SYNC_WORKER_TAG);
+ future.addListener(() -> {
+ try {
+ List workInfos = future.get();
+ if (isSyncCurrentlyRunning(workInfos)) {
+ showSyncAlreadyRunningMessage();
+ return;
+ }
+ startInitialSyncWork(workManager);
+ } catch (Exception e) {
+ LOG.error("Error checking sync status", e);
+ }
+ }, ContextCompat.getMainExecutor(requireContext()));
+ }
+
+ private boolean isSyncCurrentlyRunning(List workInfos) {
+ if (workInfos == null) return false;
+
+ for (WorkInfo info : workInfos) {
+ if (info.getState() == WorkInfo.State.RUNNING) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void showSyncAlreadyRunningMessage() {
+ LOG.info("Sync already running, not starting initial sync");
+ if (!isAdded() || getActivity() == null || getActivity().isFinishing()) return;
+
+ getActivity().runOnUiThread(() -> {
+ if (isAdded()) {
+ GB.toast(requireContext(), getString(R.string.health_connect_sync_already_running), Toast.LENGTH_SHORT, GB.INFO);
+ }
+ });
+ }
+
+ private void startInitialSyncWork(WorkManager workManager) {
+ OneTimeWorkRequest syncRequest = new OneTimeWorkRequest.Builder(HealthConnectSyncWorker.class)
+ .setInputData(new Data.Builder().putBoolean("initial_sync", true).build())
+ .addTag(HEALTH_CONNECT_SYNC_WORKER_TAG)
+ .build();
+ workManager.enqueueUniqueWork(
+ HEALTH_CONNECT_ONETIME_WORK_NAME,
+ ExistingWorkPolicy.KEEP,
+ syncRequest
+ );
+ LOG.info("Initial sync work enqueued with KEEP policy");
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ checkPermissionsAndUpdateUI();
+ startPeriodicPermissionCheck();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ stopPeriodicPermissionCheck();
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ stopPeriodicPermissionCheck();
+ }
+
+ private void startPeriodicPermissionCheck() {
+ if (permissionCheckRunnable == null) {
+ permissionCheckRunnable = new Runnable() {
+ @Override
+ public void run() {
+ checkPermissionsAndUpdateUI();
+ permissionCheckHandler.postDelayed(this, 2000);
+ }
+ };
+ }
+ permissionCheckHandler.postDelayed(permissionCheckRunnable, 2000);
+ }
+
+ private void stopPeriodicPermissionCheck() {
+ if (permissionCheckRunnable != null) {
+ permissionCheckHandler.removeCallbacks(permissionCheckRunnable);
+ }
+ }
+
+ private void checkPermissionsAndUpdateUI() {
+ if (!isAdded() || getActivity() == null || getActivity().isFinishing()) {
+ return;
+ }
+
+ BuildersKt.launch(LifecycleKt.getCoroutineScope(getLifecycle()), Dispatchers.getIO(), CoroutineStart.DEFAULT, (scope, continuation) -> {
+ Object result = HealthConnectPermissionManager.INSTANCE.checkPermissionChange(requireContext(), continuation);
+ if (result == IntrinsicsKt.getCOROUTINE_SUSPENDED()) {
+ return result;
+ }
+
+ updateUIAfterPermissionCheck();
+ return Unit.INSTANCE;
+ });
+ }
+
+ private void updateUIAfterPermissionCheck() {
+ if (!isAdded() || getActivity() == null || getActivity().isFinishing()) {
+ return;
+ }
+
+ getActivity().runOnUiThread(() -> {
+ if (!isAdded() || getActivity() == null || getActivity().isFinishing()) {
+ return;
+ }
+ handlePermissionCheckResult();
+ });
+ }
+
+ private void handlePermissionCheckResult() {
+ if (HealthConnectPermissionManager.isHealthConnectPermissionResetNeeded(requireContext())) {
+ handlePermissionsRevoked();
+ } else {
+ checkInitialPermissionsAndUpdateUI();
+ }
+ }
+
+ private void handlePermissionsRevoked() {
+ boolean wasEnabled = GBApplication.getPrefs().getBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false);
+ if (!wasEnabled) {
+ HealthConnectPermissionManager.showResetDialogIfNecessary(requireActivity());
+ return;
+ }
+
+ LOG.info("All HC permissions revoked, disabling HC and cancelling all sync work");
+ GBApplication.getPrefs().getPreferences().edit().putBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false).apply();
+ updateHealthConnectUIState(false);
+
+ WorkManager.getInstance(requireContext()).cancelUniqueWork(HEALTH_CONNECT_ONETIME_WORK_NAME);
+ WorkManager.getInstance(requireContext()).cancelAllWorkByTag(HEALTH_CONNECT_SYNC_WORKER_TAG);
+ LOG.info("Cancelled all Health Connect sync work due to permission removal");
+
+ clearSyncStatus();
+ HealthConnectPermissionManager.showResetDialogIfNecessary(requireActivity());
+ }
+
+ private void clearSyncStatus() {
+ if (healthConnectSyncStatus != null) {
+ healthConnectSyncStatus.setSummary("");
+ healthConnectSyncStatus.setVisible(false);
+ }
+ lastDisplayedStatus = null;
+ GBApplication.getPrefs().getPreferences().edit()
+ .putString(GBPrefs.HEALTH_CONNECT_SYNC_STATUS, "")
+ .apply();
+ }
+
+ private void updateSyncStatus(WorkInfo workInfo) {
+ if (healthConnectSyncStatus == null) return;
+
+ String statusSummary;
+ boolean isSyncing;
+ switch (workInfo.getState()) {
+ case ENQUEUED:
+ case RUNNING:
+ statusSummary = workInfo.getProgress().getString("progress");
+ if (statusSummary == null) {
+ statusSummary = getString(R.string.health_connect_syncing);
+ isSyncing = true;
+ } else {
+ isSyncing = !statusSummary.contains("Finished");
+ }
+ break;
+ case SUCCEEDED:
+ case FAILED:
+ case BLOCKED:
+ case CANCELLED:
+ default:
+ updateSyncStatusFromPreferences();
+ return;
+ }
+
+ if (lastDisplayedStatus != null && lastDisplayedStatus.contains("Finished") &&
+ !statusSummary.contains("Finished")) {
+ return;
+ }
+
+ if (statusSummary.equals(lastDisplayedStatus)) {
+ return;
+ }
+
+ lastDisplayedStatus = statusSummary;
+ healthConnectSyncStatus.setSummary(statusSummary);
+ }
+
+ private void updateSyncStatusFromPreferences() {
+ if (healthConnectSyncStatus == null) return;
+
+ String storedStatus = GBApplication.getPrefs().getPreferences().getString(GBPrefs.HEALTH_CONNECT_SYNC_STATUS, "");
+
+ if (storedStatus.equals(lastDisplayedStatus)) {
+ return;
+ }
+
+ lastDisplayedStatus = storedStatus;
+ healthConnectSyncStatus.setSummary(storedStatus);
+ }
+
+ private void updateHealthConnectUIState(boolean enabled) {
+ if (healthConnectEnabledPref != null) {
+ healthConnectEnabledPref.setChecked(enabled);
+ healthConnectEnabledPref.setEnabled(true);
+ }
+ if (healthConnectSyncStatus != null) {
+ healthConnectSyncStatus.setVisible(enabled);
+ }
+ if (healthConnectDisableNotice != null) healthConnectDisableNotice.setVisible(enabled);
+
+ // Keep these always enabled to prevent lockout when permission requests are denied
+ if (selectedDevicesPref != null) {
+ selectedDevicesPref.setEnabled(true);
+ }
+ if (syncOnEventPref != null) {
+ syncOnEventPref.setEnabled(true);
+ }
+
+ if (healthConnectManualSettings != null) healthConnectManualSettings.setEnabled(enabled);
+ if (healthConnectSettings != null) healthConnectSettings.setEnabled(enabled);
+ }
+
+
+ private void showSdkStatusMessage(int sdkStatus) {
+ if (healthConnectSyncStatus != null && getContext() != null) {
+ int messageResId = sdkStatus == HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED
+ ? R.string.health_connect_update_required
+ : R.string.health_connect_unsupported;
+ healthConnectSyncStatus.setSummary(getContext().getString(messageResId));
+ healthConnectSyncStatus.setVisible(true);
+ }
+ }
+
+ private void checkInitialPermissionsAndUpdateUI() {
+ Context context = getContext();
+ if (context == null || healthConnectEnabledPref == null) return;
+
+ boolean hcEnabledInPrefs = GBApplication.getPrefs().getBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false);
+ healthConnectEnabledPref.setChecked(hcEnabledInPrefs);
+
+ if (!hcEnabledInPrefs) {
+ handleHealthConnectDisabledState(context);
+ return;
+ }
+
+ int sdkStatus = HealthConnectClient.getSdkStatus(context);
+ if (sdkStatus != HealthConnectClient.SDK_AVAILABLE) {
+ handleSdkUnavailable(context, sdkStatus);
+ return;
+ }
+
+ HealthConnectClient client = HealthConnectClientProvider.healthConnectInit(context);
+ if (client == null) {
+ handleClientInitializationFailed(context);
+ return;
+ }
+
+ fetchAndProcessPermissions(client);
+ }
+
+ private void handleHealthConnectDisabledState(Context context) {
+ updateHealthConnectUIState(false);
+ if (healthConnectSyncStatus != null) {
+ healthConnectSyncStatus.setSummary("");
+ healthConnectSyncStatus.setVisible(false);
+ }
+ WorkManager.getInstance(context).cancelAllWorkByTag(HEALTH_CONNECT_SYNC_WORKER_TAG);
+ }
+
+ private void handleSdkUnavailable(Context context, int sdkStatus) {
+ GBApplication.getPrefs().getPreferences().edit().putBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false).apply();
+ updateHealthConnectUIState(false);
+ showSdkStatusMessage(sdkStatus);
+ WorkManager.getInstance(context).cancelAllWorkByTag(HEALTH_CONNECT_SYNC_WORKER_TAG);
+ }
+
+ private void handleClientInitializationFailed(Context context) {
+ GBApplication.getPrefs().getPreferences().edit().putBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false).apply();
+ updateHealthConnectUIState(false);
+ if (healthConnectSyncStatus != null) {
+ healthConnectSyncStatus.setSummary(R.string.health_connect_unavailable_summary);
+ healthConnectSyncStatus.setVisible(true);
+ }
+ WorkManager.getInstance(context).cancelAllWorkByTag(HEALTH_CONNECT_SYNC_WORKER_TAG);
+ }
+
+
+ private void fetchAndProcessPermissions(HealthConnectClient client) {
+ BuildersKt.launch(LifecycleKt.getCoroutineScope(getLifecycle()), Dispatchers.getMain(), CoroutineStart.DEFAULT, (scope, continuation) -> {
+ Object resultFromKotlin = HealthConnectPreferencesLogicKt.getGrantedHealthConnectPermissions(client, (Continuation super Set>) continuation);
+
+ if (resultFromKotlin == IntrinsicsKt.getCOROUTINE_SUSPENDED()) {
+ return IntrinsicsKt.getCOROUTINE_SUSPENDED();
+ }
+
+ Set grantedPermissions = (Set) resultFromKotlin;
+
+ try {
+ processPermissionsResult(grantedPermissions);
+ } catch (Exception e) {
+ handlePermissionsProcessingError(e);
+ }
+ return Unit.INSTANCE;
+ });
+ }
+
+ private void processPermissionsResult(Set grantedPermissions) {
+ if (grantedPermissions == null) {
+ handlePermissionsCheckError();
+ return;
+ }
+
+ boolean hasPermissions = !grantedPermissions.isEmpty();
+
+ if (GBApplication.getPrefs().getBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false) != hasPermissions) {
+ GBApplication.getPrefs().getPreferences().edit().putBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, hasPermissions).apply();
+ }
+
+ updateHealthConnectUIState(hasPermissions);
+ if (!hasPermissions) {
+ WorkManager.getInstance(requireContext()).cancelAllWorkByTag(HEALTH_CONNECT_SYNC_WORKER_TAG);
+ }
+ }
+
+ private void handlePermissionsCheckError() {
+ LOG.error("Error checking initial Health Connect permissions: Kotlin helper indicated an error.");
+ GBApplication.getPrefs().getPreferences().edit().putBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false).apply();
+ updateHealthConnectUIState(false);
+ if (healthConnectSyncStatus != null) {
+ healthConnectSyncStatus.setSummary(R.string.health_connect_permission_check_error);
+ healthConnectSyncStatus.setVisible(true);
+ }
+ WorkManager.getInstance(requireContext()).cancelAllWorkByTag(HEALTH_CONNECT_SYNC_WORKER_TAG);
+ }
+
+ private void handlePermissionsProcessingError(Exception e) {
+ LOG.error("Error processing Health Connect permissions result or updating UI", e);
+ GBApplication.getPrefs().getPreferences().edit().putBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false).apply();
+ updateHealthConnectUIState(false);
+ if (healthConnectSyncStatus != null) {
+ healthConnectSyncStatus.setSummary(R.string.health_connect_permission_check_error);
+ healthConnectSyncStatus.setVisible(true);
+ }
+ WorkManager.getInstance(requireContext()).cancelAllWorkByTag(HEALTH_CONNECT_SYNC_WORKER_TAG);
+ }
+
+ private void setupHealthConnectSwitch() {
+ if (healthConnectEnabledPref == null || getContext() == null) {
+ return;
+ }
+
+ healthConnectEnabledPref.setOnPreferenceChangeListener((preference, newValue) -> {
+ boolean enabling = (boolean) newValue;
+ Context context = getContext();
+ if (context == null) return false;
+
+ if (enabling) {
+ handleHealthConnectEnabling(context);
+ return false;
+ } else {
+ return handleHealthConnectDisabling(context);
+ }
+ });
+ }
+
+ private void handleHealthConnectEnabling(Context context) {
+ int sdkStatus = HealthConnectClient.getSdkStatus(context);
+ if (sdkStatus != HealthConnectClient.SDK_AVAILABLE) {
+ showSdkStatusMessage(sdkStatus);
+ return;
+ }
+
+ if (healthConnectSyncStatus != null) {
+ healthConnectSyncStatus.setSummary("");
+ }
+
+ if (needsDeviceSelection()) {
+ showDeviceSelectionDialog();
+ return;
+ }
+
+ if (healthConnectUtils != null) {
+ requestPermissionLauncher.launch(HealthConnectPermissionManager.getRequiredHealthConnectPermissions());
+ }
+ }
+
+ private boolean needsDeviceSelection() {
+ if (selectedDevicesPref == null || selectedDevicesPref.getKey() == null) {
+ return false;
+ }
+ Set currentValues = selectedDevicesPref.getValues();
+ return currentValues == null || currentValues.isEmpty();
+ }
+
+ private void showDeviceSelectionDialog() {
+ pendingHealthConnectPermissionRequest = true;
+ if (getParentFragmentManager().findFragmentByTag(HC_DEVICE_SELECT_DIALOG_TAG) == null) {
+ DialogFragment dialog = MultiSelectListPreferenceDialogFragmentCompat.newInstance(selectedDevicesPref.getKey());
+ dialog.setTargetFragment(HealthConnectPreferencesFragment.this, 0);
+ dialog.show(getParentFragmentManager(), HC_DEVICE_SELECT_DIALOG_TAG);
+ }
+ }
+
+ private boolean handleHealthConnectDisabling(Context context) {
+ LOG.info("User disabled Health Connect, cancelling all sync work");
+ pendingHealthConnectPermissionRequest = false;
+ GBApplication.getPrefs().getPreferences().edit().putBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false).apply();
+ updateHealthConnectUIState(false);
+
+ WorkManager.getInstance(context).cancelUniqueWork(HEALTH_CONNECT_ONETIME_WORK_NAME);
+ WorkManager.getInstance(context).cancelAllWorkByTag(HEALTH_CONNECT_SYNC_WORKER_TAG);
+ LOG.info("Cancelled all Health Connect sync work (including running syncs)");
+
+
+ if (healthConnectSyncStatus != null) {
+ healthConnectSyncStatus.setSummary("");
+ }
+ GBApplication.getPrefs().getPreferences().edit()
+ .putString(GBPrefs.HEALTH_CONNECT_SYNC_STATUS, "")
+ .apply();
+
+ return true;
+ }
+
+
+ private void setupManualSettingsLink() {
+ if (healthConnectManualSettings != null && getContext() != null) {
+ healthConnectManualSettings.setOnPreferenceClickListener(preference -> {
+ Context context = getContext();
+ if (context == null) return false;
+ try {
+ Intent healthConnectManageDataIntent = HealthConnectClient.getHealthConnectManageDataIntent(context);
+ healthConnectManageDataIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
+ startActivity(healthConnectManageDataIntent);
+ } catch (Exception e) {
+ LOG.error("Could not open Health Connect settings", e);
+ if (healthConnectSyncStatus != null) {
+ healthConnectSyncStatus.setSummary(R.string.health_connect_settings_open_error);
+ healthConnectSyncStatus.setVisible(true);
+ }
+ }
+ return true;
+ });
+ }
+ }
+
+ private void setupHealthConnectSettingsLink() {
+ if (healthConnectSettings != null && getContext() != null) {
+ healthConnectSettings.setOnPreferenceClickListener(preference -> {
+ Context context = getContext();
+ if (context == null) return false;
+ try {
+ final Intent intent;
+ if (android.os.Build.VERSION.SDK_INT < 34) {
+ intent = new Intent("androidx.health.ACTION_HEALTH_CONNECT_SETTINGS");
+ } else {
+ intent = new Intent(HealthConnectClient.getHealthConnectSettingsAction());
+ }
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
+ startActivity(intent);
+ } catch (Exception e) {
+ LOG.error("Could not open Health Connect settings", e);
+ if (healthConnectSyncStatus != null) {
+ healthConnectSyncStatus.setSummary(R.string.health_connect_settings_open_error);
+ healthConnectSyncStatus.setVisible(true);
+ }
+ }
+ return true;
+ });
+ }
+ }
+
+ private void setupDeviceMultiSelectList() {
+ if (selectedDevicesPref != null && getContext() != null) {
+ List devices = GBApplication.app().getDeviceManager().getDevices();
+ List deviceMACs = new ArrayList<>();
+ List deviceNames = new ArrayList<>();
+ for (GBDevice dev : devices) {
+ deviceMACs.add(dev.getAddress());
+ deviceNames.add(dev.getAliasOrName());
+ }
+ selectedDevicesPref.setEntryValues(deviceMACs.toArray(new String[0]));
+ selectedDevicesPref.setEntries(deviceNames.toArray(new String[0]));
+
+ cleanupDeletedDevicesFromSelection(new java.util.HashSet<>(deviceMACs));
+
+ selectedDevicesPref.setOnPreferenceChangeListener((preference, newValue) -> {
+ Context context = getContext();
+ if (context == null) {
+ return false;
+ }
+
+ if (pendingHealthConnectPermissionRequest) {
+ handlePendingPermissionRequest(context, newValue);
+ }
+ return true;
+ });
+ }
+ }
+
+ private void handlePendingPermissionRequest(Context context, Object newValue) {
+ pendingHealthConnectPermissionRequest = false;
+
+ int sdkStatus = HealthConnectClient.getSdkStatus(context);
+ if (sdkStatus != HealthConnectClient.SDK_AVAILABLE) {
+ showSdkStatusMessage(sdkStatus);
+ return;
+ }
+
+ if (healthConnectSyncStatus != null) {
+ healthConnectSyncStatus.setSummary("");
+ }
+
+ Set newSelectedValues = (newValue instanceof Set) ? (Set) newValue : null;
+ if (newSelectedValues == null || newSelectedValues.isEmpty()) {
+ LOG.warn("Device selection listener triggered but no devices selected; not launching permissions.");
+ return;
+ }
+
+ if (healthConnectUtils != null) {
+ requestPermissionLauncher.launch(HealthConnectPermissionManager.getRequiredHealthConnectPermissions());
+ } else {
+ LOG.error("healthConnectUtils is null, cannot launch permissions.");
+ }
+ }
+
+ private void cleanupDeletedDevicesFromSelection(Set validDeviceAddresses) {
+ Set selectedDevices = GBApplication.getPrefs().getPreferences()
+ .getStringSet(GBPrefs.HEALTH_CONNECT_DEVICE_SELECTION, null);
+
+ if (selectedDevices == null || selectedDevices.isEmpty()) {
+ return;
+ }
+
+ Set currentSelection = new HashSet<>(selectedDevices);
+ Set cleanedSelection = new HashSet<>();
+ int removedCount = 0;
+
+ for (String address : currentSelection) {
+ if (validDeviceAddresses.contains(address)) {
+ cleanedSelection.add(address);
+ } else {
+ LOG.info("Removing deleted device {} from Health Connect selection", address);
+ removedCount++;
+ }
+ }
+
+ if (removedCount > 0) {
+ LOG.info("Cleaned up {} deleted device(s) from Health Connect device selection", removedCount);
+ GBApplication.getPrefs().getPreferences().edit()
+ .putStringSet(GBPrefs.HEALTH_CONNECT_DEVICE_SELECTION, cleanedSelection)
+ .apply();
+
+ if (selectedDevicesPref != null) {
+ selectedDevicesPref.setValues(cleanedSelection);
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/preferences/HealthConnectPreferencesLogic.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/preferences/HealthConnectPreferencesLogic.kt
new file mode 100644
index 0000000000..d1f66b5eda
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/preferences/HealthConnectPreferencesLogic.kt
@@ -0,0 +1,37 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.activities.preferences
+
+import androidx.health.connect.client.HealthConnectClient
+import org.slf4j.LoggerFactory
+
+private val LOG = LoggerFactory.getLogger(HealthConnectPreferencesActivity::class.java)
+
+/**
+ * Helper suspend function to get granted Health Connect permissions.
+ *
+ * @param client The HealthConnectClient instance.
+ * @return A Set of permission strings if successful, or null if an error occurs.
+ */
+suspend fun getGrantedHealthConnectPermissions(client: HealthConnectClient): Set? {
+ return try {
+ client.permissionController.getGrantedPermissions()
+ } catch (e: Exception) {
+ LOG.error("Error getting Health Connect permissions in Kotlin helper", e)
+ null // Return null to indicate an error
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NewDataReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NewDataReceiver.java
new file mode 100644
index 0000000000..57ff6d12a4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NewDataReceiver.java
@@ -0,0 +1,108 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.externalevents;
+
+import static nodomain.freeyourgadget.gadgetbridge.GBApplication.ACTION_NEW_DATA;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.work.Data;
+import androidx.work.ExistingWorkPolicy;
+import androidx.work.OneTimeWorkRequest;
+import androidx.work.WorkManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectSyncWorker;
+
+public class NewDataReceiver extends BroadcastReceiver {
+ private static final String HEALTH_CONNECT_SYNC_WORKER_TAG = "HealthConnectSyncWorker";
+ private final Logger LOG = LoggerFactory.getLogger(NewDataReceiver.class);
+ private Context context;
+ private boolean registered = false;
+
+ public NewDataReceiver() {
+ }
+
+ public void registerReceiver(Context context) {
+ this.context = context;
+ IntentFilter intentFilter = new IntentFilter(ACTION_NEW_DATA);
+ LocalBroadcastManager.getInstance(this.context).registerReceiver(this, intentFilter);
+ this.registered = true;
+ LOG.info("NewDataReceiver registered for ACTION_NEW_DATA");
+ }
+
+ public void unregisterReceiver() {
+ if (this.registered) {
+ try {
+ LocalBroadcastManager.getInstance(this.context).unregisterReceiver(this);
+ this.registered = false;
+ } catch (Exception e) {
+ LOG.error("Error unregister new data receiver", e);
+ }
+ }
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ GBPrefs prefs = GBApplication.getPrefs();
+ if (ACTION_NEW_DATA.equals(intent.getAction()) &&
+ prefs.getBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false) &&
+ prefs.getBoolean(GBPrefs.HEALTH_CONNECT_SYNC_ON_EVENT, false)) {
+
+ // Extract device from the intent
+ GBDevice device = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ String deviceAddress = device != null ? device.getAddress() : null;
+
+ if (deviceAddress != null && !deviceAddress.isEmpty()) {
+ // For device-specific syncs, use a device-specific work name and APPEND policy
+ // This ensures each device's HC sync is queued and executed sequentially
+ String workName = HEALTH_CONNECT_SYNC_WORKER_TAG + "_" + deviceAddress;
+
+ OneTimeWorkRequest syncRequest = new OneTimeWorkRequest.Builder(HealthConnectSyncWorker.class)
+ .addTag(HEALTH_CONNECT_SYNC_WORKER_TAG)
+ .setInputData(
+ new Data.Builder()
+ .putString(HealthConnectSyncWorker.INPUT_DEVICE_ADDRESS, deviceAddress)
+ .build()
+ )
+ .build();
+
+ LOG.debug("Scheduling HC sync for device: {} with work name: {}", deviceAddress, workName);
+
+ // Use APPEND so multiple syncs for the same device queue up
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ workName,
+ ExistingWorkPolicy.APPEND,
+ syncRequest
+ );
+ } else {
+ // This shouldn't happen for ACTION_NEW_DATA, but handle gracefully
+ LOG.warn("ACTION_NEW_DATA received without device information, skipping HC sync");
+ }
+ }
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java
index fb758b62e3..2cba6c7067 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java
@@ -88,6 +88,7 @@ import nodomain.freeyourgadget.gadgetbridge.externalevents.IntentApiReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.KeyMissingReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.LineageOsWeatherReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.MusicPlaybackReceiver;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.NewDataReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.OmniJawsObserver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.OsmandEventReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.PebbleReceiver;
@@ -272,6 +273,7 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
private VolumeChangeReceiver mVolumeChangeReceiver = null;
private HrvCacheInvalidationReceiver mHrvCacheInvalidationReceiver = null;
+ private NewDataReceiver mNewDataReceiver = null;
private final List mCalendarReceiver = new ArrayList<>();
private CMWeatherReceiver mCMWeatherReceiver = null;
@@ -1411,6 +1413,10 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
mVolumeChangeReceiver = new VolumeChangeReceiver();
mVolumeChangeReceiver.registerReceiver(this);
}
+ if (mNewDataReceiver == null) {
+ mNewDataReceiver = new NewDataReceiver();
+ mNewDataReceiver.registerReceiver(this);
+ }
if (mTimeChangeReceiver == null) {
mTimeChangeReceiver = new TimeChangeReceiver();
IntentFilter filter = new IntentFilter();
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java
index 465ead6bc9..036832e473 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java
@@ -82,6 +82,7 @@ public class GB {
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_PEBBLE_JS = "pebble_js";
+ public static final String NOTIFICATION_CHANNEL_ID_HEALTH_CONNECT_SYNC = "gadgetbridge_health_connect_sync";
public static final int NOTIFICATION_ID = 1;
public static final int NOTIFICATION_ID_INSTALL = 2;
@@ -180,6 +181,12 @@ public class GB {
context.getString(R.string.notification_channel_pebble_js_runner),
NotificationManager.IMPORTANCE_MIN);
notificationManager.createNotificationChannel(channelPebbleJs);
+
+ NotificationChannel channelHealthConnectSync = new NotificationChannel(
+ NOTIFICATION_CHANNEL_ID_HEALTH_CONNECT_SYNC,
+ context.getString(R.string.notification_channel_health_connect_sync_name),
+ NotificationManager.IMPORTANCE_LOW);
+ notificationManager.createNotificationChannel(channelHealthConnectSync);
}
notificationChannelsCreated = true;
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBPrefs.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBPrefs.java
index 1b9b711ebd..7b53ab6766 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBPrefs.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBPrefs.java
@@ -98,6 +98,19 @@ public class GBPrefs extends Prefs {
public static final String RECONNECT_ONLY_TO_CONNECTED = "general_reconnectonlytoconnected";
public static final String BLOCK_SCREENSHOTS = "block_screenshots";
+ // HealthConnect
+ public static final String HEALTH_CONNECT_ENABLED = "health_connect_enabled";
+ public static final String HEALTH_CONNECT_MANUAL_SETTINGS = "health_connect_manual_settings";
+ public static final String HEALTH_CONNECT_SYNC_STATUS = "health_connect_sync_status";
+ public static final String HEALTH_CONNECT_DISABLE_NOTICE = "health_connect_disable_notice";
+ public static final String HEALTH_CONNECT_SYNC_ON_EVENT = "health_connect_sync_on_event";
+ public static final String HEALTH_CONNECT_DETAILED_WORKOUT_SYNC = "health_connect_detailed_workout_sync";
+ public static final String HEALTH_CONNECT_DEVICE_SELECTION = "health_connect_devices_multiselect";
+ public static final String HEALTH_CONNECT_SETTINGS = "health_connect_settings";
+ public static final String HEALTH_CONNECT_INITIAL_SYNC_START_TS = "health_connect_initial_sync_start_ts";
+ public static final String HEALTH_CONNECT_LAST_GRANTED_PERMISSIONS = "health_connect_last_granted_permissions";
+ public static final String HEALTH_CONNECT_PROMPT_FOR_FULL_DAO_RESET = "health_connect_prompt_for_full_dao_reset";
+
@Deprecated
public GBPrefs(Prefs prefs) {
this(prefs.getPreferences());
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectClientProvider.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectClientProvider.kt
new file mode 100644
index 0000000000..02413a92df
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectClientProvider.kt
@@ -0,0 +1,37 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect
+
+import android.content.Context
+import androidx.health.connect.client.HealthConnectClient
+import org.slf4j.LoggerFactory
+
+object HealthConnectClientProvider {
+ @JvmStatic
+ fun healthConnectInit(context: Context?): HealthConnectClient? {
+ if (context == null) return null
+ return when (HealthConnectClient.getSdkStatus(context)) {
+ HealthConnectClient.SDK_UNAVAILABLE -> {
+ null
+ }
+ HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> {
+ null
+ }
+ else -> HealthConnectClient.getOrCreate(context)
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectPermissionManager.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectPermissionManager.kt
new file mode 100644
index 0000000000..992daf6829
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectPermissionManager.kt
@@ -0,0 +1,317 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect
+
+import android.content.Context
+import androidx.core.content.edit
+import androidx.fragment.app.FragmentActivity
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import nodomain.freeyourgadget.gadgetbridge.GBApplication
+import nodomain.freeyourgadget.gadgetbridge.R
+import nodomain.freeyourgadget.gadgetbridge.activities.HealthConnectResetDialogFragment
+import nodomain.freeyourgadget.gadgetbridge.util.GB
+import org.slf4j.LoggerFactory
+
+data class PermissionsResultOutcome(
+ val success: Boolean,
+ val healthConnectClient: HealthConnectClient?,
+ val message: String?, // For toasts/UI feedback
+ val messageType: Int = GB.INFO,
+ val requiresUiRefresh: Boolean = false, // If UI needs to update based on permission change
+ val startSync: Boolean = false, // If a sync should be initiated
+ val promptForFullDaoReset: Boolean = false // True if UI should prompt user for full DAO reset
+)
+
+object HealthConnectPermissionManager {
+
+ private val LOG = LoggerFactory.getLogger(HealthConnectPermissionManager::class.java)
+
+ const val PREF_KEY_LAST_GRANTED_HC_PERMISSIONS = "health_connect_last_granted_permissions"
+ const val PREF_KEY_HC_PROMPT_FOR_FULL_DAO_RESET = "health_connect_prompt_for_full_dao_reset"
+
+ @JvmStatic
+ val requiredHealthConnectPermissions: Set = setOf(
+ HealthPermission.getWritePermission(StepsRecord::class),
+ HealthPermission.getWritePermission(HeartRateRecord::class),
+ HealthPermission.getWritePermission(ExerciseSessionRecord::class),
+ HealthPermission.getWritePermission(SleepSessionRecord::class),
+ HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class),
+ HealthPermission.getWritePermission(DistanceRecord::class),
+ HealthPermission.getWritePermission(Vo2MaxRecord::class),
+ HealthPermission.getWritePermission(HeartRateVariabilityRmssdRecord::class),
+ HealthPermission.getWritePermission(WeightRecord::class),
+ HealthPermission.getWritePermission(OxygenSaturationRecord::class),
+ HealthPermission.getWritePermission(BodyTemperatureRecord::class),
+ HealthPermission.getWritePermission(SkinTemperatureRecord::class)
+ )
+
+ enum class HealthConnectDataType {
+ ACTIVITY,
+ SLEEP,
+ VO2MAX,
+ HRV,
+ WEIGHT,
+ SPO2,
+ TEMPERATURE
+ }
+
+ @JvmStatic
+ fun getRequiredPermissionsForDataType(dataType: HealthConnectDataType): Set {
+ return when (dataType) {
+ HealthConnectDataType.ACTIVITY -> setOf(
+ HealthPermission.getWritePermission(StepsRecord::class),
+ HealthPermission.getWritePermission(HeartRateRecord::class),
+ HealthPermission.getWritePermission(ExerciseSessionRecord::class),
+ HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class),
+ HealthPermission.getWritePermission(DistanceRecord::class)
+ )
+ HealthConnectDataType.SLEEP -> setOf(HealthPermission.getWritePermission(SleepSessionRecord::class))
+ HealthConnectDataType.VO2MAX -> setOf(HealthPermission.getWritePermission(Vo2MaxRecord::class))
+ HealthConnectDataType.HRV -> setOf(HealthPermission.getWritePermission(HeartRateVariabilityRmssdRecord::class))
+ HealthConnectDataType.WEIGHT -> setOf(HealthPermission.getWritePermission(WeightRecord::class))
+ HealthConnectDataType.SPO2 -> setOf(HealthPermission.getWritePermission(OxygenSaturationRecord::class))
+ HealthConnectDataType.TEMPERATURE -> setOf(
+ HealthPermission.getWritePermission(BodyTemperatureRecord::class),
+ HealthPermission.getWritePermission(SkinTemperatureRecord::class)
+ )
+ }
+ }
+
+ // Helper data class for internal processing
+ private data class PermissionChangeAnalysis(
+ val finalMessage: String?,
+ val finalMessageType: Int,
+ val shouldPromptForDaoReset: Boolean,
+ val shouldStartSync: Boolean,
+ val permissionsActuallyChanged: Boolean
+ )
+
+ private fun analyzePermissionChange(
+ context: Context,
+ newRelevantPermissions: Set,
+ oldRelevantPermissions: Set
+ ): PermissionChangeAnalysis {
+ val permissionsActuallyChanged = newRelevantPermissions != oldRelevantPermissions
+
+ val shouldPromptForDaoreset = newRelevantPermissions.isEmpty() && oldRelevantPermissions.isNotEmpty()
+ if (shouldPromptForDaoreset) {
+ LOG.info("All relevant Health Connect permissions removed, will prompt for DAO reset.")
+ }
+
+ val shouldStartSync = oldRelevantPermissions.isEmpty() && newRelevantPermissions.isNotEmpty()
+ if (shouldStartSync) {
+ LOG.info("Health Connect permissions newly granted, will start sync.")
+ }
+
+ var msg: String? = null
+ var msgType = GB.INFO
+
+ if (permissionsActuallyChanged) {
+ if (newRelevantPermissions.isEmpty()) {
+ msg = context.getString(R.string.health_connect_all_denied)
+ msgType = GB.WARN
+ } else if (oldRelevantPermissions.isEmpty()) {
+ msg = context.getString(R.string.health_connect_all_granted)
+ } else {
+ msg = context.getString(R.string.health_connect_permission_changed)
+ }
+ }
+
+ return PermissionChangeAnalysis(
+ finalMessage = msg,
+ finalMessageType = msgType,
+ shouldPromptForDaoReset = shouldPromptForDaoreset,
+ shouldStartSync = shouldStartSync,
+ permissionsActuallyChanged = permissionsActuallyChanged
+ )
+ }
+
+ @JvmStatic
+ fun isHealthConnectPermissionResetNeeded(context: Context): Boolean {
+ val prefs = GBApplication.getPrefs().preferences
+ return prefs.getBoolean(PREF_KEY_HC_PROMPT_FOR_FULL_DAO_RESET, false)
+ }
+
+ @JvmStatic
+ fun setHealthConnectPermissionResetNeeded(context: Context, needed: Boolean) {
+ val prefs = GBApplication.getPrefs().preferences
+ prefs.edit {
+ putBoolean(PREF_KEY_HC_PROMPT_FOR_FULL_DAO_RESET, needed)
+ }
+ LOG.info("Set PREF_KEY_HC_PROMPT_FOR_FULL_DAO_RESET to: {}", needed)
+ }
+
+ @JvmStatic
+ fun handlePermissionsResult(context: Context, grantedPermissionsFromFlow: Set): PermissionsResultOutcome {
+ val prefs = GBApplication.getPrefs().preferences
+ val healthConnectClient = HealthConnectClientProvider.healthConnectInit(context)
+
+ if (healthConnectClient == null) {
+ LOG.warn("HealthConnectClient init failed during permission handling.")
+ return PermissionsResultOutcome(
+ success = false,
+ healthConnectClient = null,
+ message = context.getString(R.string.health_connect_failed_init),
+ messageType = GB.ERROR,
+ requiresUiRefresh = true,
+ startSync = false,
+ promptForFullDaoReset = false
+ )
+ }
+
+ val oldRelevantPermissions = prefs.getStringSet(PREF_KEY_LAST_GRANTED_HC_PERMISSIONS, emptySet()) ?: emptySet()
+ val newRelevantPermissions = grantedPermissionsFromFlow.intersect(requiredHealthConnectPermissions)
+
+ val analysis = analyzePermissionChange(context, newRelevantPermissions, oldRelevantPermissions)
+
+ if (analysis.permissionsActuallyChanged) {
+ prefs.edit { putStringSet(PREF_KEY_LAST_GRANTED_HC_PERMISSIONS, newRelevantPermissions) }
+ LOG.debug("Updated stored Health Connect permissions. New count: {}", newRelevantPermissions.size)
+ }
+
+ if (analysis.shouldPromptForDaoReset) {
+ setHealthConnectPermissionResetNeeded(context, true)
+ }
+
+ return PermissionsResultOutcome(
+ success = newRelevantPermissions.isNotEmpty(),
+ healthConnectClient = healthConnectClient,
+ message = analysis.finalMessage,
+ messageType = analysis.finalMessageType,
+ requiresUiRefresh = analysis.permissionsActuallyChanged,
+ startSync = analysis.shouldStartSync,
+ promptForFullDaoReset = analysis.shouldPromptForDaoReset
+ )
+ }
+
+ @JvmStatic
+ fun isHealthConnectEnabled(context: Context): Boolean {
+ val sdkStatus = HealthConnectClient.getSdkStatus(context)
+ if (sdkStatus != HealthConnectClient.SDK_AVAILABLE) {
+ return false
+ }
+ val prefs = GBApplication.getPrefs()
+ if (!prefs.getBoolean(nodomain.freeyourgadget.gadgetbridge.util.GBPrefs.HEALTH_CONNECT_ENABLED, false)) {
+ return false
+ }
+ val lastGranted = prefs.preferences.getStringSet(PREF_KEY_LAST_GRANTED_HC_PERMISSIONS, null)
+ return lastGranted != null && lastGranted.isNotEmpty()
+ }
+
+ suspend fun checkPermissionChange(context: Context) {
+ if (!isHealthConnectEnabled(context)) return
+
+ val client = HealthConnectClientProvider.healthConnectInit(context) ?: return
+ val currentPermissions = try {
+ client.permissionController.getGrantedPermissions()
+ } catch (e: Exception) {
+ LOG.error("Failed to get current HC permissions", e)
+ return
+ }
+
+ val oldPermissions = GBApplication.getPrefs().preferences.getStringSet(PREF_KEY_LAST_GRANTED_HC_PERMISSIONS, emptySet()) ?: emptySet()
+ val newPermissions = currentPermissions.intersect(requiredHealthConnectPermissions)
+
+ if (newPermissions != oldPermissions) {
+ LOG.info("Health Connect permissions changed outside of app flow. Old: ${oldPermissions.size}, New: ${newPermissions.size}")
+ GBApplication.getPrefs().preferences.edit {
+ putStringSet(PREF_KEY_LAST_GRANTED_HC_PERMISSIONS, newPermissions)
+ }
+ if (newPermissions.isEmpty() && oldPermissions.isNotEmpty()) {
+ LOG.info("All Health Connect permissions have been revoked.")
+ setHealthConnectPermissionResetNeeded(context, true)
+ }
+ }
+ }
+
+
+ @JvmStatic
+ fun checkAndRectifyPermissions(context: Context) {
+ GlobalScope.launch(Dispatchers.IO) {
+ LOG.info("Proactive Health Connect permission check starting.")
+ val prefs = GBApplication.getPrefs().preferences // Keep for PREF_KEY_LAST_GRANTED_HC_PERMISSIONS
+ val healthConnectClient = HealthConnectClientProvider.healthConnectInit(context)
+
+ if (healthConnectClient == null) {
+ LOG.warn("Proactive Check: HealthConnectClient init failed.")
+ return@launch
+ }
+
+ try {
+ val oldRelevantPermissions = prefs.getStringSet(PREF_KEY_LAST_GRANTED_HC_PERMISSIONS, emptySet()) ?: emptySet()
+ val systemGrantedPermissions = healthConnectClient.permissionController.getGrantedPermissions()
+ val newRelevantSystemPermissions = systemGrantedPermissions.intersect(requiredHealthConnectPermissions)
+
+ val analysis = analyzePermissionChange(context, newRelevantSystemPermissions, oldRelevantPermissions)
+
+ if (analysis.permissionsActuallyChanged) {
+ prefs.edit { putStringSet(PREF_KEY_LAST_GRANTED_HC_PERMISSIONS, newRelevantSystemPermissions) }
+ LOG.debug("Proactive check: Updated stored Health Connect permissions. New count: {}", newRelevantSystemPermissions.size)
+ }
+
+ if (analysis.shouldPromptForDaoReset) {
+ setHealthConnectPermissionResetNeeded(context, true)
+ } else {
+ // If prompt not needed, ensure flag is cleared if it was somehow true
+ if (isHealthConnectPermissionResetNeeded(context)) {
+ setHealthConnectPermissionResetNeeded(context, false)
+ LOG.debug("Proactive check: DAO reset prompt not needed, flag cleared.")
+ }
+ }
+
+ } catch (e: Exception) {
+ LOG.error("Proactive Check: Failed to get or process system granted permissions.", e)
+ }
+ }
+ }
+
+ @JvmStatic
+ fun clearAllSyncStates(context: Context) {
+ GlobalScope.launch(Dispatchers.IO) {
+ LOG.info("Clearing ALL HealthConnect sync states from DAO.")
+ try {
+ GBApplication.acquireDB().use { db ->
+ db.daoSession.healthConnectSyncStateDao.deleteAll()
+ db.daoSession.clear()
+ LOG.info("Successfully cleared all HealthConnect sync states and session cache.")
+ }
+ setHealthConnectPermissionResetNeeded(context, false) // Use new setter
+ // LOG.info("Cleared PREF_KEY_HC_PROMPT_FOR_FULL_DAO_RESET after DAO reset.") // Log is now part of setter
+
+ } catch (e: Exception) {
+ LOG.error("Failed to clear all HealthConnect sync states.", e)
+ }
+ }
+ }
+
+ @JvmStatic
+ fun showResetDialogIfNecessary(activity: FragmentActivity) {
+ if (isHealthConnectPermissionResetNeeded(activity)) { // Use new getter, activity is a Context
+ if (activity.supportFragmentManager.findFragmentByTag(HealthConnectResetDialogFragment.TAG) == null) {
+ val dialogFragment = HealthConnectResetDialogFragment()
+ dialogFragment.show(activity.supportFragmentManager, HealthConnectResetDialogFragment.TAG)
+ } else {
+ LOG.debug("HealthConnectResetDialogFragment is already showing or an attempt to show it again was made.")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectSyncWorker.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectSyncWorker.java
new file mode 100644
index 0000000000..3a4bb87665
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectSyncWorker.java
@@ -0,0 +1,145 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect;
+
+import android.app.Notification;
+import android.content.Context;
+import android.content.pm.ServiceInfo;
+import android.os.Build;
+import androidx.annotation.NonNull;
+import androidx.core.app.NotificationCompat;
+import androidx.health.connect.client.HealthConnectClient;
+import androidx.work.Data;
+import androidx.work.CoroutineWorker;
+import androidx.work.ForegroundInfo;
+import androidx.work.WorkerParameters;
+import kotlin.coroutines.Continuation;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.function.BiConsumer;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+
+public class HealthConnectSyncWorker extends CoroutineWorker {
+ private static final Logger LOG = LoggerFactory.getLogger(HealthConnectSyncWorker.class);
+ private static final int NOTIFICATION_ID = 123;
+ public static final String INPUT_DEVICE_ADDRESS = "device_address";
+
+ public HealthConnectSyncWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
+ super(context, workerParams);
+ }
+
+ @NonNull
+ @Override
+ public Object doWork(@NonNull Continuation super Result> continuation) {
+ LOG.info("Health Connect sync worker started");
+
+ try {
+ return performSync();
+ } catch (Exception e) {
+ LOG.error("Health Connect sync worker failed", e);
+ return Result.failure();
+ }
+ }
+
+ private Result performSync() throws Exception {
+ GBPrefs prefs = GBApplication.getPrefs();
+
+ if (!prefs.getBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false)) {
+ LOG.info("Health Connect is disabled, aborting sync.");
+ return Result.failure();
+ }
+
+ setForegroundAsync(createForegroundInfo());
+ setProgressAsync(new Data.Builder().putString("progress", getApplicationContext().getString(R.string.health_connect_syncing)).build());
+
+ HealthConnectClient healthConnectClient = HealthConnectClientProvider.healthConnectInit(getApplicationContext());
+ if (healthConnectClient == null) {
+ LOG.error("SyncWorker: HealthConnectClient is null, cannot perform HC sync");
+ return Result.success();
+ }
+
+ performHealthConnectSync(healthConnectClient);
+ LOG.info("Health Connect sync worker finished successfully.");
+ return Result.success();
+ }
+
+
+ private void performHealthConnectSync(HealthConnectClient healthConnectClient) throws InterruptedException {
+ LOG.info("SyncWorker: Starting HC data sync");
+
+ // Extract device address from input data, if provided
+ String deviceAddress = getInputData().getString(INPUT_DEVICE_ADDRESS);
+ if (deviceAddress != null && !deviceAddress.isEmpty()) {
+ LOG.info("SyncWorker: Syncing specific device: {}", deviceAddress);
+ } else {
+ LOG.info("SyncWorker: Syncing all selected devices");
+ }
+
+ CountDownLatch latch = new CountDownLatch(1);
+ BiConsumer summaryCallback = (summary, inProgress) -> {
+ if (!inProgress) {
+ boolean saved = GBApplication.getPrefs().getPreferences().edit()
+ .putString(GBPrefs.HEALTH_CONNECT_SYNC_STATUS, summary)
+ .commit();
+ if (!saved) {
+ LOG.warn("Failed to save final sync status to SharedPreferences");
+ }
+ }
+ setProgressAsync(new Data.Builder().putString("progress", summary).build());
+ };
+
+ new HealthConnectUtils().healthConnectDataSync(
+ getApplicationContext(),
+ healthConnectClient,
+ summaryCallback,
+ latch::countDown,
+ this,
+ deviceAddress // Pass the device address (null if not provided)
+ );
+ latch.await();
+ LOG.info("SyncWorker: HC data sync completed");
+ }
+
+
+ @NonNull
+ private ForegroundInfo createForegroundInfo() {
+ Context context = getApplicationContext();
+ String channelId = GB.NOTIFICATION_CHANNEL_ID_HEALTH_CONNECT_SYNC;
+ String title = context.getString(R.string.health_connect_sync_notification_title);
+ String message = context.getString(R.string.health_connect_sync_notification_message);
+
+ Notification notification = new NotificationCompat.Builder(context, channelId)
+ .setContentTitle(title)
+ .setContentText(message)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setOngoing(true)
+ .build();
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ return new ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
+ } else {
+ return new ForegroundInfo(NOTIFICATION_ID, notification);
+ }
+ }
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectUtils.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectUtils.kt
new file mode 100755
index 0000000000..d1880c842d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectUtils.kt
@@ -0,0 +1,754 @@
+/* Copyright (C) 2025 LLan, Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import androidx.core.content.edit
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.records.*
+import androidx.health.connect.client.records.metadata.Device
+import androidx.health.connect.client.records.metadata.Metadata
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import nodomain.freeyourgadget.gadgetbridge.GBApplication
+import nodomain.freeyourgadget.gadgetbridge.R
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider
+import nodomain.freeyourgadget.gadgetbridge.entities.HealthConnectSyncState
+import nodomain.freeyourgadget.gadgetbridge.entities.HealthConnectSyncStateDao
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectPermissionManager.PREF_KEY_LAST_GRANTED_HC_PERMISSIONS
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers.*
+import org.slf4j.LoggerFactory
+import java.time.Instant
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+import java.util.*
+import java.util.function.BiConsumer
+import kotlin.math.pow
+
+data class SyncStatistics(
+ val success: Boolean,
+ val dataTypesProcessed: Int,
+ val dataTypesSkipped: Int,
+ val recordsSyncedByType: Map, // data type name -> count of records synced
+ val dataTypesWithErrors: Set = emptySet() // data types that had errors
+)
+
+class HealthConnectUtils {
+ private val LOG = LoggerFactory.getLogger(HealthConnectUtils::class.java)
+
+ private fun addTimestamp(message: String): String {
+ return TIME_FORMATTER.format(ZonedDateTime.now()) + ": " + message
+ }
+
+ private fun updateSyncStatus(
+ message: String,
+ inProgress: Boolean,
+ summaryCallback: BiConsumer?,
+ mainHandler: Handler
+ ) {
+ val summary = addTimestamp(message)
+ GBApplication.getPrefs().preferences.edit {
+ putString(
+ GBPrefs.HEALTH_CONNECT_SYNC_STATUS,
+ summary
+ )
+ }
+
+ summaryCallback?.let {
+ mainHandler.post { it.accept(summary, inProgress) }
+ }
+ }
+
+ private fun buildSyncCompletionMessage(context: Context, stats: SyncStatistics): String {
+ if (!stats.success) {
+ val errorDetails = if (stats.dataTypesWithErrors.isNotEmpty()) {
+ " (${stats.dataTypesWithErrors.joinToString(", ")})"
+ } else {
+ ""
+ }
+ return context.getString(R.string.health_connect_finished_with_errors) + errorDetails
+ }
+
+ if (stats.dataTypesProcessed == 0) {
+ return context.getString(R.string.health_connect_finished_no_data)
+ }
+
+ val details = if (stats.recordsSyncedByType.isNotEmpty()) {
+ stats.recordsSyncedByType.entries
+ .sortedByDescending { it.value }
+ .joinToString(", ") { "${it.key}: ${it.value}" }
+ } else {
+ ""
+ }
+
+ return if (stats.dataTypesWithErrors.isNotEmpty()) {
+ val errorList = stats.dataTypesWithErrors.joinToString(", ")
+ if (details.isNotEmpty()) {
+ context.getString(R.string.health_connect_finished_with_stats_and_errors, details, errorList)
+ } else {
+ context.getString(R.string.health_connect_finished_with_errors)
+ }
+ } else {
+ if (details.isNotEmpty()) {
+ context.getString(R.string.health_connect_finished_with_stats, details)
+ } else {
+ context.getString(R.string.health_connect_finished)
+ }
+ }
+ }
+
+ @OptIn(DelicateCoroutinesApi::class)
+ fun healthConnectDataSync(
+ context: Context,
+ healthConnectClient: HealthConnectClient,
+ summaryCallback: BiConsumer?,
+ onFinished: Runnable?,
+ worker: androidx.work.CoroutineWorker? = null, // Optional worker to check for cancellation
+ deviceAddress: String? = null // Optional specific device address to sync
+ ) {
+ val mainHandler = Handler(Looper.getMainLooper())
+ updateSyncStatus(context.getString(R.string.health_connect_syncing), true, summaryCallback, mainHandler)
+
+ GlobalScope.launch(Dispatchers.IO) {
+ try {
+ val prefs = GBApplication.getPrefs()
+ val grantedPermissions = prefs.preferences.getStringSet(PREF_KEY_LAST_GRANTED_HC_PERMISSIONS, emptySet()) ?: emptySet()
+
+ // If a specific device address is provided, sync only that device
+ // Otherwise, sync all selected devices from preferences
+ val selectedDevices = if (deviceAddress != null && deviceAddress.isNotEmpty()) {
+ setOf(deviceAddress)
+ } else {
+ prefs.getStringSet("health_connect_devices_multiselect", HashSet(20))
+ }
+
+ if (selectedDevices.isNullOrEmpty()) {
+ updateSyncStatus(
+ context.getString(R.string.health_connect_no_devices_selected),
+ false,
+ summaryCallback,
+ mainHandler
+ )
+ } else if (grantedPermissions.isEmpty()) {
+ updateSyncStatus(
+ context.getString(R.string.health_connect_no_permissions),
+ false,
+ summaryCallback,
+ mainHandler
+ )
+ } else {
+ try {
+ val stats = healthConnectDataSyncImp(
+ context,
+ selectedDevices,
+ healthConnectClient,
+ summaryCallback,
+ mainHandler,
+ grantedPermissions,
+ worker
+ )
+
+ val finalMessage = buildSyncCompletionMessage(context, stats)
+ LOG.info("$HC_SYNC_TAG Final sync status: {}", finalMessage)
+ updateSyncStatus(
+ finalMessage,
+ false,
+ summaryCallback,
+ mainHandler
+ )
+ } catch (e: SyncException) {
+ LOG.error("Sync failed: ${e.message}", e)
+
+ // Check if this is a permission error
+ if (e.cause is SecurityException) {
+ LOG.warn("Sync failed due to missing permissions. Triggering permission check.")
+ // Trigger permission check which will set the reset dialog flag if needed
+ HealthConnectPermissionManager.checkPermissionChange(context)
+ }
+
+ updateSyncStatus(
+ e.message ?: context.getString(R.string.health_connect_sync_failed),
+ false,
+ summaryCallback,
+ mainHandler
+ )
+ }
+ }
+ } catch (t: Throwable) {
+ LOG.error("Critical error during healthConnectDataSync for Health Connect", t)
+ updateSyncStatus(
+ context.getString(R.string.health_connect_exception) + t.localizedMessage,
+ false,
+ summaryCallback,
+ mainHandler
+ )
+ // The overall process is considered failed if an exception occurs here
+ } finally {
+ onFinished?.run()
+ }
+ }
+ }
+
+ private suspend fun healthConnectDataSyncImp(
+ context: Context,
+ selectedDevices: Set,
+ healthConnectClient: HealthConnectClient,
+ summaryCallback: BiConsumer?,
+ mainHandler: Handler,
+ grantedPermissions: Set,
+ worker: androidx.work.CoroutineWorker? = null // Optional worker to check for cancellation
+ ): SyncStatistics {
+ var totalDataTypesProcessed = 0
+ var totalDataTypesSkipped = 0
+ val recordsSyncedByType = mutableMapOf() // Track records per data type
+ val dataTypesWithErrors = mutableSetOf() // Track which data types had errors
+
+ val offset = ZonedDateTime.now(TimeZone.getDefault().toZoneId()).offset
+ val manager = GBApplication.app().deviceManager
+ val syncIntervalInSeconds: Long = 24 * 60 * 60 // 1 day slices
+ val lookBackInSeconds: Long = 24 * 60 * 60
+
+ for (targetAddress in selectedDevices) {
+ // Check if worker has been cancelled
+ if (worker?.isStopped == true) {
+ LOG.info("$HC_SYNC_TAG Worker has been cancelled, aborting sync")
+ break
+ }
+
+ val gbDevice = manager.getDeviceByAddress(targetAddress)
+ if (gbDevice == null) {
+ LOG.error("$HC_SYNC_TAG Device for address {} not found during sync", targetAddress)
+ continue
+ }
+
+ LOG.info("$HC_SYNC_TAG Starting sync for device: {}", gbDevice.aliasOrName)
+
+ val deviceCoordinator = gbDevice.deviceCoordinator
+ val device = Device(
+ type = when (deviceCoordinator.getDeviceKind(gbDevice)) {
+ DeviceCoordinator.DeviceKind.WATCH -> Device.TYPE_WATCH
+ DeviceCoordinator.DeviceKind.PHONE -> Device.TYPE_PHONE
+ DeviceCoordinator.DeviceKind.SCALE -> Device.TYPE_SCALE
+ DeviceCoordinator.DeviceKind.RING -> Device.TYPE_RING
+ DeviceCoordinator.DeviceKind.HEAD_MOUNTED -> Device.TYPE_HEAD_MOUNTED
+ DeviceCoordinator.DeviceKind.FITNESS_BAND -> Device.TYPE_FITNESS_BAND
+ DeviceCoordinator.DeviceKind.CHEST_STRAP -> Device.TYPE_CHEST_STRAP
+ DeviceCoordinator.DeviceKind.SMART_DISPLAY -> Device.TYPE_SMART_DISPLAY
+ else -> Device.TYPE_UNKNOWN
+ },
+ manufacturer = deviceCoordinator.manufacturer,
+ model = gbDevice.model
+ )
+ dataTypeLoop@ for (dataType in HealthConnectPermissionManager.HealthConnectDataType.entries) {
+ // Check if worker has been cancelled
+ if (worker?.isStopped == true) {
+ LOG.info("$HC_SYNC_TAG Worker has been cancelled, aborting sync")
+ break@dataTypeLoop
+ }
+
+ LOG.debug("$HC_SYNC_TAG Checking data type {} for device {}", dataType.name, gbDevice.aliasOrName)
+ val permsNeededForThisDataType = HealthConnectPermissionManager.getRequiredPermissionsForDataType(dataType)
+
+ if (permsNeededForThisDataType.none { it in grantedPermissions }) {
+ LOG.info(
+ "$HC_SYNC_TAG Skipping sync for HealthConnectDataType '{}' on device {}: None of the required Health Connect permissions are granted. Needed: {}, Have: {}",
+ dataType.name,
+ gbDevice.aliasOrName,
+ permsNeededForThisDataType,
+ grantedPermissions.intersect(permsNeededForThisDataType)
+ )
+ totalDataTypesSkipped++
+ continue@dataTypeLoop
+ }
+
+ val timestampRange = try {
+ getSyncTimestampRange(context, gbDevice, deviceCoordinator, dataType)
+ } catch (e: Exception) {
+ LOG.error("$HC_SYNC_TAG Error during initial DBAccess for Health Connect for device {} and data type {}", gbDevice.aliasOrName, dataType, e)
+ dataTypesWithErrors.add(dataType.name)
+ totalDataTypesSkipped++
+ continue@dataTypeLoop
+ }
+
+ if (timestampRange == null) {
+ LOG.info("$HC_SYNC_TAG Data sync range (start/end) not determined for {} on device {}. Skipping.", dataType.name, gbDevice.aliasOrName)
+ totalDataTypesSkipped++
+ continue@dataTypeLoop
+ }
+
+ var timestampToPersistForThisDataType = timestampRange.first
+ val currentDataTypeStartTsFromDb = timestampRange.first
+ val currentDataTypeEndTsFromDb = timestampRange.second
+
+ if (currentDataTypeEndTsFromDb.isBefore(currentDataTypeStartTsFromDb) || currentDataTypeEndTsFromDb == currentDataTypeStartTsFromDb) {
+ LOG.info("$HC_SYNC_TAG No new data to sync for device {} and data type {}. Start: {}, End: {}", gbDevice.aliasOrName, dataType.name, currentDataTypeStartTsFromDb, currentDataTypeEndTsFromDb)
+ totalDataTypesSkipped++
+ continue@dataTypeLoop
+ }
+
+ LOG.info("$HC_SYNC_TAG Proceeding to sync for {}. Overall sync range for {}({}): {} to {}", dataType.name, gbDevice.aliasOrName, dataType.name, currentDataTypeStartTsFromDb, currentDataTypeEndTsFromDb)
+ totalDataTypesProcessed++
+
+
+ val metadata = when (dataType) {
+ HealthConnectPermissionManager.HealthConnectDataType.ACTIVITY -> Metadata.activelyRecorded(device)
+ else -> Metadata.autoRecorded(device)
+ }
+
+ var currentSliceStartTs = currentDataTypeStartTsFromDb
+
+ while (currentSliceStartTs.isBefore(currentDataTypeEndTsFromDb)) {
+ var currentSliceEndTs = currentSliceStartTs.plusSeconds(syncIntervalInSeconds)
+ if (currentSliceEndTs.isAfter(currentDataTypeEndTsFromDb)) {
+ currentSliceEndTs = currentDataTypeEndTsFromDb
+ }
+ if (!currentSliceEndTs.isAfter(currentSliceStartTs)) {
+ LOG.warn("$HC_SYNC_TAG Skipping sync slice for {}({}) as start and end are not distinct: {} to {}", gbDevice.aliasOrName, dataType.name, currentSliceStartTs, currentSliceEndTs)
+ break
+ }
+
+ // Check if worker has been cancelled before processing each slice
+ if (worker?.isStopped == true) {
+ LOG.info("$HC_SYNC_TAG Worker has been cancelled, aborting sync")
+ break
+ }
+
+ LOG.info("$HC_SYNC_TAG Processing slice for {}({}): {} to {}", gbDevice.aliasOrName, dataType.name, currentSliceStartTs, currentSliceEndTs)
+ val startTimeFormatted = DateTimeFormatter.ISO_LOCAL_DATE_TIME.withZone(ZoneId.systemDefault()).format(currentSliceStartTs)
+ val endTimeFormatted = DateTimeFormatter.ISO_LOCAL_DATE_TIME.withZone(ZoneId.systemDefault()).format(currentSliceEndTs)
+ val summary = context.getString(
+ R.string.health_connect_syncing_device_datatype,
+ gbDevice.aliasOrName,
+ dataType.name,
+ startTimeFormatted,
+ endTimeFormatted
+ )
+
+ updateSyncStatus(summary, true, summaryCallback, mainHandler)
+
+ val queryStartTs = currentSliceStartTs.minusSeconds(lookBackInSeconds)
+ LOG.info("$HC_SYNC_TAG Querying Gadgetbridge DB for {}({}) from {} to {}", gbDevice.aliasOrName, dataType.name, queryStartTs, currentSliceEndTs)
+
+ // Fetch activityBasedSamples under their own lock if needed for the current dataType
+ val activityBasedSamples: List? =
+ if (dataType == HealthConnectPermissionManager.HealthConnectDataType.ACTIVITY ||
+ dataType == HealthConnectPermissionManager.HealthConnectDataType.SLEEP) {
+ GBApplication.acquireDB().use { db ->
+ getActivitySamples(db, gbDevice, queryStartTs.epochSecond.toInt(), currentSliceEndTs.epochSecond.toInt())
+ }
+ } else {
+ null
+ }
+
+ try {
+ val sliceStats = syncDataTypeSlice(
+ dataType = dataType,
+ healthConnectClient = healthConnectClient,
+ gbDevice = gbDevice,
+ metadata = metadata,
+ offset = offset,
+ currentSliceStartTs = currentSliceStartTs,
+ currentSliceEndTs = currentSliceEndTs,
+ grantedPermissions = grantedPermissions,
+ activityBasedSamples = activityBasedSamples,
+ context = context
+ )
+
+ for (stat in sliceStats) {
+ val key = stat.recordType
+ val currentSynced = recordsSyncedByType.getOrDefault(key, 0)
+ recordsSyncedByType[key] = currentSynced + stat.recordsSynced
+ }
+
+ timestampToPersistForThisDataType = currentSliceEndTs
+ currentSliceStartTs = currentSliceEndTs
+ } catch (e: SyncException) {
+ LOG.warn(
+ "Failed to process slice for {}({}) from {} to {}. Will not process further slices for this data type in this run. Reason: {}",
+ gbDevice.aliasOrName,
+ dataType.name,
+ currentSliceStartTs,
+ currentSliceEndTs,
+ e.message
+ )
+ dataTypesWithErrors.add(dataType.name)
+ updateSyncStatus(
+ context.getString(
+ R.string.health_connect_sync_error_for_device,
+ gbDevice.aliasOrName,
+ dataType.name,
+ e.message ?: ""
+ ),
+ false,
+ summaryCallback,
+ mainHandler
+ )
+ break
+ }
+ } // End while loop for slices
+
+ try {
+ GBApplication.acquireDB().use { db -> // This lock remains
+ val deviceFromDb = DBHelper.getDevice(gbDevice, db.daoSession)
+ val syncStateDao = db.daoSession.healthConnectSyncStateDao
+ val syncState = HealthConnectSyncState(
+ deviceFromDb.id,
+ dataType.name,
+ timestampToPersistForThisDataType.epochSecond
+ )
+
+ LOG.info("$HC_SYNC_TAG Updating Health Connect sync state for device {}, data type {} to timestamp: {}", gbDevice.aliasOrName, dataType.name, timestampToPersistForThisDataType)
+ syncStateDao.insertOrReplace(syncState)
+ }
+ } catch (e: Exception) {
+ LOG.error("$HC_SYNC_TAG Error updating Health Connect sync state for device {}, data type {}: {}", gbDevice.aliasOrName, dataType.name, e.localizedMessage, e)
+ dataTypesWithErrors.add(dataType.name)
+ }
+ } // End dataTypeLoop
+ } // End device loop
+
+ // Log summary
+ if (totalDataTypesProcessed == 0 && totalDataTypesSkipped > 0) {
+ LOG.info("$HC_SYNC_TAG Health Connect sync completed. No data was available to sync. {} data types were skipped (no data in Gadgetbridge database or no permission).", totalDataTypesSkipped)
+ } else {
+ LOG.info("$HC_SYNC_TAG Health Connect sync completed. Processed: {}, Skipped: {}", totalDataTypesProcessed, totalDataTypesSkipped)
+ }
+
+ // Consider sync successful if at least some data types were processed
+ val syncSuccess = totalDataTypesProcessed > 0 || (totalDataTypesProcessed == 0 && totalDataTypesSkipped > 0)
+
+ LOG.info("$HC_SYNC_TAG Sync statistics - Success: {}, Processed: {}, Skipped: {}, Errors: {}, Records by type: {}",
+ syncSuccess, totalDataTypesProcessed, totalDataTypesSkipped, dataTypesWithErrors, recordsSyncedByType)
+
+ return SyncStatistics(
+ success = syncSuccess,
+ dataTypesProcessed = totalDataTypesProcessed,
+ dataTypesSkipped = totalDataTypesSkipped,
+ recordsSyncedByType = recordsSyncedByType,
+ dataTypesWithErrors = dataTypesWithErrors
+ )
+ }
+
+ companion object {
+ private val CompanionLogger = LoggerFactory.getLogger(HealthConnectUtils::class.java)
+ private val TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss")
+ internal const val CHUNK_SIZE = 200
+ internal const val MAX_SAMPLES_PER_HEART_RATE_RECORD = 1000
+ private const val MAX_RETRIES = 5
+ private const val INITIAL_DELAY_MS = 1000L
+ private const val HC_SYNC_TAG = "[HC_SYNC]"
+
+ private fun getSyncTimestampRange(
+ context: Context,
+ gbDevice: GBDevice,
+ deviceCoordinator: DeviceCoordinator,
+ dataType: HealthConnectPermissionManager.HealthConnectDataType
+ ): Pair? {
+ return GBApplication.acquireDB().use { db ->
+ val deviceFromDb = DBHelper.getDevice(gbDevice, db.daoSession)
+ if (deviceFromDb == null) {
+ CompanionLogger.error("$HC_SYNC_TAG Device not found in database for address: {}", gbDevice.address)
+ return@use null
+ }
+
+ val syncStateDao = db.daoSession.healthConnectSyncStateDao
+ if (syncStateDao == null) {
+ CompanionLogger.error("$HC_SYNC_TAG HealthConnectSyncStateDao is null for device {}", gbDevice.aliasOrName)
+ return@use null
+ }
+
+ val syncState = syncStateDao.queryBuilder()
+ .where(
+ HealthConnectSyncStateDao.Properties.DeviceId.eq(deviceFromDb.id),
+ HealthConnectSyncStateDao.Properties.DataType.eq(dataType.name)
+ )
+ .unique()
+
+ val startTs = getStartTimestamp(context, gbDevice, deviceCoordinator, db, dataType, syncState)
+ if (startTs == null) {
+ CompanionLogger.info("$HC_SYNC_TAG No starting point for sync for {} on device {}. Skipping.", dataType.name, gbDevice.aliasOrName)
+ return@use null
+ }
+
+ val endTs = getLastSampleTimestamp(deviceCoordinator, gbDevice, db, dataType)
+ if (endTs == null) {
+ CompanionLogger.info("$HC_SYNC_TAG No ending point (latest sample) for {} on device {}. Skipping.", dataType.name, gbDevice.aliasOrName)
+ return@use null
+ }
+
+ CompanionLogger.info("$HC_SYNC_TAG Determined sync range for {}({}): {} to {}", gbDevice.aliasOrName, dataType.name, startTs, endTs)
+ Pair(startTs, endTs)
+ }
+ }
+
+ private fun getStartTimestamp(
+ context: Context,
+ gbDevice: GBDevice,
+ deviceCoordinator: DeviceCoordinator,
+ db: DBHandler,
+ dataType: HealthConnectPermissionManager.HealthConnectDataType,
+ syncState: HealthConnectSyncState?
+ ): Instant? {
+ if (syncState != null) {
+ CompanionLogger.info("$HC_SYNC_TAG Found existing sync state for {}({}), last sync was at: {}", gbDevice.aliasOrName, dataType.name, Instant.ofEpochSecond(syncState.lastSyncTimestamp))
+ return Instant.ofEpochSecond(syncState.lastSyncTimestamp)
+ }
+
+ val initialSyncPrefs = context.getSharedPreferences(GBPrefs.HEALTH_CONNECT_SETTINGS, Context.MODE_PRIVATE)
+ val initialSyncStartTs = initialSyncPrefs.getLong(GBPrefs.HEALTH_CONNECT_INITIAL_SYNC_START_TS, -1L)
+
+ if (initialSyncStartTs != -1L) {
+ CompanionLogger.info("$HC_SYNC_TAG Using initial sync start timestamp for {}({}): {} ({})", gbDevice.aliasOrName, dataType.name, initialSyncStartTs, Instant.ofEpochSecond(initialSyncStartTs))
+ return Instant.ofEpochSecond(initialSyncStartTs)
+ }
+
+ val firstTs = getFirstSampleTimestamp(deviceCoordinator, gbDevice, db, dataType)
+ if (firstTs != null) {
+ CompanionLogger.info("$HC_SYNC_TAG Using first sample timestamp for {}({}): {}", gbDevice.aliasOrName, dataType.name, firstTs)
+ return firstTs
+ }
+
+ return null
+ }
+
+ private suspend fun syncDataTypeSlice(
+ dataType: HealthConnectPermissionManager.HealthConnectDataType,
+ healthConnectClient: HealthConnectClient,
+ gbDevice: GBDevice,
+ metadata: Metadata,
+ offset: java.time.ZoneOffset,
+ currentSliceStartTs: Instant,
+ currentSliceEndTs: Instant,
+ grantedPermissions: Set,
+ activityBasedSamples: List?,
+ context: Context
+ ): List {
+ val sliceStats = mutableListOf()
+
+ when (dataType) {
+ HealthConnectPermissionManager.HealthConnectDataType.ACTIVITY -> {
+ if (!activityBasedSamples.isNullOrEmpty()) {
+ sliceStats.add(StepsSyncer.sync(
+ healthConnectClient, gbDevice, metadata, offset,
+ currentSliceStartTs, currentSliceEndTs, grantedPermissions, activityBasedSamples
+ ))
+ sliceStats.add(HeartRateSyncer.sync(
+ healthConnectClient, gbDevice, metadata, offset,
+ currentSliceStartTs, currentSliceEndTs, grantedPermissions, activityBasedSamples
+ ))
+
+ // Workout syncing: only sync explicitly recorded workouts from BaseActivitySummary
+ val coordinator = gbDevice.deviceCoordinator
+ if (coordinator.supportsActivityTracks(gbDevice)) {
+ sliceStats.add(RecordedWorkoutSyncer.sync(
+ healthConnectClient, gbDevice, metadata, offset,
+ currentSliceStartTs, currentSliceEndTs, grantedPermissions, context
+ ))
+ }
+ }
+ }
+ HealthConnectPermissionManager.HealthConnectDataType.SLEEP -> {
+ if (!activityBasedSamples.isNullOrEmpty()) {
+ sliceStats.add(SleepSyncer.sync(
+ healthConnectClient, gbDevice, metadata, offset,
+ currentSliceStartTs, currentSliceEndTs, grantedPermissions, activityBasedSamples, context
+ ))
+ }
+ }
+ HealthConnectPermissionManager.HealthConnectDataType.VO2MAX -> sliceStats.add(Vo2MaxSyncer.sync(
+ healthConnectClient, gbDevice, metadata, offset,
+ currentSliceStartTs, currentSliceEndTs, grantedPermissions
+ ))
+ HealthConnectPermissionManager.HealthConnectDataType.HRV -> sliceStats.add(HrvSyncer.sync(
+ healthConnectClient, gbDevice, metadata, offset,
+ currentSliceStartTs, currentSliceEndTs, grantedPermissions
+ ))
+ HealthConnectPermissionManager.HealthConnectDataType.WEIGHT -> sliceStats.add(WeightSyncer.sync(
+ healthConnectClient, gbDevice, metadata, offset,
+ currentSliceStartTs, currentSliceEndTs, grantedPermissions
+ ))
+ HealthConnectPermissionManager.HealthConnectDataType.SPO2 -> sliceStats.add(Spo2Syncer.sync(
+ healthConnectClient, gbDevice, metadata, offset,
+ currentSliceStartTs, currentSliceEndTs, grantedPermissions
+ ))
+ HealthConnectPermissionManager.HealthConnectDataType.TEMPERATURE -> sliceStats.add(TemperatureSyncer.sync(
+ healthConnectClient, gbDevice, metadata, offset,
+ currentSliceStartTs, currentSliceEndTs, grantedPermissions
+ ))
+ }
+
+ return sliceStats
+ }
+
+
+ internal fun getActivitySamples(db: DBHandler, device: GBDevice, tsFrom: Int, tsTo: Int): List {
+ val provider = device.deviceCoordinator.getSampleProvider(device, db.daoSession)
+ if (provider == null) {
+ CompanionLogger.error("getSampleProvider for ACTIVITY/SLEEP returned null for device {}", device.name)
+ return emptyList() // Return empty list on error
+ }
+ return provider.getAllActivitySamples(tsFrom, tsTo)
+ }
+
+ private fun getFirstSampleTimestamp(
+ deviceCoordinator: DeviceCoordinator,
+ device: GBDevice,
+ db: DBHandler,
+ dataType: HealthConnectPermissionManager.HealthConnectDataType
+ ): Instant? {
+ if (dataType == HealthConnectPermissionManager.HealthConnectDataType.HRV) {
+ val summaryProvider = deviceCoordinator.getHrvSummarySampleProvider(device, db.daoSession)
+ val valueProvider = deviceCoordinator.getHrvValueSampleProvider(device, db.daoSession)
+
+ val summaryTsMilliFiltered = summaryProvider?.firstSample?.timestamp?.takeIf { it > 0 }
+ val valueTsMilliFiltered = valueProvider?.firstSample?.timestamp?.takeIf { it > 0 }
+
+ val earliestMilli = when {
+ summaryTsMilliFiltered != null && valueTsMilliFiltered != null -> minOf(summaryTsMilliFiltered, valueTsMilliFiltered)
+ summaryTsMilliFiltered != null -> summaryTsMilliFiltered
+ else -> valueTsMilliFiltered
+ }
+ return earliestMilli?.let { Instant.ofEpochMilli(it) }
+ }
+
+ val provider = getProviderForDataType(deviceCoordinator, device, db, dataType)
+
+ return when (provider) {
+ is TimeSampleProvider<*> -> {
+ provider.firstSample?.timestamp?.takeIf { it > 0 }?.let { Instant.ofEpochMilli(it) }
+ }
+ is AbstractSampleProvider<*> -> { // For ActivitySample based providers
+ provider.firstActivitySample?.timestamp?.takeIf { it > 0 }?.let { Instant.ofEpochSecond(it.toLong()) }
+ }
+ else -> {
+ CompanionLogger.error("No suitable provider found or provider is null for getFirstSampleTimestamp, dataType: {}, device: {}", dataType, device.name)
+ null
+ }
+ }
+ }
+
+ private fun getLastSampleTimestamp(
+ deviceCoordinator: DeviceCoordinator,
+ device: GBDevice,
+ db: DBHandler,
+ dataType: HealthConnectPermissionManager.HealthConnectDataType
+ ): Instant? {
+ if (dataType == HealthConnectPermissionManager.HealthConnectDataType.HRV) {
+ val summaryProvider = deviceCoordinator.getHrvSummarySampleProvider(device, db.daoSession)
+ val valueProvider = deviceCoordinator.getHrvValueSampleProvider(device, db.daoSession)
+
+ val summaryTsMilliFiltered = summaryProvider?.latestSample?.timestamp?.takeIf { it > 0 }
+ val valueTsMilliFiltered = valueProvider?.latestSample?.timestamp?.takeIf { it > 0 }
+
+ val latestMilli = when {
+ summaryTsMilliFiltered != null && valueTsMilliFiltered != null -> maxOf(summaryTsMilliFiltered, valueTsMilliFiltered)
+ summaryTsMilliFiltered != null -> summaryTsMilliFiltered
+ else -> valueTsMilliFiltered
+ }
+ return latestMilli?.let { Instant.ofEpochMilli(it) }
+ }
+ val provider = getProviderForDataType(deviceCoordinator, device, db, dataType)
+ return when (provider) {
+ is TimeSampleProvider<*> -> {
+ provider.latestSample?.timestamp?.takeIf { it > 0 }?.let { Instant.ofEpochMilli(it) }
+ }
+ is AbstractSampleProvider<*> -> { // For ActivitySample based providers
+ provider.latestActivitySample?.timestamp?.takeIf { it > 0 }?.let { Instant.ofEpochSecond(it.toLong()) }
+ }
+ else -> {
+ CompanionLogger.error("No suitable provider found or provider is null for getLastSampleTimestamp, dataType: {}, device: {}", dataType, device.name)
+ null
+ }
+ }
+ }
+
+ private fun getProviderForDataType(
+ coordinator: DeviceCoordinator,
+ device: GBDevice,
+ db: DBHandler,
+ dataType: HealthConnectPermissionManager.HealthConnectDataType
+ ): Any? { // Return type is Any? as providers have different base types
+ return when (dataType) {
+ HealthConnectPermissionManager.HealthConnectDataType.ACTIVITY, HealthConnectPermissionManager.HealthConnectDataType.SLEEP -> coordinator.getSampleProvider(device, db.daoSession)
+ HealthConnectPermissionManager.HealthConnectDataType.VO2MAX -> coordinator.getVo2MaxSampleProvider(device, db.daoSession)
+ // For HRV, either summary or value provider can be used, prefer summary if available
+ HealthConnectPermissionManager.HealthConnectDataType.HRV -> coordinator.getHrvSummarySampleProvider(device, db.daoSession) ?: coordinator.getHrvValueSampleProvider(device, db.daoSession)
+ HealthConnectPermissionManager.HealthConnectDataType.WEIGHT -> coordinator.getWeightSampleProvider(device, db.daoSession)
+ // For SpO2 and Temperature, there might be a specific provider or fallback to general sample provider
+ HealthConnectPermissionManager.HealthConnectDataType.SPO2 -> coordinator.getSpo2SampleProvider(device, db.daoSession) // Potentially add fallback if needed: ?: coordinator.getSampleProvider(device, db.daoSession)
+ HealthConnectPermissionManager.HealthConnectDataType.TEMPERATURE -> coordinator.getTemperatureSampleProvider(device, db.daoSession) // Potentially add fallback: ?: coordinator.getSampleProvider(device, db.daoSession)
+ }
+ }
+
+ @Throws(SyncException::class)
+ internal suspend fun insertRecords(records: List, healthConnectClient: HealthConnectClient) {
+ if (records.isEmpty()) {
+ return
+ }
+
+ var lastException: Exception? = null
+ for (i in 0..MAX_RETRIES) {
+ try {
+ healthConnectClient.insertRecords(records)
+ CompanionLogger.debug("Successfully inserted {} records into Health Connect.", records.size)
+ return // Success
+ } catch (e: SecurityException) {
+ // Permission error - don't retry, abort immediately
+ CompanionLogger.error("Permission error while inserting records. Aborting sync.", e)
+ throw SyncException(
+ GBApplication.getContext().getString(
+ R.string.health_connect_permission_denied,
+ e.localizedMessage ?: ""
+ ),
+ e
+ )
+ } catch (e: Exception) {
+ lastException = e
+ if (i < MAX_RETRIES) {
+ val delayMillis = INITIAL_DELAY_MS * 2.0.pow(i).toLong()
+ CompanionLogger.warn(
+ "Caught exception while inserting records. Retrying in ${delayMillis}ms... (Attempt ${i + 1}/$MAX_RETRIES)",
+ e
+ )
+ delay(delayMillis)
+ }
+ }
+ }
+ // If the loop completes, all retries have failed
+ throw SyncException(
+ GBApplication.getContext().getString(
+ R.string.health_connect_sync_failed_retries,
+ lastException?.localizedMessage ?: ""
+ ),
+ lastException
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/SyncException.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/SyncException.kt
new file mode 100644
index 0000000000..d8b843e701
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/SyncException.kt
@@ -0,0 +1,20 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect
+
+class SyncException(message: String, cause: Throwable? = null) : Exception(message, cause)
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HealthConnectSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HealthConnectSyncer.kt
new file mode 100644
index 0000000000..e08a08c05b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HealthConnectSyncer.kt
@@ -0,0 +1,88 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
+
+import android.content.Context
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.records.metadata.Metadata
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample
+import java.time.Instant
+import java.time.ZoneOffset
+
+/**
+ * Statistics returned by a syncer after processing a slice.
+ */
+data class SyncerStatistics(
+ val recordsSynced: Int = 0,
+ val recordsSkipped: Int = 0,
+ val recordType: String = ""
+)
+
+/**
+ * Base interface for synchronizing specific data types from Gadgetbridge to Health Connect.
+ * This interface defines the common parameters that all syncers need.
+ */
+internal sealed interface HealthConnectSyncer {
+ /**
+ * Common parameters for all syncers.
+ */
+ suspend fun sync(
+ healthConnectClient: HealthConnectClient,
+ gbDevice: GBDevice,
+ metadata: Metadata,
+ offset: ZoneOffset,
+ sliceStartBoundary: Instant,
+ sliceEndBoundary: Instant,
+ grantedPermissions: Set
+ ): SyncerStatistics
+}
+
+/**
+ * Interface for syncers that require pre-fetched ActivitySample data.
+ * Used by syncers that process activity-based data like steps and heart rate.
+ */
+internal interface ActivitySampleSyncer {
+ suspend fun sync(
+ healthConnectClient: HealthConnectClient,
+ gbDevice: GBDevice,
+ metadata: Metadata,
+ offset: ZoneOffset,
+ sliceStartBoundary: Instant,
+ sliceEndBoundary: Instant,
+ grantedPermissions: Set,
+ deviceSamples: List
+ ): SyncerStatistics
+}
+
+/**
+ * Interface for syncers that require both ActivitySample data and Android Context.
+ * Used by syncers that need localized strings or other context-dependent resources.
+ */
+internal interface ContextualActivitySampleSyncer {
+ suspend fun sync(
+ healthConnectClient: HealthConnectClient,
+ gbDevice: GBDevice,
+ metadata: Metadata,
+ offset: ZoneOffset,
+ sliceStartBoundary: Instant,
+ sliceEndBoundary: Instant,
+ grantedPermissions: Set,
+ deviceSamples: List,
+ context: Context
+ ): SyncerStatistics
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HeartRateSync.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HeartRateSync.kt
new file mode 100644
index 0000000000..ac79c54d3f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HeartRateSync.kt
@@ -0,0 +1,154 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
+
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.HeartRateRecord
+import androidx.health.connect.client.records.Record
+import androidx.health.connect.client.records.metadata.Metadata
+import nodomain.freeyourgadget.gadgetbridge.GBApplication
+import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils
+import org.slf4j.LoggerFactory
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.ZonedDateTime
+
+private val LOG = LoggerFactory.getLogger("HeartRateSyncer")
+
+internal object HeartRateSyncer : ActivitySampleSyncer {
+ override suspend fun sync(
+ healthConnectClient: HealthConnectClient,
+ gbDevice: GBDevice,
+ metadata: Metadata,
+ offset: ZoneOffset,
+ sliceStartBoundary: Instant,
+ sliceEndBoundary: Instant,
+ grantedPermissions: Set,
+ deviceSamples: List
+ ): SyncerStatistics {
+
+ val deviceName = gbDevice.aliasOrName // Use Gadgetbridge device name for logging
+
+ // 1. Permission Check
+ if (HealthPermission.getWritePermission(HeartRateRecord::class) !in grantedPermissions) {
+ LOG.info("Skipping Heart Rate sync for device '$deviceName'; HeartRateRecord permission not granted.")
+ return SyncerStatistics(recordType = "HeartRate")
+ }
+
+ // 2. Relevant Input Data Check
+ val prefs: Prefs = GBApplication.getPrefs()
+
+ val validHRSamples = deviceSamples
+ .filter { it.heartRate in 20..250 }
+ .sortedBy { it.timestamp }
+
+ if (validHRSamples.isEmpty()) {
+ LOG.info("No valid heart rate samples found for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordType = "HeartRate")
+ }
+
+ LOG.info("Processing ${validHRSamples.size} valid heart rate samples for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+
+ val heartRateRecordList = mutableListOf()
+ val currentHcSamples = mutableListOf()
+ var previousSampleTimestamp: Instant? = null
+ var skippedCount = 0
+
+ for (gbSample in validHRSamples) {
+ val currentSampleTimestamp = Instant.ofEpochSecond(gbSample.timestamp.toLong())
+
+ // Ensure sample is strictly within the slice [sliceStartBoundary, sliceEndBoundary)
+ if (currentSampleTimestamp.isBefore(sliceStartBoundary) || !currentSampleTimestamp.isBefore(sliceEndBoundary)) {
+ skippedCount++
+ continue
+ }
+
+ previousSampleTimestamp?.let { prevTs ->
+ val newDay = ZonedDateTime.ofInstant(prevTs, offset).toLocalDate() != ZonedDateTime.ofInstant(currentSampleTimestamp, offset).toLocalDate()
+ val gapTooLong = currentSampleTimestamp.epochSecond - prevTs.epochSecond > 15 * 60 // 15 min gap
+ val samplesFull = currentHcSamples.size >= HealthConnectUtils.MAX_SAMPLES_PER_HEART_RATE_RECORD // Use a defined constant
+
+ if (newDay || gapTooLong || samplesFull) {
+ if (currentHcSamples.isNotEmpty()) {
+ val recordStartTime = currentHcSamples.first().time
+ var recordEndTime = currentHcSamples.last().time
+
+ if (recordEndTime == recordStartTime) { // Ensure duration is positive for HC
+ recordEndTime = recordEndTime.plusSeconds(1)
+ }
+
+ if (recordEndTime.isAfter(recordStartTime)) { // Should always be true after adjustment if currentHcSamples is not empty
+ LOG.debug(
+ "Creating HeartRateRecord for device '{}' from {} to {} with {} samples.",
+ deviceName,
+ recordStartTime,
+ recordEndTime,
+ currentHcSamples.size
+ )
+ heartRateRecordList.add(HeartRateRecord(recordStartTime, offset, recordEndTime, offset, ArrayList(currentHcSamples), metadata))
+ } else {
+ LOG.warn("Skipping HeartRateRecord for device '$deviceName' from $recordStartTime to $recordEndTime due to invalid duration even after adjustment.")
+ }
+ }
+ currentHcSamples.clear()
+ }
+ }
+ currentHcSamples.add(HeartRateRecord.Sample(currentSampleTimestamp, gbSample.heartRate.toLong()))
+ previousSampleTimestamp = currentSampleTimestamp
+ }
+
+ if (currentHcSamples.isNotEmpty()) {
+ val recordStartTime = currentHcSamples.first().time
+ var recordEndTime = currentHcSamples.last().time
+ if (recordEndTime == recordStartTime) { // Ensure duration is positive for HC
+ recordEndTime = recordEndTime.plusSeconds(1)
+ }
+
+ if (recordEndTime.isAfter(recordStartTime)) {
+ LOG.debug(
+ "Creating final HeartRateRecord for device '{}' from {} to {} with {} samples.",
+ deviceName,
+ recordStartTime,
+ recordEndTime,
+ currentHcSamples.size
+ )
+ heartRateRecordList.add(HeartRateRecord(recordStartTime, offset, recordEndTime, offset, ArrayList(currentHcSamples), metadata))
+ } else {
+ LOG.warn("Skipping final HeartRateRecord for device '$deviceName' from $recordStartTime to $recordEndTime due to invalid duration even after adjustment.")
+ }
+ }
+
+ if (heartRateRecordList.isEmpty()) {
+ LOG.info("No valid HeartRateRecord(s) created for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary after processing ${validHRSamples.size} samples.")
+ return SyncerStatistics(recordsSkipped = skippedCount, recordType = "HeartRate")
+ }
+
+ LOG.info("Attempting to insert ${heartRateRecordList.size} HeartRateRecord(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ for (chunk in heartRateRecordList.chunked(HealthConnectUtils.CHUNK_SIZE)) {
+ HealthConnectUtils.insertRecords(chunk, healthConnectClient)
+ }
+
+ LOG.info("Successfully inserted ${heartRateRecordList.size} HeartRateRecord(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordsSynced = heartRateRecordList.size, recordsSkipped = skippedCount, recordType = "HeartRate")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HrvSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HrvSyncer.kt
new file mode 100644
index 0000000000..885f091009
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/HrvSyncer.kt
@@ -0,0 +1,144 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
+
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord
+import androidx.health.connect.client.records.Record
+import androidx.health.connect.client.records.metadata.Metadata
+import nodomain.freeyourgadget.gadgetbridge.GBApplication
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.SyncException
+import org.slf4j.LoggerFactory
+import java.time.Instant
+import java.time.ZoneOffset
+
+private val LOG = LoggerFactory.getLogger("HrvSyncer")
+
+internal object HrvSyncer : HealthConnectSyncer {
+
+ private fun createHrvRecordsFromSamples(
+ valueSamples: List,
+ sliceStartBoundary: Instant,
+ sliceEndBoundary: Instant,
+ offset: ZoneOffset,
+ metadata: Metadata,
+ deviceName: String
+ ): List {
+ if (valueSamples.isEmpty()) {
+ LOG.info("No HRV value samples provided to create records for device '$deviceName'.")
+ return emptyList()
+ }
+
+ LOG.info("Processing ${valueSamples.size} HRV value samples for device '$deviceName'.")
+ val records = mutableListOf()
+ for (sample in valueSamples) {
+ val timestamp = Instant.ofEpochMilli(sample.timestamp)
+
+ if (sample.value <= 0) {
+ LOG.debug(
+ "Skipping HRV value sample for device '{}' at {} due to non-positive value: {}.",
+ deviceName,
+ timestamp,
+ sample.value
+ )
+ continue
+ }
+
+ if (timestamp.isBefore(sliceStartBoundary) || !timestamp.isBefore(sliceEndBoundary)) {
+ LOG.debug(
+ "Skipping HRV value sample for device '{}' at {} (value: {}) as it's outside the slice {} - {}.",
+ deviceName,
+ timestamp,
+ sample.value,
+ sliceStartBoundary,
+ sliceEndBoundary
+ )
+ continue
+ }
+
+ records.add(
+ HeartRateVariabilityRmssdRecord(
+ time = timestamp,
+ zoneOffset = offset,
+ heartRateVariabilityMillis = sample.value.toDouble(),
+ metadata = metadata
+ )
+ )
+ }
+ return records
+ }
+
+ override suspend fun sync(
+ healthConnectClient: HealthConnectClient,
+ gbDevice: GBDevice,
+ metadata: Metadata,
+ offset: ZoneOffset,
+ sliceStartBoundary: Instant,
+ sliceEndBoundary: Instant,
+ grantedPermissions: Set
+ ): SyncerStatistics {
+ val deviceName = gbDevice.aliasOrName
+
+ if (HealthPermission.getWritePermission(HeartRateVariabilityRmssdRecord::class) !in grantedPermissions) {
+ LOG.info("Skipping HRV sync for device '$deviceName'; HeartRateVariabilityRmssdRecord permission not granted.")
+ return SyncerStatistics(recordType = "HRV")
+ }
+
+ val coordinator = gbDevice.deviceCoordinator
+ val valueSamples: List = try {
+ GBApplication.acquireDB().use { dbInstance ->
+ val valueProvider = coordinator.getHrvValueSampleProvider(gbDevice, dbInstance.daoSession)
+ if (valueProvider == null) {
+ LOG.info("HRV Value Provider not available for device '$deviceName'.")
+ return SyncerStatistics(recordType = "HRV")
+ }
+ valueProvider.getAllSamples(sliceStartBoundary.toEpochMilli(), sliceEndBoundary.toEpochMilli())
+ }
+ } catch (e: Exception) {
+ throw SyncException("Error fetching HRV value samples for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.", e)
+ }
+
+ if (valueSamples.isEmpty()) {
+ LOG.info("No HRV value samples found by provider for device '$deviceName' in slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordType = "HRV")
+ }
+
+ val recordsToInsert = createHrvRecordsFromSamples(
+ valueSamples,
+ sliceStartBoundary,
+ sliceEndBoundary,
+ offset,
+ metadata,
+ deviceName
+ )
+
+ if (recordsToInsert.isEmpty()) {
+ LOG.info("No valid HRV records to insert for device '$deviceName' in slice $sliceStartBoundary to $sliceEndBoundary after processing samples.")
+ return SyncerStatistics(recordType = "HRV")
+ }
+
+ LOG.info("Attempting to insert ${recordsToInsert.size} HeartRateVariabilityRmssdRecord(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ HealthConnectUtils.insertRecords(recordsToInsert, healthConnectClient)
+
+ LOG.info("Successfully inserted ${recordsToInsert.size} HeartRateVariabilityRmssdRecord(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordsSynced = recordsToInsert.size, recordType = "HRV")
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/RecordedWorkoutSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/RecordedWorkoutSyncer.kt
new file mode 100644
index 0000000000..62dc76871b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/RecordedWorkoutSyncer.kt
@@ -0,0 +1,690 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
+
+import android.annotation.SuppressLint
+import android.content.Context
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.permission.HealthPermission.Companion.PERMISSION_WRITE_EXERCISE_ROUTE
+import androidx.health.connect.client.records.DistanceRecord
+import androidx.health.connect.client.records.ElevationGainedRecord
+import androidx.health.connect.client.records.ExerciseRoute
+import androidx.health.connect.client.records.ExerciseSessionRecord
+import androidx.health.connect.client.records.HeartRateRecord
+import androidx.health.connect.client.records.PowerRecord
+import androidx.health.connect.client.records.Record
+import androidx.health.connect.client.records.SpeedRecord
+import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
+import androidx.health.connect.client.records.metadata.Metadata
+import androidx.health.connect.client.units.Energy
+import androidx.health.connect.client.units.Length
+import androidx.health.connect.client.units.Power
+import androidx.health.connect.client.units.Velocity
+import nodomain.freeyourgadget.gadgetbridge.GBApplication
+import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsTrackViewModel
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils
+import org.slf4j.LoggerFactory
+import java.io.File
+import java.time.Instant
+import java.time.ZoneOffset
+import java.util.Date
+
+private val LOG = LoggerFactory.getLogger("RecordedWorkoutSyncer")
+
+/**
+ * Syncs workouts from BaseActivitySummary for devices that support activity tracks.
+ * This provides accurate workout data with GPS, detailed metrics, etc.
+ *
+ * Use this syncer for devices where coordinator.supportsActivityTracks() returns true.
+ */
+@SuppressLint("RestrictedApi")
+internal object RecordedWorkoutSyncer {
+ suspend fun sync(
+ healthConnectClient: HealthConnectClient,
+ gbDevice: GBDevice,
+ metadata: Metadata,
+ offset: ZoneOffset,
+ sliceStartBoundary: Instant,
+ sliceEndBoundary: Instant,
+ grantedPermissions: Set,
+ context: Context
+ ): SyncerStatistics {
+
+ val deviceName = gbDevice.aliasOrName
+
+ val prefs = Prefs(GBApplication.getPrefs().preferences)
+ val useDetailedSync = prefs.getBoolean(GBPrefs.HEALTH_CONNECT_DETAILED_WORKOUT_SYNC, true)
+
+ // Permission Check for ExerciseSessionRecord
+ if (HealthPermission.getWritePermission(ExerciseSessionRecord::class) !in grantedPermissions) {
+ LOG.info("Skipping Recorded Workout sync for device '$deviceName'; ExerciseSessionRecord permission not granted.")
+ return SyncerStatistics(recordType = "Workout")
+ }
+
+ // Query BaseActivitySummary from database
+ val workouts = queryWorkoutsFromDatabase(gbDevice, sliceStartBoundary, sliceEndBoundary)
+
+ if (workouts.isEmpty()) {
+ LOG.info("No workouts found in database for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordType = "Workout")
+ }
+
+ LOG.info("Found ${workouts.size} workout(s) from BaseActivitySummary for device '$deviceName' in slice: $sliceStartBoundary to $sliceEndBoundary.")
+
+ var workoutsProcessedInThisSlice = 0
+ val workoutRecordList = mutableListOf()
+
+ for (workout in workouts) {
+ try {
+ val workoutStartInstant = workout.startTime.toInstant()
+ val workoutEndInstant = workout.endTime.toInstant()
+
+ if (workoutEndInstant.isBefore(workoutStartInstant)) {
+ LOG.warn("Skipping invalid workout for device '$deviceName' (Type: ${workout.activityKind}): End time $workoutEndInstant is before start time $workoutStartInstant.")
+ continue
+ }
+
+ workoutsProcessedInThisSlice++
+ LOG.info("Processing workout for device '$deviceName' (Type: ${workout.activityKind}, Start: $workoutStartInstant, End: $workoutEndInstant).")
+
+ val recordsToInsert = mutableListOf()
+ val activityKind = ActivityKind.fromCode(workout.activityKind)
+ val exerciseType = WorkoutSyncerUtils.mapActivityKindToExerciseType(activityKind)
+
+ // Skip non-exercise activities (but allow UNKNOWN since it's in BaseActivitySummary - was explicitly recorded)
+ if (activityKind == ActivityKind.NOT_MEASURED ||
+ activityKind == ActivityKind.NOT_WORN ||
+ ActivityKind.isSleep(activityKind)) {
+ LOG.debug("Skipping non-exercising or sleep-related ActivityKind {} for device '{}'.", activityKind, deviceName)
+ workoutsProcessedInThisSlice--
+ continue
+ }
+
+ var activityPoints: List? = null
+
+ if (useDetailedSync) {
+ activityPoints = loadActivityPoints(workout, deviceName)
+ }
+
+ if (activityPoints != null && activityPoints.isNotEmpty()) {
+ LOG.info("Using detailed sync with ${activityPoints.size} activity points for workout (Type: ${activityKind}, Start: $workoutStartInstant).")
+ processDetailedWorkout(
+ workout,
+ activityPoints,
+ workoutStartInstant,
+ workoutEndInstant,
+ offset,
+ metadata,
+ grantedPermissions,
+ recordsToInsert,
+ deviceName,
+ activityKind,
+ exerciseType,
+ context
+ )
+ } else {
+ if (useDetailedSync) {
+ if (activityPoints == null) {
+ LOG.info("No track file available for workout, falling back to aggregate data.")
+ } else {
+ LOG.info("Track file contains no activity points, falling back to aggregate data.")
+ }
+ }
+ processAggregateWorkout(
+ workout,
+ workoutStartInstant,
+ workoutEndInstant,
+ offset,
+ metadata,
+ grantedPermissions,
+ recordsToInsert,
+ deviceName,
+ activityKind,
+ exerciseType,
+ context
+ )
+ }
+
+ if (recordsToInsert.isEmpty()) {
+ LOG.warn("No records were created for workout (Type: ${activityKind}, Start: $workoutStartInstant) for device '$deviceName'. This should not happen.")
+ continue
+ }
+
+ HealthConnectUtils.insertRecords(recordsToInsert, healthConnectClient)
+ LOG.info("Successfully inserted ${recordsToInsert.size} record(s) for workout (Type: ${activityKind}, Start: $workoutStartInstant) for device '$deviceName'.")
+ workoutRecordList.addAll(recordsToInsert)
+ } catch (e: Exception) {
+ LOG.error("Error processing workout for device '$deviceName'", e)
+ // Continue with next workout instead of failing entire sync
+ }
+ }
+
+ if (workoutsProcessedInThisSlice == 0 && workouts.isNotEmpty()) {
+ LOG.info("No workouts were processed for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary (e.g., all invalid).")
+ } else if (workoutsProcessedInThisSlice > 0) {
+ LOG.info("Finished processing $workoutsProcessedInThisSlice workout(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ }
+
+ if (workoutRecordList.isEmpty()) {
+ LOG.info("No valid ExerciseSessionRecord(s) created for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordType = "Workout")
+ }
+
+ LOG.info("Successfully inserted ${workoutRecordList.size} ExerciseSessionRecord(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordsSynced = workoutRecordList.size, recordType = "Workout")
+ }
+
+
+ private fun queryWorkoutsFromDatabase(
+ gbDevice: GBDevice,
+ sliceStartBoundary: Instant,
+ sliceEndBoundary: Instant
+ ): List {
+ val db: DBHandler = GBApplication.acquireDB()
+ try {
+ val device = DBHelper.getDevice(gbDevice, db.daoSession)
+ if (device == null) {
+ LOG.warn("Device not found in database for '{}'", gbDevice.aliasOrName)
+ return emptyList()
+ }
+
+ val startDate = Date.from(sliceStartBoundary)
+ val endDate = Date.from(sliceEndBoundary)
+
+ return db.daoSession.baseActivitySummaryDao.queryBuilder()
+ .where(
+ BaseActivitySummaryDao.Properties.DeviceId.eq(device.id),
+ BaseActivitySummaryDao.Properties.StartTime.ge(startDate),
+ BaseActivitySummaryDao.Properties.StartTime.lt(endDate)
+ )
+ .build()
+ .list()
+ } catch (e: Exception) {
+ LOG.error("Error querying workouts from database for device '{}'", gbDevice.aliasOrName, e)
+ return emptyList()
+ } finally {
+ GBApplication.releaseDB()
+ }
+ }
+
+ private fun loadActivityPoints(workout: BaseActivitySummary, deviceName: String): List? {
+ val trackFilePath = workout.rawDetailsPath
+ if (trackFilePath.isNullOrBlank()) {
+ LOG.debug("No track file path available for workout on device '$deviceName'.")
+ return null
+ }
+
+ val trackFile = File(trackFilePath)
+ if (!trackFile.exists() || !trackFile.canRead()) {
+ LOG.warn("Track file does not exist or cannot be read: $trackFilePath")
+ return null
+ }
+
+ return try {
+ val points = MapsTrackViewModel.getActivityPoints(trackFile)
+ if (points.isEmpty()) {
+ LOG.debug("Track file contains no activity points: $trackFilePath")
+ null
+ } else {
+ points
+ }
+ } catch (e: Exception) {
+ LOG.error("Error loading activity points from track file: $trackFilePath", e)
+ null
+ }
+ }
+
+ private fun processDetailedWorkout(
+ workout: BaseActivitySummary,
+ activityPoints: List,
+ workoutStartInstant: Instant,
+ workoutEndInstant: Instant,
+ offset: ZoneOffset,
+ metadata: Metadata,
+ grantedPermissions: Set,
+ recordsToInsert: MutableList,
+ deviceName: String,
+ activityKind: ActivityKind,
+ exerciseType: Int,
+ context: Context
+ ) {
+ // Build GPS route if location data is available and permission is granted
+ val exerciseRoute = if (PERMISSION_WRITE_EXERCISE_ROUTE in grantedPermissions) {
+ val locationPoints = activityPoints
+ .filter { it.location != null && it.time != null }
+ .mapNotNull { point ->
+ val pointInstant = point.time.toInstant()
+ if (pointInstant.isBefore(workoutStartInstant) || pointInstant.isAfter(workoutEndInstant)) {
+ return@mapNotNull null
+ }
+ val location = point.location ?: return@mapNotNull null
+ ExerciseRoute.Location(
+ time = pointInstant,
+ latitude = location.latitude,
+ longitude = location.longitude,
+ altitude = if (location.hasAltitude()) Length.meters(location.altitude) else null,
+ horizontalAccuracy = if (location.hasHdop()) Length.meters(location.hdop) else null,
+ verticalAccuracy = if (location.hasVdop()) Length.meters(location.vdop) else null
+ )
+ }
+
+ if (locationPoints.isNotEmpty()) {
+ LOG.info("Adding GPS route with ${locationPoints.size} location points for workout on device '$deviceName'.")
+ ExerciseRoute(locationPoints)
+ } else {
+ null
+ }
+ } else {
+ null
+ }
+
+ recordsToInsert.add(
+ ExerciseSessionRecord(
+ startTime = workoutStartInstant,
+ startZoneOffset = offset,
+ endTime = workoutEndInstant,
+ endZoneOffset = offset,
+ exerciseType = exerciseType,
+ title = workout.name ?: activityKind.getLabel(context),
+ exerciseRoute = exerciseRoute,
+ metadata = metadata
+ )
+ )
+
+ addDetailedHeartRateRecords(activityPoints, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
+ addDetailedSpeedRecords(activityPoints, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
+ addDetailedPowerRecords(activityPoints, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
+
+ val summaryData = parseSummaryData(workout.summaryData)
+ if (summaryData != null) {
+ addDistanceRecord(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
+ addCaloriesRecord(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
+ addElevationGainedRecord(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
+ }
+ }
+
+ private fun processAggregateWorkout(
+ workout: BaseActivitySummary,
+ workoutStartInstant: Instant,
+ workoutEndInstant: Instant,
+ offset: ZoneOffset,
+ metadata: Metadata,
+ grantedPermissions: Set,
+ recordsToInsert: MutableList,
+ deviceName: String,
+ activityKind: ActivityKind,
+ exerciseType: Int,
+ context: Context
+ ) {
+ recordsToInsert.add(
+ ExerciseSessionRecord(
+ startTime = workoutStartInstant,
+ startZoneOffset = offset,
+ endTime = workoutEndInstant,
+ endZoneOffset = offset,
+ exerciseType = exerciseType,
+ title = workout.name ?: activityKind.getLabel(context),
+ metadata = metadata
+ )
+ )
+
+ val summaryData = parseSummaryData(workout.summaryData)
+ if (summaryData != null) {
+ addDistanceRecord(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
+ addHeartRateRecord(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
+ addSpeedRecord(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
+ addCaloriesRecord(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
+ addElevationGainedRecord(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
+ }
+ }
+
+ private fun addDetailedHeartRateRecords(
+ activityPoints: List,
+ startTime: Instant,
+ endTime: Instant,
+ offset: ZoneOffset,
+ metadata: Metadata,
+ grantedPermissions: Set,
+ recordsToInsert: MutableList,
+ deviceName: String
+ ) {
+ val hrPermission = HealthPermission.getWritePermission(HeartRateRecord::class)
+ if (hrPermission !in grantedPermissions) {
+ return
+ }
+
+ val hrSamples = activityPoints
+ .filter { it.heartRate > 0 && it.time != null }
+ .map { point ->
+ val pointInstant = point.time.toInstant()
+ if (pointInstant.isBefore(startTime) || pointInstant.isAfter(endTime)) {
+ return@map null
+ }
+ HeartRateRecord.Sample(
+ time = pointInstant,
+ beatsPerMinute = point.heartRate.toLong()
+ )
+ }
+ .filterNotNull()
+
+ if (hrSamples.isNotEmpty()) {
+ recordsToInsert.add(
+ HeartRateRecord(
+ startTime = startTime,
+ startZoneOffset = offset,
+ endTime = endTime,
+ endZoneOffset = offset,
+ samples = hrSamples,
+ metadata = metadata
+ )
+ )
+ LOG.debug("Added detailed HeartRateRecord with ${hrSamples.size} samples for workout on device '$deviceName'.")
+ }
+ }
+
+ private fun addDetailedSpeedRecords(
+ activityPoints: List,
+ startTime: Instant,
+ endTime: Instant,
+ offset: ZoneOffset,
+ metadata: Metadata,
+ grantedPermissions: Set,
+ recordsToInsert: MutableList,
+ deviceName: String
+ ) {
+ val speedPermission = HealthPermission.getWritePermission(SpeedRecord::class)
+ if (speedPermission !in grantedPermissions) {
+ return
+ }
+
+ val speedSamples = activityPoints
+ .filter { it.speed > 0 && it.time != null }
+ .map { point ->
+ val pointInstant = point.time.toInstant()
+ if (pointInstant.isBefore(startTime) || pointInstant.isAfter(endTime)) {
+ return@map null
+ }
+ SpeedRecord.Sample(
+ time = pointInstant,
+ speed = Velocity.metersPerSecond(point.speed.toDouble())
+ )
+ }
+ .filterNotNull()
+
+ if (speedSamples.isNotEmpty()) {
+ recordsToInsert.add(
+ SpeedRecord(
+ startTime = startTime,
+ startZoneOffset = offset,
+ endTime = endTime,
+ endZoneOffset = offset,
+ samples = speedSamples,
+ metadata = metadata
+ )
+ )
+ LOG.debug("Added detailed SpeedRecord with ${speedSamples.size} samples for workout on device '$deviceName'.")
+ }
+ }
+
+ private fun addDetailedPowerRecords(
+ activityPoints: List,
+ startTime: Instant,
+ endTime: Instant,
+ offset: ZoneOffset,
+ metadata: Metadata,
+ grantedPermissions: Set,
+ recordsToInsert: MutableList,
+ deviceName: String
+ ) {
+ val powerPermission = HealthPermission.getWritePermission(PowerRecord::class)
+ if (powerPermission !in grantedPermissions) {
+ return
+ }
+
+ val powerSamples = activityPoints
+ .filter { it.power > 0 && it.time != null }
+ .map { point ->
+ val pointInstant = point.time.toInstant()
+ if (pointInstant.isBefore(startTime) || pointInstant.isAfter(endTime)) {
+ return@map null
+ }
+ PowerRecord.Sample(
+ time = pointInstant,
+ power = Power.watts(point.power.toDouble())
+ )
+ }
+ .filterNotNull()
+
+ if (powerSamples.isNotEmpty()) {
+ recordsToInsert.add(
+ PowerRecord(
+ startTime = startTime,
+ startZoneOffset = offset,
+ endTime = endTime,
+ endZoneOffset = offset,
+ samples = powerSamples,
+ metadata = metadata
+ )
+ )
+ LOG.debug("Added detailed PowerRecord with ${powerSamples.size} samples for workout on device '$deviceName'.")
+ }
+ }
+
+ private fun parseSummaryData(summaryDataJson: String?): ActivitySummaryData? {
+ if (summaryDataJson.isNullOrBlank()) {
+ return null
+ }
+ return try {
+ ActivitySummaryData.fromJson(summaryDataJson)
+ } catch (e: Exception) {
+ LOG.warn("Failed to parse summaryData JSON", e)
+ null
+ }
+ }
+
+ private fun addDistanceRecord(
+ summaryData: ActivitySummaryData,
+ startTime: Instant,
+ endTime: Instant,
+ offset: ZoneOffset,
+ metadata: Metadata,
+ grantedPermissions: Set,
+ recordsToInsert: MutableList,
+ deviceName: String
+ ) {
+ val distancePermission = HealthPermission.getWritePermission(DistanceRecord::class)
+
+ if (distancePermission !in grantedPermissions) {
+ return
+ }
+
+ val distanceMeters = summaryData.getNumber(ActivitySummaryEntries.DISTANCE_METERS, 0.0)
+ if (distanceMeters.toDouble() > 0) {
+ recordsToInsert.add(
+ DistanceRecord(
+ startTime = startTime,
+ startZoneOffset = offset,
+ endTime = endTime,
+ endZoneOffset = offset,
+ distance = Length.meters(distanceMeters.toDouble()),
+ metadata = metadata
+ )
+ )
+ LOG.debug("Added DistanceRecord ({} meters) for workout at {} for device '{}'.", distanceMeters, startTime, deviceName)
+ }
+ }
+
+ private fun addHeartRateRecord(
+ summaryData: ActivitySummaryData,
+ startTime: Instant,
+ endTime: Instant,
+ offset: ZoneOffset,
+ metadata: Metadata,
+ grantedPermissions: Set,
+ recordsToInsert: MutableList,
+ deviceName: String
+ ) {
+ val hrPermission = HealthPermission.getWritePermission(HeartRateRecord::class)
+
+ if (hrPermission !in grantedPermissions) {
+ return
+ }
+
+ val hrAvg = summaryData.getNumber(ActivitySummaryEntries.HR_AVG, 0.0)
+ if (hrAvg.toDouble() > 0) {
+ val midTime = startTime.plusSeconds((endTime.epochSecond - startTime.epochSecond) / 2)
+ recordsToInsert.add(
+ HeartRateRecord(
+ startTime = midTime,
+ startZoneOffset = offset,
+ endTime = midTime,
+ endZoneOffset = offset,
+ samples = listOf(
+ HeartRateRecord.Sample(
+ time = midTime,
+ beatsPerMinute = hrAvg.toLong()
+ )
+ ),
+ metadata = metadata
+ )
+ )
+ LOG.debug("Added HeartRateRecord (avg: {} bpm) for workout at {} for device '{}'.", hrAvg, startTime, deviceName)
+ }
+ }
+
+ private fun addSpeedRecord(
+ summaryData: ActivitySummaryData,
+ startTime: Instant,
+ endTime: Instant,
+ offset: ZoneOffset,
+ metadata: Metadata,
+ grantedPermissions: Set,
+ recordsToInsert: MutableList,
+ deviceName: String
+ ) {
+ val speedPermission = HealthPermission.getWritePermission(SpeedRecord::class)
+
+ if (speedPermission !in grantedPermissions) {
+ return
+ }
+
+ val speedAvg = summaryData.getNumber(ActivitySummaryEntries.SPEED_AVG, 0.0)
+ if (speedAvg.toDouble() > 0) {
+ val midTime = startTime.plusSeconds((endTime.epochSecond - startTime.epochSecond) / 2)
+ recordsToInsert.add(
+ SpeedRecord(
+ startTime = midTime,
+ startZoneOffset = offset,
+ endTime = midTime,
+ endZoneOffset = offset,
+ samples = listOf(
+ SpeedRecord.Sample(
+ time = midTime,
+ speed = Velocity.metersPerSecond(speedAvg.toDouble())
+ )
+ ),
+ metadata = metadata
+ )
+ )
+ LOG.debug("Added SpeedRecord (avg: {} m/s) for workout at {} for device '{}'.", speedAvg, startTime, deviceName)
+ }
+ }
+
+ private fun addCaloriesRecord(
+ summaryData: ActivitySummaryData,
+ startTime: Instant,
+ endTime: Instant,
+ offset: ZoneOffset,
+ metadata: Metadata,
+ grantedPermissions: Set,
+ recordsToInsert: MutableList,
+ deviceName: String
+ ) {
+ val caloriesPermission = HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class)
+
+ if (caloriesPermission !in grantedPermissions) {
+ return
+ }
+
+ val caloriesBurnt = summaryData.getNumber(ActivitySummaryEntries.CALORIES_BURNT, 0.0)
+ if (caloriesBurnt.toDouble() > 0) {
+ recordsToInsert.add(
+ TotalCaloriesBurnedRecord(
+ startTime = startTime,
+ startZoneOffset = offset,
+ endTime = endTime,
+ endZoneOffset = offset,
+ energy = Energy.kilocalories(caloriesBurnt.toDouble()),
+ metadata = metadata
+ )
+ )
+ LOG.debug("Added TotalCaloriesBurnedRecord ({} kcal) for workout at {} for device '{}'.", caloriesBurnt, startTime, deviceName)
+ }
+ }
+
+ private fun addElevationGainedRecord(
+ summaryData: ActivitySummaryData,
+ startTime: Instant,
+ endTime: Instant,
+ offset: ZoneOffset,
+ metadata: Metadata,
+ grantedPermissions: Set,
+ recordsToInsert: MutableList,
+ deviceName: String
+ ) {
+ val elevationPermission = HealthPermission.getWritePermission(ElevationGainedRecord::class)
+
+ if (elevationPermission !in grantedPermissions) {
+ return
+ }
+
+ var elevationGain = summaryData.getNumber(ActivitySummaryEntries.ELEVATION_GAIN, 0.0).toDouble()
+ if (elevationGain == 0.0) {
+ elevationGain = summaryData.getNumber(ActivitySummaryEntries.TOTAL_ASCENT, 0.0).toDouble()
+ }
+ if (elevationGain == 0.0) {
+ elevationGain = summaryData.getNumber(ActivitySummaryEntries.ASCENT_METERS, 0.0).toDouble()
+ }
+
+ if (elevationGain > 0) {
+ recordsToInsert.add(
+ ElevationGainedRecord(
+ startTime = startTime,
+ startZoneOffset = offset,
+ endTime = endTime,
+ endZoneOffset = offset,
+ elevation = Length.meters(elevationGain),
+ metadata = metadata
+ )
+ )
+ LOG.debug("Added ElevationGainedRecord ({} m) for workout at {} for device '{}'.", elevationGain, startTime, deviceName)
+ }
+ }
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/SleepSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/SleepSyncer.kt
new file mode 100644
index 0000000000..f62c07dedf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/SleepSyncer.kt
@@ -0,0 +1,258 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
+
+import android.content.Context
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.SleepSessionRecord
+import androidx.health.connect.client.records.metadata.Metadata
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.SleepAnalysis
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils
+import org.slf4j.LoggerFactory
+import java.time.Instant
+import java.time.ZoneOffset
+
+private val LOG = LoggerFactory.getLogger("SleepSyncer")
+
+internal object SleepSyncer : ContextualActivitySampleSyncer {
+
+ override suspend fun sync(
+ healthConnectClient: HealthConnectClient,
+ gbDevice: GBDevice,
+ metadata: Metadata,
+ offset: ZoneOffset,
+ sliceStartBoundary: Instant,
+ sliceEndBoundary: Instant,
+ grantedPermissions: Set,
+ deviceSamples: List,
+ context: Context
+ ): SyncerStatistics {
+
+ val deviceName = gbDevice.aliasOrName
+
+
+ if (HealthPermission.getWritePermission(SleepSessionRecord::class) !in grantedPermissions) {
+ LOG.info("Skipping Sleep sync for device '$deviceName'; SleepSessionRecord permission not granted.")
+ return SyncerStatistics(recordType = "Sleep")
+ }
+
+ if (deviceSamples.isEmpty()) {
+ LOG.info("No device samples provided for sleep analysis for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordType = "Sleep")
+ }
+
+ val sortedDeviceSamples = deviceSamples.sortedBy { it.timestamp }
+
+ val sleepAnalysis = SleepAnalysis()
+ val allIdentifiedSessions = sleepAnalysis.calculateSleepSessions(sortedDeviceSamples)
+
+ if (allIdentifiedSessions.isEmpty()) {
+ LOG.info("No sleep sessions identified by SleepAnalysis for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordType = "Sleep")
+ }
+
+ LOG.info("SleepAnalysis identified ${allIdentifiedSessions.size} sleep sessions for device '$deviceName'. Filtering by slice: $sliceStartBoundary to $sliceEndBoundary.")
+
+ // Convert all sessions to records, filtering out invalid ones
+ val sleepSessionRecordList = allIdentifiedSessions.mapNotNull { analysisSession ->
+ sleepSessionToRecord(
+ analysisSession = analysisSession,
+ sortedDeviceSamples = sortedDeviceSamples,
+ sliceStartBoundary = sliceStartBoundary,
+ sliceEndBoundary = sliceEndBoundary,
+ offset = offset,
+ metadata = metadata,
+ context = context,
+ deviceName = deviceName
+ )
+ }
+
+ val skippedCount = allIdentifiedSessions.size - sleepSessionRecordList.size
+
+ if (skippedCount > 0) {
+ LOG.info("Skipped $skippedCount sleep session(s) for device '$deviceName' (outside slice, no samples, no valid stages, or invalid timings).")
+ }
+
+ LOG.info("Finished processing ${sleepSessionRecordList.size} valid sleep session(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+
+ if (sleepSessionRecordList.isEmpty()) {
+ LOG.info("No valid SleepSessionRecord(s) created for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordType = "Sleep", recordsSkipped = skippedCount)
+ }
+
+ LOG.info("Attempting to insert ${sleepSessionRecordList.size} SleepSessionRecord(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ HealthConnectUtils.insertRecords(sleepSessionRecordList, healthConnectClient)
+ LOG.info("Successfully inserted SleepSessionRecord(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordsSynced = sleepSessionRecordList.size, recordsSkipped = skippedCount, recordType = "Sleep")
+ }
+
+ /**
+ * Converts a sleep analysis session to a SleepSessionRecord.
+ * Returns null if the session is invalid or should be skipped (e.g., outside slice, no valid stages).
+ */
+ private fun sleepSessionToRecord(
+ analysisSession: SleepAnalysis.SleepSession,
+ sortedDeviceSamples: List,
+ sliceStartBoundary: Instant,
+ sliceEndBoundary: Instant,
+ offset: ZoneOffset,
+ metadata: Metadata,
+ context: Context,
+ deviceName: String
+ ): SleepSessionRecord? {
+ // Get session boundaries from SleepAnalysis (timestamps of first and last sample in the session)
+ val sessionBoundaryStart = analysisSession.sleepStart.toInstant()
+ val sessionBoundaryEndInclusive = analysisSession.sleepEnd.toInstant()
+
+ // Check if session overlaps with the current slice
+ if (!sessionBoundaryStart.isBefore(sliceEndBoundary) || !sessionBoundaryEndInclusive.isAfter(sliceStartBoundary)) {
+ LOG.debug(
+ "Skipping sleep session (identified by SleepAnalysis) for device '{}' (Timeframe: {} to {}) as it does not overlap with current slice ({} to {}).",
+ deviceName,
+ sessionBoundaryStart,
+ sessionBoundaryEndInclusive,
+ sliceStartBoundary,
+ sliceEndBoundary
+ )
+ return null
+ }
+
+ // Filter the original (sorted) device samples that fall within this specific session's timeframe
+ val samplesForThisSession = sortedDeviceSamples.filter {
+ val sampleEpochSeconds = it.timestamp.toLong()
+ sampleEpochSeconds >= (analysisSession.sleepStart.time / 1000L) &&
+ sampleEpochSeconds <= (analysisSession.sleepEnd.time / 1000L)
+ }
+
+ if (samplesForThisSession.isEmpty()) {
+ LOG.debug(
+ "Skipping session from SleepAnalysis for device '{}' as no samples were found in the original list for its timeframe ({} to {}).",
+ deviceName,
+ sessionBoundaryStart,
+ sessionBoundaryEndInclusive
+ )
+ return null
+ }
+
+ val nominalSessionStart = samplesForThisSession.first().timestamp.toLong().let { Instant.ofEpochSecond(it) }
+ val nominalSessionEnd = samplesForThisSession.last().timestamp.toLong().let { Instant.ofEpochSecond(it) }
+
+ LOG.info("Processing sleep session (identified by SleepAnalysis) for device '$deviceName' (Nominal sample range: $nominalSessionStart to $nominalSessionEnd) as it overlaps with slice $sliceStartBoundary to $sliceEndBoundary.")
+
+ // Build sleep stages from samples
+ val stages = buildSleepStages(samplesForThisSession, deviceName)
+
+ if (stages.isEmpty()) {
+ LOG.warn("No valid sleep stages derived for session (Nominal range: $nominalSessionStart to $nominalSessionEnd, identified by SleepAnalysis) for device '$deviceName'. Skipping this session.")
+ return null
+ }
+
+ val recordFinalStartTime = stages.first().startTime
+ val recordFinalEndTime = stages.last().endTime
+
+ if (!recordFinalEndTime.isAfter(recordFinalStartTime)) {
+ LOG.warn("Skipping sleep session for device '$deviceName' due to invalid overall stage timings after processing (End: $recordFinalEndTime, Start: $recordFinalStartTime). Stages: ${stages.size}")
+ return null
+ }
+
+ LOG.info("Prepared SleepSessionRecord for device '$deviceName' (Session: $recordFinalStartTime to $recordFinalEndTime). Stages: ${stages.size}")
+
+ return SleepSessionRecord(
+ startTime = recordFinalStartTime,
+ startZoneOffset = offset,
+ endTime = recordFinalEndTime,
+ endZoneOffset = offset,
+ title = context.getString(nodomain.freeyourgadget.gadgetbridge.R.string.health_connect_sleep_session_title, deviceName),
+ notes = context.getString(nodomain.freeyourgadget.gadgetbridge.R.string.health_connect_sleep_session_notes, deviceName),
+ stages = stages,
+ metadata = metadata
+ )
+ }
+
+ /**
+ * Builds sleep stages from activity samples by grouping consecutive samples of the same type.
+ */
+ private fun buildSleepStages(
+ samplesForThisSession: List,
+ deviceName: String
+ ): List {
+ val stages = mutableListOf()
+ var currentIndex = 0
+
+ while (currentIndex < samplesForThisSession.size) {
+ val firstSampleOfStage = samplesForThisSession[currentIndex]
+ val stageType = mapActivityKindToSleepStage(firstSampleOfStage.kind)
+
+ if (stageType == SleepSessionRecord.STAGE_TYPE_UNKNOWN) {
+ currentIndex++
+ continue
+ }
+
+ val stageStartTime = Instant.ofEpochSecond(firstSampleOfStage.timestamp.toLong())
+ var nextDifferentSampleIndex = currentIndex + 1
+ while (nextDifferentSampleIndex < samplesForThisSession.size &&
+ mapActivityKindToSleepStage(samplesForThisSession[nextDifferentSampleIndex].kind) == stageType) {
+ nextDifferentSampleIndex++
+ }
+
+ val stageEndTime: Instant
+ if (nextDifferentSampleIndex < samplesForThisSession.size) {
+ // Stage ends when the next, different sample begins
+ stageEndTime = Instant.ofEpochSecond(samplesForThisSession[nextDifferentSampleIndex].timestamp.toLong())
+ } else {
+ // This is the last stage of this session. End time is slightly after the last sample's timestamp.
+ val lastSampleTimestamp = samplesForThisSession.last().timestamp.toLong()
+ val provisionalEnd = Instant.ofEpochSecond(lastSampleTimestamp)
+ // Ensure endTime is exclusive and after startTime
+ stageEndTime = if (provisionalEnd.plusMillis(1).isAfter(stageStartTime)) {
+ provisionalEnd.plusMillis(1)
+ } else {
+ stageStartTime.plusSeconds(1) // Fallback for very short/single sample stages
+ }
+ }
+
+ if (stageEndTime.isAfter(stageStartTime)) {
+ stages.add(SleepSessionRecord.Stage(stageStartTime, stageEndTime, stageType))
+ } else {
+ LOG.trace(
+ "Skipping zero or negative duration stage for device '{}' at {} (type {}), proposed end {}.",
+ deviceName,
+ stageStartTime,
+ stageType,
+ stageEndTime
+ )
+ }
+ currentIndex = nextDifferentSampleIndex
+ }
+
+ return stages
+ }
+
+ private fun mapActivityKindToSleepStage(activityKind: ActivityKind): Int {
+ return when (activityKind) {
+ ActivityKind.DEEP_SLEEP -> SleepSessionRecord.STAGE_TYPE_DEEP
+ ActivityKind.LIGHT_SLEEP -> SleepSessionRecord.STAGE_TYPE_LIGHT
+ ActivityKind.REM_SLEEP -> SleepSessionRecord.STAGE_TYPE_REM
+ ActivityKind.AWAKE_SLEEP -> SleepSessionRecord.STAGE_TYPE_AWAKE
+ else -> SleepSessionRecord.STAGE_TYPE_UNKNOWN
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/Spo2Syncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/Spo2Syncer.kt
new file mode 100644
index 0000000000..c2eace4e0c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/Spo2Syncer.kt
@@ -0,0 +1,161 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
+
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.OxygenSaturationRecord
+import androidx.health.connect.client.records.Record
+import androidx.health.connect.client.records.metadata.Metadata
+import androidx.health.connect.client.units.Percentage
+import androidx.health.connect.client.records.metadata.Device
+import nodomain.freeyourgadget.gadgetbridge.GBApplication
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractSpo2Sample
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.SyncException
+import org.slf4j.LoggerFactory
+import java.time.Instant
+import java.time.ZoneOffset
+
+private val LOG = LoggerFactory.getLogger("Spo2Syncer")
+
+internal object Spo2Syncer : HealthConnectSyncer {
+ override suspend fun sync(
+ healthConnectClient: HealthConnectClient,
+ gbDevice: GBDevice,
+ metadata: Metadata,
+ offset: ZoneOffset,
+ sliceStartBoundary: Instant,
+ sliceEndBoundary: Instant,
+ grantedPermissions: Set
+ ): SyncerStatistics {
+ val deviceName = gbDevice.aliasOrName
+
+ if (HealthPermission.getWritePermission(OxygenSaturationRecord::class) !in grantedPermissions) {
+ LOG.info("Skipping SpO2 sync for device '$deviceName'; OxygenSaturationRecord permission not granted.")
+ return SyncerStatistics(recordType = "SpO2")
+ }
+
+ // Create Device object for metadata
+ val deviceCoordinator = gbDevice.deviceCoordinator
+ val device = Device(
+ type = when (deviceCoordinator.getDeviceKind(gbDevice)) {
+ nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator.DeviceKind.WATCH -> Device.TYPE_WATCH
+ nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator.DeviceKind.PHONE -> Device.TYPE_PHONE
+ nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator.DeviceKind.SCALE -> Device.TYPE_SCALE
+ nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator.DeviceKind.RING -> Device.TYPE_RING
+ nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator.DeviceKind.HEAD_MOUNTED -> Device.TYPE_HEAD_MOUNTED
+ nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator.DeviceKind.FITNESS_BAND -> Device.TYPE_FITNESS_BAND
+ nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator.DeviceKind.CHEST_STRAP -> Device.TYPE_CHEST_STRAP
+ nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator.DeviceKind.SMART_DISPLAY -> Device.TYPE_SMART_DISPLAY
+ else -> Device.TYPE_UNKNOWN
+ },
+ manufacturer = deviceCoordinator.manufacturer,
+ model = gbDevice.model
+ )
+
+ val samples = try {
+ GBApplication.acquireDB().use { dbInstance ->
+ val provider = gbDevice.deviceCoordinator.getSpo2SampleProvider(gbDevice, dbInstance.daoSession)
+ if (provider == null) {
+ LOG.warn("Spo2SampleProvider not found for device '$deviceName'. Skipping SpO2 sync for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return@use emptyList()
+ }
+ provider.getAllSamples(sliceStartBoundary.toEpochMilli(), sliceEndBoundary.toEpochMilli())
+ }
+ } catch (e: Exception) {
+ throw SyncException("Error fetching SpO2 samples for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.", e)
+ }
+
+ LOG.info("Found ${samples.size} SpO2 samples for device '$deviceName' in slice $sliceStartBoundary to $sliceEndBoundary.")
+
+ if (samples.isEmpty()) {
+ LOG.info("No SpO2 samples to process for device '$deviceName' in slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordType = "SpO2")
+ }
+
+ val recordsToInsert = mutableListOf()
+ var skippedCount = 0
+ for (sample in samples) {
+ val timestamp = Instant.ofEpochMilli(sample.timestamp)
+ val spo2Value = sample.spo2
+ val spo2AsDouble = spo2Value.toDouble()
+
+ if (timestamp.isBefore(sliceStartBoundary) || !timestamp.isBefore(sliceEndBoundary)) {
+ LOG.debug(
+ "Skipping SpO2 sample for device '{}' at {} (value: {}) as it's outside the slice {} - {}.",
+ deviceName,
+ timestamp,
+ spo2AsDouble,
+ sliceStartBoundary,
+ sliceEndBoundary
+ )
+ skippedCount++
+ continue
+ }
+
+ if (spo2AsDouble <= 0 || spo2AsDouble > 100) {
+ LOG.debug(
+ "Skipping SpO2 sample for device '{}' at {} with invalid value ({}). Valid range: >0 and <=100.",
+ deviceName,
+ timestamp,
+ spo2AsDouble
+ )
+ skippedCount++
+ continue
+ }
+
+ // Create appropriate metadata based on measurement type
+ val sampleMetadata = when (sample.type) {
+ Spo2Sample.Type.MANUAL -> {
+ LOG.trace("SpO2 sample at {} for device '{}' is manually recorded", timestamp, deviceName)
+ Metadata.activelyRecorded(device)
+ }
+ Spo2Sample.Type.AUTOMATIC -> {
+ LOG.trace("SpO2 sample at {} for device '{}' is automatically recorded", timestamp, deviceName)
+ Metadata.autoRecorded(device)
+ }
+ Spo2Sample.Type.UNKNOWN -> {
+ LOG.trace("SpO2 sample at {} for device '{}' has unknown type, using autoRecorded", timestamp, deviceName)
+ Metadata.autoRecorded(device)
+ }
+ }
+
+ recordsToInsert.add(
+ OxygenSaturationRecord(
+ time = timestamp,
+ zoneOffset = offset,
+ percentage = Percentage(spo2AsDouble),
+ metadata = sampleMetadata
+ )
+ )
+ }
+
+ if (recordsToInsert.isEmpty()) {
+ LOG.info("No valid SpO2 records to insert for device '$deviceName' in slice $sliceStartBoundary to $sliceEndBoundary after filtering.")
+ return SyncerStatistics(recordsSkipped = skippedCount, recordType = "SpO2")
+ }
+
+ LOG.info("Attempting to insert ${recordsToInsert.size} OxygenSaturationRecord(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ HealthConnectUtils.insertRecords(recordsToInsert, healthConnectClient)
+
+ LOG.info("Successfully inserted ${recordsToInsert.size} OxygenSaturationRecord(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordsSynced = recordsToInsert.size, recordsSkipped = skippedCount, recordType = "SpO2")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/StepsSync.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/StepsSync.kt
new file mode 100644
index 0000000000..44ce1c12a3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/StepsSync.kt
@@ -0,0 +1,105 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
+
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.Record
+import androidx.health.connect.client.records.StepsRecord
+import androidx.health.connect.client.records.metadata.Metadata
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils
+import org.slf4j.LoggerFactory
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.temporal.ChronoUnit
+
+private val LOG = LoggerFactory.getLogger("StepsSyncer")
+
+internal object StepsSyncer : ActivitySampleSyncer {
+ override suspend fun sync(
+ healthConnectClient: HealthConnectClient,
+ gbDevice: GBDevice,
+ metadata: Metadata,
+ offset: ZoneOffset,
+ sliceStartBoundary: Instant,
+ sliceEndBoundary: Instant,
+ grantedPermissions: Set,
+ deviceSamples: List
+ ): SyncerStatistics {
+
+ val deviceName = gbDevice.aliasOrName
+
+ // 1. Permission Check
+ if (HealthPermission.getWritePermission(StepsRecord::class) !in grantedPermissions) {
+ LOG.info("Skipping Steps sync for device '$deviceName'; StepsRecord permission not granted.")
+ return SyncerStatistics(recordType = "Steps")
+ }
+
+ // 2. Relevant Input Data Check
+ val relevantSamples = deviceSamples.filter { it.steps > 0 }.sortedBy { it.timestamp }
+
+ if (relevantSamples.isEmpty()) {
+ LOG.info("No relevant step samples (>0) for device '$deviceName' in the provided deviceSamples for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordType = "Steps")
+ }
+
+ LOG.info("Processing ${relevantSamples.size} samples for steps for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+
+ val stepsRecordList = mutableListOf()
+ var skippedCount = 0
+ relevantSamples.forEach { currentSample ->
+ val stepsInMinute = currentSample.steps.toLong()
+ if (stepsInMinute > 0) {
+ val endTs = Instant.ofEpochSecond(currentSample.timestamp.toLong())
+ val startTs = endTs.minus(1, ChronoUnit.MINUTES)
+
+ // Ensure the record's interval [startTs, endTs) overlaps with the slice [sliceStartBoundary, sliceEndBoundary)
+ if (endTs.isAfter(sliceStartBoundary) && startTs.isBefore(sliceEndBoundary)) {
+ stepsRecordList.add(StepsRecord(startTs, offset, endTs, offset, stepsInMinute, metadata))
+ } else {
+ skippedCount++
+ LOG.debug(
+ "Skipping steps for device '{}' for sample at {} (interval {} to {}) as its interval is outside the slice {} - {}.",
+ deviceName,
+ endTs,
+ startTs,
+ endTs,
+ sliceStartBoundary,
+ sliceEndBoundary
+ )
+ }
+ }
+ }
+
+ // 3. No Valid Records to Insert
+ if (stepsRecordList.isEmpty()) {
+ LOG.info("No valid StepsRecord created for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary after processing ${relevantSamples.size} samples.")
+ return SyncerStatistics(recordsSkipped = skippedCount, recordType = "Steps")
+ }
+
+ // 4. Insertion (with chunking)
+ LOG.info("Attempting to insert ${stepsRecordList.size} StepsRecord(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ for (chunk in stepsRecordList.chunked(HealthConnectUtils.CHUNK_SIZE)) {
+ HealthConnectUtils.insertRecords(chunk, healthConnectClient)
+ }
+
+ LOG.info("Successfully inserted ${stepsRecordList.size} StepsRecord(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordsSynced = stepsRecordList.size, recordsSkipped = skippedCount, recordType = "Steps")
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/TemperatureSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/TemperatureSyncer.kt
new file mode 100755
index 0000000000..f8cbbf2536
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/TemperatureSyncer.kt
@@ -0,0 +1,330 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
+
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.BodyTemperatureRecord
+import androidx.health.connect.client.records.Record
+import androidx.health.connect.client.records.SkinTemperatureRecord
+import androidx.health.connect.client.records.metadata.Metadata
+import androidx.health.connect.client.units.Temperature
+import androidx.health.connect.client.units.TemperatureDelta
+import nodomain.freeyourgadget.gadgetbridge.GBApplication
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.SyncException
+import org.slf4j.LoggerFactory
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.temporal.ChronoUnit
+
+private val LOG = LoggerFactory.getLogger("TemperatureSyncer")
+
+private const val MIN_PLAUSIBLE_BODY_TEMP_C = 25.0
+private const val MAX_PLAUSIBLE_BODY_TEMP_C = 45.0
+private const val MIN_PLAUSIBLE_SKIN_TEMP_C = 15.0
+private const val MAX_PLAUSIBLE_SKIN_TEMP_C = 45.0
+private const val DEFAULT_SKIN_BASELINE_C = 33.0
+
+internal object TemperatureSyncer : HealthConnectSyncer {
+ override suspend fun sync(
+ healthConnectClient: HealthConnectClient,
+ gbDevice: GBDevice,
+ metadata: Metadata,
+ offset: ZoneOffset,
+ sliceStartBoundary: Instant,
+ sliceEndBoundary: Instant,
+ grantedPermissions: Set
+ ): SyncerStatistics {
+ val deviceName = gbDevice.aliasOrName
+ val deviceAddress = gbDevice.address
+
+ // Clean up cache for devices that no longer exist
+ cleanupStaleBaselines()
+
+ val canWriteBodyTemp = HealthPermission.getWritePermission(BodyTemperatureRecord::class) in grantedPermissions
+ val canWriteSkinTemp = HealthPermission.getWritePermission(SkinTemperatureRecord::class) in grantedPermissions
+
+ if (!canWriteBodyTemp && !canWriteSkinTemp) {
+ LOG.info("Skipping Temperature sync for device '$deviceName'; relevant permissions (Body, Skin) not granted.")
+ return SyncerStatistics(recordsSynced = 0, recordsSkipped = 0, recordType = "Temperature")
+ }
+
+ val samples: List = try {
+ GBApplication.acquireDB().use { db ->
+ val provider = gbDevice.deviceCoordinator.getTemperatureSampleProvider(gbDevice, db.daoSession)
+ if (provider == null) {
+ LOG.warn("TemperatureSampleProvider not found for device '$deviceName'. Skipping Temperature sync for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return@use emptyList()
+ }
+ provider.getAllSamples(sliceStartBoundary.toEpochMilli(), sliceEndBoundary.toEpochMilli())
+ }
+ } catch (e: Exception) {
+ throw SyncException("Error fetching temperature samples for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.", e)
+ }
+
+ if (samples.isEmpty()) {
+ LOG.info("No temperature samples found for device '$deviceName' in slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordsSynced = 0, recordsSkipped = 0, recordType = "Temperature")
+ }
+
+ val bodySamples = samples.filter { it.temperatureType == TemperatureSample.TYPE_BODY }
+ val skinSamples = samples.filter { it.temperatureType == TemperatureSample.TYPE_SKIN }
+ LOG.info("Found ${samples.size} temperature samples for '$deviceName': ${bodySamples.size} body, ${skinSamples.size} skin, in slice $sliceStartBoundary to $sliceEndBoundary.")
+
+ val recordsToInsert = mutableListOf()
+
+ if (canWriteBodyTemp && bodySamples.isNotEmpty()) {
+ LOG.info("Processing ${bodySamples.size} body temperature samples for '$deviceName'.")
+ for (sample in bodySamples) {
+ val sampleTemp = sample.temperature.toDouble()
+ if (sampleTemp !in MIN_PLAUSIBLE_BODY_TEMP_C..MAX_PLAUSIBLE_BODY_TEMP_C) {
+ LOG.debug(
+ "Skipping Body Temperature sample for device '{}' at {} due to implausible value: {}°C.",
+ deviceName,
+ Instant.ofEpochMilli(sample.timestamp),
+ sample.temperature
+ )
+ continue
+ }
+ val timestamp = Instant.ofEpochMilli(sample.timestamp)
+ if (!timestamp.isBefore(sliceStartBoundary) && timestamp.isBefore(sliceEndBoundary)) {
+ recordsToInsert.add(
+ BodyTemperatureRecord(
+ time = timestamp,
+ zoneOffset = offset,
+ temperature = Temperature.celsius(sampleTemp),
+ measurementLocation = sample.temperatureLocation,
+ metadata = metadata
+ )
+ )
+ } else {
+ LOG.debug(
+ "Skipping Body Temperature sample for device '{}' at {} (value: {}°C) as it's outside slice {} - {}.",
+ deviceName,
+ timestamp,
+ sample.temperature,
+ sliceStartBoundary,
+ sliceEndBoundary
+ )
+ }
+ }
+ } else if (bodySamples.isNotEmpty()) {
+ LOG.info("Skipping BodyTemperatureRecord sync for ${bodySamples.size} samples for '$deviceName'; specific permission not granted.")
+ }
+
+ var totalRecordsSynced = 0
+ val totalRecordsSkipped = 0
+
+ if (canWriteSkinTemp && skinSamples.isNotEmpty()) {
+ LOG.info("Processing ${skinSamples.size} skin temperature samples for '$deviceName'.")
+
+ val baselineHelper = baselineHelperCachePerDevice.getOrPut(deviceAddress) {
+ LOG.info("Creating new SkinBaselineHelper for device '$deviceName' ($deviceAddress).")
+ SkinBaselineHelper(deviceAddress)
+ }
+ val baselineForCurrentRecord = baselineHelper.getBaselineForDay(sliceStartBoundary, gbDevice)
+
+ val validSkinSamplesInSlice = skinSamples.filter {
+ val ts = Instant.ofEpochMilli(it.timestamp)
+ val tempC = it.temperature.toDouble()
+ (tempC in MIN_PLAUSIBLE_SKIN_TEMP_C..MAX_PLAUSIBLE_SKIN_TEMP_C) &&
+ (!ts.isBefore(sliceStartBoundary) && ts.isBefore(sliceEndBoundary))
+ }.sortedBy { it.timestamp }
+
+ if (validSkinSamplesInSlice.isNotEmpty()) {
+ val recordStartTime = Instant.ofEpochMilli(validSkinSamplesInSlice.first().timestamp)
+ val recordEndTime = Instant.ofEpochMilli(validSkinSamplesInSlice.last().timestamp).plusSeconds(1)
+
+ // Calculate deltas as change since last measurement, not from baseline
+ val deltas = mutableListOf()
+ var previousTempC: Double? = null
+
+ for (sample in validSkinSamplesInSlice) {
+ val currentTempC = sample.temperature.toDouble()
+ val deltaValue = if (previousTempC != null) {
+ currentTempC - previousTempC
+ } else {
+ // First measurement: delta from baseline
+ currentTempC - baselineForCurrentRecord
+ }
+
+ deltas.add(
+ SkinTemperatureRecord.Delta(
+ time = Instant.ofEpochMilli(sample.timestamp),
+ delta = TemperatureDelta.celsius(deltaValue)
+ )
+ )
+ previousTempC = currentTempC
+ }
+
+ if (deltas.isNotEmpty()) {
+ recordsToInsert.add(
+ SkinTemperatureRecord(
+ startTime = recordStartTime,
+ startZoneOffset = offset,
+ endTime = recordEndTime,
+ endZoneOffset = offset,
+ deltas = deltas,
+ baseline = Temperature.celsius(baselineForCurrentRecord),
+ metadata = metadata
+ )
+ )
+ }
+ } else {
+ LOG.info("No valid skin temperature samples (plausible and within slice) found for '$deviceName' in slice $sliceStartBoundary - $sliceEndBoundary.")
+ }
+ } else if (skinSamples.isNotEmpty()) {
+ LOG.info("Skipping SkinTemperatureRecord sync for ${skinSamples.size} samples for '$deviceName'; specific permission not granted.")
+ }
+
+ if (recordsToInsert.isEmpty()) {
+ LOG.info("No temperature records (Body/Skin) to insert for '$deviceName' in slice $sliceStartBoundary to $sliceEndBoundary after filtering and permission checks.")
+ return SyncerStatistics(recordsSynced = 0, recordsSkipped = 0, recordType = "Temperature")
+ }
+
+ LOG.info("Attempting to insert ${recordsToInsert.size} TemperatureRecord(s) (Body/Skin) for '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ HealthConnectUtils.insertRecords(recordsToInsert, healthConnectClient)
+
+ totalRecordsSynced += recordsToInsert.size
+
+ LOG.info("Successfully inserted TemperatureRecord(s) (Body/Skin) for '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ LOG.info("Temperature sync completed for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary. Total synced: $totalRecordsSynced")
+ return SyncerStatistics(recordsSynced = totalRecordsSynced, recordsSkipped = totalRecordsSkipped, recordType = "Temperature")
+ }
+
+ // Helper class to manage dynamic baseline calculation per device
+ private class SkinBaselineHelper(private val deviceAddress: String) {
+ // Stores averages for up to the last 3 relevant days. Key is the start of the day (Instant).
+ private val dailyAverages = LinkedHashMap() // Oldest to newest
+ var currentCalculatedBaseline: Double = DEFAULT_SKIN_BASELINE_C
+ private set
+
+ fun getBaselineForDay(dayToProcess: Instant, gbDevice: GBDevice): Double {
+ val targetDayStart = dayToProcess.truncatedTo(ChronoUnit.DAYS)
+
+ // 1. Ensure the average for targetDayStart is in our map if it hasn't been processed yet
+ if (!dailyAverages.containsKey(targetDayStart)) {
+ try {
+ val dayEnd = targetDayStart.plus(1, ChronoUnit.DAYS)
+ LOG.debug(
+ "SkinBaselineHelper for '{}': Fetching samples for day {} to {}.",
+ deviceAddress,
+ targetDayStart,
+ dayEnd
+ )
+
+ val samplesForDay: List = GBApplication.acquireDB().use { db ->
+ val tempProvider = gbDevice.deviceCoordinator.getTemperatureSampleProvider(gbDevice, db.daoSession)
+ if (tempProvider == null) {
+ LOG.warn("SkinBaselineHelper for '$deviceAddress': TemperatureSampleProvider not found. Cannot calculate daily average for $targetDayStart.")
+ return@use emptyList()
+ }
+ // Assuming getAllSamples returns List or List
+ tempProvider.getAllSamples(targetDayStart.toEpochMilli(), dayEnd.toEpochMilli())
+ }
+
+ val skinSamplesForDay = samplesForDay.filter {
+ val tempC = it.temperature.toDouble()
+ it.temperatureType == TemperatureSample.TYPE_SKIN && tempC >= MIN_PLAUSIBLE_SKIN_TEMP_C && tempC <= MAX_PLAUSIBLE_SKIN_TEMP_C
+ }
+
+ if (skinSamplesForDay.isNotEmpty()) {
+ val avgForDay = skinSamplesForDay.map { it.temperature.toDouble() }.average()
+ dailyAverages[targetDayStart] = avgForDay // Add or update
+ LOG.debug(
+ "SkinBaselineHelper for '{}': Calculated avg for {}: {}°C from {} samples.",
+ deviceAddress,
+ targetDayStart,
+ avgForDay,
+ skinSamplesForDay.size
+ )
+ } else {
+ LOG.debug(
+ "SkinBaselineHelper for '{}': No valid skin samples for {}.",
+ deviceAddress,
+ targetDayStart
+ )
+ }
+ } catch (e: Exception) {
+ LOG.error("SkinBaselineHelper for '$deviceAddress': Error fetching/processing samples for day $targetDayStart.", e)
+ }
+ }
+
+ // 2. Prune dailyAverages to keep only up to the last 3 days ending on or before targetDayStart
+ val threeDayWindowStart = targetDayStart.minus(2, ChronoUnit.DAYS)
+ val iterator = dailyAverages.entries.iterator()
+ while(iterator.hasNext()){
+ val entry = iterator.next()
+ if(entry.key.isBefore(threeDayWindowStart) || entry.key.isAfter(targetDayStart)){
+ iterator.remove()
+ }
+ }
+ val sortedKeys = dailyAverages.keys.sortedDescending().take(3)
+ val tempMap = LinkedHashMap()
+ for(key in sortedKeys.sorted()){
+ dailyAverages[key]?.let { tempMap[key] = it }
+ }
+ dailyAverages.clear()
+ dailyAverages.putAll(tempMap)
+
+ // 3. Calculate the new baseline using up to 3 components
+ val actualValues = dailyAverages.values.toList()
+
+ currentCalculatedBaseline = if (actualValues.isEmpty()) {
+ DEFAULT_SKIN_BASELINE_C
+ } else {
+ val sumActuals = actualValues.sum()
+ val countActuals = actualValues.size
+ (sumActuals + DEFAULT_SKIN_BASELINE_C * (3 - countActuals)) / 3.0
+ }
+ LOG.debug(
+ "SkinBaselineHelper for '{}': New baseline for {}: {}°C (based on {} daily avgs: {})",
+ deviceAddress,
+ targetDayStart,
+ currentCalculatedBaseline,
+ actualValues.size,
+ actualValues
+ )
+ return currentCalculatedBaseline
+ }
+ }
+
+ // Cache baseline helpers per device address
+ private val baselineHelperCachePerDevice = mutableMapOf()
+
+ /**
+ * Cleans up baseline cache entries for devices that no longer exist.
+ * This prevents memory leaks from accumulating helpers for removed devices.
+ */
+ private fun cleanupStaleBaselines() {
+ val currentDeviceAddresses = GBApplication.app().deviceManager.devices
+ .map { it.address }
+ .toSet()
+
+ val iterator = baselineHelperCachePerDevice.keys.iterator()
+ while (iterator.hasNext()) {
+ val cachedAddress = iterator.next()
+ if (cachedAddress !in currentDeviceAddresses) {
+ iterator.remove()
+ LOG.debug("Cleaned up baseline cache for removed device: {}", cachedAddress)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/Vo2MaxSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/Vo2MaxSyncer.kt
new file mode 100644
index 0000000000..3797ea0533
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/Vo2MaxSyncer.kt
@@ -0,0 +1,117 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
+
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.Record
+import androidx.health.connect.client.records.Vo2MaxRecord
+import androidx.health.connect.client.records.metadata.Metadata
+import nodomain.freeyourgadget.gadgetbridge.GBApplication
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.SyncException
+import org.slf4j.LoggerFactory
+import java.time.Instant
+import java.time.ZoneOffset
+
+private val LOG = LoggerFactory.getLogger("Vo2MaxSyncer")
+
+internal object Vo2MaxSyncer : HealthConnectSyncer {
+ override suspend fun sync(
+ healthConnectClient: HealthConnectClient,
+ gbDevice: GBDevice,
+ metadata: Metadata,
+ offset: ZoneOffset,
+ sliceStartBoundary: Instant,
+ sliceEndBoundary: Instant,
+ grantedPermissions: Set
+ ): SyncerStatistics {
+ val deviceName = gbDevice.aliasOrName
+
+ if (HealthPermission.getWritePermission(Vo2MaxRecord::class) !in grantedPermissions) {
+ LOG.info("Skipping VO2Max sync for device '$deviceName'; Vo2MaxRecord permission not granted.")
+ return SyncerStatistics(recordType = "VO2Max")
+ }
+
+ val samples = try {
+ GBApplication.acquireDB().use { dbInstance ->
+ val provider = gbDevice.deviceCoordinator.getVo2MaxSampleProvider(gbDevice, dbInstance.daoSession)
+
+ if (provider == null) {
+ LOG.warn("Vo2MaxSampleProvider not found for device '$deviceName'. Skipping VO2Max sync for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return@use emptyList()
+ }
+ provider.getAllSamples(sliceStartBoundary.toEpochMilli(), sliceEndBoundary.toEpochMilli())
+ }
+ } catch (e: Exception) {
+ throw SyncException("Error fetching VO2Max samples for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.", e)
+ }
+
+ if (samples.isEmpty()) {
+ LOG.info("No VO2Max samples found for device '$deviceName' in slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordType = "VO2Max")
+ }
+
+ LOG.info("Found ${samples.size} VO2Max samples for device '$deviceName' in slice $sliceStartBoundary to $sliceEndBoundary.")
+
+ val recordsToInsert = mutableListOf()
+ var skippedCount = 0
+ for (sample in samples) {
+ val timestamp = Instant.ofEpochMilli(sample.timestamp)
+
+ if (sample.value <= 0) {
+ LOG.warn("Skipping VO2Max sample for device '$deviceName' at $timestamp due to invalid value: ${sample.value}.")
+ skippedCount++
+ continue
+ }
+
+ if (!timestamp.isBefore(sliceStartBoundary) && timestamp.isBefore(sliceEndBoundary)) {
+ recordsToInsert.add(
+ Vo2MaxRecord(
+ time = timestamp,
+ zoneOffset = offset,
+ vo2MillilitersPerMinuteKilogram = sample.value.toDouble(),
+ measurementMethod = Vo2MaxRecord.MEASUREMENT_METHOD_OTHER,
+ metadata = metadata
+ )
+ )
+ } else {
+ LOG.debug(
+ "Skipping VO2Max sample for device '{}' at {} as it's outside the slice {} - {}",
+ deviceName,
+ timestamp,
+ sliceStartBoundary,
+ sliceEndBoundary
+ )
+ skippedCount++
+ }
+ }
+
+ if (recordsToInsert.isEmpty()) {
+ LOG.info("No valid VO2Max records to insert for device '$deviceName' in slice $sliceStartBoundary to $sliceEndBoundary after filtering.")
+ return SyncerStatistics(recordsSkipped = skippedCount, recordType = "VO2Max")
+ }
+
+ LOG.info("Attempting to insert ${recordsToInsert.size} Vo2MaxRecord(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ HealthConnectUtils.insertRecords(recordsToInsert, healthConnectClient)
+
+ LOG.info("Successfully inserted ${recordsToInsert.size} Vo2MaxRecord(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordsSynced = recordsToInsert.size, recordsSkipped = skippedCount, recordType = "VO2Max")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/WeightSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/WeightSyncer.kt
new file mode 100644
index 0000000000..722c1aeb58
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/WeightSyncer.kt
@@ -0,0 +1,127 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
+
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.Record
+import androidx.health.connect.client.records.WeightRecord
+import androidx.health.connect.client.records.metadata.Metadata
+import androidx.health.connect.client.units.Mass
+import nodomain.freeyourgadget.gadgetbridge.GBApplication
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import nodomain.freeyourgadget.gadgetbridge.model.WeightSample
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils
+import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.SyncException
+import org.slf4j.LoggerFactory
+import java.time.Instant
+import java.time.ZoneOffset
+
+private val LOG = LoggerFactory.getLogger("WeightSyncer")
+
+internal object WeightSyncer : HealthConnectSyncer {
+ override suspend fun sync(
+ healthConnectClient: HealthConnectClient,
+ gbDevice: GBDevice,
+ metadata: Metadata,
+ offset: ZoneOffset,
+ sliceStartBoundary: Instant,
+ sliceEndBoundary: Instant,
+ grantedPermissions: Set
+ ): SyncerStatistics {
+ val deviceName = gbDevice.aliasOrName
+
+ if (HealthPermission.getWritePermission(WeightRecord::class) !in grantedPermissions) {
+ LOG.info("Skipping Weight sync for device '$deviceName'; WeightRecord permission not granted.")
+ return SyncerStatistics(recordType = "Weight")
+ }
+
+ val samples = try {
+ GBApplication.acquireDB().use { dbInstance ->
+ val provider = gbDevice.deviceCoordinator.getWeightSampleProvider(gbDevice, dbInstance.daoSession)
+
+ if (provider == null) {
+ LOG.warn("WeightSampleProvider not found for device '$deviceName'. Skipping Weight sync for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return@use emptyList()
+ }
+ provider.getAllSamples(sliceStartBoundary.toEpochMilli(), sliceEndBoundary.toEpochMilli())
+ }
+ } catch (e: Exception) {
+ throw SyncException("Error fetching Weight samples for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.", e)
+ }
+
+ LOG.info("Found ${samples.size} Weight samples for device '$deviceName' in slice $sliceStartBoundary to $sliceEndBoundary.")
+
+ if (samples.isEmpty()) {
+ LOG.info("No Weight samples to process for device '$deviceName' in slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordType = "Weight")
+ }
+
+ val recordsToInsert = mutableListOf()
+ var skippedCount = 0
+ for (sample in samples) {
+ val timestampValue = sample.timestamp
+ val weightInKgValue = sample.getWeightKg()
+
+ val timestamp = Instant.ofEpochMilli(timestampValue)
+
+ if (timestamp.isBefore(sliceStartBoundary) || !timestamp.isBefore(sliceEndBoundary)) {
+ LOG.debug(
+ "Skipping Weight sample for device '{}' at {} (weight: {}kg) as it's outside the slice {} - {}.",
+ deviceName,
+ timestamp,
+ weightInKgValue,
+ sliceStartBoundary,
+ sliceEndBoundary
+ )
+ skippedCount++
+ continue
+ }
+
+ if (weightInKgValue <= 0) {
+ LOG.debug(
+ "Skipping Weight sample for device '{}' at {} due to invalid weight: {}kg (must be > 0).",
+ deviceName,
+ timestamp,
+ weightInKgValue
+ )
+ skippedCount++
+ continue
+ }
+
+ recordsToInsert.add(
+ WeightRecord(
+ time = timestamp,
+ zoneOffset = offset,
+ weight = Mass.kilograms(weightInKgValue.toDouble()),
+ metadata = metadata
+ )
+ )
+ }
+
+ if (recordsToInsert.isEmpty()) {
+ LOG.info("No valid Weight records to insert for device '$deviceName' in slice $sliceStartBoundary to $sliceEndBoundary after filtering.")
+ return SyncerStatistics(recordsSkipped = skippedCount, recordType = "Weight")
+ }
+
+ LOG.info("Attempting to insert ${recordsToInsert.size} WeightRecord(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ HealthConnectUtils.insertRecords(recordsToInsert, healthConnectClient)
+
+ LOG.info("Successfully inserted ${recordsToInsert.size} WeightRecord(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
+ return SyncerStatistics(recordsSynced = recordsToInsert.size, recordsSkipped = skippedCount, recordType = "Weight")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/WorkoutSyncerUtils.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/WorkoutSyncerUtils.kt
new file mode 100644
index 0000000000..7adcd75977
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/WorkoutSyncerUtils.kt
@@ -0,0 +1,143 @@
+/* Copyright (C) 2025 Gideon Zenz
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
+
+import androidx.health.connect.client.records.ExerciseSessionRecord
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind
+
+/**
+ * Shared utilities for workout syncing.
+ */
+internal object WorkoutSyncerUtils {
+ /**
+ * Maps Gadgetbridge ActivityKind to Health Connect ExerciseSessionRecord type.
+ */
+ fun mapActivityKindToExerciseType(activityKind: ActivityKind): Int {
+ return when (activityKind) {
+ ActivityKind.RUNNING, ActivityKind.OUTDOOR_RUNNING, ActivityKind.CROSS_COUNTRY_RUNNING, ActivityKind.TRAIL_RUN -> ExerciseSessionRecord.EXERCISE_TYPE_RUNNING
+ ActivityKind.WALKING, ActivityKind.OUTDOOR_WALKING, ActivityKind.RACE_WALKING -> ExerciseSessionRecord.EXERCISE_TYPE_WALKING
+ ActivityKind.SWIMMING -> ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL
+ ActivityKind.CYCLING, ActivityKind.OUTDOOR_CYCLING -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING
+ ActivityKind.TREADMILL, ActivityKind.INDOOR_RUNNING -> ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL
+ ActivityKind.INDOOR_CYCLING, ActivityKind.DYNAMIC_CYCLE, ActivityKind.SPINNING -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY
+ ActivityKind.SWIMMING_OPENWATER, ActivityKind.FINSWIMMING -> ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER
+ ActivityKind.ELLIPTICAL_TRAINER, ActivityKind.CROSS_TRAINER, ActivityKind.AIR_WALKER -> ExerciseSessionRecord.EXERCISE_TYPE_ELLIPTICAL
+ ActivityKind.JUMP_ROPING, ActivityKind.BUNGEE_JUMPING -> ExerciseSessionRecord.EXERCISE_TYPE_GYMNASTICS
+ ActivityKind.YOGA -> ExerciseSessionRecord.EXERCISE_TYPE_YOGA
+ ActivityKind.SOCCER, ActivityKind.BEACH_SOCCER, ActivityKind.FUTSAL -> ExerciseSessionRecord.EXERCISE_TYPE_SOCCER
+ ActivityKind.ROWING_MACHINE -> ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE
+ ActivityKind.ROWING, ActivityKind.PADDLING, ActivityKind.KAYAKING, ActivityKind.CANOEING, ActivityKind.RAFTING, ActivityKind.DRAGON_BOAT -> ExerciseSessionRecord.EXERCISE_TYPE_ROWING
+ ActivityKind.CRICKET -> ExerciseSessionRecord.EXERCISE_TYPE_CRICKET
+ ActivityKind.BASKETBALL -> ExerciseSessionRecord.EXERCISE_TYPE_BASKETBALL
+ ActivityKind.PINGPONG, ActivityKind.TABLE_TENNIS -> ExerciseSessionRecord.EXERCISE_TYPE_TABLE_TENNIS
+ ActivityKind.BADMINTON, ActivityKind.SHUTTLECOCK -> ExerciseSessionRecord.EXERCISE_TYPE_BADMINTON
+ ActivityKind.STRENGTH_TRAINING, ActivityKind.WEIGHTLIFTING, ActivityKind.DUMBBELL, ActivityKind.BARBELL, ActivityKind.DEADLIFT, ActivityKind.PULL_UPS, ActivityKind.PUSH_UPS, ActivityKind.SIT_UPS, ActivityKind.PLANK, ActivityKind.BURPEE, ActivityKind.ABS, ActivityKind.BACK, ActivityKind.UPPER_BODY, ActivityKind.LOWER_BODY, ActivityKind.SMITH_MACHINE, ActivityKind.BATTLE_ROPE -> ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING
+ ActivityKind.HIKING, ActivityKind.MOUNTAIN_HIKE, ActivityKind.TREKKING -> ExerciseSessionRecord.EXERCISE_TYPE_HIKING
+ ActivityKind.CLIMBING, ActivityKind.ROCK_CLIMBING, ActivityKind.CLIMB_INDOOR, ActivityKind.BOULDERING, ActivityKind.FLOOR_CLIMBING, ActivityKind.MOUNTAINEERING -> ExerciseSessionRecord.EXERCISE_TYPE_ROCK_CLIMBING
+ ActivityKind.HANDCYCLING, ActivityKind.HANDCYCLING_INDOOR -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY
+ ActivityKind.E_BIKE -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING
+ ActivityKind.BIKE_COMMUTE -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING
+ ActivityKind.STAIR_STEPPER, ActivityKind.STAIR_CLIMBER, ActivityKind.STAIRS -> ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING_MACHINE
+ ActivityKind.PILATES -> ExerciseSessionRecord.EXERCISE_TYPE_PILATES
+ ActivityKind.POOL_SWIM, ActivityKind.ARTISTIC_SWIMMING -> ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL
+ ActivityKind.TENNIS, ActivityKind.PLATFORM_TENNIS, ActivityKind.PICKLEBALL, ActivityKind.PADEL, ActivityKind.SQUASH, ActivityKind.RACQUETBALL -> ExerciseSessionRecord.EXERCISE_TYPE_TENNIS
+ ActivityKind.AMERICAN_FOOTBALL, ActivityKind.AUSTRALIAN_FOOTBALL, ActivityKind.RUGBY -> ExerciseSessionRecord.EXERCISE_TYPE_FOOTBALL_AMERICAN
+ ActivityKind.CARDIO, ActivityKind.AEROBICS, ActivityKind.AEROBIC_EXERCISE, ActivityKind.AEROBIC_COMBO, ActivityKind.STEP_AEROBICS -> ExerciseSessionRecord.EXERCISE_TYPE_DANCING
+ ActivityKind.BREATHWORK -> ExerciseSessionRecord.EXERCISE_TYPE_GUIDED_BREATHING
+ ActivityKind.MEDITATION, ActivityKind.MIND_AND_BODY -> ExerciseSessionRecord.EXERCISE_TYPE_GUIDED_BREATHING
+ ActivityKind.INDOOR_WALKING -> ExerciseSessionRecord.EXERCISE_TYPE_WALKING
+ ActivityKind.XC_CLASSIC_SKI, ActivityKind.CROSS_COUNTRY_SKIING -> ExerciseSessionRecord.EXERCISE_TYPE_SKIING
+ ActivityKind.SKIING -> ExerciseSessionRecord.EXERCISE_TYPE_SKIING
+ ActivityKind.SNOWBOARDING -> ExerciseSessionRecord.EXERCISE_TYPE_SNOWBOARDING
+ ActivityKind.GOLF -> ExerciseSessionRecord.EXERCISE_TYPE_GOLF
+ ActivityKind.INLINE_SKATING, ActivityKind.ROLLER_SKATING, ActivityKind.SKATING -> ExerciseSessionRecord.EXERCISE_TYPE_SKATING
+ ActivityKind.ICE_SKATING, ActivityKind.INDOOR_ICE_SKATING -> ExerciseSessionRecord.EXERCISE_TYPE_ICE_SKATING
+ ActivityKind.SNOWSHOE -> ExerciseSessionRecord.EXERCISE_TYPE_SNOWSHOEING
+ ActivityKind.STAND_UP_PADDLEBOARDING -> ExerciseSessionRecord.EXERCISE_TYPE_PADDLING
+ ActivityKind.SURFING, ActivityKind.FLOWRIDING -> ExerciseSessionRecord.EXERCISE_TYPE_SURFING
+ ActivityKind.WAKEBOARDING, ActivityKind.WAKESURFING -> ExerciseSessionRecord.EXERCISE_TYPE_SURFING
+ ActivityKind.WATER_SKIING -> ExerciseSessionRecord.EXERCISE_TYPE_SURFING
+ ActivityKind.WINDSURFING -> ExerciseSessionRecord.EXERCISE_TYPE_SURFING
+ ActivityKind.KITESURFING -> ExerciseSessionRecord.EXERCISE_TYPE_SURFING
+ ActivityKind.BOXING -> ExerciseSessionRecord.EXERCISE_TYPE_BOXING
+ ActivityKind.BASEBALL -> ExerciseSessionRecord.EXERCISE_TYPE_BASEBALL
+ ActivityKind.SOFTBALL, ActivityKind.SOFTBALL_SLOW_PITCH -> ExerciseSessionRecord.EXERCISE_TYPE_SOFTBALL
+ ActivityKind.HIIT -> ExerciseSessionRecord.EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING
+ ActivityKind.HOCKEY, ActivityKind.ICE_HOCKEY -> ExerciseSessionRecord.EXERCISE_TYPE_ICE_HOCKEY
+ ActivityKind.LACROSSE, ActivityKind.LACROSS -> ExerciseSessionRecord.EXERCISE_TYPE_ICE_HOCKEY
+ ActivityKind.VOLLEYBALL, ActivityKind.BEACH_VOLLEYBALL -> ExerciseSessionRecord.EXERCISE_TYPE_VOLLEYBALL
+ ActivityKind.MIXED_MARTIAL_ARTS, ActivityKind.FREE_SPARRING, ActivityKind.BODY_COMBAT, ActivityKind.CARDIO_COMBAT -> ExerciseSessionRecord.EXERCISE_TYPE_MARTIAL_ARTS
+ ActivityKind.DANCE, ActivityKind.BELLY_DANCE, ActivityKind.JAZZ_DANCE, ActivityKind.LATIN_DANCE, ActivityKind.BALLET, ActivityKind.STREET_DANCE, ActivityKind.ZUMBA, ActivityKind.BALLROOM_DANCE, ActivityKind.BREAKING, ActivityKind.FOLK_DANCE, ActivityKind.HIP_HOP, ActivityKind.MODERN_DANCE, ActivityKind.POLE_DANCE, ActivityKind.SQUARE_DANCE, ActivityKind.PLAZA_DANCING -> ExerciseSessionRecord.EXERCISE_TYPE_DANCING
+ ActivityKind.KICKBOXING, ActivityKind.TAE_BO -> ExerciseSessionRecord.EXERCISE_TYPE_BOXING
+ ActivityKind.CROSSFIT, ActivityKind.FUNCTIONAL_TRAINING, ActivityKind.PHYSICAL_TRAINING, ActivityKind.FREE_TRAINING, ActivityKind.FITNESS_EXERCISES -> ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING
+ ActivityKind.TAEKWONDO, ActivityKind.KARATE, ActivityKind.JUDO, ActivityKind.JUJITSU, ActivityKind.KENDO, ActivityKind.MUAY_THAI, ActivityKind.MARTIAL_ARTS -> ExerciseSessionRecord.EXERCISE_TYPE_MARTIAL_ARTS
+ ActivityKind.FENCING -> ExerciseSessionRecord.EXERCISE_TYPE_FENCING
+ ActivityKind.CORE_TRAINING -> ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING
+ ActivityKind.HORIZONTAL_BAR, ActivityKind.PARALLEL_BAR, ActivityKind.PARALLEL_BARS, ActivityKind.MASS_GYMNASTICS -> ExerciseSessionRecord.EXERCISE_TYPE_GYMNASTICS
+ ActivityKind.GYMNASTICS -> ExerciseSessionRecord.EXERCISE_TYPE_GYMNASTICS
+ ActivityKind.TRAMPOLINE -> ExerciseSessionRecord.EXERCISE_TYPE_CALISTHENICS
+ ActivityKind.TAI_CHI -> ExerciseSessionRecord.EXERCISE_TYPE_YOGA
+ ActivityKind.HULA_HOOPING, ActivityKind.HULA_HOOP -> ExerciseSessionRecord.EXERCISE_TYPE_GYMNASTICS
+ ActivityKind.ARCHERY -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
+ ActivityKind.HORSE_RIDING, ActivityKind.EQUESTRIAN -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
+ ActivityKind.WRESTLING -> ExerciseSessionRecord.EXERCISE_TYPE_MARTIAL_ARTS
+ ActivityKind.HANDBALL -> ExerciseSessionRecord.EXERCISE_TYPE_HANDBALL
+ ActivityKind.SAILING, ActivityKind.SAIL_RACE, ActivityKind.SAIL_EXPEDITION -> ExerciseSessionRecord.EXERCISE_TYPE_SAILING
+ ActivityKind.SKATEBOARDING -> ExerciseSessionRecord.EXERCISE_TYPE_SKATING
+ ActivityKind.PARKOUR -> ExerciseSessionRecord.EXERCISE_TYPE_CALISTHENICS
+ ActivityKind.STRETCHING, ActivityKind.FLEXIBILITY, ActivityKind.ROLLING -> ExerciseSessionRecord.EXERCISE_TYPE_STRETCHING
+ ActivityKind.WATER_POLO -> ExerciseSessionRecord.EXERCISE_TYPE_WATER_POLO
+ ActivityKind.OBSTACLE_RACE -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
+ ActivityKind.SLEDDING, ActivityKind.BOBSLEIGH, ActivityKind.LUGE -> ExerciseSessionRecord.EXERCISE_TYPE_SKIING
+ ActivityKind.BIATHLON -> ExerciseSessionRecord.EXERCISE_TYPE_RUNNING
+ ActivityKind.ORIENTEERING -> ExerciseSessionRecord.EXERCISE_TYPE_RUNNING
+ ActivityKind.TRIATHLON, ActivityKind.MULTISPORT -> ExerciseSessionRecord.EXERCISE_TYPE_RUNNING
+ ActivityKind.DIVING, ActivityKind.FREE_DIVING, ActivityKind.APNEA_TRAINING, ActivityKind.APNEA_TEST, ActivityKind.SCUBA_DIVING, ActivityKind.SNORKELING -> ExerciseSessionRecord.EXERCISE_TYPE_SCUBA_DIVING
+ ActivityKind.PARAGLIDING, ActivityKind.HANG_GLIDING, ActivityKind.JUMPMASTER, ActivityKind.PARACHUTING, ActivityKind.SKY_DIVING -> ExerciseSessionRecord.EXERCISE_TYPE_PARAGLIDING
+ ActivityKind.TRACK_AND_FIELD, ActivityKind.ATHLETICS, ActivityKind.JAVELIN, ActivityKind.LONG_JUMP, ActivityKind.HIGH_JUMP, ActivityKind.SHOT -> ExerciseSessionRecord.EXERCISE_TYPE_RUNNING
+ ActivityKind.DISC_GOLF, ActivityKind.FRISBEE, ActivityKind.ULTIMATE_DISC -> ExerciseSessionRecord.EXERCISE_TYPE_FRISBEE_DISC
+ ActivityKind.WATER_TUBING, ActivityKind.JET_SKIING, ActivityKind.WATER_SCOOTER -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
+ ActivityKind.BMX -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING
+ ActivityKind.KABADDI -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
+ ActivityKind.CURLING -> ExerciseSessionRecord.EXERCISE_TYPE_ICE_SKATING
+ ActivityKind.COOLDOWN -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
+ ActivityKind.PUSH_WALK_SPEED, ActivityKind.INDOOR_PUSH_WALK_SPEED -> ExerciseSessionRecord.EXERCISE_TYPE_WHEELCHAIR
+ ActivityKind.PUSH_RUN_SPEED, ActivityKind.INDOOR_PUSH_RUN_SPEED -> ExerciseSessionRecord.EXERCISE_TYPE_WHEELCHAIR
+ ActivityKind.FITNESS_GAMING -> ExerciseSessionRecord.EXERCISE_TYPE_DANCING
+
+ ActivityKind.ACTIVITY, ActivityKind.EXERCISE, ActivityKind.TRAINING,
+ ActivityKind.FITNESS_EQUIPMENT, ActivityKind.INDOOR_FITNESS, ActivityKind.SOMATOSENSORY_GAME, ActivityKind.VIDEO_GAMING,
+ ActivityKind.HEALTH_SNAPSHOT, ActivityKind.TACTICAL, ActivityKind.GRINDING, ActivityKind.WINTER_SPORT, ActivityKind.TEAM_SPORT,
+ ActivityKind.SNOW_SPORTS, ActivityKind.DISC_SPORTS, ActivityKind.OTHER_WATER_SPORTS, ActivityKind.OTHER_WINTER_SPORTS,
+ ActivityKind.MARINE, ActivityKind.PARA_SPORT, ActivityKind.RACKET, ActivityKind.NAVIGATE, ActivityKind.INDOOR_TRACK,
+ ActivityKind.TRANSITION, ActivityKind.VIVOMOVE_HR_TRANSITION,
+ ActivityKind.FLYING, ActivityKind.MOTORCYCLING, ActivityKind.BOATING, ActivityKind.DRIVING, ActivityKind.HUNTING, ActivityKind.FISHING,
+ ActivityKind.AUTO_RACING, ActivityKind.SNOWMOBILING, ActivityKind.KARTING, ActivityKind.BILLIARDS, ActivityKind.BOWLING,
+ ActivityKind.DARTS, ActivityKind.KITE_FLYING, ActivityKind.SWING, ActivityKind.GATEBALL, ActivityKind.SEPAK_TAKRAW,
+ ActivityKind.BOARD_GAME, ActivityKind.BRIDGE, ActivityKind.CHECKERS, ActivityKind.CHESS, ActivityKind.ESPORTS,
+ ActivityKind.HACKY_SACK, ActivityKind.JAI_ALAI, ActivityKind.SHUFFLEBOARD, ActivityKind.TABLE_FOOTBALL,
+ ActivityKind.TUG_OF_WAR, ActivityKind.WALL_BALL, ActivityKind.WEIQI, ActivityKind.LASER_TAG, ActivityKind.BILLIARD_POOL,
+ ActivityKind.ATV, ActivityKind.POWERBOATING, ActivityKind.SHOOTING, ActivityKind.BOCCE,
+ ActivityKind.UNKNOWN, ActivityKind.NOT_MEASURED, ActivityKind.NOT_WORN -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
+
+ ActivityKind.LIGHT_SLEEP, ActivityKind.DEEP_SLEEP, ActivityKind.REM_SLEEP, ActivityKind.AWAKE_SLEEP, ActivityKind.SLEEP_ANY -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
+
+ else -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
+ }
+ }
+}
+
diff --git a/app/src/main/res/layout/dialog_health_connect_initial_sync.xml b/app/src/main/res/layout/dialog_health_connect_initial_sync.xml
new file mode 100644
index 0000000000..13a5880f97
--- /dev/null
+++ b/app/src/main/res/layout/dialog_health_connect_initial_sync.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/permissions_rationale.xml b/app/src/main/res/layout/permissions_rationale.xml
new file mode 100644
index 0000000000..c7805ee7ad
--- /dev/null
+++ b/app/src/main/res/layout/permissions_rationale.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
index 7c77f6ea71..d9fd5c5ee0 100644
--- a/app/src/main/res/values/arrays.xml
+++ b/app/src/main/res/values/arrays.xml
@@ -5343,4 +5343,11 @@
- never
+
+ - @string/health_connect_initial_sync_3_days
+ - @string/health_connect_initial_sync_7_days
+ - @string/health_connect_initial_sync_30_days
+ - @string/health_connect_initial_sync_all
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9e931d4746..710ab23eac 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -207,6 +207,7 @@
General settings
Other
System
+ Synchronization
Audio
Calendar
Connection
@@ -4518,4 +4519,66 @@
Search for app updates
Automatically search for watch app and watchface updates when opening the app manager
SOS contact
+
+ Health Connect
+ Enable Health Connect Export
+ Manage Health Connect Data
+ Health Connect Settings
+ Open Android Health Connect settings
+ To disable Health Connect Export, remove all permissions of Gadgetbridge from the Health Connect App
+ Depending on the amount of data, a Health Connect sync might take several minutes.
+ Select devices
+ Select which devices to include the data from for sharing with Health Connect
+ Health Connect not supported on this Android Version
+ Health Connect Provider Update Required
+ Could not open Health Connect\'s settings screen. Please try opening the Health Connect app manually.
+ Sync after device sync
+ Sync to Health Connect after a selected device has finished syncing its activity data.
+ Privacy Policy and Permissions
+ Gadgetbridge collects data from your devices and, with your permission, shares it with Health Connect. This data is stored on your device and is not shared with any third parties.
+ Health Connect Sync
+ Syncing data with Health Connect…
+ Health Connect is not available on this system.
+ Error checking Health Connect permissions.
+ All Health Connect permissions for Gadgetbridge\'s data types were denied or revoked.
+ Failed to initialize Health Connect client after permission change.
+ Health Connect enabled. Data sync will begin for granted data types.
+ Health Connect permissions changed.
+ No devices selected for Health Connect sync.
+ No Health Connect permissions granted. Cannot sync.
+ Finished Health Connect Data Sync for all selected devices.
+ Finished Health Connect Data Sync (No data available to sync)
+ Finished Health Connect Data Sync (%1$s)
+ Finished Health Connect Data Sync (%1$s) [Errors: %2$s]
+ Health Connect Sync experienced errors, some data will not be available.
+ Syncing…
+ Health Connect Sync
+ Sync Status
+ Detailed workout sync
+ Sync GPS routes and per-second metrics (heart rate, speed, power) for workouts. Provides richer data but may take longer.
+ Sync from a custom date
+ Initial Health Connect Sync
+ Choose how much data to sync for the initial Health Connect synchronization:
+ Sync last:
+ Select Date
+ 3 days
+ 7 days
+ 30 days
+ Sync all
+ Sync is already running.
+ Sync Now?
+ Do you want to start synchronizing your data to Health Connect now?
+ Sync Now
+ Sync Later
+ All Health Connect permissions for Gadgetbridge have been revoked. Did you also remove all Data? If yes, resetting the sync history will allow to re-sync all old data if you give permissions again.
+ Reset Sync History
+ Keep Sync History
+ Health Connect Data Sync:
+ Sync failed.
+ Sync failed after multiple attempts. Last error: %1$s
+ Syncing %1$s %2$s: %3$s to %4$s
+ Sync error for %1$s (%2$s): %3$s
+ Permission denied: %1$s. Please check Health Connect permissions.
+ Sleep Session from %1$s
+ Sleep data from Gadgetbridge device: %1$s.
diff --git a/app/src/main/res/xml/debug_preferences_other_actions.xml b/app/src/main/res/xml/debug_preferences_other_actions.xml
index e8f616987e..99d3b40add 100644
--- a/app/src/main/res/xml/debug_preferences_other_actions.xml
+++ b/app/src/main/res/xml/debug_preferences_other_actions.xml
@@ -46,6 +46,13 @@
android:persistent="false"
android:title="Reboot" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml
index ee2eb94757..7ec264aa1b 100644
--- a/app/src/main/res/xml/preferences.xml
+++ b/app/src/main/res/xml/preferences.xml
@@ -324,6 +324,11 @@
android:key="pref_category_sleepasandroid"
android:title="@string/sleepasandroid_settings" />
+
+
+
+
+
diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectSyncerTest.kt b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectSyncerTest.kt
new file mode 100644
index 0000000000..2f9c7cd74d
--- /dev/null
+++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectSyncerTest.kt
@@ -0,0 +1,109 @@
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class HealthConnectSyncerTest {
+
+ @Test
+ fun testSyncerStatistics_defaultInitialization() {
+ val stats = SyncerStatistics()
+
+ assertEquals(0, stats.recordsSynced)
+ assertEquals(0, stats.recordsSkipped)
+ assertEquals("", stats.recordType)
+ }
+
+ @Test
+ fun testSyncerStatistics_withRecordsSynced() {
+ val stats = SyncerStatistics(recordsSynced = 5, recordType = "Steps")
+
+ assertEquals(5, stats.recordsSynced)
+ assertEquals(0, stats.recordsSkipped)
+ assertEquals("Steps", stats.recordType)
+ }
+
+ @Test
+ fun testSyncerStatistics_withRecordsSkipped() {
+ val stats = SyncerStatistics(recordsSkipped = 3, recordType = "HeartRate")
+
+ assertEquals(0, stats.recordsSynced)
+ assertEquals(3, stats.recordsSkipped)
+ assertEquals("HeartRate", stats.recordType)
+ }
+
+ @Test
+ fun testSyncerStatistics_withBothSyncedAndSkipped() {
+ val stats = SyncerStatistics(
+ recordsSynced = 10,
+ recordsSkipped = 5,
+ recordType = "Sleep"
+ )
+
+ assertEquals(10, stats.recordsSynced)
+ assertEquals(5, stats.recordsSkipped)
+ assertEquals("Sleep", stats.recordType)
+ }
+
+ @Test
+ fun testSyncerStatistics_withOnlyRecordType() {
+ val stats = SyncerStatistics(recordType = "Weight")
+
+ assertEquals(0, stats.recordsSynced)
+ assertEquals(0, stats.recordsSkipped)
+ assertEquals("Weight", stats.recordType)
+ }
+
+ @Test
+ fun testSyncerStatistics_dataClassEquality() {
+ val stats1 = SyncerStatistics(recordsSynced = 5, recordsSkipped = 2, recordType = "HRV")
+ val stats2 = SyncerStatistics(recordsSynced = 5, recordsSkipped = 2, recordType = "HRV")
+ val stats3 = SyncerStatistics(recordsSynced = 3, recordsSkipped = 2, recordType = "HRV")
+
+ assertEquals(stats1, stats2)
+ assertNotEquals(stats1, stats3)
+ }
+
+ @Test
+ fun testSyncerStatistics_copy() {
+ val original = SyncerStatistics(recordsSynced = 10, recordsSkipped = 5, recordType = "SpO2")
+ val copy = original.copy(recordsSynced = 15)
+
+ assertEquals(15, copy.recordsSynced)
+ assertEquals(5, copy.recordsSkipped)
+ assertEquals("SpO2", copy.recordType)
+
+ // Original should be unchanged
+ assertEquals(10, original.recordsSynced)
+ }
+
+ @Test
+ fun testSyncerStatistics_toString() {
+ val stats = SyncerStatistics(recordsSynced = 7, recordsSkipped = 3, recordType = "Temperature")
+ val stringRepresentation = stats.toString()
+
+ assertTrue(stringRepresentation.contains("recordsSynced=7"))
+ assertTrue(stringRepresentation.contains("recordsSkipped=3"))
+ assertTrue(stringRepresentation.contains("recordType=Temperature"))
+ }
+
+ @Test
+ fun testSyncerStatistics_totalRecords() {
+ val stats = SyncerStatistics(recordsSynced = 100, recordsSkipped = 25, recordType = "Workout")
+ val total = stats.recordsSynced + stats.recordsSkipped
+
+ assertEquals(125, total)
+ }
+
+ @Test
+ fun testSyncerStatistics_hasWork() {
+ val noWork = SyncerStatistics(recordType = "Test")
+ val hasWork = SyncerStatistics(recordsSynced = 1, recordType = "Test")
+ val hasSkipped = SyncerStatistics(recordsSkipped = 1, recordType = "Test")
+
+ assertEquals(0, noWork.recordsSynced + noWork.recordsSkipped)
+ assertTrue((hasWork.recordsSynced + hasWork.recordsSkipped) > 0)
+ assertTrue((hasSkipped.recordsSynced + hasSkipped.recordsSkipped) > 0)
+ }
+}
+
diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/SyncExceptionTest.kt b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/SyncExceptionTest.kt
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/WorkoutSyncerUtilsTest.kt b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/WorkoutSyncerUtilsTest.kt
new file mode 100644
index 0000000000..f9191bfa92
--- /dev/null
+++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/WorkoutSyncerUtilsTest.kt
@@ -0,0 +1,267 @@
+package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
+
+import androidx.health.connect.client.records.ExerciseSessionRecord
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind
+import org.junit.Assert.*
+import org.junit.Test
+
+class WorkoutSyncerUtilsTest {
+
+ @Test
+ fun testMapActivityKind_running() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.RUNNING)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.OUTDOOR_RUNNING)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_walking() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_WALKING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.WALKING)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_WALKING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.OUTDOOR_WALKING)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_cycling() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_BIKING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.CYCLING)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_BIKING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.OUTDOOR_CYCLING)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_indoorCycling() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.INDOOR_CYCLING)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.SPINNING)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_swimming() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.SWIMMING)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.POOL_SWIM)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_openWaterSwimming() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.SWIMMING_OPENWATER)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_treadmill() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.TREADMILL)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.INDOOR_RUNNING)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_strengthTraining() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.STRENGTH_TRAINING)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.WEIGHTLIFTING)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.CROSSFIT)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_yoga() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_YOGA,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.YOGA)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_YOGA,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.TAI_CHI)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_sports() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_SOCCER,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.SOCCER)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_BASKETBALL,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.BASKETBALL)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_TENNIS,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.TENNIS)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_hiking() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_HIKING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.HIKING)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_HIKING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.TREKKING)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_climbing() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_ROCK_CLIMBING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.CLIMBING)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_ROCK_CLIMBING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.ROCK_CLIMBING)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_skiing() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_SKIING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.SKIING)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_SNOWBOARDING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.SNOWBOARDING)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_martialArts() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_MARTIAL_ARTS,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.MARTIAL_ARTS)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_MARTIAL_ARTS,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.KARATE)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_MARTIAL_ARTS,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.TAEKWONDO)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_dancing() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_DANCING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.DANCE)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_DANCING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.ZUMBA)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_hiit() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.HIIT)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_pilates() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_PILATES,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.PILATES)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_rowing() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_ROWING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.ROWING)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.ROWING_MACHINE)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_golf() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_GOLF,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.GOLF)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_guidedBreathing() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_GUIDED_BREATHING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.BREATHWORK)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_GUIDED_BREATHING,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.MEDITATION)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_unknownActivity() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.UNKNOWN)
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT,
+ WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.ARCHERY)
+ )
+ }
+
+ @Test
+ fun testMapActivityKind_allTypesMapToValidExerciseType() {
+ // Test that all activity kinds map to a valid exercise type (no exceptions thrown)
+ for (activityKind in ActivityKind.entries) {
+ val exerciseType = WorkoutSyncerUtils.mapActivityKindToExerciseType(activityKind)
+ assertTrue("Exercise type should be a valid int", exerciseType >= 0)
+ }
+ }
+}
+