diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 699cfc41c..3b1339e6b 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -129,8 +129,8 @@ limitations under the License. - - + + @@ -138,7 +138,7 @@ limitations under the License. - + - + + + + + diff --git a/src/main/java/de/dennisguse/opentracks/HealthConnectPermissionsRationaleActivity.java b/src/main/java/de/dennisguse/opentracks/HealthConnectPermissionsRationaleActivity.java index 13a808f5a..8d054b30c 100644 --- a/src/main/java/de/dennisguse/opentracks/HealthConnectPermissionsRationaleActivity.java +++ b/src/main/java/de/dennisguse/opentracks/HealthConnectPermissionsRationaleActivity.java @@ -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; } } diff --git a/src/main/java/de/dennisguse/opentracks/TrackStoppedActivity.java b/src/main/java/de/dennisguse/opentracks/TrackStoppedActivity.java index a22924338..4f2ce5a78 100644 --- a/src/main/java/de/dennisguse/opentracks/TrackStoppedActivity.java +++ b/src/main/java/de/dennisguse/opentracks/TrackStoppedActivity.java @@ -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(); }); diff --git a/src/main/java/de/dennisguse/opentracks/data/ContentProviderUtils.java b/src/main/java/de/dennisguse/opentracks/data/ContentProviderUtils.java index 54541de2b..09d8cd350 100644 --- a/src/main/java/de/dennisguse/opentracks/data/ContentProviderUtils.java +++ b/src/main/java/de/dennisguse/opentracks/data/ContentProviderUtils.java @@ -168,29 +168,6 @@ 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 2cb86408b..29d0e3a63 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,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 trackIds) { ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context); diff --git a/src/main/java/de/dennisguse/opentracks/io/healthconnect/HealthConnectExporter.java b/src/main/java/de/dennisguse/opentracks/io/healthconnect/HealthConnectExporter.java deleted file mode 100644 index 9d337d7a4..000000000 --- a/src/main/java/de/dennisguse/opentracks/io/healthconnect/HealthConnectExporter.java +++ /dev/null @@ -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 granted = (Set) 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 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 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; - } - } -} diff --git a/src/main/java/de/dennisguse/opentracks/io/healthconnect/HealthConnectWorker.java b/src/main/java/de/dennisguse/opentracks/io/healthconnect/HealthConnectWorker.java deleted file mode 100644 index 84e0a7c7a..000000000 --- a/src/main/java/de/dennisguse/opentracks/io/healthconnect/HealthConnectWorker.java +++ /dev/null @@ -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 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/io/healthconnect/exporter/HealthConnectUtils.java b/src/main/java/de/dennisguse/opentracks/io/healthconnect/exporter/HealthConnectUtils.java new file mode 100644 index 000000000..591a0070c --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/io/healthconnect/exporter/HealthConnectUtils.java @@ -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 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 getGrantedPermissions(Context context) { + try { + HealthConnectClient client = HealthConnectClient.getOrCreate(context); + PermissionController controller = client.getPermissionController(); + + return (Set) 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); + } +} diff --git a/src/main/java/de/dennisguse/opentracks/io/healthconnect/exporter/HealthConnectWorker.java b/src/main/java/de/dennisguse/opentracks/io/healthconnect/exporter/HealthConnectWorker.java new file mode 100644 index 000000000..65832d4f1 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/io/healthconnect/exporter/HealthConnectWorker.java @@ -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 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 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 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) { + } +} diff --git a/src/main/java/de/dennisguse/opentracks/settings/HealthConnect.java b/src/main/java/de/dennisguse/opentracks/settings/HealthConnect.java new file mode 100644 index 000000000..23b063c56 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/settings/HealthConnect.java @@ -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(); + } + } +} diff --git a/src/main/java/de/dennisguse/opentracks/settings/HealthConnectSettingsActivity.java b/src/main/java/de/dennisguse/opentracks/settings/HealthConnectSettingsActivity.java deleted file mode 100644 index 34ec82575..000000000 --- a/src/main/java/de/dennisguse/opentracks/settings/HealthConnectSettingsActivity.java +++ /dev/null @@ -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> healthConnectPermissionLauncher; - private boolean pendingEnableExport; - private boolean pendingEnableDetailed; - private boolean pendingShowInitialSyncDialog; - private Set 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> 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 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 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 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(); - } -} diff --git a/src/main/java/de/dennisguse/opentracks/settings/ImportExportSettingsFragment.java b/src/main/java/de/dennisguse/opentracks/settings/ImportExportSettingsFragment.java index dd97b95ce..9b97b0286 100644 --- a/src/main/java/de/dennisguse/opentracks/settings/ImportExportSettingsFragment.java +++ b/src/main/java/de/dennisguse/opentracks/settings/ImportExportSettingsFragment.java @@ -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() { diff --git a/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java b/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java index bb6485e9f..327e0ff26 100644 --- a/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java +++ b/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java @@ -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() { diff --git a/src/main/res/drawable/ic_favorite_border_24.xml b/src/main/res/drawable/ic_favorite_border_24.xml deleted file mode 100644 index 268688b8e..000000000 --- a/src/main/res/drawable/ic_favorite_border_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/src/main/res/drawable/ic_storage_24.xml b/src/main/res/drawable/ic_storage_24.xml deleted file mode 100644 index ffbc5f00b..000000000 --- a/src/main/res/drawable/ic_storage_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/src/main/res/drawable/ic_sync_24.xml b/src/main/res/drawable/ic_sync_24.xml deleted file mode 100644 index b18177b79..000000000 --- a/src/main/res/drawable/ic_sync_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/src/main/res/layout/activity_health_connect_permissions_rationale.xml b/src/main/res/layout/activity_health_connect_permissions_rationale.xml index e291d88b1..101d913e3 100644 --- a/src/main/res/layout/activity_health_connect_permissions_rationale.xml +++ b/src/main/res/layout/activity_health_connect_permissions_rationale.xml @@ -11,23 +11,22 @@ android:fitsSystemWindows="true"> + app:title="@string/app_name" /> + android:orientation="vertical"> - - - - - - diff --git a/src/main/res/layout/activity_health_connect_settings.xml b/src/main/res/layout/activity_health_connect_settings.xml deleted file mode 100644 index 6878f5526..000000000 --- a/src/main/res/layout/activity_health_connect_settings.xml +++ /dev/null @@ -1,347 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/res/layout/dialog_health_connect_initial_sync.xml b/src/main/res/layout/dialog_health_connect_initial_sync.xml deleted file mode 100644 index 1a6cba365..000000000 --- a/src/main/res/layout/dialog_health_connect_initial_sync.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - -