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 c231beac1..3b1339e6b 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -69,6 +69,9 @@ limitations under the License.
+
+
+
+
+
+ android:name=".HealthConnectPermissionsRationaleActivity"
+ android:exported="true">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -410,5 +436,6 @@ limitations under the License.
+
diff --git a/src/main/java/de/dennisguse/opentracks/HealthConnectPermissionsRationaleActivity.java b/src/main/java/de/dennisguse/opentracks/HealthConnectPermissionsRationaleActivity.java
new file mode 100644
index 000000000..8d054b30c
--- /dev/null
+++ b/src/main/java/de/dennisguse/opentracks/HealthConnectPermissionsRationaleActivity.java
@@ -0,0 +1,28 @@
+package de.dennisguse.opentracks;
+
+import android.os.Bundle;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import de.dennisguse.opentracks.databinding.ActivityHealthConnectPermissionsRationaleBinding;
+
+public class HealthConnectPermissionsRationaleActivity extends AppCompatActivity {
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ ActivityHealthConnectPermissionsRationaleBinding viewBinding = ActivityHealthConnectPermissionsRationaleBinding.inflate(getLayoutInflater());
+ setSupportActionBar(viewBinding.healthConnectActivityToolbar);
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+
+ setContentView(viewBinding.getRoot());
+ }
+
+ @Override
+ public boolean onSupportNavigateUp() {
+ getOnBackPressedDispatcher().onBackPressed();
+ return true;
+ }
+}
diff --git a/src/main/java/de/dennisguse/opentracks/TrackStoppedActivity.java b/src/main/java/de/dennisguse/opentracks/TrackStoppedActivity.java
index 27f869d0f..4f2ce5a78 100644
--- a/src/main/java/de/dennisguse/opentracks/TrackStoppedActivity.java
+++ b/src/main/java/de/dennisguse/opentracks/TrackStoppedActivity.java
@@ -18,6 +18,7 @@ import de.dennisguse.opentracks.data.models.TrackBuilder;
import de.dennisguse.opentracks.databinding.TrackStoppedBinding;
import de.dennisguse.opentracks.fragments.ChooseActivityTypeDialogFragment;
import de.dennisguse.opentracks.io.file.exporter.ExportUtils;
+import de.dennisguse.opentracks.io.healthconnect.exporter.HealthConnectUtils;
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.ui.aggregatedStatistics.ConfirmDeleteDialogFragment;
@@ -93,6 +94,7 @@ public class TrackStoppedActivity extends AbstractTrackDeleteActivity implements
viewBinding.finishButton.setOnClickListener(v -> {
storeTrackMetaData(contentProviderUtils, track);
ExportUtils.postWorkoutExport(this, trackId);
+ HealthConnectUtils.postExportTrack(this, trackId);
finish();
});
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..29d0e3a63 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
@@ -36,7 +36,7 @@ public class ExportUtils {
private static final String TAG = ExportUtils.class.getSimpleName();
public static void postWorkoutExport(AppCompatActivity context, Track.Id trackId) {
- if (PreferencesUtils.shouldInstantExportAfterWorkout()) {
+ if (PreferencesUtils.shouldInstantExportToFile()) {
TrackFileFormat trackFileFormat = PreferencesUtils.getExportTrackFileFormat();
DocumentFile directory = IntentUtils.toDocumentFile(context, PreferencesUtils.getDefaultExportDirectoryUri());
diff --git a/src/main/java/de/dennisguse/opentracks/io/healthconnect/exporter/HealthConnectUtils.java b/src/main/java/de/dennisguse/opentracks/io/healthconnect/exporter/HealthConnectUtils.java
new file mode 100644
index 000000000..591a0070c
--- /dev/null
+++ b/src/main/java/de/dennisguse/opentracks/io/healthconnect/exporter/HealthConnectUtils.java
@@ -0,0 +1,93 @@
+package de.dennisguse.opentracks.io.healthconnect.exporter;
+
+import android.content.Context;
+
+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.ExerciseSessionRecord;
+import androidx.work.Data;
+import androidx.work.ExistingWorkPolicy;
+import androidx.work.OneTimeWorkRequest;
+import androidx.work.WorkManager;
+import androidx.work.WorkRequest;
+
+import java.util.Set;
+
+import de.dennisguse.opentracks.data.models.Track;
+import de.dennisguse.opentracks.settings.PreferencesUtils;
+import kotlin.coroutines.Continuation;
+import kotlin.jvm.JvmClassMappingKt;
+import kotlin.jvm.functions.Function2;
+import kotlinx.coroutines.BuildersKt;
+
+/**
+ * Exports a single track as an ExerciseSession to Health Connect.
+ * Uses the Track.uuid as clientRecordId to prevent duplicates on re-export.
+ */
+public class HealthConnectUtils {
+
+ private static final String TAG = HealthConnectUtils.class.getSimpleName();
+
+ static final String WRITE_EXERCISE_PERMISSION = HealthPermission.getWritePermission(JvmClassMappingKt.getKotlinClass(ExerciseSessionRecord.class));
+ static final String WRITE_EXERCISE_ROUTE_PERMISSION = HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE;
+
+ //TODO Should be package private
+ public static final Set PERMISSIONS = Set.of(WRITE_EXERCISE_PERMISSION, WRITE_EXERCISE_ROUTE_PERMISSION);
+
+ private HealthConnectUtils() {
+ }
+
+ public static boolean isAvailable(Context context) {
+ return HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE;
+ }
+
+ public static Set getGrantedPermissions(Context context) {
+ try {
+ HealthConnectClient client = HealthConnectClient.getOrCreate(context);
+ PermissionController controller = client.getPermissionController();
+
+ return (Set) BuildersKt.runBlocking(
+ kotlin.coroutines.EmptyCoroutineContext.INSTANCE,
+ (Function2) (scope, cont) -> controller.getGrantedPermissions((Continuation) cont));
+ } catch (InterruptedException e) {
+ return Set.of();
+ }
+ }
+
+ static boolean hasRequiredPermissions(Context context) {
+ return getGrantedPermissions(context)
+ .containsAll(PERMISSIONS);
+ }
+
+ public static void postExportTrack(Context context, Track.Id trackId) {
+ if (!PreferencesUtils.shouldInstantExportToHealthConnect()) {
+ return;
+ }
+
+ WorkRequest request = new OneTimeWorkRequest.Builder(HealthConnectWorker.class)
+ .addTag(HealthConnectWorker.WORK_TAG)
+ .setInputData(new Data.Builder()
+ .putLong(HealthConnectWorker.TRACK_ID_KEY, trackId.id())
+ .build())
+ .build();
+ WorkManager.getInstance(context).enqueue(request);
+ }
+
+ public static void postExportAll(Context context) {
+ OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(HealthConnectWorker.class)
+ .addTag(HealthConnectWorker.WORK_TAG)
+ .build();
+
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ HealthConnectWorker.WORK_TAG,
+ ExistingWorkPolicy.REPLACE,
+ request
+ );
+ }
+
+ public static void cancelHealthConnectExport(Context context) {
+ WorkManager workManager = WorkManager.getInstance(context);
+ workManager.cancelAllWorkByTag(HealthConnectWorker.WORK_TAG);
+ }
+}
diff --git a/src/main/java/de/dennisguse/opentracks/io/healthconnect/exporter/HealthConnectWorker.java b/src/main/java/de/dennisguse/opentracks/io/healthconnect/exporter/HealthConnectWorker.java
new file mode 100644
index 000000000..65832d4f1
--- /dev/null
+++ b/src/main/java/de/dennisguse/opentracks/io/healthconnect/exporter/HealthConnectWorker.java
@@ -0,0 +1,185 @@
+package de.dennisguse.opentracks.io.healthconnect.exporter;
+
+import android.content.Context;
+import android.os.Build;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.health.connect.client.HealthConnectClient;
+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 androidx.work.Worker;
+import androidx.work.WorkerParameters;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import de.dennisguse.opentracks.data.ContentProviderUtils;
+import de.dennisguse.opentracks.data.TrackPointIterator;
+import de.dennisguse.opentracks.data.models.ActivityType;
+import de.dennisguse.opentracks.data.models.Position;
+import de.dennisguse.opentracks.data.models.Track;
+import de.dennisguse.opentracks.data.models.TrackPoint;
+import kotlin.coroutines.Continuation;
+import kotlin.jvm.functions.Function2;
+import kotlinx.coroutines.BuildersKt;
+
+/**
+ * Exports a single track as an ExerciseSession to Health Connect.
+ * Uses the Track.uuid as clientRecordId to prevent duplicates on re-export.
+ */
+//TODO add reasons for failures (if shown in the UI: must be localized)
+//TODO Export sensor data (permissions needed)
+//TODO Show progress: like ExportActivity? Or use a notification (aka handle it as a background process)
+public class HealthConnectWorker extends Worker {
+
+ private static final String TAG = HealthConnectWorker.class.getSimpleName();
+ static final String WORK_TAG = "HealthConnectSync";
+
+ static final String TRACK_ID_KEY = "trackId";
+
+ public HealthConnectWorker(Context context, WorkerParameters params) {
+ super(context, params);
+ }
+
+ @NonNull
+ @Override
+ public Result doWork() {
+ if (!HealthConnectUtils.isAvailable(getApplicationContext())) {
+ Log.w(TAG, "Health Connect SDK not available");
+ return Result.failure();
+ }
+
+ //TODO If not all permissions are granted, export the data that was granted.
+ if (!HealthConnectUtils.hasRequiredPermissions(getApplicationContext())) {
+ Log.w(TAG, "Health Connect required permissions not granted");
+ return Result.failure();
+ }
+
+ Track.Id trackId = new Track.Id(getInputData().getLong(TRACK_ID_KEY, -1L));
+ if (trackId.id() != -1L) {
+ return exportTrack(trackId);
+ }
+ return exportAll();
+ }
+
+ private Result exportTrack(Track.Id trackId) {
+ try {
+ export(getApplicationContext(), trackId);
+ 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);
+ return Result.failure();
+ }
+ }
+
+ private Result exportAll() {
+ List