From 75ed3120bd37a60f76a11fbb725eb4e726dbd707 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 1 May 2026 18:10:27 +0300 Subject: [PATCH 1/7] HealthConnect: Add permissions rationale activity --- src/main/AndroidManifest.xml | 19 ++++++ .../PermissionsRationaleActivity.java | 39 +++++++++++ .../layout/activity_permissions_rationale.xml | 67 +++++++++++++++++++ src/main/res/values/strings.xml | 4 ++ 4 files changed, 129 insertions(+) create mode 100644 src/main/java/de/dennisguse/opentracks/PermissionsRationaleActivity.java create mode 100644 src/main/res/layout/activity_permissions_rationale.xml diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index c231beac1..85e5e3ced 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -126,6 +126,25 @@ limitations under the License. + + + + + + + + + + + + + diff --git a/src/main/java/de/dennisguse/opentracks/PermissionsRationaleActivity.java b/src/main/java/de/dennisguse/opentracks/PermissionsRationaleActivity.java new file mode 100644 index 000000000..c37c0d773 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/PermissionsRationaleActivity.java @@ -0,0 +1,39 @@ +package de.dennisguse.opentracks; + +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import de.dennisguse.opentracks.databinding.ActivityPermissionsRationaleBinding; +import de.dennisguse.opentracks.ui.util.ViewUtils; + +public class PermissionsRationaleActivity extends AbstractActivity { + + private ActivityPermissionsRationaleBinding viewBinding; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setTitle(R.string.health_connect_permissions_rationale_title); + viewBinding.permissionsRationaleAppUrl.setText(getString(R.string.about_url, getString(R.string.app_web_url))); + setSupportActionBar(viewBinding.bottomAppBarLayout.bottomAppBar); + + ViewUtils.makeClickableLinks(findViewById(android.R.id.content)); + } + + @NonNull + @Override + protected View createRootView() { + viewBinding = ActivityPermissionsRationaleBinding.inflate(getLayoutInflater()); + return viewBinding.getRoot(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + viewBinding = null; + } +} diff --git a/src/main/res/layout/activity_permissions_rationale.xml b/src/main/res/layout/activity_permissions_rationale.xml new file mode 100644 index 000000000..962628195 --- /dev/null +++ b/src/main/res/layout/activity_permissions_rationale.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 4cd054a72..0e8f03e12 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -679,6 +679,10 @@ limitations under the License. Hold to stop Export track to storage after recording is finished Instant post-workout export + Health Connect access + How OpenTracks uses Health Connect + OpenTracks only writes finished activities to Health Connect when you explicitly enable Health Connect export in settings. + The requested Health Connect permission allows OpenTracks to export your recorded workouts. You can disable export again at any time in OpenTracks settings. Track export directory Primary From f0616fc0430abb356e5da9b19fc688f8e4638a38 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 1 May 2026 18:11:56 +0300 Subject: [PATCH 2/7] HealthConnect: Add exercise session exporter --- build.gradle | 1 + src/main/AndroidManifest.xml | 4 + .../healthconnect/HealthConnectExporter.java | 206 ++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 src/main/java/de/dennisguse/opentracks/io/healthconnect/HealthConnectExporter.java diff --git a/build.gradle b/build.gradle index b6ae340a1..68aad6a69 100644 --- a/build.gradle +++ b/build.gradle @@ -156,6 +156,7 @@ dependencies { implementation 'androidx.mediarouter:mediarouter:1.8.1' implementation 'androidx.core:core-location-altitude:1.0.0' implementation 'androidx.work:work-runtime:2.11.2' + implementation 'androidx.health.connect:connect-client:1.1.0' androidTestImplementation 'androidx.test:core:1.7.0' androidTestImplementation 'androidx.test.ext:junit:1.3.0' diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 85e5e3ced..d9671b70e 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -69,6 +69,9 @@ limitations under the License. + + + + diff --git a/src/main/java/de/dennisguse/opentracks/io/healthconnect/HealthConnectExporter.java b/src/main/java/de/dennisguse/opentracks/io/healthconnect/HealthConnectExporter.java new file mode 100644 index 000000000..9d337d7a4 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/io/healthconnect/HealthConnectExporter.java @@ -0,0 +1,206 @@ +package de.dennisguse.opentracks.io.healthconnect; + +import android.content.Context; +import android.os.Build; +import android.util.Log; + +import androidx.health.connect.client.HealthConnectClient; +import androidx.health.connect.client.PermissionController; +import androidx.health.connect.client.permission.HealthPermission; +import androidx.health.connect.client.records.ExerciseRoute; +import androidx.health.connect.client.records.ExerciseSessionRecord; +import androidx.health.connect.client.records.metadata.Device; +import androidx.health.connect.client.records.metadata.Metadata; +import androidx.health.connect.client.units.Length; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import de.dennisguse.opentracks.data.ContentProviderUtils; +import de.dennisguse.opentracks.data.models.ActivityType; +import de.dennisguse.opentracks.data.models.Track; +import de.dennisguse.opentracks.data.models.TrackPoint; +import kotlin.coroutines.Continuation; +import kotlin.jvm.JvmClassMappingKt; +import kotlin.jvm.functions.Function2; +import kotlinx.coroutines.BuildersKt; +import kotlinx.coroutines.CoroutineScope; + +public class HealthConnectExporter { + + private static final String TAG = HealthConnectExporter.class.getSimpleName(); + + public static final String WRITE_EXERCISE_PERMISSION = + HealthPermission.getWritePermission(JvmClassMappingKt.getKotlinClass(ExerciseSessionRecord.class)); + public static final String WRITE_EXERCISE_ROUTE_PERMISSION = + HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE; + + private HealthConnectExporter() {} + + public static boolean isAvailable(Context context) { + return HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE; + } + + public static boolean hasRequiredPermissions(Context context, boolean includeRoute) { + try { + HealthConnectClient client = HealthConnectClient.getOrCreate(context); + PermissionController controller = client.getPermissionController(); + @SuppressWarnings({"unchecked", "rawtypes"}) + Set granted = (Set) BuildersKt.runBlocking( + kotlin.coroutines.EmptyCoroutineContext.INSTANCE, + new Function2() { + @Override + public Object invoke(Object scope, Object cont) { + return controller.getGrantedPermissions((Continuation) cont); + } + }); + return granted.contains(WRITE_EXERCISE_PERMISSION) + && (!includeRoute || granted.contains(WRITE_EXERCISE_ROUTE_PERMISSION)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + /** + * Exports a single track as an ExerciseSession to Health Connect. + * Uses the track UUID as clientRecordId to prevent duplicates on re-export. + * Blocks the calling thread — must be called from a background thread. + */ + public static ExportResult export(Context context, Track.Id trackId, boolean includeDetailedRoute) throws Exception { + ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context); + Track track = contentProviderUtils.getTrack(trackId); + if (track == null) { + throw new RuntimeException("Track not found: " + trackId); + } + + Instant sessionStartTime = track.startTime().toInstant(); + Instant sessionEndTime = track.stopTime().toInstant(); + + ExerciseRoute exerciseRoute = includeDetailedRoute + ? createExerciseRoute(contentProviderUtils, trackId, sessionStartTime, sessionEndTime) + : null; + + HealthConnectClient client = HealthConnectClient.getOrCreate(context); + + Device device = new Device(Device.TYPE_PHONE, Build.MANUFACTURER, Build.MODEL); + Metadata metadata = Metadata.activelyRecorded(device, track.uuid().toString()); + + ExerciseSessionRecord record = new ExerciseSessionRecord( + sessionStartTime, + track.startTime().getOffset(), + sessionEndTime, + track.stopTime().getOffset(), + metadata, + toExerciseType(track.activityType()), + track.name(), + null, + Collections.emptyList(), + Collections.emptyList(), + exerciseRoute); + + List records = Collections.singletonList(record); + @SuppressWarnings({"unchecked", "rawtypes"}) + Object ignored = BuildersKt.runBlocking( + kotlin.coroutines.EmptyCoroutineContext.INSTANCE, + new Function2() { + @Override + public Object invoke(Object scope, Object cont) { + return client.insertRecords(records, (Continuation) cont); + } + }); + + Log.i(TAG, "Exported track to Health Connect: " + track.name()); + return new ExportResult(track.name(), exerciseRoute != null); + } + + private static ExerciseRoute createExerciseRoute( + ContentProviderUtils contentProviderUtils, + Track.Id trackId, + Instant sessionStartTime, + Instant sessionEndTime) { + + List route = new ArrayList<>(); + Instant lastTime = null; + int droppedOutsideSessionRange = 0; + + try (var iterator = contentProviderUtils.getTrackPointIterator(trackId, null)) { + while (iterator.hasNext()) { + TrackPoint trackPoint = iterator.next(); + if (trackPoint.type() != TrackPoint.Type.TRACKPOINT) continue; + + var position = trackPoint.position(); + Instant time = position.time(); + if (time == null || !position.hasValidLocation()) continue; + if (time.isBefore(sessionStartTime) || !time.isBefore(sessionEndTime)) { + droppedOutsideSessionRange++; + continue; + } + if (lastTime != null && !time.isAfter(lastTime)) continue; + + Length hAcc = position.horizontalAccuracy() != null + ? Length.meters(position.horizontalAccuracy().toM()) : null; + Length vAcc = position.verticalAccuracy() != null + ? Length.meters(position.verticalAccuracy().toM()) : null; + Length alt = position.altitude() != null + ? Length.meters(position.altitude().toM()) : null; + + route.add(new ExerciseRoute.Location( + time, + position.latitude(), + position.longitude(), + hAcc, + vAcc, + alt)); + lastTime = time; + } + } + + if (droppedOutsideSessionRange > 0) { + Log.i(TAG, "Dropped " + droppedOutsideSessionRange + + " route point(s) outside session range for track " + trackId.id()); + } + + return route.isEmpty() ? null : new ExerciseRoute(route); + } + + private static int toExerciseType(ActivityType activityType) { + return switch (activityType) { + case RUNNING, STREET_RUNNING, TRAIL_RUNNING, TRACK_RUNNING -> + ExerciseSessionRecord.EXERCISE_TYPE_RUNNING; + case BIKING, CYCLING, ROAD_BIKING, MOUNTAIN_BIKING, TRACK_CYCLING -> + ExerciseSessionRecord.EXERCISE_TYPE_BIKING; + case WALKING, SPEED_WALKING -> + ExerciseSessionRecord.EXERCISE_TYPE_WALKING; + case HIKING, OFF_TRAIL_HIKING, TRAIL_HIKING -> + ExerciseSessionRecord.EXERCISE_TYPE_HIKING; + case CROSS_COUNTRY_SKIING, SKIING -> + ExerciseSessionRecord.EXERCISE_TYPE_SKIING; + case SNOW_BOARDING -> + ExerciseSessionRecord.EXERCISE_TYPE_SNOWBOARDING; + case KAYAKING -> + ExerciseSessionRecord.EXERCISE_TYPE_PADDLING; + case ROWING -> + ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE; + case SWIMMING -> + ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL; + case SWIMMING_OPEN -> + ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER; + default -> + ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT; + }; + } + + public static class ExportResult { + public final String trackName; + public final boolean includedRoute; + + public ExportResult(String trackName, boolean includedRoute) { + this.trackName = trackName; + this.includedRoute = includedRoute; + } + } +} From 713e454e44a425dc0681e7052a883baf354cd99e Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 1 May 2026 18:12:03 +0300 Subject: [PATCH 3/7] HealthConnect: Queue exports in the background --- .../opentracks/TrackStoppedActivity.java | 1 + .../opentracks/data/ContentProviderUtils.java | 23 ++ .../io/file/exporter/ExportUtils.java | 44 ++++ .../io/healthconnect/HealthConnectWorker.java | 201 ++++++++++++++++++ .../opentracks/settings/PreferencesUtils.java | 100 ++++++++- src/main/res/values/settings.xml | 12 ++ src/main/res/values/strings.xml | 47 ++++ 7 files changed, 422 insertions(+), 6 deletions(-) create mode 100644 src/main/java/de/dennisguse/opentracks/io/healthconnect/HealthConnectWorker.java diff --git a/src/main/java/de/dennisguse/opentracks/TrackStoppedActivity.java b/src/main/java/de/dennisguse/opentracks/TrackStoppedActivity.java index 27f869d0f..a22924338 100644 --- a/src/main/java/de/dennisguse/opentracks/TrackStoppedActivity.java +++ b/src/main/java/de/dennisguse/opentracks/TrackStoppedActivity.java @@ -93,6 +93,7 @@ public class TrackStoppedActivity extends AbstractTrackDeleteActivity implements viewBinding.finishButton.setOnClickListener(v -> { storeTrackMetaData(contentProviderUtils, track); ExportUtils.postWorkoutExport(this, trackId); + ExportUtils.postWorkoutHealthConnectExport(this, trackId); finish(); }); diff --git a/src/main/java/de/dennisguse/opentracks/data/ContentProviderUtils.java b/src/main/java/de/dennisguse/opentracks/data/ContentProviderUtils.java index 09d8cd350..54541de2b 100644 --- a/src/main/java/de/dennisguse/opentracks/data/ContentProviderUtils.java +++ b/src/main/java/de/dennisguse/opentracks/data/ContentProviderUtils.java @@ -168,6 +168,29 @@ public class ContentProviderUtils { return tracks; } + public List getTracksOrderedByStartTime(Instant startTimeInclusive) { + ArrayList tracks = new ArrayList<>(); + String selection = null; + String[] selectionArgs = null; + + if (startTimeInclusive != null) { + selection = TracksColumns.STARTTIME + ">=?"; + selectionArgs = new String[]{Long.toString(startTimeInclusive.toEpochMilli())}; + } + + String sortOrder = TracksColumns.STARTTIME + " ASC, " + TracksColumns._ID + " ASC"; + try (Cursor cursor = getTrackCursor(selection, selectionArgs, sortOrder)) { + if (cursor != null && cursor.moveToFirst()) { + tracks.ensureCapacity(cursor.getCount()); + do { + tracks.add(createTrack(cursor)); + } while (cursor.moveToNext()); + } + } + + return tracks; + } + public TrackListIterator searchTracks(String searchQuery) { // Needed, because MARKER_COUNT is a virtual column and has to be explicitly requested. final String[] PROJECTION = new String[]{ diff --git a/src/main/java/de/dennisguse/opentracks/io/file/exporter/ExportUtils.java b/src/main/java/de/dennisguse/opentracks/io/file/exporter/ExportUtils.java index 10e34bd50..2cb86408b 100644 --- a/src/main/java/de/dennisguse/opentracks/io/file/exporter/ExportUtils.java +++ b/src/main/java/de/dennisguse/opentracks/io/file/exporter/ExportUtils.java @@ -12,6 +12,7 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.documentfile.provider.DocumentFile; import androidx.work.Data; +import androidx.work.ExistingWorkPolicy; import androidx.work.OneTimeWorkRequest; import androidx.work.WorkInfo; import androidx.work.WorkManager; @@ -27,6 +28,7 @@ import de.dennisguse.opentracks.data.ContentProviderUtils; import de.dennisguse.opentracks.data.models.Track; import de.dennisguse.opentracks.io.file.TrackFileFormat; import de.dennisguse.opentracks.io.file.TrackFilenameGenerator; +import de.dennisguse.opentracks.io.healthconnect.HealthConnectWorker; import de.dennisguse.opentracks.settings.PreferencesUtils; import de.dennisguse.opentracks.settings.SettingsActivity; import de.dennisguse.opentracks.util.IntentUtils; @@ -67,6 +69,48 @@ public class ExportUtils { } } + public static void postWorkoutHealthConnectExport(Context context, Track.Id trackId) { + if (!PreferencesUtils.shouldExportToHealthConnect()) { + return; + } + + if (PreferencesUtils.shouldRunHealthConnectInitialSync()) { + startHealthConnectInitialSync(context, PreferencesUtils.getHealthConnectInitialSyncStartTimeMillis()); + return; + } + + WorkRequest request = new OneTimeWorkRequest.Builder(HealthConnectWorker.class) + .addTag(HealthConnectWorker.WORK_TAG) + .setInputData(new Data.Builder() + .putString(HealthConnectWorker.MODE_KEY, HealthConnectWorker.MODE_SINGLE_TRACK) + .putLong(HealthConnectWorker.TRACK_ID_KEY, trackId.id()) + .build()) + .build(); + WorkManager.getInstance(context).enqueue(request); + } + + public static void startHealthConnectInitialSync(Context context, long startTimeMillis) { + OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(HealthConnectWorker.class) + .addTag(HealthConnectWorker.WORK_TAG) + .setInputData(new Data.Builder() + .putString(HealthConnectWorker.MODE_KEY, HealthConnectWorker.MODE_INITIAL_SYNC) + .putLong(HealthConnectWorker.INITIAL_SYNC_START_TIME_MILLIS_KEY, startTimeMillis) + .build()) + .build(); + + WorkManager.getInstance(context).enqueueUniqueWork( + HealthConnectWorker.INITIAL_SYNC_WORK_NAME, + ExistingWorkPolicy.KEEP, + request + ); + } + + public static void cancelHealthConnectExport(Context context) { + WorkManager workManager = WorkManager.getInstance(context); + workManager.cancelUniqueWork(HealthConnectWorker.INITIAL_SYNC_WORK_NAME); + workManager.cancelAllWorkByTag(HealthConnectWorker.WORK_TAG); + } + public static void exportTrack(Context context, DocumentFile directory, @Nullable String filenameForMultiple, TrackFileFormat trackFileFormat, List trackIds) { ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context); diff --git a/src/main/java/de/dennisguse/opentracks/io/healthconnect/HealthConnectWorker.java b/src/main/java/de/dennisguse/opentracks/io/healthconnect/HealthConnectWorker.java new file mode 100644 index 000000000..84e0a7c7a --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/io/healthconnect/HealthConnectWorker.java @@ -0,0 +1,201 @@ +package de.dennisguse.opentracks.io.healthconnect; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.StringRes; +import androidx.work.Data; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import java.text.DateFormat; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import de.dennisguse.opentracks.R; +import de.dennisguse.opentracks.data.ContentProviderUtils; +import de.dennisguse.opentracks.data.models.Track; +import de.dennisguse.opentracks.settings.PreferencesUtils; + +public class HealthConnectWorker extends Worker { + + private static final String TAG = HealthConnectWorker.class.getSimpleName(); + public static final String WORK_TAG = "HealthConnectSync"; + public static final String INITIAL_SYNC_WORK_NAME = "HealthConnectInitialSync"; + public static final String MODE_KEY = "mode"; + public static final String MODE_SINGLE_TRACK = "single_track"; + public static final String MODE_INITIAL_SYNC = "initial_sync"; + public static final String TRACK_ID_KEY = "trackId"; + public static final String INITIAL_SYNC_START_TIME_MILLIS_KEY = "initialSyncStartTimeMillis"; + private static final long UNSET_TIME_MILLIS = Long.MIN_VALUE; + private static final long SYNC_ALL_TIME_MILLIS = -1L; + + public HealthConnectWorker(Context context, WorkerParameters params) { + super(context, params); + } + + @Override + public Result doWork() { + boolean includeDetailedRoute = PreferencesUtils.shouldExportDetailedHealthConnectWorkout(); + + if (!PreferencesUtils.shouldExportToHealthConnect()) { + Log.i(TAG, "Skipping Health Connect export because export is disabled"); + return Result.success(); + } + + if (!HealthConnectExporter.isAvailable(getApplicationContext())) { + Log.w(TAG, "Health Connect SDK not available"); + updateStatus(R.string.health_connect_sync_status_failure, "Health Connect not available"); + return Result.failure(new Data.Builder().putString("error", "Health Connect not available").build()); + } + + if (!HealthConnectExporter.hasRequiredPermissions(getApplicationContext(), includeDetailedRoute)) { + Log.w(TAG, "Health Connect required permissions not granted"); + updateStatus(R.string.health_connect_sync_status_failure, "Health Connect permission not granted"); + return Result.failure(new Data.Builder().putString("error", "Health Connect permission not granted").build()); + } + + String mode = getInputData().getString(MODE_KEY); + if (MODE_INITIAL_SYNC.equals(mode)) { + return doInitialSync(includeDetailedRoute); + } + return doSingleTrackExport(includeDetailedRoute); + } + + private Result doSingleTrackExport(boolean includeDetailedRoute) { + long trackId = getInputData().getLong(TRACK_ID_KEY, -1L); + if (trackId == -1L) { + Log.e(TAG, "No track ID provided"); + updateStatus(R.string.health_connect_sync_status_failure, "No track ID provided"); + return Result.failure(); + } + + try { + HealthConnectExporter.ExportResult result = + HealthConnectExporter.export(getApplicationContext(), new Track.Id(trackId), includeDetailedRoute); + String routeSuffix = result.includedRoute + ? getApplicationContext().getString(R.string.health_connect_sync_status_route_suffix) + : ""; + PreferencesUtils.setHealthConnectLastSyncStatus( + prefixedStatus(getApplicationContext().getString( + R.string.health_connect_sync_status_success, result.trackName, routeSuffix))); + return Result.success(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return Result.retry(); + } catch (Exception e) { + Log.e(TAG, "Health Connect export failed for track " + trackId, e); + updateStatus(R.string.health_connect_sync_status_failure, + e.getMessage() != null ? e.getMessage() : "Unknown error"); + return Result.failure(new Data.Builder().putString("error", e.getMessage()).build()); + } + } + + private Result doInitialSync(boolean includeDetailedRoute) { + long configuredStartTimeMillis = getInputData().getLong( + INITIAL_SYNC_START_TIME_MILLIS_KEY, + PreferencesUtils.getHealthConnectInitialSyncStartTimeMillis()); + + if (!PreferencesUtils.hasHealthConnectInitialSyncConfigured()) { + Log.i(TAG, "Skipping initial sync because no start time was configured"); + return Result.success(); + } + + Instant startInstant = configuredStartTimeMillis == SYNC_ALL_TIME_MILLIS + ? null + : Instant.ofEpochMilli(configuredStartTimeMillis); + + List rawTracks = new ContentProviderUtils(getApplicationContext()) + .getTracksOrderedByStartTime(startInstant); + List allTracks = new ArrayList<>(); + for (Track track : rawTracks) { + if (track.stopTime().isAfter(track.startTime())) { + allTracks.add(track); + } + } + + long resumeStartTimeMillis = PreferencesUtils.getHealthConnectInitialSyncCursorStartTimeMillis(); + long resumeTrackId = PreferencesUtils.getHealthConnectInitialSyncCursorTrackId(); + List remainingTracks = new ArrayList<>(); + for (Track track : allTracks) { + long trackStartMillis = track.startTime().toInstant().toEpochMilli(); + if (resumeStartTimeMillis == UNSET_TIME_MILLIS + || trackStartMillis > resumeStartTimeMillis + || (trackStartMillis == resumeStartTimeMillis && track.id().id() > resumeTrackId)) { + remainingTracks.add(track); + } + } + + if (allTracks.isEmpty()) { + PreferencesUtils.clearHealthConnectInitialSyncCursor(); + PreferencesUtils.setHealthConnectInitialSyncCompleted(true); + setStatus(getApplicationContext().getString(R.string.health_connect_sync_status_initial_no_tracks)); + return Result.success(); + } + + if (remainingTracks.isEmpty()) { + PreferencesUtils.clearHealthConnectInitialSyncCursor(); + PreferencesUtils.setHealthConnectInitialSyncCompleted(true); + setStatus(getApplicationContext().getString( + R.string.health_connect_sync_status_initial_success, allTracks.size())); + return Result.success(); + } + + for (int index = 0; index < remainingTracks.size(); index++) { + if (isStopped()) { + Log.i(TAG, "Initial Health Connect sync stopped by WorkManager"); + return Result.success(); + } + + if (!PreferencesUtils.shouldExportToHealthConnect()) { + Log.i(TAG, "Stopping initial Health Connect sync because export was disabled"); + setStatus(getApplicationContext().getString(R.string.health_connect_sync_status_initial_stopped)); + return Result.success(); + } + + Track track = remainingTracks.get(index); + String trackName = track.name().isBlank() ? "Workout " + track.id().id() : track.name(); + setStatus(getApplicationContext().getString( + R.string.health_connect_sync_status_initial_progress, + index + 1, remainingTracks.size(), trackName)); + + try { + HealthConnectExporter.export(getApplicationContext(), track.id(), includeDetailedRoute); + PreferencesUtils.setHealthConnectInitialSyncCursorStartTimeMillis( + track.startTime().toInstant().toEpochMilli()); + PreferencesUtils.setHealthConnectInitialSyncCursorTrackId(track.id().id()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return Result.retry(); + } catch (Exception e) { + Log.e(TAG, "Initial Health Connect sync failed for track " + track.id().id(), e); + PreferencesUtils.setHealthConnectInitialSyncCompleted(false); + setStatus(getApplicationContext().getString( + R.string.health_connect_sync_status_initial_failure, + trackName, e.getMessage() != null ? e.getMessage() : "Unknown error")); + return Result.failure(new Data.Builder().putString("error", e.getMessage()).build()); + } + } + + PreferencesUtils.clearHealthConnectInitialSyncCursor(); + PreferencesUtils.setHealthConnectInitialSyncCompleted(true); + setStatus(getApplicationContext().getString( + R.string.health_connect_sync_status_initial_success, allTracks.size())); + return Result.success(); + } + + private void updateStatus(@StringRes int messageResId, String detail) { + setStatus(getApplicationContext().getString(messageResId, detail)); + } + + private void setStatus(String message) { + PreferencesUtils.setHealthConnectLastSyncStatus(prefixedStatus(message)); + } + + private String prefixedStatus(String message) { + String timestamp = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(new Date()); + return timestamp + ": " + message; + } +} diff --git a/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java b/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java index b47056472..bb6485e9f 100644 --- a/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java +++ b/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java @@ -71,6 +71,7 @@ public class PreferencesUtils { private static SharedPreferences sharedPreferences; private static Resources resources; + private static final long LONG_UNSET = Long.MIN_VALUE; /** * Must be called during application startup. @@ -168,24 +169,36 @@ public class PreferencesUtils { } } + private static long getLong(int keyId, long defaultValue) { + try { + return sharedPreferences.getLong(getKey(keyId), defaultValue); + } catch (ClassCastException e) { + // Ignore. + } + + try { + String stringValue = sharedPreferences.getString(getKey(keyId), null); + return Long.parseLong(stringValue); + } catch (NumberFormatException e) { + return defaultValue; + } + } + public static String getString(int keyId, String defaultValue) { return sharedPreferences.getString(getKey(keyId), defaultValue); } - @VisibleForTesting - public static void setString(int keyId, String value) { + static void setString(int keyId, String value) { Editor editor = sharedPreferences.edit(); editor.putString(getKey(keyId), value); editor.apply(); } - @VisibleForTesting - public static void setString(int keyId, int valueId) { + static void setString(int keyId, int valueId) { setString(keyId, resources.getString(valueId)); } - @VisibleForTesting - public static void setBoolean(int keyId, Boolean value) { + static void setBoolean(int keyId, Boolean value) { Editor editor = sharedPreferences.edit(); editor.putBoolean(getKey(keyId), value); editor.apply(); @@ -197,6 +210,12 @@ public class PreferencesUtils { editor.apply(); } + private static void setLong(int keyId, long value) { + Editor editor = sharedPreferences.edit(); + editor.putLong(getKey(keyId), value); + editor.apply(); + } + public static boolean isPublicAPIenabled() { return getBoolean(R.string.publicapi_enabled_key, resources.getBoolean(R.bool.publicapi_enabled_default)); } @@ -666,6 +685,75 @@ public class PreferencesUtils { return getBoolean(R.string.post_workout_export_enabled_key, INSTANT_POST_WORKOUT_EXPORT_DEFAULT) && isDefaultExportDirectoryUri(); } + public static boolean shouldExportToHealthConnect() { + final boolean DEFAULT = resources.getBoolean(R.bool.health_connect_export_enabled_default); + return getBoolean(R.string.health_connect_export_enabled_key, DEFAULT); + } + + public static boolean shouldExportDetailedHealthConnectWorkout() { + final boolean DEFAULT = resources.getBoolean(R.bool.health_connect_detailed_sync_default); + return getBoolean(R.string.health_connect_detailed_sync_key, DEFAULT); + } + + public static String getHealthConnectLastSyncStatus() { + return getString(R.string.health_connect_last_sync_status_key, resources.getString(R.string.health_connect_sync_status_never)); + } + + public static void setHealthConnectLastSyncStatus(String value) { + setString(R.string.health_connect_last_sync_status_key, value); + } + + public static boolean hasHealthConnectInitialSyncConfigured() { + return getLong(R.string.health_connect_initial_sync_start_time_key, LONG_UNSET) != LONG_UNSET; + } + + public static long getHealthConnectInitialSyncStartTimeMillis() { + return getLong(R.string.health_connect_initial_sync_start_time_key, LONG_UNSET); + } + + public static void setHealthConnectInitialSyncStartTimeMillis(long value) { + setLong(R.string.health_connect_initial_sync_start_time_key, value); + } + + public static long getHealthConnectInitialSyncCursorStartTimeMillis() { + return getLong(R.string.health_connect_initial_sync_cursor_start_time_key, LONG_UNSET); + } + + public static void setHealthConnectInitialSyncCursorStartTimeMillis(long value) { + setLong(R.string.health_connect_initial_sync_cursor_start_time_key, value); + } + + public static long getHealthConnectInitialSyncCursorTrackId() { + return getLong(R.string.health_connect_initial_sync_cursor_track_id_key, LONG_UNSET); + } + + public static void setHealthConnectInitialSyncCursorTrackId(long value) { + setLong(R.string.health_connect_initial_sync_cursor_track_id_key, value); + } + + public static boolean isHealthConnectInitialSyncCompleted() { + return getBoolean(R.string.health_connect_initial_sync_completed_key, resources.getBoolean(R.bool.health_connect_initial_sync_completed_default)); + } + + public static void setHealthConnectInitialSyncCompleted(boolean value) { + setBoolean(R.string.health_connect_initial_sync_completed_key, value); + } + + public static void resetHealthConnectInitialSyncState(long startTimeMillis) { + setHealthConnectInitialSyncStartTimeMillis(startTimeMillis); + clearHealthConnectInitialSyncCursor(); + setHealthConnectInitialSyncCompleted(false); + } + + public static void clearHealthConnectInitialSyncCursor() { + setHealthConnectInitialSyncCursorStartTimeMillis(LONG_UNSET); + setHealthConnectInitialSyncCursorTrackId(LONG_UNSET); + } + + public static boolean shouldRunHealthConnectInitialSync() { + return shouldExportToHealthConnect() && hasHealthConnectInitialSyncConfigured() && !isHealthConnectInitialSyncCompleted(); + } + public static TrackFilenameGenerator getTrackFileformatGenerator() { String DEFAULT = resources.getString(R.string.export_filename_format_default); TrackFilenameGenerator generator = new TrackFilenameGenerator(getString(R.string.export_filename_format_key, DEFAULT)); diff --git a/src/main/res/values/settings.xml b/src/main/res/values/settings.xml index 900491b77..d1ab2c8ce 100644 --- a/src/main/res/values/settings.xml +++ b/src/main/res/values/settings.xml @@ -271,6 +271,18 @@ preventReimportTrackKey true + healthConnectExportEnabled + false + healthConnectDetailedSync + false + healthConnectLastSyncStatus + healthConnectSettings + healthConnectInitialSyncStartTime + healthConnectInitialSyncCursorStartTime + healthConnectInitialSyncCursorTrackId + healthConnectInitialSyncCompleted + false + settingsAPI apiPackageKey apiEnabledKey diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 0e8f03e12..a8dccbdf1 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -679,6 +679,53 @@ limitations under the License. Hold to stop Export track to storage after recording is finished Instant post-workout export + Export to Health Connect + Export activity to Health Connect after recording is finished + Health Connect is not available on this device + Health Connect + Configure export and synchronization with Health Connect + Health Connect export is enabled + Health Connect export and detailed GPS route sync are enabled + General settings + Manage Health Connect data + Health Connect settings + Open Android Health Connect settings + To fully disconnect Health Connect, remove OpenTracks permissions in the Health Connect app. + Synchronization + Sync status + No Health Connect export yet. + A Health Connect sync is already running. + Manual sync + Choose a time range and sync existing workouts to Health Connect. + Enable Health Connect export to manually sync existing workouts. + Sync now + Detailed workout sync + Sync the recorded GPS route with workouts. This provides richer workout data but may take longer. + Enable Health Connect export to include detailed route data. + Depending on the amount of data, a Health Connect export might take a while. + Finished Health Connect export for %1$s%2$s + Health Connect export failed: %1$s + with GPS route + Initial sync + Choose how much existing workout history OpenTracks should sync to Health Connect. + Use a preset time range + Last 3 days + Last 7 days + Last 30 days + All recorded workouts + Choose date… + Choose start date + Sync now + Sync existing workouts from %1$s to Health Connect now? + Sync now + Sync later + Initial Health Connect sync is ready for %1$s. + Starting initial Health Connect sync for %1$s. + Initial Health Connect sync %1$d of %2$d: %3$s + Finished initial Health Connect sync for %1$d workout(s) + No recorded workouts found for initial Health Connect sync. + Initial Health Connect sync stopped because export was disabled. + Initial Health Connect sync failed for %1$s: %2$s Health Connect access How OpenTracks uses Health Connect OpenTracks only writes finished activities to Health Connect when you explicitly enable Health Connect export in settings. From dbd3e2fc5c3e35bda3ceadbca16b7f49d9fddf0f Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 1 May 2026 18:12:10 +0300 Subject: [PATCH 4/7] HealthConnect: Add settings screen resources --- .../res/drawable/ic_favorite_border_24.xml | 10 + src/main/res/drawable/ic_storage_24.xml | 10 + src/main/res/drawable/ic_sync_24.xml | 10 + .../activity_health_connect_settings.xml | 347 ++++++++++++++++++ .../dialog_health_connect_initial_sync.xml | 52 +++ 5 files changed, 429 insertions(+) create mode 100644 src/main/res/drawable/ic_favorite_border_24.xml create mode 100644 src/main/res/drawable/ic_storage_24.xml create mode 100644 src/main/res/drawable/ic_sync_24.xml create mode 100644 src/main/res/layout/activity_health_connect_settings.xml create mode 100644 src/main/res/layout/dialog_health_connect_initial_sync.xml diff --git a/src/main/res/drawable/ic_favorite_border_24.xml b/src/main/res/drawable/ic_favorite_border_24.xml new file mode 100644 index 000000000..268688b8e --- /dev/null +++ b/src/main/res/drawable/ic_favorite_border_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_storage_24.xml b/src/main/res/drawable/ic_storage_24.xml new file mode 100644 index 000000000..ffbc5f00b --- /dev/null +++ b/src/main/res/drawable/ic_storage_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_sync_24.xml b/src/main/res/drawable/ic_sync_24.xml new file mode 100644 index 000000000..b18177b79 --- /dev/null +++ b/src/main/res/drawable/ic_sync_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/layout/activity_health_connect_settings.xml b/src/main/res/layout/activity_health_connect_settings.xml new file mode 100644 index 000000000..6878f5526 --- /dev/null +++ b/src/main/res/layout/activity_health_connect_settings.xml @@ -0,0 +1,347 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/layout/dialog_health_connect_initial_sync.xml b/src/main/res/layout/dialog_health_connect_initial_sync.xml new file mode 100644 index 000000000..1a6cba365 --- /dev/null +++ b/src/main/res/layout/dialog_health_connect_initial_sync.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + +