diff --git a/src/main/java/de/dennisguse/opentracks/TrackStoppedActivity.java b/src/main/java/de/dennisguse/opentracks/TrackStoppedActivity.java index 27f869d0f..a22924338 100644 --- a/src/main/java/de/dennisguse/opentracks/TrackStoppedActivity.java +++ b/src/main/java/de/dennisguse/opentracks/TrackStoppedActivity.java @@ -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(); }); diff --git a/src/main/java/de/dennisguse/opentracks/data/ContentProviderUtils.java b/src/main/java/de/dennisguse/opentracks/data/ContentProviderUtils.java index 09d8cd350..54541de2b 100644 --- a/src/main/java/de/dennisguse/opentracks/data/ContentProviderUtils.java +++ b/src/main/java/de/dennisguse/opentracks/data/ContentProviderUtils.java @@ -168,6 +168,29 @@ public class ContentProviderUtils { return tracks; } + public List getTracksOrderedByStartTime(Instant startTimeInclusive) { + ArrayList 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[]{ diff --git a/src/main/java/de/dennisguse/opentracks/io/file/exporter/ExportUtils.java b/src/main/java/de/dennisguse/opentracks/io/file/exporter/ExportUtils.java index 10e34bd50..2cb86408b 100644 --- a/src/main/java/de/dennisguse/opentracks/io/file/exporter/ExportUtils.java +++ b/src/main/java/de/dennisguse/opentracks/io/file/exporter/ExportUtils.java @@ -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 trackIds) { ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context); diff --git a/src/main/java/de/dennisguse/opentracks/io/healthconnect/HealthConnectWorker.java b/src/main/java/de/dennisguse/opentracks/io/healthconnect/HealthConnectWorker.java new file mode 100644 index 000000000..84e0a7c7a --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/io/healthconnect/HealthConnectWorker.java @@ -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 rawTracks = new ContentProviderUtils(getApplicationContext()) + .getTracksOrderedByStartTime(startInstant); + List 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 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; + } +} diff --git a/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java b/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java index b47056472..bb6485e9f 100644 --- a/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java +++ b/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java @@ -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)); diff --git a/src/main/res/values/settings.xml b/src/main/res/values/settings.xml index 900491b77..d1ab2c8ce 100644 --- a/src/main/res/values/settings.xml +++ b/src/main/res/values/settings.xml @@ -271,6 +271,18 @@ preventReimportTrackKey true + healthConnectExportEnabled + false + healthConnectDetailedSync + false + healthConnectLastSyncStatus + healthConnectSettings + healthConnectInitialSyncStartTime + healthConnectInitialSyncCursorStartTime + healthConnectInitialSyncCursorTrackId + healthConnectInitialSyncCompleted + false + settingsAPI apiPackageKey apiEnabledKey diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 0e8f03e12..a8dccbdf1 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -679,6 +679,53 @@ limitations under the License. Hold to stop Export track to storage after recording is finished Instant post-workout export + Export to Health Connect + Export activity to Health Connect after recording is finished + Health Connect is not available on this device + Health Connect + Configure export and synchronization with Health Connect + Health Connect export is enabled + Health Connect export and detailed GPS route sync are enabled + General settings + Manage Health Connect data + Health Connect settings + Open Android Health Connect settings + To fully disconnect Health Connect, remove OpenTracks permissions in the Health Connect app. + Synchronization + Sync status + No Health Connect export yet. + A Health Connect sync is already running. + Manual sync + Choose a time range and sync existing workouts to Health Connect. + Enable Health Connect export to manually sync existing workouts. + Sync now + Detailed workout sync + Sync the recorded GPS route with workouts. This provides richer workout data but may take longer. + Enable Health Connect export to include detailed route data. + Depending on the amount of data, a Health Connect export might take a while. + Finished Health Connect export for %1$s%2$s + Health Connect export failed: %1$s + with GPS route + Initial sync + Choose how much existing workout history OpenTracks should sync to Health Connect. + Use a preset time range + Last 3 days + Last 7 days + Last 30 days + All recorded workouts + Choose date… + Choose start date + Sync now + Sync existing workouts from %1$s to Health Connect now? + Sync now + Sync later + Initial Health Connect sync is ready for %1$s. + Starting initial Health Connect sync for %1$s. + Initial Health Connect sync %1$d of %2$d: %3$s + Finished initial Health Connect sync for %1$d workout(s) + No recorded workouts found for initial Health Connect sync. + Initial Health Connect sync stopped because export was disabled. + Initial Health Connect sync failed for %1$s: %2$s Health Connect access How OpenTracks uses Health Connect OpenTracks only writes finished activities to Health Connect when you explicitly enable Health Connect export in settings.