mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
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:
@@ -291,6 +291,10 @@ dependencies {
|
||||
|
||||
// Needed for Armenian transliteration
|
||||
implementation 'org.ahocorasick:ahocorasick:0.6.3'
|
||||
|
||||
// Android Health Connect
|
||||
implementation 'com.google.guava:guava:33.4.8-android' // This is needed because CameraActivity.java line 41 doesn't have a ListenableFuture package
|
||||
implementation 'androidx.health.connect:connect-client:1.1.0'
|
||||
}
|
||||
|
||||
preBuild.dependsOn(":GBDaoGenerator:genSources")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
|
||||
<!--
|
||||
Comment in for testing Pebble Emulator
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
@@ -108,6 +109,25 @@
|
||||
<!-- Needed to show notification with targetSdkVersion 33 with Android 13 -->
|
||||
<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
|
||||
android:name="android.hardware.bluetooth"
|
||||
android:required="true" />
|
||||
@@ -217,6 +237,10 @@
|
||||
android:name=".activities.InternetHelperPreferencesActivity"
|
||||
android:label="@string/prefs_internet_helper_title"
|
||||
android:parentActivityName=".activities.SettingsActivity" />
|
||||
<activity
|
||||
android:name=".activities.preferences.HealthConnectPreferencesActivity"
|
||||
android:label="@string/healthconnect_settings"
|
||||
android:parentActivityName=".activities.SettingsActivity" />
|
||||
<activity
|
||||
android:name=".activities.AboutUserPreferencesActivity"
|
||||
android:label="@string/activity_prefs_about_you"
|
||||
@@ -559,7 +583,12 @@
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
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
|
||||
android:name=".externalevents.NotificationListener"
|
||||
android:label="@string/app_name"
|
||||
@@ -1042,6 +1071,41 @@
|
||||
android:name=".devices.cycling_sensor.activity.CyclingLiveDataActivity"
|
||||
android:launchMode="singleInstance"
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -126,6 +126,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.PermissionsUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.backup.PeriodicZipExporter;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectPermissionManager;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.preferences.DevicePrefs;
|
||||
|
||||
/**
|
||||
@@ -357,6 +358,10 @@ public class GBApplication extends Application {
|
||||
startNotificationCollectorMonitorService();
|
||||
|
||||
BondingUtil.StartObservingAll(getBaseContext());
|
||||
|
||||
if (prefs.getBoolean(GBPrefs.HEALTH_CONNECT_ENABLED, false)) {
|
||||
HealthConnectPermissionManager.checkAndRectifyPermissions(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void startNotificationCollectorMonitorService() {
|
||||
|
||||
+15
-1
@@ -279,10 +279,19 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragmentCompa
|
||||
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);
|
||||
if (preference == null) {
|
||||
LOG.warn("Preference {} not found", key);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -292,6 +301,11 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragmentCompa
|
||||
switchPreference.setChecked(prefs.getBoolean(key, switchPreference.isChecked()));
|
||||
} else if (preference instanceof ListPreference listPreference) {
|
||||
listPreference.setValue(prefs.getString(key, listPreference.getValue()));
|
||||
} else if (preference instanceof MultiSelectListPreference multiSelectListPreference) {
|
||||
Set<String> values = prefs.getStringSet(key, multiSelectListPreference.getValues());
|
||||
if (values != null) {
|
||||
multiSelectListPreference.setValues(values);
|
||||
}
|
||||
} else if (preference instanceof EditTextPreference editTextPreference) {
|
||||
editTextPreference.setText(prefs.getString(key, editTextPreference.getText()));
|
||||
} else if (preference instanceof XTimePreference xTimePreference) {
|
||||
|
||||
+201
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+62
@@ -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)
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+10
@@ -65,6 +65,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.automations.AutomationsSe
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.charts.ChartsPreferencesActivity;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryPairingPreferenceActivity;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsSettingsActivity;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.preferences.HealthConnectPreferencesActivity;
|
||||
import nodomain.freeyourgadget.gadgetbridge.externalevents.TimeChangeReceiver;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
||||
@@ -363,6 +364,15 @@ public class SettingsActivity extends AbstractSettingsActivityV2 {
|
||||
}
|
||||
}
|
||||
|
||||
pref = findPreference("pref_category_healthconnect");
|
||||
if (pref != null) {
|
||||
pref.setOnPreferenceClickListener(preference -> {
|
||||
Intent enableIntent = new Intent(requireContext(), HealthConnectPreferencesActivity.class);
|
||||
startActivity(enableIntent);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
pref = findPreference("pref_category_notifications");
|
||||
if (pref != null) {
|
||||
pref.setOnPreferenceClickListener(preference -> {
|
||||
|
||||
+23
@@ -96,6 +96,28 @@ class OtherDebugFragment : AbstractDebugFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
onClick(PREF_DEBUG_RESET_HC_SYNC_STATE) {
|
||||
MaterialAlertDialogBuilder(requireActivity())
|
||||
.setCancelable(true)
|
||||
.setTitle("Reset HC sync state")
|
||||
.setMessage("Reset Health Connect sync state? This will allow all data to be re-synced.")
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
try {
|
||||
val db = GBApplication.acquireDB()
|
||||
try {
|
||||
db.daoSession.healthConnectSyncStateDao.deleteAll()
|
||||
GB.toast("Health Connect sync state reset successfully", Toast.LENGTH_SHORT, GB.INFO)
|
||||
} finally {
|
||||
GBApplication.releaseDB()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
GB.toast("Failed to reset Health Connect sync state", Toast.LENGTH_LONG, GB.ERROR, e)
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.Cancel) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
|
||||
onClick(PREF_DEBUG_FACTORY_RESET) {
|
||||
MaterialAlertDialogBuilder(requireActivity())
|
||||
.setCancelable(true)
|
||||
@@ -119,6 +141,7 @@ class OtherDebugFragment : AbstractDebugFragment() {
|
||||
private const val PREF_DEBUG_FETCH_DEBUG_LOGS = "pref_debug_fetch_debug_logs"
|
||||
private const val PREF_DEBUG_HEADER_RESET = "pref_debug_header_reset"
|
||||
private const val PREF_DEBUG_REBOOT = "pref_debug_reboot"
|
||||
private const val PREF_DEBUG_RESET_HC_SYNC_STATE = "pref_debug_reset_hc_sync_state"
|
||||
private const val PREF_DEBUG_FACTORY_RESET = "pref_debug_factory_reset"
|
||||
}
|
||||
}
|
||||
|
||||
+764
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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
|
||||
}
|
||||
}
|
||||
+108
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+6
@@ -88,6 +88,7 @@ import nodomain.freeyourgadget.gadgetbridge.externalevents.IntentApiReceiver;
|
||||
import nodomain.freeyourgadget.gadgetbridge.externalevents.KeyMissingReceiver;
|
||||
import nodomain.freeyourgadget.gadgetbridge.externalevents.LineageOsWeatherReceiver;
|
||||
import nodomain.freeyourgadget.gadgetbridge.externalevents.MusicPlaybackReceiver;
|
||||
import nodomain.freeyourgadget.gadgetbridge.externalevents.NewDataReceiver;
|
||||
import nodomain.freeyourgadget.gadgetbridge.externalevents.OmniJawsObserver;
|
||||
import nodomain.freeyourgadget.gadgetbridge.externalevents.OsmandEventReceiver;
|
||||
import nodomain.freeyourgadget.gadgetbridge.externalevents.PebbleReceiver;
|
||||
@@ -272,6 +273,7 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
|
||||
|
||||
private VolumeChangeReceiver mVolumeChangeReceiver = null;
|
||||
private HrvCacheInvalidationReceiver mHrvCacheInvalidationReceiver = null;
|
||||
private NewDataReceiver mNewDataReceiver = null;
|
||||
|
||||
private final List<CalendarReceiver> mCalendarReceiver = new ArrayList<>();
|
||||
private CMWeatherReceiver mCMWeatherReceiver = null;
|
||||
@@ -1411,6 +1413,10 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
|
||||
mVolumeChangeReceiver = new VolumeChangeReceiver();
|
||||
mVolumeChangeReceiver.registerReceiver(this);
|
||||
}
|
||||
if (mNewDataReceiver == null) {
|
||||
mNewDataReceiver = new NewDataReceiver();
|
||||
mNewDataReceiver.registerReceiver(this);
|
||||
}
|
||||
if (mTimeChangeReceiver == null) {
|
||||
mTimeChangeReceiver = new TimeChangeReceiver();
|
||||
IntentFilter filter = new IntentFilter();
|
||||
|
||||
@@ -82,6 +82,7 @@ public class GB {
|
||||
public static final String NOTIFICATION_CHANNEL_ID_FULL_BATTERY = "full_battery";
|
||||
public static final String NOTIFICATION_CHANNEL_ID_GPS = "gps";
|
||||
public static final String NOTIFICATION_CHANNEL_ID_PEBBLE_JS = "pebble_js";
|
||||
public static final String NOTIFICATION_CHANNEL_ID_HEALTH_CONNECT_SYNC = "gadgetbridge_health_connect_sync";
|
||||
|
||||
public static final int NOTIFICATION_ID = 1;
|
||||
public static final int NOTIFICATION_ID_INSTALL = 2;
|
||||
@@ -180,6 +181,12 @@ public class GB {
|
||||
context.getString(R.string.notification_channel_pebble_js_runner),
|
||||
NotificationManager.IMPORTANCE_MIN);
|
||||
notificationManager.createNotificationChannel(channelPebbleJs);
|
||||
|
||||
NotificationChannel channelHealthConnectSync = new NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID_HEALTH_CONNECT_SYNC,
|
||||
context.getString(R.string.notification_channel_health_connect_sync_name),
|
||||
NotificationManager.IMPORTANCE_LOW);
|
||||
notificationManager.createNotificationChannel(channelHealthConnectSync);
|
||||
}
|
||||
|
||||
notificationChannelsCreated = true;
|
||||
|
||||
@@ -98,6 +98,19 @@ public class GBPrefs extends Prefs {
|
||||
public static final String RECONNECT_ONLY_TO_CONNECTED = "general_reconnectonlytoconnected";
|
||||
public static final String BLOCK_SCREENSHOTS = "block_screenshots";
|
||||
|
||||
// HealthConnect
|
||||
public static final String HEALTH_CONNECT_ENABLED = "health_connect_enabled";
|
||||
public static final String HEALTH_CONNECT_MANUAL_SETTINGS = "health_connect_manual_settings";
|
||||
public static final String HEALTH_CONNECT_SYNC_STATUS = "health_connect_sync_status";
|
||||
public static final String HEALTH_CONNECT_DISABLE_NOTICE = "health_connect_disable_notice";
|
||||
public static final String HEALTH_CONNECT_SYNC_ON_EVENT = "health_connect_sync_on_event";
|
||||
public static final String HEALTH_CONNECT_DETAILED_WORKOUT_SYNC = "health_connect_detailed_workout_sync";
|
||||
public static final String HEALTH_CONNECT_DEVICE_SELECTION = "health_connect_devices_multiselect";
|
||||
public static final String HEALTH_CONNECT_SETTINGS = "health_connect_settings";
|
||||
public static final String HEALTH_CONNECT_INITIAL_SYNC_START_TS = "health_connect_initial_sync_start_ts";
|
||||
public static final String HEALTH_CONNECT_LAST_GRANTED_PERMISSIONS = "health_connect_last_granted_permissions";
|
||||
public static final String HEALTH_CONNECT_PROMPT_FOR_FULL_DAO_RESET = "health_connect_prompt_for_full_dao_reset";
|
||||
|
||||
@Deprecated
|
||||
public GBPrefs(Prefs prefs) {
|
||||
this(prefs.getPreferences());
|
||||
|
||||
+37
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+317
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+145
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+754
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -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)
|
||||
|
||||
+88
@@ -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
|
||||
}
|
||||
+154
@@ -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")
|
||||
}
|
||||
}
|
||||
+144
@@ -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")
|
||||
}
|
||||
}
|
||||
+690
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+258
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
@@ -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")
|
||||
}
|
||||
}
|
||||
+105
@@ -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")
|
||||
}
|
||||
}
|
||||
+330
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+117
@@ -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")
|
||||
}
|
||||
}
|
||||
+127
@@ -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")
|
||||
}
|
||||
}
|
||||
+143
@@ -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>
|
||||
@@ -5343,4 +5343,11 @@
|
||||
<item>never</item>
|
||||
</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>
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
<string name="pref_header_general">General settings</string>
|
||||
<string name="pref_header_other">Other</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_calendar">Calendar</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_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>
|
||||
<!-- 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>
|
||||
|
||||
@@ -46,6 +46,13 @@
|
||||
android:persistent="false"
|
||||
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
|
||||
android:defaultValue="false"
|
||||
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>
|
||||
@@ -324,6 +324,11 @@
|
||||
android:key="pref_category_sleepasandroid"
|
||||
android:title="@string/sleepasandroid_settings" />
|
||||
|
||||
<Preference
|
||||
android:icon="@drawable/ic_heart"
|
||||
android:key="pref_category_healthconnect"
|
||||
android:title="@string/healthconnect_settings" />
|
||||
|
||||
<PreferenceScreen
|
||||
android:icon="@drawable/ic_partly_cloudy_day"
|
||||
android:key="pref_screen_weather"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<manifest xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-sdk tools:overrideLibrary="androidx.health.connect.client" />
|
||||
</manifest>
|
||||
|
||||
+109
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+267
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user