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));
+12
View File
@@ -271,6 +271,18 @@
<string name="import_prevent_reimport_key" translatable="false">preventReimportTrackKey</string>
<bool name="import_prevent_reimport_default">true</bool>
<string name="health_connect_export_enabled_key" translatable="false">healthConnectExportEnabled</string>
<bool name="health_connect_export_enabled_default" translatable="false">false</bool>
<string name="health_connect_detailed_sync_key" translatable="false">healthConnectDetailedSync</string>
<bool name="health_connect_detailed_sync_default" translatable="false">false</bool>
<string name="health_connect_last_sync_status_key" translatable="false">healthConnectLastSyncStatus</string>
<string name="health_connect_settings_key" translatable="false">healthConnectSettings</string>
<string name="health_connect_initial_sync_start_time_key" translatable="false">healthConnectInitialSyncStartTime</string>
<string name="health_connect_initial_sync_cursor_start_time_key" translatable="false">healthConnectInitialSyncCursorStartTime</string>
<string name="health_connect_initial_sync_cursor_track_id_key" translatable="false">healthConnectInitialSyncCursorTrackId</string>
<string name="health_connect_initial_sync_completed_key" translatable="false">healthConnectInitialSyncCompleted</string>
<bool name="health_connect_initial_sync_completed_default" translatable="false">false</bool>
<string name="settings_api_key" translatable="false">settingsAPI</string>
<string name="publicapi_package_key" translatable="false">apiPackageKey</string>
<string name="publicapi_enabled_key" translatable="false">apiEnabledKey</string>
+47
View File
@@ -679,6 +679,53 @@ limitations under the License.
<string name="hold_to_stop">Hold to stop</string>
<string name="instant_export_enabled_summary">Export track to storage after recording is finished</string>
<string name="instant_export_enabled_title">Instant post-workout export</string>
<string name="health_connect_export_enabled_title">Export to Health Connect</string>
<string name="health_connect_export_enabled_summary">Export activity to Health Connect after recording is finished</string>
<string name="health_connect_not_available">Health Connect is not available on this device</string>
<string name="health_connect_screen_title">Health Connect</string>
<string name="health_connect_settings_summary">Configure export and synchronization with Health Connect</string>
<string name="health_connect_settings_summary_enabled">Health Connect export is enabled</string>
<string name="health_connect_settings_summary_enabled_detailed">Health Connect export and detailed GPS route sync are enabled</string>
<string name="health_connect_section_general">General settings</string>
<string name="health_connect_manage_data_title">Manage Health Connect data</string>
<string name="health_connect_open_settings_title">Health Connect settings</string>
<string name="health_connect_open_settings_summary">Open Android Health Connect settings</string>
<string name="health_connect_disable_note">To fully disconnect Health Connect, remove OpenTracks permissions in the Health Connect app.</string>
<string name="health_connect_section_sync">Synchronization</string>
<string name="health_connect_sync_status_title">Sync status</string>
<string name="health_connect_sync_status_never">No Health Connect export yet.</string>
<string name="health_connect_sync_already_running">A Health Connect sync is already running.</string>
<string name="health_connect_manual_sync_title">Manual sync</string>
<string name="health_connect_manual_sync_summary">Choose a time range and sync existing workouts to Health Connect.</string>
<string name="health_connect_manual_sync_requires_export">Enable Health Connect export to manually sync existing workouts.</string>
<string name="health_connect_manual_sync_button">Sync now</string>
<string name="health_connect_detailed_sync_title">Detailed workout sync</string>
<string name="health_connect_detailed_sync_summary">Sync the recorded GPS route with workouts. This provides richer workout data but may take longer.</string>
<string name="health_connect_detailed_sync_requires_export">Enable Health Connect export to include detailed route data.</string>
<string name="health_connect_sync_note">Depending on the amount of data, a Health Connect export might take a while.</string>
<string name="health_connect_sync_status_success">Finished Health Connect export for %1$s%2$s</string>
<string name="health_connect_sync_status_failure">Health Connect export failed: %1$s</string>
<string name="health_connect_sync_status_route_suffix"> with GPS route</string>
<string name="health_connect_initial_sync_dialog_title">Initial sync</string>
<string name="health_connect_initial_sync_dialog_message">Choose how much existing workout history OpenTracks should sync to Health Connect.</string>
<string name="health_connect_initial_sync_preset">Use a preset time range</string>
<string name="health_connect_initial_sync_last_3_days">Last 3 days</string>
<string name="health_connect_initial_sync_last_7_days">Last 7 days</string>
<string name="health_connect_initial_sync_last_30_days">Last 30 days</string>
<string name="health_connect_initial_sync_all">All recorded workouts</string>
<string name="health_connect_initial_sync_custom_date">Choose date…</string>
<string name="health_connect_initial_sync_select_date">Choose start date</string>
<string name="health_connect_sync_now_title">Sync now</string>
<string name="health_connect_sync_now_message">Sync existing workouts from %1$s to Health Connect now?</string>
<string name="health_connect_sync_now">Sync now</string>
<string name="health_connect_sync_later">Sync later</string>
<string name="health_connect_sync_status_initial_pending">Initial Health Connect sync is ready for %1$s.</string>
<string name="health_connect_sync_status_initial_starting">Starting initial Health Connect sync for %1$s.</string>
<string name="health_connect_sync_status_initial_progress">Initial Health Connect sync %1$d of %2$d: %3$s</string>
<string name="health_connect_sync_status_initial_success">Finished initial Health Connect sync for %1$d workout(s)</string>
<string name="health_connect_sync_status_initial_no_tracks">No recorded workouts found for initial Health Connect sync.</string>
<string name="health_connect_sync_status_initial_stopped">Initial Health Connect sync stopped because export was disabled.</string>
<string name="health_connect_sync_status_initial_failure">Initial Health Connect sync failed for %1$s: %2$s</string>
<string name="health_connect_permissions_rationale_title">Health Connect access</string>
<string name="health_connect_permissions_rationale_heading">How OpenTracks uses Health Connect</string>
<string name="health_connect_permissions_rationale_summary">OpenTracks only writes finished activities to Health Connect when you explicitly enable Health Connect export in settings.</string>