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; + } + } +}