Adding HealthConnect sync for:

Body Temperature
    Skin Temperature
    Distance
    Exercise
    Heart rate
    Heart rate variability
    Oxygen saturation
    Sleep
    Steps
    Total calories burned
    VO2 max
    Weight

The feature is configurable via Settings->External Integrations->Health Connect

Based on https://codeberg.org/LLan/Gadgetbridge

Kudos to LLan for the preliminary work!
This commit is contained in:
Gideon Zenz
2026-01-03 15:03:21 +01:00
committed by José Rebelo
parent ac557cabf3
commit aea638d4be
42 changed files with 5560 additions and 2 deletions
+4
View File
@@ -291,6 +291,10 @@ dependencies {
// Needed for Armenian transliteration // Needed for Armenian transliteration
implementation 'org.ahocorasick:ahocorasick:0.6.3' 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") preBuild.dependsOn(":GBDaoGenerator:genSources")
+65 -1
View File
@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<!-- <!--
Comment in for testing Pebble Emulator Comment in for testing Pebble Emulator
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
@@ -108,6 +109,25 @@
<!-- Needed to show notification with targetSdkVersion 33 with Android 13 --> <!-- Needed to show notification with targetSdkVersion 33 with Android 13 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Needed for Health Connect See this for more: https://developer.android.com/health-and-fitness/guides/health-connect/plan/data-types#alpha10 -->
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE"/>
<uses-permission android:name="android.permission.health.WRITE_STEPS"/>
<uses-permission android:name="android.permission.health.WRITE_EXERCISE"/>
<uses-permission android:name="android.permission.health.WRITE_SLEEP"/>
<uses-permission android:name="android.permission.health.WRITE_TOTAL_CALORIES_BURNED"/>
<uses-permission android:name="android.permission.health.WRITE_DISTANCE"/>
<uses-permission android:name="android.permission.health.WRITE_VO2_MAX"/>
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE_VARIABILITY"/>
<uses-permission android:name="android.permission.health.WRITE_WEIGHT"/>
<uses-permission android:name="android.permission.health.WRITE_OXYGEN_SATURATION"/>
<uses-permission android:name="android.permission.health.WRITE_BODY_TEMPERATURE" />
<uses-permission android:name="android.permission.health.WRITE_SKIN_TEMPERATURE" />
<uses-permission android:name="android.permission.health.WRITE_ELEVATION_GAINED" />
<uses-permission android:name="android.permission.health.WRITE_SPEED" />
<uses-permission android:name="android.permission.health.WRITE_POWER" />
<uses-permission android:name="android.permission.health.WRITE_EXERCISE_ROUTE" />
<uses-sdk tools:overrideLibrary="androidx.health.connect.client"/>
<uses-feature <uses-feature
android:name="android.hardware.bluetooth" android:name="android.hardware.bluetooth"
android:required="true" /> android:required="true" />
@@ -217,6 +237,10 @@
android:name=".activities.InternetHelperPreferencesActivity" android:name=".activities.InternetHelperPreferencesActivity"
android:label="@string/prefs_internet_helper_title" android:label="@string/prefs_internet_helper_title"
android:parentActivityName=".activities.SettingsActivity" /> android:parentActivityName=".activities.SettingsActivity" />
<activity
android:name=".activities.preferences.HealthConnectPreferencesActivity"
android:label="@string/healthconnect_settings"
android:parentActivityName=".activities.SettingsActivity" />
<activity <activity
android:name=".activities.AboutUserPreferencesActivity" android:name=".activities.AboutUserPreferencesActivity"
android:label="@string/activity_prefs_about_you" android:label="@string/activity_prefs_about_you"
@@ -559,7 +583,12 @@
<service <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync"
android:exported="false" /> tools:node="merge"
android:exported="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Used for syncing data with Health Connect" />
</service>
<service <service
android:name=".externalevents.NotificationListener" android:name=".externalevents.NotificationListener"
android:label="@string/app_name" android:label="@string/app_name"
@@ -1042,6 +1071,41 @@
android:name=".devices.cycling_sensor.activity.CyclingLiveDataActivity" android:name=".devices.cycling_sensor.activity.CyclingLiveDataActivity"
android:launchMode="singleInstance" android:launchMode="singleInstance"
android:exported="true" /> android:exported="true" />
<!-- For supported versions through Android 13, create an activity to show the rationale
of Health Connect permissions once users click the privacy policy link. -->
<activity
android:name=".activities.PermissionsRationaleActivity"
android:exported="true">
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>
<!-- For versions starting Android 14, create an activity alias to show the rationale
of Health Connect permissions once users click the privacy policy link. -->
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:targetActivity=".activities.PermissionsRationaleActivity"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
</application> </application>
<queries>
<package android:name="com.google.android.apps.healthdata" />
<!--
This is required such that the app can query and resolve packages that have written
Health Connect data, and obtain their friendly App Name and Icon to show for data
attribution in the app
-->
<intent>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent>
</queries>
</manifest> </manifest>
@@ -126,6 +126,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue;
import nodomain.freeyourgadget.gadgetbridge.util.PermissionsUtils; import nodomain.freeyourgadget.gadgetbridge.util.PermissionsUtils;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs; import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
import nodomain.freeyourgadget.gadgetbridge.util.backup.PeriodicZipExporter; import nodomain.freeyourgadget.gadgetbridge.util.backup.PeriodicZipExporter;
import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectPermissionManager;
import nodomain.freeyourgadget.gadgetbridge.util.preferences.DevicePrefs; import nodomain.freeyourgadget.gadgetbridge.util.preferences.DevicePrefs;
/** /**
@@ -357,6 +358,10 @@ public class GBApplication extends Application {
startNotificationCollectorMonitorService(); startNotificationCollectorMonitorService();
BondingUtil.StartObservingAll(getBaseContext()); BondingUtil.StartObservingAll(getBaseContext());
if (prefs.getBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false)) {
HealthConnectPermissionManager.checkAndRectifyPermissions(this);
}
} }
private void startNotificationCollectorMonitorService() { private void startNotificationCollectorMonitorService() {
@@ -279,10 +279,19 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragmentCompa
return; return;
} }
// Skip internal programmatic preferences that aren't in XML
final Set<String> 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); final Preference preference = findPreference(key);
if (preference == null) { if (preference == null) {
LOG.warn("Preference {} not found", key); LOG.warn("Preference {} not found", key);
return; return;
} }
@@ -292,6 +301,11 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragmentCompa
switchPreference.setChecked(prefs.getBoolean(key, switchPreference.isChecked())); switchPreference.setChecked(prefs.getBoolean(key, switchPreference.isChecked()));
} else if (preference instanceof ListPreference listPreference) { } else if (preference instanceof ListPreference listPreference) {
listPreference.setValue(prefs.getString(key, listPreference.getValue())); listPreference.setValue(prefs.getString(key, listPreference.getValue()));
} else if (preference instanceof MultiSelectListPreference multiSelectListPreference) {
Set<String> values = prefs.getStringSet(key, multiSelectListPreference.getValues());
if (values != null) {
multiSelectListPreference.setValues(values);
}
} else if (preference instanceof EditTextPreference editTextPreference) { } else if (preference instanceof EditTextPreference editTextPreference) {
editTextPreference.setText(prefs.getString(key, editTextPreference.getText())); editTextPreference.setText(prefs.getString(key, editTextPreference.getText()));
} else if (preference instanceof XTimePreference xTimePreference) { } else if (preference instanceof XTimePreference xTimePreference) {
@@ -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 <https://www.gnu.org/licenses/>. */
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<CharSequence> 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<Long> 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();
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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)
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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);
}
}
@@ -65,6 +65,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.automations.AutomationsSe
import nodomain.freeyourgadget.gadgetbridge.activities.charts.ChartsPreferencesActivity; import nodomain.freeyourgadget.gadgetbridge.activities.charts.ChartsPreferencesActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryPairingPreferenceActivity; import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryPairingPreferenceActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsSettingsActivity; import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsSettingsActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.preferences.HealthConnectPreferencesActivity;
import nodomain.freeyourgadget.gadgetbridge.externalevents.TimeChangeReceiver; import nodomain.freeyourgadget.gadgetbridge.externalevents.TimeChangeReceiver;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB; 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"); pref = findPreference("pref_category_notifications");
if (pref != null) { if (pref != null) {
pref.setOnPreferenceClickListener(preference -> { pref.setOnPreferenceClickListener(preference -> {
@@ -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) { onClick(PREF_DEBUG_FACTORY_RESET) {
MaterialAlertDialogBuilder(requireActivity()) MaterialAlertDialogBuilder(requireActivity())
.setCancelable(true) .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_FETCH_DEBUG_LOGS = "pref_debug_fetch_debug_logs"
private const val PREF_DEBUG_HEADER_RESET = "pref_debug_header_reset" 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_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" private const val PREF_DEBUG_FACTORY_RESET = "pref_debug_factory_reset"
} }
} }
@@ -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 <https://www.gnu.org/licenses/>. */
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<Set<String>> 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<WorkInfo> 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<List<WorkInfo>> future = workManager.getWorkInfosByTag(HEALTH_CONNECT_SYNC_WORKER_TAG);
future.addListener(() -> {
try {
List<WorkInfo> 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<WorkInfo> workInfos) {
if (workInfos == null) return false;
for (WorkInfo info : workInfos) {
if (info.getState() == WorkInfo.State.RUNNING) {
return true;
}
}
return false;
}
private void showSyncAlreadyRunningMessage() {
LOG.info("Sync already running, not starting initial sync");
if (!isAdded() || getActivity() == null || getActivity().isFinishing()) return;
getActivity().runOnUiThread(() -> {
if (isAdded()) {
GB.toast(requireContext(), getString(R.string.health_connect_sync_already_running), Toast.LENGTH_SHORT, GB.INFO);
}
});
}
private void startInitialSyncWork(WorkManager workManager) {
OneTimeWorkRequest syncRequest = new OneTimeWorkRequest.Builder(HealthConnectSyncWorker.class)
.setInputData(new Data.Builder().putBoolean("initial_sync", true).build())
.addTag(HEALTH_CONNECT_SYNC_WORKER_TAG)
.build();
workManager.enqueueUniqueWork(
HEALTH_CONNECT_ONETIME_WORK_NAME,
ExistingWorkPolicy.KEEP,
syncRequest
);
LOG.info("Initial sync work enqueued with KEEP policy");
}
@Override
public void onResume() {
super.onResume();
checkPermissionsAndUpdateUI();
startPeriodicPermissionCheck();
}
@Override
public void onPause() {
super.onPause();
stopPeriodicPermissionCheck();
}
@Override
public void onDestroyView() {
super.onDestroyView();
stopPeriodicPermissionCheck();
}
private void startPeriodicPermissionCheck() {
if (permissionCheckRunnable == null) {
permissionCheckRunnable = new Runnable() {
@Override
public void run() {
checkPermissionsAndUpdateUI();
permissionCheckHandler.postDelayed(this, 2000);
}
};
}
permissionCheckHandler.postDelayed(permissionCheckRunnable, 2000);
}
private void stopPeriodicPermissionCheck() {
if (permissionCheckRunnable != null) {
permissionCheckHandler.removeCallbacks(permissionCheckRunnable);
}
}
private void checkPermissionsAndUpdateUI() {
if (!isAdded() || getActivity() == null || getActivity().isFinishing()) {
return;
}
BuildersKt.launch(LifecycleKt.getCoroutineScope(getLifecycle()), Dispatchers.getIO(), CoroutineStart.DEFAULT, (scope, continuation) -> {
Object result = HealthConnectPermissionManager.INSTANCE.checkPermissionChange(requireContext(), continuation);
if (result == IntrinsicsKt.getCOROUTINE_SUSPENDED()) {
return result;
}
updateUIAfterPermissionCheck();
return Unit.INSTANCE;
});
}
private void updateUIAfterPermissionCheck() {
if (!isAdded() || getActivity() == null || getActivity().isFinishing()) {
return;
}
getActivity().runOnUiThread(() -> {
if (!isAdded() || getActivity() == null || getActivity().isFinishing()) {
return;
}
handlePermissionCheckResult();
});
}
private void handlePermissionCheckResult() {
if (HealthConnectPermissionManager.isHealthConnectPermissionResetNeeded(requireContext())) {
handlePermissionsRevoked();
} else {
checkInitialPermissionsAndUpdateUI();
}
}
private void handlePermissionsRevoked() {
boolean wasEnabled = GBApplication.getPrefs().getBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false);
if (!wasEnabled) {
HealthConnectPermissionManager.showResetDialogIfNecessary(requireActivity());
return;
}
LOG.info("All HC permissions revoked, disabling HC and cancelling all sync work");
GBApplication.getPrefs().getPreferences().edit().putBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false).apply();
updateHealthConnectUIState(false);
WorkManager.getInstance(requireContext()).cancelUniqueWork(HEALTH_CONNECT_ONETIME_WORK_NAME);
WorkManager.getInstance(requireContext()).cancelAllWorkByTag(HEALTH_CONNECT_SYNC_WORKER_TAG);
LOG.info("Cancelled all Health Connect sync work due to permission removal");
clearSyncStatus();
HealthConnectPermissionManager.showResetDialogIfNecessary(requireActivity());
}
private void clearSyncStatus() {
if (healthConnectSyncStatus != null) {
healthConnectSyncStatus.setSummary("");
healthConnectSyncStatus.setVisible(false);
}
lastDisplayedStatus = null;
GBApplication.getPrefs().getPreferences().edit()
.putString(GBPrefs.HEALTH_CONNECT_SYNC_STATUS, "")
.apply();
}
private void updateSyncStatus(WorkInfo workInfo) {
if (healthConnectSyncStatus == null) return;
String statusSummary;
boolean isSyncing;
switch (workInfo.getState()) {
case ENQUEUED:
case RUNNING:
statusSummary = workInfo.getProgress().getString("progress");
if (statusSummary == null) {
statusSummary = getString(R.string.health_connect_syncing);
isSyncing = true;
} else {
isSyncing = !statusSummary.contains("Finished");
}
break;
case SUCCEEDED:
case FAILED:
case BLOCKED:
case CANCELLED:
default:
updateSyncStatusFromPreferences();
return;
}
if (lastDisplayedStatus != null && lastDisplayedStatus.contains("Finished") &&
!statusSummary.contains("Finished")) {
return;
}
if (statusSummary.equals(lastDisplayedStatus)) {
return;
}
lastDisplayedStatus = statusSummary;
healthConnectSyncStatus.setSummary(statusSummary);
}
private void updateSyncStatusFromPreferences() {
if (healthConnectSyncStatus == null) return;
String storedStatus = GBApplication.getPrefs().getPreferences().getString(GBPrefs.HEALTH_CONNECT_SYNC_STATUS, "");
if (storedStatus.equals(lastDisplayedStatus)) {
return;
}
lastDisplayedStatus = storedStatus;
healthConnectSyncStatus.setSummary(storedStatus);
}
private void updateHealthConnectUIState(boolean enabled) {
if (healthConnectEnabledPref != null) {
healthConnectEnabledPref.setChecked(enabled);
healthConnectEnabledPref.setEnabled(true);
}
if (healthConnectSyncStatus != null) {
healthConnectSyncStatus.setVisible(enabled);
}
if (healthConnectDisableNotice != null) healthConnectDisableNotice.setVisible(enabled);
// Keep these always enabled to prevent lockout when permission requests are denied
if (selectedDevicesPref != null) {
selectedDevicesPref.setEnabled(true);
}
if (syncOnEventPref != null) {
syncOnEventPref.setEnabled(true);
}
if (healthConnectManualSettings != null) healthConnectManualSettings.setEnabled(enabled);
if (healthConnectSettings != null) healthConnectSettings.setEnabled(enabled);
}
private void showSdkStatusMessage(int sdkStatus) {
if (healthConnectSyncStatus != null && getContext() != null) {
int messageResId = sdkStatus == HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED
? R.string.health_connect_update_required
: R.string.health_connect_unsupported;
healthConnectSyncStatus.setSummary(getContext().getString(messageResId));
healthConnectSyncStatus.setVisible(true);
}
}
private void checkInitialPermissionsAndUpdateUI() {
Context context = getContext();
if (context == null || healthConnectEnabledPref == null) return;
boolean hcEnabledInPrefs = GBApplication.getPrefs().getBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false);
healthConnectEnabledPref.setChecked(hcEnabledInPrefs);
if (!hcEnabledInPrefs) {
handleHealthConnectDisabledState(context);
return;
}
int sdkStatus = HealthConnectClient.getSdkStatus(context);
if (sdkStatus != HealthConnectClient.SDK_AVAILABLE) {
handleSdkUnavailable(context, sdkStatus);
return;
}
HealthConnectClient client = HealthConnectClientProvider.healthConnectInit(context);
if (client == null) {
handleClientInitializationFailed(context);
return;
}
fetchAndProcessPermissions(client);
}
private void handleHealthConnectDisabledState(Context context) {
updateHealthConnectUIState(false);
if (healthConnectSyncStatus != null) {
healthConnectSyncStatus.setSummary("");
healthConnectSyncStatus.setVisible(false);
}
WorkManager.getInstance(context).cancelAllWorkByTag(HEALTH_CONNECT_SYNC_WORKER_TAG);
}
private void handleSdkUnavailable(Context context, int sdkStatus) {
GBApplication.getPrefs().getPreferences().edit().putBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false).apply();
updateHealthConnectUIState(false);
showSdkStatusMessage(sdkStatus);
WorkManager.getInstance(context).cancelAllWorkByTag(HEALTH_CONNECT_SYNC_WORKER_TAG);
}
private void handleClientInitializationFailed(Context context) {
GBApplication.getPrefs().getPreferences().edit().putBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false).apply();
updateHealthConnectUIState(false);
if (healthConnectSyncStatus != null) {
healthConnectSyncStatus.setSummary(R.string.health_connect_unavailable_summary);
healthConnectSyncStatus.setVisible(true);
}
WorkManager.getInstance(context).cancelAllWorkByTag(HEALTH_CONNECT_SYNC_WORKER_TAG);
}
private void fetchAndProcessPermissions(HealthConnectClient client) {
BuildersKt.launch(LifecycleKt.getCoroutineScope(getLifecycle()), Dispatchers.getMain(), CoroutineStart.DEFAULT, (scope, continuation) -> {
Object resultFromKotlin = HealthConnectPreferencesLogicKt.getGrantedHealthConnectPermissions(client, (Continuation<? super Set<String>>) continuation);
if (resultFromKotlin == IntrinsicsKt.getCOROUTINE_SUSPENDED()) {
return IntrinsicsKt.getCOROUTINE_SUSPENDED();
}
Set<String> grantedPermissions = (Set<String>) resultFromKotlin;
try {
processPermissionsResult(grantedPermissions);
} catch (Exception e) {
handlePermissionsProcessingError(e);
}
return Unit.INSTANCE;
});
}
private void processPermissionsResult(Set<String> 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<String> 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<GBDevice> devices = GBApplication.app().getDeviceManager().getDevices();
List<String> deviceMACs = new ArrayList<>();
List<String> 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<String> newSelectedValues = (newValue instanceof Set) ? (Set<String>) 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<String> validDeviceAddresses) {
Set<String> selectedDevices = GBApplication.getPrefs().getPreferences()
.getStringSet(GBPrefs.HEALTH_CONNECT_DEVICE_SELECTION, null);
if (selectedDevices == null || selectedDevices.isEmpty()) {
return;
}
Set<String> currentSelection = new HashSet<>(selectedDevices);
Set<String> 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);
}
}
}
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<String>? {
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
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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");
}
}
}
}
@@ -88,6 +88,7 @@ import nodomain.freeyourgadget.gadgetbridge.externalevents.IntentApiReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.KeyMissingReceiver; import nodomain.freeyourgadget.gadgetbridge.externalevents.KeyMissingReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.LineageOsWeatherReceiver; import nodomain.freeyourgadget.gadgetbridge.externalevents.LineageOsWeatherReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.MusicPlaybackReceiver; import nodomain.freeyourgadget.gadgetbridge.externalevents.MusicPlaybackReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.NewDataReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.OmniJawsObserver; import nodomain.freeyourgadget.gadgetbridge.externalevents.OmniJawsObserver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.OsmandEventReceiver; import nodomain.freeyourgadget.gadgetbridge.externalevents.OsmandEventReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.PebbleReceiver; import nodomain.freeyourgadget.gadgetbridge.externalevents.PebbleReceiver;
@@ -272,6 +273,7 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
private VolumeChangeReceiver mVolumeChangeReceiver = null; private VolumeChangeReceiver mVolumeChangeReceiver = null;
private HrvCacheInvalidationReceiver mHrvCacheInvalidationReceiver = null; private HrvCacheInvalidationReceiver mHrvCacheInvalidationReceiver = null;
private NewDataReceiver mNewDataReceiver = null;
private final List<CalendarReceiver> mCalendarReceiver = new ArrayList<>(); private final List<CalendarReceiver> mCalendarReceiver = new ArrayList<>();
private CMWeatherReceiver mCMWeatherReceiver = null; private CMWeatherReceiver mCMWeatherReceiver = null;
@@ -1411,6 +1413,10 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
mVolumeChangeReceiver = new VolumeChangeReceiver(); mVolumeChangeReceiver = new VolumeChangeReceiver();
mVolumeChangeReceiver.registerReceiver(this); mVolumeChangeReceiver.registerReceiver(this);
} }
if (mNewDataReceiver == null) {
mNewDataReceiver = new NewDataReceiver();
mNewDataReceiver.registerReceiver(this);
}
if (mTimeChangeReceiver == null) { if (mTimeChangeReceiver == null) {
mTimeChangeReceiver = new TimeChangeReceiver(); mTimeChangeReceiver = new TimeChangeReceiver();
IntentFilter filter = new IntentFilter(); IntentFilter filter = new IntentFilter();
@@ -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_FULL_BATTERY = "full_battery";
public static final String NOTIFICATION_CHANNEL_ID_GPS = "gps"; 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_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 = 1;
public static final int NOTIFICATION_ID_INSTALL = 2; public static final int NOTIFICATION_ID_INSTALL = 2;
@@ -180,6 +181,12 @@ public class GB {
context.getString(R.string.notification_channel_pebble_js_runner), context.getString(R.string.notification_channel_pebble_js_runner),
NotificationManager.IMPORTANCE_MIN); NotificationManager.IMPORTANCE_MIN);
notificationManager.createNotificationChannel(channelPebbleJs); 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; notificationChannelsCreated = true;
@@ -98,6 +98,19 @@ public class GBPrefs extends Prefs {
public static final String RECONNECT_ONLY_TO_CONNECTED = "general_reconnectonlytoconnected"; public static final String RECONNECT_ONLY_TO_CONNECTED = "general_reconnectonlytoconnected";
public static final String BLOCK_SCREENSHOTS = "block_screenshots"; 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 @Deprecated
public GBPrefs(Prefs prefs) { public GBPrefs(Prefs prefs) {
this(prefs.getPreferences()); this(prefs.getPreferences());
@@ -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 <https://www.gnu.org/licenses/>. */
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)
}
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<String> = 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<String> {
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<String>,
oldRelevantPermissions: Set<String>
): 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<String>): 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.")
}
}
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.util.healthconnect;
import android.app.Notification;
import android.content.Context;
import android.content.pm.ServiceInfo;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.health.connect.client.HealthConnectClient;
import androidx.work.Data;
import androidx.work.CoroutineWorker;
import androidx.work.ForegroundInfo;
import androidx.work.WorkerParameters;
import kotlin.coroutines.Continuation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.CountDownLatch;
import java.util.function.BiConsumer;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
public class HealthConnectSyncWorker extends CoroutineWorker {
private static final Logger LOG = LoggerFactory.getLogger(HealthConnectSyncWorker.class);
private static final int NOTIFICATION_ID = 123;
public static final String INPUT_DEVICE_ADDRESS = "device_address";
public HealthConnectSyncWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
}
@NonNull
@Override
public Object doWork(@NonNull Continuation<? super Result> continuation) {
LOG.info("Health Connect sync worker started");
try {
return performSync();
} catch (Exception e) {
LOG.error("Health Connect sync worker failed", e);
return Result.failure();
}
}
private Result performSync() throws Exception {
GBPrefs prefs = GBApplication.getPrefs();
if (!prefs.getBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false)) {
LOG.info("Health Connect is disabled, aborting sync.");
return Result.failure();
}
setForegroundAsync(createForegroundInfo());
setProgressAsync(new Data.Builder().putString("progress", getApplicationContext().getString(R.string.health_connect_syncing)).build());
HealthConnectClient healthConnectClient = HealthConnectClientProvider.healthConnectInit(getApplicationContext());
if (healthConnectClient == null) {
LOG.error("SyncWorker: HealthConnectClient is null, cannot perform HC sync");
return Result.success();
}
performHealthConnectSync(healthConnectClient);
LOG.info("Health Connect sync worker finished successfully.");
return Result.success();
}
private void performHealthConnectSync(HealthConnectClient healthConnectClient) throws InterruptedException {
LOG.info("SyncWorker: Starting HC data sync");
// Extract device address from input data, if provided
String deviceAddress = getInputData().getString(INPUT_DEVICE_ADDRESS);
if (deviceAddress != null && !deviceAddress.isEmpty()) {
LOG.info("SyncWorker: Syncing specific device: {}", deviceAddress);
} else {
LOG.info("SyncWorker: Syncing all selected devices");
}
CountDownLatch latch = new CountDownLatch(1);
BiConsumer<String, Boolean> 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);
}
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<String, Int>, // data type name -> count of records synced
val dataTypesWithErrors: Set<String> = 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<String, Boolean>?,
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<String, Boolean>?,
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<String>,
healthConnectClient: HealthConnectClient,
summaryCallback: BiConsumer<String, Boolean>?,
mainHandler: Handler,
grantedPermissions: Set<String>,
worker: androidx.work.CoroutineWorker? = null // Optional worker to check for cancellation
): SyncStatistics {
var totalDataTypesProcessed = 0
var totalDataTypesSkipped = 0
val recordsSyncedByType = mutableMapOf<String, Int>() // Track records per data type
val dataTypesWithErrors = mutableSetOf<String>() // 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<ActivitySample>? =
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<Instant, Instant>? {
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<String>,
activityBasedSamples: List<ActivitySample>?,
context: Context
): List<SyncerStatistics> {
val sliceStats = mutableListOf<SyncerStatistics>()
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<ActivitySample> {
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<Record>, 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
)
}
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.util.healthconnect
class SyncException(message: String, cause: Throwable? = null) : Exception(message, cause)
@@ -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 <https://www.gnu.org/licenses/>. */
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<String>
): 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<String>,
deviceSamples: List<ActivitySample>
): 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<String>,
deviceSamples: List<ActivitySample>,
context: Context
): SyncerStatistics
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<String>,
deviceSamples: List<ActivitySample>
): 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<Record>()
val currentHcSamples = mutableListOf<HeartRateRecord.Sample>()
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")
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<HrvValueSample>,
sliceStartBoundary: Instant,
sliceEndBoundary: Instant,
offset: ZoneOffset,
metadata: Metadata,
deviceName: String
): List<Record> {
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<Record>()
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<String>
): 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<HrvValueSample> = 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")
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<String>,
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<Record>()
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<Record>()
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<ActivityPoint>? = 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<BaseActivitySummary> {
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<ActivityPoint>? {
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<ActivityPoint>,
workoutStartInstant: Instant,
workoutEndInstant: Instant,
offset: ZoneOffset,
metadata: Metadata,
grantedPermissions: Set<String>,
recordsToInsert: MutableList<Record>,
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<String>,
recordsToInsert: MutableList<Record>,
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<ActivityPoint>,
startTime: Instant,
endTime: Instant,
offset: ZoneOffset,
metadata: Metadata,
grantedPermissions: Set<String>,
recordsToInsert: MutableList<Record>,
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<ActivityPoint>,
startTime: Instant,
endTime: Instant,
offset: ZoneOffset,
metadata: Metadata,
grantedPermissions: Set<String>,
recordsToInsert: MutableList<Record>,
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<ActivityPoint>,
startTime: Instant,
endTime: Instant,
offset: ZoneOffset,
metadata: Metadata,
grantedPermissions: Set<String>,
recordsToInsert: MutableList<Record>,
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<String>,
recordsToInsert: MutableList<Record>,
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<String>,
recordsToInsert: MutableList<Record>,
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<String>,
recordsToInsert: MutableList<Record>,
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<String>,
recordsToInsert: MutableList<Record>,
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<String>,
recordsToInsert: MutableList<Record>,
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)
}
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<String>,
deviceSamples: List<ActivitySample>,
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<ActivitySample>,
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<ActivitySample>,
deviceName: String
): List<SleepSessionRecord.Stage> {
val stages = mutableListOf<SleepSessionRecord.Stage>()
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
}
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<String>
): 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<AbstractSpo2Sample>()
}
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<Record>()
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")
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<String>,
deviceSamples: List<ActivitySample>
): 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<Record>()
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")
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<String>
): 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<TemperatureSample> = 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<Record>()
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<SkinTemperatureRecord.Delta>()
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<Instant, Double>() // 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<TemperatureSample> = 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<TemperatureSample?> or List<TemperatureSample>
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<Instant, Double>()
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<String, SkinBaselineHelper>()
/**
* 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)
}
}
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<String>
): 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<Vo2MaxSample>()
}
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<Record>()
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")
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<String>
): 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<WeightSample>()
}
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<Record>()
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")
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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
}
}
}
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/health_connect_initial_sync_dialog_message"
android:textSize="14sp"
android:paddingBottom="16dp" />
<RadioGroup
android:id="@+id/radio_group_sync_period"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<RadioButton
android:id="@+id/radio_preset"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/health_connect_initial_sync_preset" />
<Spinner
android:id="@+id/spinner_sync_period"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="8dp" />
<RadioButton
android:id="@+id/radio_custom_date"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/health_connect_initial_sync_custom_date" />
<Button
android:id="@+id/button_select_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="16dp"
android:text="@string/health_connect_initial_sync_select_date"
style="?android:attr/buttonBarButtonStyle" />
</RadioGroup>
</LinearLayout>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/health_connect_permissions_rationale_title"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/health_connect_permissions_rationale_message" />
</LinearLayout>
+7
View File
@@ -5343,4 +5343,11 @@
<item>never</item> <item>never</item>
</string-array> </string-array>
<string-array name="health_connect_initial_sync_periods">
<item>@string/health_connect_initial_sync_3_days</item>
<item>@string/health_connect_initial_sync_7_days</item>
<item>@string/health_connect_initial_sync_30_days</item>
<item>@string/health_connect_initial_sync_all</item>
</string-array>
</resources> </resources>
+63
View File
@@ -207,6 +207,7 @@
<string name="pref_header_general">General settings</string> <string name="pref_header_general">General settings</string>
<string name="pref_header_other">Other</string> <string name="pref_header_other">Other</string>
<string name="pref_header_system">System</string> <string name="pref_header_system">System</string>
<string name="pref_header_sync">Synchronization</string>
<string name="pref_header_audio">Audio</string> <string name="pref_header_audio">Audio</string>
<string name="pref_header_calendar">Calendar</string> <string name="pref_header_calendar">Calendar</string>
<string name="pref_header_connection">Connection</string> <string name="pref_header_connection">Connection</string>
@@ -4518,4 +4519,66 @@
<string name="pref_title_pebble_search_app_updates">Search for app updates</string> <string name="pref_title_pebble_search_app_updates">Search for app updates</string>
<string name="pref_summary_pebble_search_app_updates">Automatically search for watch app and watchface updates when opening the app manager</string> <string name="pref_summary_pebble_search_app_updates">Automatically search for watch app and watchface updates when opening the app manager</string>
<string name="pref_sos_contact_title">SOS contact</string> <string name="pref_sos_contact_title">SOS contact</string>
<!-- Export Health Connect preferences -->
<string name="healthconnect_settings">Health Connect</string>
<string name="pref_health_connect_enabled">Enable Health Connect Export</string>
<string name="pref_health_connect_manual_settings">Manage Health Connect Data</string>
<string name="pref_health_connect_settings">Health Connect Settings</string>
<string name="pref_health_connect_settings_summary">Open Android Health Connect settings</string>
<string name="pref_health_connect_disable_notice">To disable Health Connect Export, remove all permissions of Gadgetbridge from the Health Connect App</string>
<string name="pref_health_connect_sync_notice">Depending on the amount of data, a Health Connect sync might take several minutes.</string>
<string name="pref_health_connect_device_selection">Select devices</string>
<string name="pref_health_connect_device_selection_summary">Select which devices to include the data from for sharing with Health Connect</string>
<string name="health_connect_unsupported">Health Connect not supported on this Android Version</string>
<string name="health_connect_update_required">Health Connect Provider Update Required</string>
<string name="health_connect_settings_open_error">Could not open Health Connect\'s settings screen. Please try opening the Health Connect app manually.</string>
<string name="pref_health_connect_sync_on_event_title">Sync after device sync</string>
<string name="pref_health_connect_sync_on_event_summary">Sync to Health Connect after a selected device has finished syncing its activity data.</string>
<string name="health_connect_permissions_rationale_title">Privacy Policy and Permissions</string>
<string name="health_connect_permissions_rationale_message">Gadgetbridge collects data from your devices and, with your permission, shares it with Health Connect. This data is stored on your device and is not shared with any third parties.</string>
<string name="health_connect_sync_notification_title">Health Connect Sync</string>
<string name="health_connect_sync_notification_message">Syncing data with Health Connect…</string>
<string name="health_connect_unavailable_summary">Health Connect is not available on this system.</string>
<string name="health_connect_permission_check_error">Error checking Health Connect permissions.</string>
<string name="health_connect_all_denied">All Health Connect permissions for Gadgetbridge\'s data types were denied or revoked.</string>
<string name="health_connect_failed_init">Failed to initialize Health Connect client after permission change.</string>
<string name="health_connect_all_granted">Health Connect enabled. Data sync will begin for granted data types.</string>
<string name="health_connect_permission_changed">Health Connect permissions changed.</string>
<string name="health_connect_no_devices_selected">No devices selected for Health Connect sync.</string>
<string name="health_connect_no_permissions">No Health Connect permissions granted. Cannot sync.</string>
<string name="health_connect_finished">Finished Health Connect Data Sync for all selected devices.</string>
<string name="health_connect_finished_no_data">Finished Health Connect Data Sync (No data available to sync)</string>
<string name="health_connect_finished_with_stats">Finished Health Connect Data Sync (%1$s)</string>
<string name="health_connect_finished_with_stats_and_errors">Finished Health Connect Data Sync (%1$s) [Errors: %2$s]</string>
<string name="health_connect_finished_with_errors">Health Connect Sync experienced errors, some data will not be available.</string>
<string name="health_connect_syncing">Syncing…</string>
<string name="notification_channel_health_connect_sync_name">Health Connect Sync</string>
<string name="health_connect_sync_status_title">Sync Status</string>
<string name="pref_health_connect_detailed_workout_sync_title">Detailed workout sync</string>
<string name="pref_health_connect_detailed_workout_sync_summary">Sync GPS routes and per-second metrics (heart rate, speed, power) for workouts. Provides richer data but may take longer.</string>
<string name="health_connect_initial_sync_custom_date">Sync from a custom date</string>
<string name="health_connect_initial_sync_dialog_title">Initial Health Connect Sync</string>
<string name="health_connect_initial_sync_dialog_message">Choose how much data to sync for the initial Health Connect synchronization:</string>
<string name="health_connect_initial_sync_preset">Sync last:</string>
<string name="health_connect_initial_sync_select_date">Select Date</string>
<string name="health_connect_initial_sync_3_days">3 days</string>
<string name="health_connect_initial_sync_7_days">7 days</string>
<string name="health_connect_initial_sync_30_days">30 days</string>
<string name="health_connect_initial_sync_all">Sync all</string>
<string name="health_connect_sync_already_running">Sync is already running.</string>
<string name="health_connect_sync_now_title">Sync Now?</string>
<string name="health_connect_sync_now_message">Do you want to start synchronizing your data to Health Connect now?</string>
<string name="health_connect_sync_now">Sync Now</string>
<string name="health_connect_sync_later">Sync Later</string>
<string name="health_connect_prompt_full_dao_reset">All Health Connect permissions for Gadgetbridge have been revoked. Did you also remove all Data? If yes, resetting the sync history will allow to re-sync all old data if you give permissions again.</string>
<string name="health_connect_reset_sync_history">Reset Sync History</string>
<string name="health_connect_keep_sync_history">Keep Sync History</string>
<string name="health_connect_exception">Health Connect Data Sync: </string>
<string name="health_connect_sync_failed">Sync failed.</string>
<string name="health_connect_sync_failed_retries">Sync failed after multiple attempts. Last error: %1$s</string>
<string name="health_connect_syncing_device_datatype">Syncing %1$s %2$s: %3$s to %4$s</string>
<string name="health_connect_sync_error_for_device">Sync error for %1$s (%2$s): %3$s</string>
<string name="health_connect_permission_denied">Permission denied: %1$s. Please check Health Connect permissions.</string>
<string name="health_connect_sleep_session_title">Sleep Session from %1$s</string>
<string name="health_connect_sleep_session_notes">Sleep data from Gadgetbridge device: %1$s.</string>
</resources> </resources>
@@ -46,6 +46,13 @@
android:persistent="false" android:persistent="false"
android:title="Reboot" /> android:title="Reboot" />
<Preference
android:icon="@drawable/ic_refresh"
android:key="pref_debug_reset_hc_sync_state"
android:persistent="false"
android:summary="Reset Health Connect sync state and allow re-syncing all data"
android:title="Reset HC sync state" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:defaultValue="false" android:defaultValue="false"
android:icon="@drawable/ic_warning_gray" android:icon="@drawable/ic_warning_gray"
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory
android:key="pref_key_healthconnect_general"
android:title="@string/pref_header_general"
app:iconSpaceReserved="false">
<SwitchPreferenceCompat
android:defaultValue="false"
android:icon="@drawable/ic_heart"
android:key="health_connect_enabled"
android:layout="@layout/preference_checkbox"
android:title="@string/pref_health_connect_enabled"
app:iconSpaceReserved="true" />
<Preference
android:icon="@drawable/ic_settings"
android:key="health_connect_manual_settings"
android:title="@string/pref_health_connect_manual_settings"
app:iconSpaceReserved="true" />
<Preference
android:icon="@drawable/ic_settings"
android:key="health_connect_settings"
android:title="@string/pref_health_connect_settings"
android:summary="@string/pref_health_connect_settings_summary"
app:iconSpaceReserved="true" />
<Preference
android:icon="@drawable/ic_info"
android:key="health_connect_disable_notice"
android:summary="@string/pref_health_connect_disable_notice" />
</PreferenceCategory>
<PreferenceCategory
android:key="pref_key_healthconnect_sync"
android:title="@string/pref_header_sync"
app:iconSpaceReserved="false">
<Preference
android:key="health_connect_sync_status"
android:title="@string/health_connect_sync_status_title"
android:selectable="false"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="health_connect_sync_on_event"
android:layout="@layout/preference_checkbox"
android:title="@string/pref_health_connect_sync_on_event_title"
android:summary="@string/pref_health_connect_sync_on_event_summary"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="health_connect_detailed_workout_sync"
android:layout="@layout/preference_checkbox"
android:title="@string/pref_health_connect_detailed_workout_sync_title"
android:summary="@string/pref_health_connect_detailed_workout_sync_summary"
app:iconSpaceReserved="false" />
<Preference
android:icon="@drawable/ic_info"
android:key="health_connect_sync_notice"
android:summary="@string/pref_health_connect_sync_notice" />
</PreferenceCategory>
<PreferenceCategory
android:key="pref_key_healthconnect_devices"
android:title="@string/bottom_nav_devices"
app:iconSpaceReserved="false">
<MultiSelectListPreference
android:dialogTitle="@string/pref_health_connect_device_selection"
android:entries="@array/empty_array"
android:entryValues="@array/empty_array"
android:key="health_connect_devices_multiselect"
android:title="@string/pref_health_connect_device_selection"
android:summary="@string/pref_health_connect_device_selection_summary"
app:iconSpaceReserved="false" />
</PreferenceCategory>
</androidx.preference.PreferenceScreen>
+5
View File
@@ -324,6 +324,11 @@
android:key="pref_category_sleepasandroid" android:key="pref_category_sleepasandroid"
android:title="@string/sleepasandroid_settings" /> android:title="@string/sleepasandroid_settings" />
<Preference
android:icon="@drawable/ic_heart"
android:key="pref_category_healthconnect"
android:title="@string/healthconnect_settings" />
<PreferenceScreen <PreferenceScreen
android:icon="@drawable/ic_partly_cloudy_day" android:icon="@drawable/ic_partly_cloudy_day"
android:key="pref_screen_weather" android:key="pref_screen_weather"
+4
View File
@@ -0,0 +1,4 @@
<manifest xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="androidx.health.connect.client" />
</manifest>
@@ -0,0 +1,109 @@
package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
import org.junit.Assert.*
import org.junit.Test
class HealthConnectSyncerTest {
@Test
fun testSyncerStatistics_defaultInitialization() {
val stats = SyncerStatistics()
assertEquals(0, stats.recordsSynced)
assertEquals(0, stats.recordsSkipped)
assertEquals("", stats.recordType)
}
@Test
fun testSyncerStatistics_withRecordsSynced() {
val stats = SyncerStatistics(recordsSynced = 5, recordType = "Steps")
assertEquals(5, stats.recordsSynced)
assertEquals(0, stats.recordsSkipped)
assertEquals("Steps", stats.recordType)
}
@Test
fun testSyncerStatistics_withRecordsSkipped() {
val stats = SyncerStatistics(recordsSkipped = 3, recordType = "HeartRate")
assertEquals(0, stats.recordsSynced)
assertEquals(3, stats.recordsSkipped)
assertEquals("HeartRate", stats.recordType)
}
@Test
fun testSyncerStatistics_withBothSyncedAndSkipped() {
val stats = SyncerStatistics(
recordsSynced = 10,
recordsSkipped = 5,
recordType = "Sleep"
)
assertEquals(10, stats.recordsSynced)
assertEquals(5, stats.recordsSkipped)
assertEquals("Sleep", stats.recordType)
}
@Test
fun testSyncerStatistics_withOnlyRecordType() {
val stats = SyncerStatistics(recordType = "Weight")
assertEquals(0, stats.recordsSynced)
assertEquals(0, stats.recordsSkipped)
assertEquals("Weight", stats.recordType)
}
@Test
fun testSyncerStatistics_dataClassEquality() {
val stats1 = SyncerStatistics(recordsSynced = 5, recordsSkipped = 2, recordType = "HRV")
val stats2 = SyncerStatistics(recordsSynced = 5, recordsSkipped = 2, recordType = "HRV")
val stats3 = SyncerStatistics(recordsSynced = 3, recordsSkipped = 2, recordType = "HRV")
assertEquals(stats1, stats2)
assertNotEquals(stats1, stats3)
}
@Test
fun testSyncerStatistics_copy() {
val original = SyncerStatistics(recordsSynced = 10, recordsSkipped = 5, recordType = "SpO2")
val copy = original.copy(recordsSynced = 15)
assertEquals(15, copy.recordsSynced)
assertEquals(5, copy.recordsSkipped)
assertEquals("SpO2", copy.recordType)
// Original should be unchanged
assertEquals(10, original.recordsSynced)
}
@Test
fun testSyncerStatistics_toString() {
val stats = SyncerStatistics(recordsSynced = 7, recordsSkipped = 3, recordType = "Temperature")
val stringRepresentation = stats.toString()
assertTrue(stringRepresentation.contains("recordsSynced=7"))
assertTrue(stringRepresentation.contains("recordsSkipped=3"))
assertTrue(stringRepresentation.contains("recordType=Temperature"))
}
@Test
fun testSyncerStatistics_totalRecords() {
val stats = SyncerStatistics(recordsSynced = 100, recordsSkipped = 25, recordType = "Workout")
val total = stats.recordsSynced + stats.recordsSkipped
assertEquals(125, total)
}
@Test
fun testSyncerStatistics_hasWork() {
val noWork = SyncerStatistics(recordType = "Test")
val hasWork = SyncerStatistics(recordsSynced = 1, recordType = "Test")
val hasSkipped = SyncerStatistics(recordsSkipped = 1, recordType = "Test")
assertEquals(0, noWork.recordsSynced + noWork.recordsSkipped)
assertTrue((hasWork.recordsSynced + hasWork.recordsSkipped) > 0)
assertTrue((hasSkipped.recordsSynced + hasSkipped.recordsSkipped) > 0)
}
}
@@ -0,0 +1,267 @@
package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
import androidx.health.connect.client.records.ExerciseSessionRecord
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind
import org.junit.Assert.*
import org.junit.Test
class WorkoutSyncerUtilsTest {
@Test
fun testMapActivityKind_running() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.RUNNING)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.OUTDOOR_RUNNING)
)
}
@Test
fun testMapActivityKind_walking() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_WALKING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.WALKING)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_WALKING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.OUTDOOR_WALKING)
)
}
@Test
fun testMapActivityKind_cycling() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_BIKING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.CYCLING)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_BIKING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.OUTDOOR_CYCLING)
)
}
@Test
fun testMapActivityKind_indoorCycling() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.INDOOR_CYCLING)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.SPINNING)
)
}
@Test
fun testMapActivityKind_swimming() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.SWIMMING)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.POOL_SWIM)
)
}
@Test
fun testMapActivityKind_openWaterSwimming() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.SWIMMING_OPENWATER)
)
}
@Test
fun testMapActivityKind_treadmill() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.TREADMILL)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.INDOOR_RUNNING)
)
}
@Test
fun testMapActivityKind_strengthTraining() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.STRENGTH_TRAINING)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.WEIGHTLIFTING)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.CROSSFIT)
)
}
@Test
fun testMapActivityKind_yoga() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_YOGA,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.YOGA)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_YOGA,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.TAI_CHI)
)
}
@Test
fun testMapActivityKind_sports() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_SOCCER,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.SOCCER)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_BASKETBALL,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.BASKETBALL)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_TENNIS,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.TENNIS)
)
}
@Test
fun testMapActivityKind_hiking() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_HIKING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.HIKING)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_HIKING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.TREKKING)
)
}
@Test
fun testMapActivityKind_climbing() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_ROCK_CLIMBING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.CLIMBING)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_ROCK_CLIMBING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.ROCK_CLIMBING)
)
}
@Test
fun testMapActivityKind_skiing() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_SKIING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.SKIING)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_SNOWBOARDING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.SNOWBOARDING)
)
}
@Test
fun testMapActivityKind_martialArts() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_MARTIAL_ARTS,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.MARTIAL_ARTS)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_MARTIAL_ARTS,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.KARATE)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_MARTIAL_ARTS,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.TAEKWONDO)
)
}
@Test
fun testMapActivityKind_dancing() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_DANCING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.DANCE)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_DANCING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.ZUMBA)
)
}
@Test
fun testMapActivityKind_hiit() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.HIIT)
)
}
@Test
fun testMapActivityKind_pilates() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_PILATES,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.PILATES)
)
}
@Test
fun testMapActivityKind_rowing() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_ROWING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.ROWING)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.ROWING_MACHINE)
)
}
@Test
fun testMapActivityKind_golf() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_GOLF,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.GOLF)
)
}
@Test
fun testMapActivityKind_guidedBreathing() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_GUIDED_BREATHING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.BREATHWORK)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_GUIDED_BREATHING,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.MEDITATION)
)
}
@Test
fun testMapActivityKind_unknownActivity() {
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.UNKNOWN)
)
assertEquals(
ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT,
WorkoutSyncerUtils.mapActivityKindToExerciseType(ActivityKind.ARCHERY)
)
}
@Test
fun testMapActivityKind_allTypesMapToValidExerciseType() {
// Test that all activity kinds map to a valid exercise type (no exceptions thrown)
for (activityKind in ActivityKind.entries) {
val exerciseType = WorkoutSyncerUtils.mapActivityKindToExerciseType(activityKind)
assertTrue("Exercise type should be a valid int", exerciseType >= 0)
}
}
}