diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index d9671b70e..64f6ebb79 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -129,6 +129,8 @@ limitations under the License.
+
+
diff --git a/src/main/java/de/dennisguse/opentracks/settings/HealthConnectSettingsActivity.java b/src/main/java/de/dennisguse/opentracks/settings/HealthConnectSettingsActivity.java
new file mode 100644
index 000000000..34ec82575
--- /dev/null
+++ b/src/main/java/de/dennisguse/opentracks/settings/HealthConnectSettingsActivity.java
@@ -0,0 +1,368 @@
+package de.dennisguse.opentracks.settings;
+
+import android.content.ActivityNotFoundException;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.util.Log;
+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 android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+import androidx.health.connect.client.HealthConnectClient;
+import androidx.health.connect.client.PermissionController;
+import androidx.work.WorkInfo;
+import androidx.work.WorkManager;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.android.material.datepicker.MaterialDatePicker;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.text.DateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.Date;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import de.dennisguse.opentracks.AbstractActivity;
+import de.dennisguse.opentracks.R;
+import de.dennisguse.opentracks.databinding.ActivityHealthConnectSettingsBinding;
+import de.dennisguse.opentracks.io.file.exporter.ExportUtils;
+import de.dennisguse.opentracks.io.healthconnect.HealthConnectExporter;
+import de.dennisguse.opentracks.io.healthconnect.HealthConnectWorker;
+
+public class HealthConnectSettingsActivity extends AbstractActivity {
+
+ private static final String TAG = HealthConnectSettingsActivity.class.getSimpleName();
+ private static final long SYNC_ALL_TIME_MILLIS = -1L;
+ private static final long UNSELECTED_TIME_MILLIS = Long.MIN_VALUE;
+
+ private ActivityHealthConnectSettingsBinding viewBinding;
+ private ActivityResultLauncher> healthConnectPermissionLauncher;
+ private boolean pendingEnableExport;
+ private boolean pendingEnableDetailed;
+ private boolean pendingShowInitialSyncDialog;
+ private Set pendingPermissions = Set.of();
+ private boolean updatingUi;
+ private long customInitialSyncStartTimeMillis = UNSELECTED_TIME_MILLIS;
+
+ private final SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener =
+ (sharedPreferences, key) -> runOnUiThread(this::bindState);
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setSupportActionBar(viewBinding.topAppBar);
+ viewBinding.topAppBar.setNavigationOnClickListener(v -> getOnBackPressedDispatcher().onBackPressed());
+
+ healthConnectPermissionLauncher = registerForActivityResult(
+ PermissionController.createRequestPermissionResultContract(),
+ grantedPermissions -> {
+ boolean exportGranted = grantedPermissions.contains(HealthConnectExporter.WRITE_EXERCISE_PERMISSION);
+ boolean routeGranted = !pendingPermissions.contains(HealthConnectExporter.WRITE_EXERCISE_ROUTE_PERMISSION)
+ || grantedPermissions.contains(HealthConnectExporter.WRITE_EXERCISE_ROUTE_PERMISSION);
+ boolean exportEnabled = pendingEnableExport && exportGranted;
+
+ PreferencesUtils.setBoolean(R.string.health_connect_export_enabled_key, exportEnabled);
+ PreferencesUtils.setBoolean(
+ R.string.health_connect_detailed_sync_key,
+ pendingEnableDetailed && exportGranted && routeGranted
+ );
+
+ if (!exportEnabled) {
+ ExportUtils.cancelHealthConnectExport(this);
+ }
+
+ bindState();
+
+ if (exportEnabled && pendingShowInitialSyncDialog) {
+ showInitialSyncDialog();
+ }
+ }
+ );
+
+ setTitle(R.string.health_connect_screen_title);
+ bindActions();
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ PreferencesUtils.registerOnSharedPreferenceChangeListenerSilent(preferenceChangeListener);
+ }
+
+ @Override
+ protected void onStop() {
+ PreferencesUtils.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener);
+ super.onStop();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ bindState();
+ }
+
+ private void bindActions() {
+ viewBinding.healthConnectExportRow.setOnClickListener(v ->
+ viewBinding.healthConnectExportSwitch.performClick());
+ viewBinding.healthConnectDetailedRow.setOnClickListener(v -> {
+ if (viewBinding.healthConnectDetailedSwitch.isEnabled()) {
+ viewBinding.healthConnectDetailedSwitch.performClick();
+ }
+ });
+ viewBinding.healthConnectManageDataRow.setOnClickListener(v ->
+ openIntent(HealthConnectClient.getHealthConnectManageDataIntent(this)));
+ viewBinding.healthConnectSettingsRow.setOnClickListener(v ->
+ openIntent(new Intent(HealthConnectClient.getHealthConnectSettingsAction())));
+ viewBinding.healthConnectManualSyncButton.setOnClickListener(v -> startManualSyncFlow());
+
+ viewBinding.healthConnectExportSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ if (updatingUi) return;
+
+ if (isChecked) {
+ requestPermissions(true, PreferencesUtils.shouldExportDetailedHealthConnectWorkout());
+ } else {
+ PreferencesUtils.setBoolean(R.string.health_connect_export_enabled_key, false);
+ ExportUtils.cancelHealthConnectExport(this);
+ bindState();
+ }
+ });
+
+ viewBinding.healthConnectDetailedSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ if (updatingUi) return;
+
+ if (isChecked) {
+ requestPermissions(true, true);
+ } else {
+ PreferencesUtils.setBoolean(R.string.health_connect_detailed_sync_key, false);
+ bindState();
+ }
+ });
+ }
+
+ private void bindState() {
+ boolean available = HealthConnectExporter.isAvailable(this);
+ boolean exportEnabled = available && PreferencesUtils.shouldExportToHealthConnect();
+ boolean detailedEnabled = available && PreferencesUtils.shouldExportDetailedHealthConnectWorkout();
+
+ updatingUi = true;
+ viewBinding.healthConnectExportSwitch.setChecked(exportEnabled);
+ viewBinding.healthConnectDetailedSwitch.setChecked(detailedEnabled);
+ updatingUi = false;
+
+ viewBinding.healthConnectExportSwitch.setEnabled(available);
+ viewBinding.healthConnectDetailedSwitch.setEnabled(available && exportEnabled);
+ viewBinding.healthConnectManualSyncButton.setEnabled(available && exportEnabled);
+
+ viewBinding.healthConnectManageDataRow.setEnabled(available);
+ viewBinding.healthConnectSettingsRow.setEnabled(available);
+ viewBinding.healthConnectManageDataRow.setAlpha(available ? 1f : 0.5f);
+ viewBinding.healthConnectSettingsRow.setAlpha(available ? 1f : 0.5f);
+
+ viewBinding.healthConnectAvailabilityNotice.setVisibility(available ? View.GONE : View.VISIBLE);
+ viewBinding.healthConnectExportSummary.setText(
+ available ? R.string.health_connect_export_enabled_summary : R.string.health_connect_not_available
+ );
+ viewBinding.healthConnectDetailedSummary.setText(
+ exportEnabled ? R.string.health_connect_detailed_sync_summary : R.string.health_connect_detailed_sync_requires_export
+ );
+ viewBinding.healthConnectManualSyncSummary.setText(
+ exportEnabled ? R.string.health_connect_manual_sync_summary : R.string.health_connect_manual_sync_requires_export
+ );
+ viewBinding.healthConnectSyncStatusSummary.setText(PreferencesUtils.getHealthConnectLastSyncStatus());
+ }
+
+ private void startManualSyncFlow() {
+ if (!HealthConnectExporter.isAvailable(this) || !PreferencesUtils.shouldExportToHealthConnect()) {
+ bindState();
+ return;
+ }
+
+ ListenableFuture> workInfosFuture =
+ WorkManager.getInstance(this).getWorkInfosForUniqueWork(HealthConnectWorker.INITIAL_SYNC_WORK_NAME);
+ workInfosFuture.addListener(() -> {
+ try {
+ boolean syncRunning = workInfosFuture.get().stream().anyMatch(workInfo -> !workInfo.getState().isFinished());
+ runOnUiThread(() -> {
+ if (syncRunning) {
+ Toast.makeText(this, R.string.health_connect_sync_already_running, Toast.LENGTH_SHORT).show();
+ } else {
+ showInitialSyncDialog();
+ }
+ });
+ } catch (Exception e) {
+ Log.w(TAG, "Unable to determine Health Connect sync state", e);
+ runOnUiThread(this::showInitialSyncDialog);
+ }
+ }, ContextCompat.getMainExecutor(this));
+ }
+
+ private void requestPermissions(boolean enableExport, boolean enableDetailed) {
+ if (!HealthConnectExporter.isAvailable(this)) {
+ bindState();
+ return;
+ }
+
+ pendingEnableExport = enableExport;
+ pendingEnableDetailed = enableDetailed;
+ pendingShowInitialSyncDialog = enableExport
+ && !PreferencesUtils.shouldExportToHealthConnect()
+ && !PreferencesUtils.hasHealthConnectInitialSyncConfigured()
+ && !PreferencesUtils.isHealthConnectInitialSyncCompleted();
+
+ LinkedHashSet permissions = new LinkedHashSet<>();
+ permissions.add(HealthConnectExporter.WRITE_EXERCISE_PERMISSION);
+ if (enableDetailed) {
+ permissions.add(HealthConnectExporter.WRITE_EXERCISE_ROUTE_PERMISSION);
+ }
+ pendingPermissions = permissions;
+
+ healthConnectPermissionLauncher.launch(permissions);
+ }
+
+ private void showInitialSyncDialog() {
+ View dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_health_connect_initial_sync, null);
+ RadioGroup radioGroup = dialogView.findViewById(R.id.radio_group_sync_period);
+ RadioButton radioPreset = dialogView.findViewById(R.id.radio_preset);
+ Spinner spinnerPreset = dialogView.findViewById(R.id.spinner_sync_period);
+ Button buttonSelectDate = dialogView.findViewById(R.id.button_select_date);
+
+ String[] options = new String[]{
+ getString(R.string.health_connect_initial_sync_last_3_days),
+ getString(R.string.health_connect_initial_sync_last_7_days),
+ getString(R.string.health_connect_initial_sync_last_30_days),
+ getString(R.string.health_connect_initial_sync_all),
+ };
+ ArrayAdapter adapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, options);
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ spinnerPreset.setAdapter(adapter);
+ spinnerPreset.setSelection(1);
+
+ customInitialSyncStartTimeMillis = UNSELECTED_TIME_MILLIS;
+ buttonSelectDate.setText(R.string.health_connect_initial_sync_select_date);
+
+ radioGroup.setOnCheckedChangeListener((group, checkedId) -> {
+ boolean presetSelected = checkedId == R.id.radio_preset;
+ spinnerPreset.setEnabled(presetSelected);
+ buttonSelectDate.setEnabled(checkedId == R.id.radio_custom_date);
+ });
+ radioPreset.setChecked(true);
+ spinnerPreset.setEnabled(true);
+ buttonSelectDate.setEnabled(false);
+ buttonSelectDate.setOnClickListener(v -> showInitialSyncDatePicker(buttonSelectDate));
+
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.health_connect_initial_sync_dialog_title)
+ .setView(dialogView)
+ .setPositiveButton(android.R.string.ok, (dialog, which) ->
+ handleInitialSyncSelection(radioPreset.isChecked(), spinnerPreset.getSelectedItemPosition()))
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ }
+
+ private void showInitialSyncDatePicker(Button buttonSelectDate) {
+ MaterialDatePicker datePicker = MaterialDatePicker.Builder.datePicker()
+ .setTitleText(R.string.health_connect_initial_sync_select_date)
+ .setSelection(MaterialDatePicker.todayInUtcMilliseconds())
+ .build();
+
+ datePicker.addOnPositiveButtonClickListener(selection -> {
+ customInitialSyncStartTimeMillis = Instant.ofEpochMilli(selection)
+ .atZone(ZoneId.systemDefault())
+ .toLocalDate()
+ .atStartOfDay(ZoneId.systemDefault())
+ .toInstant()
+ .toEpochMilli();
+ buttonSelectDate.setText(describeInitialSyncSelection(customInitialSyncStartTimeMillis));
+ });
+
+ datePicker.show(getSupportFragmentManager(), "health_connect_initial_sync_date");
+ }
+
+ private void handleInitialSyncSelection(boolean presetSelected, int selectedPresetPosition) {
+ if (presetSelected) {
+ switch (selectedPresetPosition) {
+ case 0 -> showInitialSyncConfirmation(daysAgoMillis(3));
+ case 1 -> showInitialSyncConfirmation(daysAgoMillis(7));
+ case 2 -> showInitialSyncConfirmation(daysAgoMillis(30));
+ case 3 -> showInitialSyncConfirmation(SYNC_ALL_TIME_MILLIS);
+ default -> showInitialSyncConfirmation(daysAgoMillis(7));
+ }
+ return;
+ }
+
+ if (customInitialSyncStartTimeMillis != UNSELECTED_TIME_MILLIS) {
+ showInitialSyncConfirmation(customInitialSyncStartTimeMillis);
+ return;
+ }
+
+ showInitialSyncConfirmation(daysAgoMillis(7));
+ }
+
+ private void showInitialSyncConfirmation(long startTimeMillis) {
+ String selectionDescription = describeInitialSyncSelection(startTimeMillis);
+
+ PreferencesUtils.resetHealthConnectInitialSyncState(startTimeMillis);
+
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.health_connect_sync_now_title)
+ .setMessage(getString(R.string.health_connect_sync_now_message, selectionDescription))
+ .setPositiveButton(R.string.health_connect_sync_now, (dialog, which) -> {
+ PreferencesUtils.setHealthConnectLastSyncStatus(
+ prefixedStatus(getString(R.string.health_connect_sync_status_initial_starting, selectionDescription))
+ );
+ ExportUtils.startHealthConnectInitialSync(this, startTimeMillis);
+ })
+ .setNegativeButton(R.string.health_connect_sync_later, (dialog, which) -> PreferencesUtils.setHealthConnectLastSyncStatus(
+ prefixedStatus(getString(R.string.health_connect_sync_status_initial_pending, selectionDescription))
+ ))
+ .show();
+ }
+
+ private String describeInitialSyncSelection(long startTimeMillis) {
+ if (startTimeMillis == SYNC_ALL_TIME_MILLIS) {
+ return getString(R.string.health_connect_initial_sync_all);
+ }
+
+ return DateFormat.getDateInstance(DateFormat.MEDIUM).format(new Date(startTimeMillis));
+ }
+
+ private long daysAgoMillis(int days) {
+ return Instant.now()
+ .minusSeconds(days * 24L * 60L * 60L)
+ .toEpochMilli();
+ }
+
+ private String prefixedStatus(String message) {
+ String timestamp = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(new Date());
+ return timestamp + ": " + message;
+ }
+
+ private void openIntent(Intent intent) {
+ try {
+ startActivity(intent);
+ } catch (ActivityNotFoundException e) {
+ Toast.makeText(this, R.string.health_connect_not_available, Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ @NonNull
+ @Override
+ protected View createRootView() {
+ viewBinding = ActivityHealthConnectSettingsBinding.inflate(getLayoutInflater());
+ return viewBinding.getRoot();
+ }
+}
diff --git a/src/main/java/de/dennisguse/opentracks/settings/ImportExportSettingsFragment.java b/src/main/java/de/dennisguse/opentracks/settings/ImportExportSettingsFragment.java
index d67b428f0..dd97b95ce 100644
--- a/src/main/java/de/dennisguse/opentracks/settings/ImportExportSettingsFragment.java
+++ b/src/main/java/de/dennisguse/opentracks/settings/ImportExportSettingsFragment.java
@@ -1,5 +1,6 @@
package de.dennisguse.opentracks.settings;
+import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
@@ -12,13 +13,13 @@ import androidx.preference.PreferenceFragmentCompat;
import java.util.Map;
import de.dennisguse.opentracks.R;
+import de.dennisguse.opentracks.io.healthconnect.HealthConnectExporter;
import de.dennisguse.opentracks.io.file.TrackFileFormat;
import de.dennisguse.opentracks.io.file.TrackFilenameGenerator;
import de.dennisguse.opentracks.util.IntentUtils;
-public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
- private static final String TAG = ImportExportSettingsFragment.class.getSimpleName();
+public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
@@ -27,6 +28,7 @@ public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
setExportTrackFileFormatOptions();
setExportDirectorySummary();
setFilenameTemplate();
+ setHealthConnectPreference();
}
@Override
@@ -40,11 +42,37 @@ public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
super.onResume();
setExportDirectorySummary();
+ setHealthConnectPreference();
Preference instantExportEnabledPreference = findPreference(getString(R.string.post_workout_export_enabled_key));
instantExportEnabledPreference.setEnabled(PreferencesUtils.isDefaultExportDirectoryUri());
}
+ private void setHealthConnectPreference() {
+ Preference preference = findPreference(getString(R.string.health_connect_settings_key));
+ if (preference == null) return;
+
+ preference.setOnPreferenceClickListener(pref -> {
+ startActivity(new Intent(requireContext(), HealthConnectSettingsActivity.class));
+ return true;
+ });
+
+ if (!HealthConnectExporter.isAvailable(requireContext())) {
+ preference.setSummary(R.string.health_connect_not_available);
+ return;
+ }
+
+ if (PreferencesUtils.shouldExportToHealthConnect()) {
+ preference.setSummary(
+ PreferencesUtils.shouldExportDetailedHealthConnectWorkout()
+ ? R.string.health_connect_settings_summary_enabled_detailed
+ : R.string.health_connect_settings_summary_enabled
+ );
+ } else {
+ preference.setSummary(R.string.health_connect_settings_summary);
+ }
+ }
+
private void setExportTrackFileFormatOptions() {
Map options = TrackFileFormat.toPreferenceIdLabelMap(getResources(),
TrackFileFormat.KMZ_WITH_TRACKDETAIL_AND_SENSORDATA_AND_PICTURES,
diff --git a/src/main/res/xml/settings_import_export.xml b/src/main/res/xml/settings_import_export.xml
index a9c5dc180..777efef3f 100644
--- a/src/main/res/xml/settings_import_export.xml
+++ b/src/main/res/xml/settings_import_export.xml
@@ -51,5 +51,9 @@
android:key="@string/export_filename_format_key"
android:title="@string/settings_export_filename_title"
android:defaultValue="@string/export_filename_format_default" />
+
-
\ No newline at end of file
+