HealthConnect: Wire settings screen

This commit is contained in:
Manuel
2026-05-01 18:12:17 +03:00
committed by Dennis Guse
parent dbd3e2fc5c
commit 9d55ac261f
4 changed files with 405 additions and 3 deletions
@@ -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<Set<String>> healthConnectPermissionLauncher;
private boolean pendingEnableExport;
private boolean pendingEnableDetailed;
private boolean pendingShowInitialSyncDialog;
private Set<String> 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<List<WorkInfo>> 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<String> 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<String> 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<Long> 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();
}
}
@@ -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<String, String> options = TrackFileFormat.toPreferenceIdLabelMap(getResources(),
TrackFileFormat.KMZ_WITH_TRACKDETAIL_AND_SENSORDATA_AND_PICTURES,