From 9d55ac261fdd48c61d79c3f48fa9870dafa55538 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 1 May 2026 18:12:17 +0300 Subject: [PATCH] HealthConnect: Wire settings screen --- src/main/AndroidManifest.xml | 2 + .../HealthConnectSettingsActivity.java | 368 ++++++++++++++++++ .../ImportExportSettingsFragment.java | 32 +- src/main/res/xml/settings_import_export.xml | 6 +- 4 files changed, 405 insertions(+), 3 deletions(-) create mode 100644 src/main/java/de/dennisguse/opentracks/settings/HealthConnectSettingsActivity.java 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 +