Health Connect: minimal implementation for writing route data.

This commit is contained in:
Dennis Guse
2026-05-31 17:42:43 +02:00
committed by Dennis Guse
parent a66c1b9407
commit 95e6448cba
54 changed files with 448 additions and 1514 deletions
@@ -1,39 +1,28 @@
package de.dennisguse.opentracks;
import android.os.Bundle;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import de.dennisguse.opentracks.databinding.ActivityHealthConnectPermissionsRationaleBinding;
import de.dennisguse.opentracks.ui.util.ViewUtils;
public class HealthConnectPermissionsRationaleActivity extends AbstractActivity {
private ActivityHealthConnectPermissionsRationaleBinding viewBinding;
public class HealthConnectPermissionsRationaleActivity extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setTitle(R.string.health_connect_permissions_rationale_title);
viewBinding.healthConnectPermissionsRationaleAppUrl.setText(getString(R.string.about_url, getString(R.string.app_web_url)));
setSupportActionBar(viewBinding.bottomAppBarLayout.bottomAppBar);
ActivityHealthConnectPermissionsRationaleBinding viewBinding = ActivityHealthConnectPermissionsRationaleBinding.inflate(getLayoutInflater());
setSupportActionBar(viewBinding.healthConnectActivityToolbar);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
ViewUtils.makeClickableLinks(findViewById(android.R.id.content));
}
@NonNull
@Override
protected View createRootView() {
viewBinding = ActivityHealthConnectPermissionsRationaleBinding.inflate(getLayoutInflater());
return viewBinding.getRoot();
setContentView(viewBinding.getRoot());
}
@Override
protected void onDestroy() {
super.onDestroy();
viewBinding = null;
public boolean onSupportNavigateUp() {
getOnBackPressedDispatcher().onBackPressed();
return true;
}
}
@@ -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,7 +94,7 @@ public class TrackStoppedActivity extends AbstractTrackDeleteActivity implements
viewBinding.finishButton.setOnClickListener(v -> {
storeTrackMetaData(contentProviderUtils, track);
ExportUtils.postWorkoutExport(this, trackId);
ExportUtils.postWorkoutHealthConnectExport(this, trackId);
HealthConnectUtils.postExportTrack(this, trackId);
finish();
});
@@ -168,29 +168,6 @@ public class ContentProviderUtils {
return tracks;
}
public List<Track> getTracksOrderedByStartTime(Instant startTimeInclusive) {
ArrayList<Track> 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[]{
@@ -12,7 +12,6 @@ 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;
@@ -28,7 +27,6 @@ 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;
@@ -38,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());
@@ -69,48 +67,6 @@ 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<Track.Id> trackIds) {
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context);
@@ -1,206 +0,0 @@
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;
}
}
}
@@ -1,201 +0,0 @@
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<Track> rawTracks = new ContentProviderUtils(getApplicationContext())
.getTracksOrderedByStartTime(startInstant);
List<Track> 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<Track> 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;
}
}
@@ -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<String> 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<String> getGrantedPermissions(Context context) {
try {
HealthConnectClient client = HealthConnectClient.getOrCreate(context);
PermissionController controller = client.getPermissionController();
return (Set<String>) 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);
}
}
@@ -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<Track> allTracks = new ContentProviderUtils(getApplicationContext())
.getTracks();
for (Track track : allTracks) {
if (isStopped()) {
Log.i(TAG, "Initial Health Connect sync stopped by WorkManager");
return Result.success();
}
try {
export(getApplicationContext(), track.id());
} catch (InterruptedException e) {
return Result.retry();
} catch (Exception e) {
Log.e(TAG, "Initial Health Connect sync failed for track " + track.id().id(), e);
return Result.failure();
}
}
return Result.success();
}
private static ExportResult export(Context context, Track.Id trackId) throws InterruptedException {
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context);
Track track = contentProviderUtils.getTrack(trackId);
if (track == null) {
throw new RuntimeException("Track not found: " + trackId);
}
ExerciseRoute exerciseRoute = createExerciseRoute(contentProviderUtils, trackId);
HealthConnectClient client = HealthConnectClient.getOrCreate(context);
ExerciseSessionRecord record = new ExerciseSessionRecord(
track.startTime().toInstant(),
track.startTime().getOffset(),
track.stopTime().toInstant(),
track.stopTime().getOffset(),
Metadata.activelyRecorded(
new Device(Device.TYPE_PHONE, Build.MANUFACTURER, Build.MODEL),
track.uuid().toString()
),
toExerciseType(track.activityType()),
track.name(),
track.description(),
Collections.emptyList(),
Collections.emptyList(),
exerciseRoute);
List<ExerciseSessionRecord> records = Collections.singletonList(record);
Object ignored = BuildersKt.runBlocking(
kotlin.coroutines.EmptyCoroutineContext.INSTANCE,
(Function2) (scope, cont) -> client.insertRecords(records, (Continuation) cont));
Log.i(TAG, "Exported track to Health Connect: " + track.name());
return new ExportResult(track.name());
}
private static ExerciseRoute createExerciseRoute(ContentProviderUtils contentProviderUtils, Track.Id trackId) {
List<ExerciseRoute.Location> route = new ArrayList<>();
try (TrackPointIterator iterator = contentProviderUtils.getTrackPointIterator(trackId, null)) {
while (iterator.hasNext()) {
TrackPoint trackPoint = iterator.next();
if (trackPoint.type() != TrackPoint.Type.TRACKPOINT) continue;
Position position = trackPoint.position();
route.add(new ExerciseRoute.Location(
trackPoint.getTime(),
position.latitude(),
position.longitude(),
position.horizontalAccuracy() != null ? Length.meters(position.horizontalAccuracy().toM()) : null,
position.verticalAccuracy() != null ? Length.meters(position.verticalAccuracy().toM()) : null,
position.altitude() != null ? Length.meters(position.altitude().toM()) : null)
);
}
}
return 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 record ExportResult(String trackName) {
}
}
@@ -0,0 +1,54 @@
package de.dennisguse.opentracks.settings;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.health.connect.client.HealthConnectClient;
import androidx.health.connect.client.PermissionController;
import de.dennisguse.opentracks.io.healthconnect.exporter.HealthConnectUtils;
public class HealthConnect {
public static class Settings extends AppCompatActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
startActivity(new Intent(HealthConnectClient.getHealthConnectSettingsAction()));
finish();
}
}
public static class ConfigurePermissions extends AppCompatActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (!HealthConnectUtils.isAvailable(this)) {
finish();
}
registerForActivityResult(
PermissionController.createRequestPermissionResultContract(),
grantedPermissions -> {
// TODO User feedback via Toast.makeText()?
finish();
}
).launch(HealthConnectUtils.PERMISSIONS);
}
}
public static class ExportAll extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
HealthConnectUtils.postExportAll(this);
finish();
}
}
}
@@ -1,368 +0,0 @@
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,6 +1,5 @@
package de.dennisguse.opentracks.settings;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
@@ -13,9 +12,9 @@ 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.io.healthconnect.exporter.HealthConnectUtils;
import de.dennisguse.opentracks.util.IntentUtils;
@@ -28,7 +27,11 @@ public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
setExportTrackFileFormatOptions();
setExportDirectorySummary();
setFilenameTemplate();
setHealthConnectPreference();
findPreference("healthConnectPermissions").setSummaryProvider(preference -> {
//TODO This should be localized and prefixes trimmed.
return String.join(",", HealthConnectUtils.getGrantedPermissions(getActivity()));
});
}
@Override
@@ -42,35 +45,21 @@ public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
super.onResume();
setExportDirectorySummary();
setHealthConnectPreference();
findPreference(getString(R.string.post_workout_export_enabled_key)).setEnabled(PreferencesUtils.isDefaultExportDirectoryUri());
Preference instantExportEnabledPreference = findPreference(getString(R.string.post_workout_export_enabled_key));
instantExportEnabledPreference.setEnabled(PreferencesUtils.isDefaultExportDirectoryUri());
setHealthConnectPreference();
}
private void setHealthConnectPreference() {
Preference preference = findPreference(getString(R.string.health_connect_settings_key));
if (preference == null) return;
boolean healthConnectAvailable = HealthConnectUtils.isAvailable(requireContext());
findPreference("healthConnectCategory")
.setEnabled(healthConnectAvailable);
preference.setOnPreferenceClickListener(pref -> {
startActivity(new Intent(requireContext(), HealthConnectSettingsActivity.class));
return true;
});
if (!HealthConnectExporter.isAvailable(requireContext())) {
preference.setSummary(R.string.health_connect_not_available);
if (!healthConnectAvailable) {
findPreference(getString(R.string.post_workout_export_health_connect_enabled_key))
.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() {
@@ -679,79 +679,14 @@ public class PreferencesUtils {
return entries;
}
public static boolean shouldInstantExportAfterWorkout() {
public static boolean shouldInstantExportToFile() {
final boolean INSTANT_POST_WORKOUT_EXPORT_DEFAULT = resources.getBoolean(R.bool.post_workout_export_enabled_default);
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 boolean shouldInstantExportToHealthConnect() {
final boolean DEFAULT = resources.getBoolean(R.bool.post_workout_export_health_connect_enabled_default);
return getBoolean(R.string.post_workout_export_health_connect_enabled_key, DEFAULT);
}
public static TrackFilenameGenerator getTrackFileformatGenerator() {