HealthConnect: Queue exports in the background

This commit is contained in:
Manuel
2026-05-01 18:12:03 +03:00
committed by Dennis Guse
parent f0616fc043
commit 713e454e44
7 changed files with 422 additions and 6 deletions
@@ -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();
});
@@ -168,6 +168,29 @@ 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,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<Track.Id> trackIds) {
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context);
@@ -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<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;
}
}
@@ -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));