HealthConnect: Add exercise session exporter

This commit is contained in:
Manuel
2026-05-01 18:11:56 +03:00
committed by Dennis Guse
parent 75ed3120bd
commit f0616fc043
3 changed files with 211 additions and 0 deletions
+1
View File
@@ -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'
+4
View File
@@ -69,6 +69,9 @@ limitations under the License.
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"
android:minSdkVersion="33" />
<uses-permission android:name="android.permission.health.WRITE_EXERCISE" />
<uses-permission android:name="android.permission.health.WRITE_EXERCISE_ROUTE" />
<application
android:name=".Startup"
android:allowBackup="true"
@@ -429,5 +432,6 @@ limitations under the License.
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
<package android:name="com.google.android.apps.healthdata" />
</queries>
</manifest>
@@ -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<String> granted = (Set<String>) 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<ExerciseSessionRecord> 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<ExerciseRoute.Location> 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;
}
}
}