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>) 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 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 @@ + + + + + + + + + + + + + +