forked from upstream-mirrors/OpenTracks
Health Connect: minimal implementation for writing route data.
This commit is contained in:
+9
-20
@@ -1,39 +1,28 @@
|
||||
package de.dennisguse.opentracks;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import de.dennisguse.opentracks.databinding.ActivityHealthConnectPermissionsRationaleBinding;
|
||||
import de.dennisguse.opentracks.ui.util.ViewUtils;
|
||||
|
||||
public class HealthConnectPermissionsRationaleActivity extends AbstractActivity {
|
||||
|
||||
private ActivityHealthConnectPermissionsRationaleBinding viewBinding;
|
||||
public class HealthConnectPermissionsRationaleActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
setTitle(R.string.health_connect_permissions_rationale_title);
|
||||
viewBinding.healthConnectPermissionsRationaleAppUrl.setText(getString(R.string.about_url, getString(R.string.app_web_url)));
|
||||
setSupportActionBar(viewBinding.bottomAppBarLayout.bottomAppBar);
|
||||
ActivityHealthConnectPermissionsRationaleBinding viewBinding = ActivityHealthConnectPermissionsRationaleBinding.inflate(getLayoutInflater());
|
||||
setSupportActionBar(viewBinding.healthConnectActivityToolbar);
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
|
||||
ViewUtils.makeClickableLinks(findViewById(android.R.id.content));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
protected View createRootView() {
|
||||
viewBinding = ActivityHealthConnectPermissionsRationaleBinding.inflate(getLayoutInflater());
|
||||
return viewBinding.getRoot();
|
||||
setContentView(viewBinding.getRoot());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
viewBinding = null;
|
||||
public boolean onSupportNavigateUp() {
|
||||
getOnBackPressedDispatcher().onBackPressed();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import de.dennisguse.opentracks.data.models.TrackBuilder;
|
||||
import de.dennisguse.opentracks.databinding.TrackStoppedBinding;
|
||||
import de.dennisguse.opentracks.fragments.ChooseActivityTypeDialogFragment;
|
||||
import de.dennisguse.opentracks.io.file.exporter.ExportUtils;
|
||||
import de.dennisguse.opentracks.io.healthconnect.exporter.HealthConnectUtils;
|
||||
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
|
||||
import de.dennisguse.opentracks.settings.PreferencesUtils;
|
||||
import de.dennisguse.opentracks.ui.aggregatedStatistics.ConfirmDeleteDialogFragment;
|
||||
@@ -93,7 +94,7 @@ public class TrackStoppedActivity extends AbstractTrackDeleteActivity implements
|
||||
viewBinding.finishButton.setOnClickListener(v -> {
|
||||
storeTrackMetaData(contentProviderUtils, track);
|
||||
ExportUtils.postWorkoutExport(this, trackId);
|
||||
ExportUtils.postWorkoutHealthConnectExport(this, trackId);
|
||||
HealthConnectUtils.postExportTrack(this, trackId);
|
||||
finish();
|
||||
});
|
||||
|
||||
|
||||
@@ -168,29 +168,6 @@ public class ContentProviderUtils {
|
||||
return tracks;
|
||||
}
|
||||
|
||||
public List<Track> getTracksOrderedByStartTime(Instant startTimeInclusive) {
|
||||
ArrayList<Track> tracks = new ArrayList<>();
|
||||
String selection = null;
|
||||
String[] selectionArgs = null;
|
||||
|
||||
if (startTimeInclusive != null) {
|
||||
selection = TracksColumns.STARTTIME + ">=?";
|
||||
selectionArgs = new String[]{Long.toString(startTimeInclusive.toEpochMilli())};
|
||||
}
|
||||
|
||||
String sortOrder = TracksColumns.STARTTIME + " ASC, " + TracksColumns._ID + " ASC";
|
||||
try (Cursor cursor = getTrackCursor(selection, selectionArgs, sortOrder)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
tracks.ensureCapacity(cursor.getCount());
|
||||
do {
|
||||
tracks.add(createTrack(cursor));
|
||||
} while (cursor.moveToNext());
|
||||
}
|
||||
}
|
||||
|
||||
return tracks;
|
||||
}
|
||||
|
||||
public TrackListIterator searchTracks(String searchQuery) {
|
||||
// Needed, because MARKER_COUNT is a virtual column and has to be explicitly requested.
|
||||
final String[] PROJECTION = new String[]{
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
import androidx.work.Data;
|
||||
import androidx.work.ExistingWorkPolicy;
|
||||
import androidx.work.OneTimeWorkRequest;
|
||||
import androidx.work.WorkInfo;
|
||||
import androidx.work.WorkManager;
|
||||
@@ -28,7 +27,6 @@ import de.dennisguse.opentracks.data.ContentProviderUtils;
|
||||
import de.dennisguse.opentracks.data.models.Track;
|
||||
import de.dennisguse.opentracks.io.file.TrackFileFormat;
|
||||
import de.dennisguse.opentracks.io.file.TrackFilenameGenerator;
|
||||
import de.dennisguse.opentracks.io.healthconnect.HealthConnectWorker;
|
||||
import de.dennisguse.opentracks.settings.PreferencesUtils;
|
||||
import de.dennisguse.opentracks.settings.SettingsActivity;
|
||||
import de.dennisguse.opentracks.util.IntentUtils;
|
||||
@@ -38,7 +36,7 @@ public class ExportUtils {
|
||||
private static final String TAG = ExportUtils.class.getSimpleName();
|
||||
|
||||
public static void postWorkoutExport(AppCompatActivity context, Track.Id trackId) {
|
||||
if (PreferencesUtils.shouldInstantExportAfterWorkout()) {
|
||||
if (PreferencesUtils.shouldInstantExportToFile()) {
|
||||
TrackFileFormat trackFileFormat = PreferencesUtils.getExportTrackFileFormat();
|
||||
DocumentFile directory = IntentUtils.toDocumentFile(context, PreferencesUtils.getDefaultExportDirectoryUri());
|
||||
|
||||
@@ -69,48 +67,6 @@ public class ExportUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static void postWorkoutHealthConnectExport(Context context, Track.Id trackId) {
|
||||
if (!PreferencesUtils.shouldExportToHealthConnect()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (PreferencesUtils.shouldRunHealthConnectInitialSync()) {
|
||||
startHealthConnectInitialSync(context, PreferencesUtils.getHealthConnectInitialSyncStartTimeMillis());
|
||||
return;
|
||||
}
|
||||
|
||||
WorkRequest request = new OneTimeWorkRequest.Builder(HealthConnectWorker.class)
|
||||
.addTag(HealthConnectWorker.WORK_TAG)
|
||||
.setInputData(new Data.Builder()
|
||||
.putString(HealthConnectWorker.MODE_KEY, HealthConnectWorker.MODE_SINGLE_TRACK)
|
||||
.putLong(HealthConnectWorker.TRACK_ID_KEY, trackId.id())
|
||||
.build())
|
||||
.build();
|
||||
WorkManager.getInstance(context).enqueue(request);
|
||||
}
|
||||
|
||||
public static void startHealthConnectInitialSync(Context context, long startTimeMillis) {
|
||||
OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(HealthConnectWorker.class)
|
||||
.addTag(HealthConnectWorker.WORK_TAG)
|
||||
.setInputData(new Data.Builder()
|
||||
.putString(HealthConnectWorker.MODE_KEY, HealthConnectWorker.MODE_INITIAL_SYNC)
|
||||
.putLong(HealthConnectWorker.INITIAL_SYNC_START_TIME_MILLIS_KEY, startTimeMillis)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
HealthConnectWorker.INITIAL_SYNC_WORK_NAME,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
public static void cancelHealthConnectExport(Context context) {
|
||||
WorkManager workManager = WorkManager.getInstance(context);
|
||||
workManager.cancelUniqueWork(HealthConnectWorker.INITIAL_SYNC_WORK_NAME);
|
||||
workManager.cancelAllWorkByTag(HealthConnectWorker.WORK_TAG);
|
||||
}
|
||||
|
||||
public static void exportTrack(Context context, DocumentFile directory, @Nullable String filenameForMultiple, TrackFileFormat trackFileFormat, List<Track.Id> trackIds) {
|
||||
|
||||
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context);
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
package de.dennisguse.opentracks.io.healthconnect;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.health.connect.client.HealthConnectClient;
|
||||
import androidx.health.connect.client.PermissionController;
|
||||
import androidx.health.connect.client.permission.HealthPermission;
|
||||
import androidx.health.connect.client.records.ExerciseRoute;
|
||||
import androidx.health.connect.client.records.ExerciseSessionRecord;
|
||||
import androidx.health.connect.client.records.metadata.Device;
|
||||
import androidx.health.connect.client.records.metadata.Metadata;
|
||||
import androidx.health.connect.client.units.Length;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import de.dennisguse.opentracks.data.ContentProviderUtils;
|
||||
import de.dennisguse.opentracks.data.models.ActivityType;
|
||||
import de.dennisguse.opentracks.data.models.Track;
|
||||
import de.dennisguse.opentracks.data.models.TrackPoint;
|
||||
import kotlin.coroutines.Continuation;
|
||||
import kotlin.jvm.JvmClassMappingKt;
|
||||
import kotlin.jvm.functions.Function2;
|
||||
import kotlinx.coroutines.BuildersKt;
|
||||
import kotlinx.coroutines.CoroutineScope;
|
||||
|
||||
public class HealthConnectExporter {
|
||||
|
||||
private static final String TAG = HealthConnectExporter.class.getSimpleName();
|
||||
|
||||
public static final String WRITE_EXERCISE_PERMISSION =
|
||||
HealthPermission.getWritePermission(JvmClassMappingKt.getKotlinClass(ExerciseSessionRecord.class));
|
||||
public static final String WRITE_EXERCISE_ROUTE_PERMISSION =
|
||||
HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE;
|
||||
|
||||
private HealthConnectExporter() {}
|
||||
|
||||
public static boolean isAvailable(Context context) {
|
||||
return HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE;
|
||||
}
|
||||
|
||||
public static boolean hasRequiredPermissions(Context context, boolean includeRoute) {
|
||||
try {
|
||||
HealthConnectClient client = HealthConnectClient.getOrCreate(context);
|
||||
PermissionController controller = client.getPermissionController();
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
Set<String> granted = (Set<String>) BuildersKt.runBlocking(
|
||||
kotlin.coroutines.EmptyCoroutineContext.INSTANCE,
|
||||
new Function2() {
|
||||
@Override
|
||||
public Object invoke(Object scope, Object cont) {
|
||||
return controller.getGrantedPermissions((Continuation) cont);
|
||||
}
|
||||
});
|
||||
return granted.contains(WRITE_EXERCISE_PERMISSION)
|
||||
&& (!includeRoute || granted.contains(WRITE_EXERCISE_ROUTE_PERMISSION));
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports a single track as an ExerciseSession to Health Connect.
|
||||
* Uses the track UUID as clientRecordId to prevent duplicates on re-export.
|
||||
* Blocks the calling thread — must be called from a background thread.
|
||||
*/
|
||||
public static ExportResult export(Context context, Track.Id trackId, boolean includeDetailedRoute) throws Exception {
|
||||
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context);
|
||||
Track track = contentProviderUtils.getTrack(trackId);
|
||||
if (track == null) {
|
||||
throw new RuntimeException("Track not found: " + trackId);
|
||||
}
|
||||
|
||||
Instant sessionStartTime = track.startTime().toInstant();
|
||||
Instant sessionEndTime = track.stopTime().toInstant();
|
||||
|
||||
ExerciseRoute exerciseRoute = includeDetailedRoute
|
||||
? createExerciseRoute(contentProviderUtils, trackId, sessionStartTime, sessionEndTime)
|
||||
: null;
|
||||
|
||||
HealthConnectClient client = HealthConnectClient.getOrCreate(context);
|
||||
|
||||
Device device = new Device(Device.TYPE_PHONE, Build.MANUFACTURER, Build.MODEL);
|
||||
Metadata metadata = Metadata.activelyRecorded(device, track.uuid().toString());
|
||||
|
||||
ExerciseSessionRecord record = new ExerciseSessionRecord(
|
||||
sessionStartTime,
|
||||
track.startTime().getOffset(),
|
||||
sessionEndTime,
|
||||
track.stopTime().getOffset(),
|
||||
metadata,
|
||||
toExerciseType(track.activityType()),
|
||||
track.name(),
|
||||
null,
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
exerciseRoute);
|
||||
|
||||
List<ExerciseSessionRecord> records = Collections.singletonList(record);
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
Object ignored = BuildersKt.runBlocking(
|
||||
kotlin.coroutines.EmptyCoroutineContext.INSTANCE,
|
||||
new Function2() {
|
||||
@Override
|
||||
public Object invoke(Object scope, Object cont) {
|
||||
return client.insertRecords(records, (Continuation) cont);
|
||||
}
|
||||
});
|
||||
|
||||
Log.i(TAG, "Exported track to Health Connect: " + track.name());
|
||||
return new ExportResult(track.name(), exerciseRoute != null);
|
||||
}
|
||||
|
||||
private static ExerciseRoute createExerciseRoute(
|
||||
ContentProviderUtils contentProviderUtils,
|
||||
Track.Id trackId,
|
||||
Instant sessionStartTime,
|
||||
Instant sessionEndTime) {
|
||||
|
||||
List<ExerciseRoute.Location> route = new ArrayList<>();
|
||||
Instant lastTime = null;
|
||||
int droppedOutsideSessionRange = 0;
|
||||
|
||||
try (var iterator = contentProviderUtils.getTrackPointIterator(trackId, null)) {
|
||||
while (iterator.hasNext()) {
|
||||
TrackPoint trackPoint = iterator.next();
|
||||
if (trackPoint.type() != TrackPoint.Type.TRACKPOINT) continue;
|
||||
|
||||
var position = trackPoint.position();
|
||||
Instant time = position.time();
|
||||
if (time == null || !position.hasValidLocation()) continue;
|
||||
if (time.isBefore(sessionStartTime) || !time.isBefore(sessionEndTime)) {
|
||||
droppedOutsideSessionRange++;
|
||||
continue;
|
||||
}
|
||||
if (lastTime != null && !time.isAfter(lastTime)) continue;
|
||||
|
||||
Length hAcc = position.horizontalAccuracy() != null
|
||||
? Length.meters(position.horizontalAccuracy().toM()) : null;
|
||||
Length vAcc = position.verticalAccuracy() != null
|
||||
? Length.meters(position.verticalAccuracy().toM()) : null;
|
||||
Length alt = position.altitude() != null
|
||||
? Length.meters(position.altitude().toM()) : null;
|
||||
|
||||
route.add(new ExerciseRoute.Location(
|
||||
time,
|
||||
position.latitude(),
|
||||
position.longitude(),
|
||||
hAcc,
|
||||
vAcc,
|
||||
alt));
|
||||
lastTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
if (droppedOutsideSessionRange > 0) {
|
||||
Log.i(TAG, "Dropped " + droppedOutsideSessionRange
|
||||
+ " route point(s) outside session range for track " + trackId.id());
|
||||
}
|
||||
|
||||
return route.isEmpty() ? null : new ExerciseRoute(route);
|
||||
}
|
||||
|
||||
private static int toExerciseType(ActivityType activityType) {
|
||||
return switch (activityType) {
|
||||
case RUNNING, STREET_RUNNING, TRAIL_RUNNING, TRACK_RUNNING ->
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_RUNNING;
|
||||
case BIKING, CYCLING, ROAD_BIKING, MOUNTAIN_BIKING, TRACK_CYCLING ->
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_BIKING;
|
||||
case WALKING, SPEED_WALKING ->
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_WALKING;
|
||||
case HIKING, OFF_TRAIL_HIKING, TRAIL_HIKING ->
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_HIKING;
|
||||
case CROSS_COUNTRY_SKIING, SKIING ->
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_SKIING;
|
||||
case SNOW_BOARDING ->
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_SNOWBOARDING;
|
||||
case KAYAKING ->
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_PADDLING;
|
||||
case ROWING ->
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE;
|
||||
case SWIMMING ->
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL;
|
||||
case SWIMMING_OPEN ->
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER;
|
||||
default ->
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT;
|
||||
};
|
||||
}
|
||||
|
||||
public static class ExportResult {
|
||||
public final String trackName;
|
||||
public final boolean includedRoute;
|
||||
|
||||
public ExportResult(String trackName, boolean includedRoute) {
|
||||
this.trackName = trackName;
|
||||
this.includedRoute = includedRoute;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package de.dennisguse.opentracks.io.healthconnect;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.work.Data;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import de.dennisguse.opentracks.R;
|
||||
import de.dennisguse.opentracks.data.ContentProviderUtils;
|
||||
import de.dennisguse.opentracks.data.models.Track;
|
||||
import de.dennisguse.opentracks.settings.PreferencesUtils;
|
||||
|
||||
public class HealthConnectWorker extends Worker {
|
||||
|
||||
private static final String TAG = HealthConnectWorker.class.getSimpleName();
|
||||
public static final String WORK_TAG = "HealthConnectSync";
|
||||
public static final String INITIAL_SYNC_WORK_NAME = "HealthConnectInitialSync";
|
||||
public static final String MODE_KEY = "mode";
|
||||
public static final String MODE_SINGLE_TRACK = "single_track";
|
||||
public static final String MODE_INITIAL_SYNC = "initial_sync";
|
||||
public static final String TRACK_ID_KEY = "trackId";
|
||||
public static final String INITIAL_SYNC_START_TIME_MILLIS_KEY = "initialSyncStartTimeMillis";
|
||||
private static final long UNSET_TIME_MILLIS = Long.MIN_VALUE;
|
||||
private static final long SYNC_ALL_TIME_MILLIS = -1L;
|
||||
|
||||
public HealthConnectWorker(Context context, WorkerParameters params) {
|
||||
super(context, params);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result doWork() {
|
||||
boolean includeDetailedRoute = PreferencesUtils.shouldExportDetailedHealthConnectWorkout();
|
||||
|
||||
if (!PreferencesUtils.shouldExportToHealthConnect()) {
|
||||
Log.i(TAG, "Skipping Health Connect export because export is disabled");
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
if (!HealthConnectExporter.isAvailable(getApplicationContext())) {
|
||||
Log.w(TAG, "Health Connect SDK not available");
|
||||
updateStatus(R.string.health_connect_sync_status_failure, "Health Connect not available");
|
||||
return Result.failure(new Data.Builder().putString("error", "Health Connect not available").build());
|
||||
}
|
||||
|
||||
if (!HealthConnectExporter.hasRequiredPermissions(getApplicationContext(), includeDetailedRoute)) {
|
||||
Log.w(TAG, "Health Connect required permissions not granted");
|
||||
updateStatus(R.string.health_connect_sync_status_failure, "Health Connect permission not granted");
|
||||
return Result.failure(new Data.Builder().putString("error", "Health Connect permission not granted").build());
|
||||
}
|
||||
|
||||
String mode = getInputData().getString(MODE_KEY);
|
||||
if (MODE_INITIAL_SYNC.equals(mode)) {
|
||||
return doInitialSync(includeDetailedRoute);
|
||||
}
|
||||
return doSingleTrackExport(includeDetailedRoute);
|
||||
}
|
||||
|
||||
private Result doSingleTrackExport(boolean includeDetailedRoute) {
|
||||
long trackId = getInputData().getLong(TRACK_ID_KEY, -1L);
|
||||
if (trackId == -1L) {
|
||||
Log.e(TAG, "No track ID provided");
|
||||
updateStatus(R.string.health_connect_sync_status_failure, "No track ID provided");
|
||||
return Result.failure();
|
||||
}
|
||||
|
||||
try {
|
||||
HealthConnectExporter.ExportResult result =
|
||||
HealthConnectExporter.export(getApplicationContext(), new Track.Id(trackId), includeDetailedRoute);
|
||||
String routeSuffix = result.includedRoute
|
||||
? getApplicationContext().getString(R.string.health_connect_sync_status_route_suffix)
|
||||
: "";
|
||||
PreferencesUtils.setHealthConnectLastSyncStatus(
|
||||
prefixedStatus(getApplicationContext().getString(
|
||||
R.string.health_connect_sync_status_success, result.trackName, routeSuffix)));
|
||||
return Result.success();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return Result.retry();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Health Connect export failed for track " + trackId, e);
|
||||
updateStatus(R.string.health_connect_sync_status_failure,
|
||||
e.getMessage() != null ? e.getMessage() : "Unknown error");
|
||||
return Result.failure(new Data.Builder().putString("error", e.getMessage()).build());
|
||||
}
|
||||
}
|
||||
|
||||
private Result doInitialSync(boolean includeDetailedRoute) {
|
||||
long configuredStartTimeMillis = getInputData().getLong(
|
||||
INITIAL_SYNC_START_TIME_MILLIS_KEY,
|
||||
PreferencesUtils.getHealthConnectInitialSyncStartTimeMillis());
|
||||
|
||||
if (!PreferencesUtils.hasHealthConnectInitialSyncConfigured()) {
|
||||
Log.i(TAG, "Skipping initial sync because no start time was configured");
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
Instant startInstant = configuredStartTimeMillis == SYNC_ALL_TIME_MILLIS
|
||||
? null
|
||||
: Instant.ofEpochMilli(configuredStartTimeMillis);
|
||||
|
||||
List<Track> rawTracks = new ContentProviderUtils(getApplicationContext())
|
||||
.getTracksOrderedByStartTime(startInstant);
|
||||
List<Track> allTracks = new ArrayList<>();
|
||||
for (Track track : rawTracks) {
|
||||
if (track.stopTime().isAfter(track.startTime())) {
|
||||
allTracks.add(track);
|
||||
}
|
||||
}
|
||||
|
||||
long resumeStartTimeMillis = PreferencesUtils.getHealthConnectInitialSyncCursorStartTimeMillis();
|
||||
long resumeTrackId = PreferencesUtils.getHealthConnectInitialSyncCursorTrackId();
|
||||
List<Track> remainingTracks = new ArrayList<>();
|
||||
for (Track track : allTracks) {
|
||||
long trackStartMillis = track.startTime().toInstant().toEpochMilli();
|
||||
if (resumeStartTimeMillis == UNSET_TIME_MILLIS
|
||||
|| trackStartMillis > resumeStartTimeMillis
|
||||
|| (trackStartMillis == resumeStartTimeMillis && track.id().id() > resumeTrackId)) {
|
||||
remainingTracks.add(track);
|
||||
}
|
||||
}
|
||||
|
||||
if (allTracks.isEmpty()) {
|
||||
PreferencesUtils.clearHealthConnectInitialSyncCursor();
|
||||
PreferencesUtils.setHealthConnectInitialSyncCompleted(true);
|
||||
setStatus(getApplicationContext().getString(R.string.health_connect_sync_status_initial_no_tracks));
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
if (remainingTracks.isEmpty()) {
|
||||
PreferencesUtils.clearHealthConnectInitialSyncCursor();
|
||||
PreferencesUtils.setHealthConnectInitialSyncCompleted(true);
|
||||
setStatus(getApplicationContext().getString(
|
||||
R.string.health_connect_sync_status_initial_success, allTracks.size()));
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
for (int index = 0; index < remainingTracks.size(); index++) {
|
||||
if (isStopped()) {
|
||||
Log.i(TAG, "Initial Health Connect sync stopped by WorkManager");
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
if (!PreferencesUtils.shouldExportToHealthConnect()) {
|
||||
Log.i(TAG, "Stopping initial Health Connect sync because export was disabled");
|
||||
setStatus(getApplicationContext().getString(R.string.health_connect_sync_status_initial_stopped));
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
Track track = remainingTracks.get(index);
|
||||
String trackName = track.name().isBlank() ? "Workout " + track.id().id() : track.name();
|
||||
setStatus(getApplicationContext().getString(
|
||||
R.string.health_connect_sync_status_initial_progress,
|
||||
index + 1, remainingTracks.size(), trackName));
|
||||
|
||||
try {
|
||||
HealthConnectExporter.export(getApplicationContext(), track.id(), includeDetailedRoute);
|
||||
PreferencesUtils.setHealthConnectInitialSyncCursorStartTimeMillis(
|
||||
track.startTime().toInstant().toEpochMilli());
|
||||
PreferencesUtils.setHealthConnectInitialSyncCursorTrackId(track.id().id());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return Result.retry();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Initial Health Connect sync failed for track " + track.id().id(), e);
|
||||
PreferencesUtils.setHealthConnectInitialSyncCompleted(false);
|
||||
setStatus(getApplicationContext().getString(
|
||||
R.string.health_connect_sync_status_initial_failure,
|
||||
trackName, e.getMessage() != null ? e.getMessage() : "Unknown error"));
|
||||
return Result.failure(new Data.Builder().putString("error", e.getMessage()).build());
|
||||
}
|
||||
}
|
||||
|
||||
PreferencesUtils.clearHealthConnectInitialSyncCursor();
|
||||
PreferencesUtils.setHealthConnectInitialSyncCompleted(true);
|
||||
setStatus(getApplicationContext().getString(
|
||||
R.string.health_connect_sync_status_initial_success, allTracks.size()));
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
private void updateStatus(@StringRes int messageResId, String detail) {
|
||||
setStatus(getApplicationContext().getString(messageResId, detail));
|
||||
}
|
||||
|
||||
private void setStatus(String message) {
|
||||
PreferencesUtils.setHealthConnectLastSyncStatus(prefixedStatus(message));
|
||||
}
|
||||
|
||||
private String prefixedStatus(String message) {
|
||||
String timestamp = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(new Date());
|
||||
return timestamp + ": " + message;
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package de.dennisguse.opentracks.io.healthconnect.exporter;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.health.connect.client.HealthConnectClient;
|
||||
import androidx.health.connect.client.PermissionController;
|
||||
import androidx.health.connect.client.permission.HealthPermission;
|
||||
import androidx.health.connect.client.records.ExerciseSessionRecord;
|
||||
import androidx.work.Data;
|
||||
import androidx.work.ExistingWorkPolicy;
|
||||
import androidx.work.OneTimeWorkRequest;
|
||||
import androidx.work.WorkManager;
|
||||
import androidx.work.WorkRequest;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import de.dennisguse.opentracks.data.models.Track;
|
||||
import de.dennisguse.opentracks.settings.PreferencesUtils;
|
||||
import kotlin.coroutines.Continuation;
|
||||
import kotlin.jvm.JvmClassMappingKt;
|
||||
import kotlin.jvm.functions.Function2;
|
||||
import kotlinx.coroutines.BuildersKt;
|
||||
|
||||
/**
|
||||
* Exports a single track as an ExerciseSession to Health Connect.
|
||||
* Uses the Track.uuid as clientRecordId to prevent duplicates on re-export.
|
||||
*/
|
||||
public class HealthConnectUtils {
|
||||
|
||||
private static final String TAG = HealthConnectUtils.class.getSimpleName();
|
||||
|
||||
static final String WRITE_EXERCISE_PERMISSION = HealthPermission.getWritePermission(JvmClassMappingKt.getKotlinClass(ExerciseSessionRecord.class));
|
||||
static final String WRITE_EXERCISE_ROUTE_PERMISSION = HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE;
|
||||
|
||||
//TODO Should be package private
|
||||
public static final Set<String> PERMISSIONS = Set.of(WRITE_EXERCISE_PERMISSION, WRITE_EXERCISE_ROUTE_PERMISSION);
|
||||
|
||||
private HealthConnectUtils() {
|
||||
}
|
||||
|
||||
public static boolean isAvailable(Context context) {
|
||||
return HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE;
|
||||
}
|
||||
|
||||
public static Set<String> getGrantedPermissions(Context context) {
|
||||
try {
|
||||
HealthConnectClient client = HealthConnectClient.getOrCreate(context);
|
||||
PermissionController controller = client.getPermissionController();
|
||||
|
||||
return (Set<String>) BuildersKt.runBlocking(
|
||||
kotlin.coroutines.EmptyCoroutineContext.INSTANCE,
|
||||
(Function2) (scope, cont) -> controller.getGrantedPermissions((Continuation) cont));
|
||||
} catch (InterruptedException e) {
|
||||
return Set.of();
|
||||
}
|
||||
}
|
||||
|
||||
static boolean hasRequiredPermissions(Context context) {
|
||||
return getGrantedPermissions(context)
|
||||
.containsAll(PERMISSIONS);
|
||||
}
|
||||
|
||||
public static void postExportTrack(Context context, Track.Id trackId) {
|
||||
if (!PreferencesUtils.shouldInstantExportToHealthConnect()) {
|
||||
return;
|
||||
}
|
||||
|
||||
WorkRequest request = new OneTimeWorkRequest.Builder(HealthConnectWorker.class)
|
||||
.addTag(HealthConnectWorker.WORK_TAG)
|
||||
.setInputData(new Data.Builder()
|
||||
.putLong(HealthConnectWorker.TRACK_ID_KEY, trackId.id())
|
||||
.build())
|
||||
.build();
|
||||
WorkManager.getInstance(context).enqueue(request);
|
||||
}
|
||||
|
||||
public static void postExportAll(Context context) {
|
||||
OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(HealthConnectWorker.class)
|
||||
.addTag(HealthConnectWorker.WORK_TAG)
|
||||
.build();
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
HealthConnectWorker.WORK_TAG,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
public static void cancelHealthConnectExport(Context context) {
|
||||
WorkManager workManager = WorkManager.getInstance(context);
|
||||
workManager.cancelAllWorkByTag(HealthConnectWorker.WORK_TAG);
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
package de.dennisguse.opentracks.io.healthconnect.exporter;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.health.connect.client.HealthConnectClient;
|
||||
import androidx.health.connect.client.records.ExerciseRoute;
|
||||
import androidx.health.connect.client.records.ExerciseSessionRecord;
|
||||
import androidx.health.connect.client.records.metadata.Device;
|
||||
import androidx.health.connect.client.records.metadata.Metadata;
|
||||
import androidx.health.connect.client.units.Length;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import de.dennisguse.opentracks.data.ContentProviderUtils;
|
||||
import de.dennisguse.opentracks.data.TrackPointIterator;
|
||||
import de.dennisguse.opentracks.data.models.ActivityType;
|
||||
import de.dennisguse.opentracks.data.models.Position;
|
||||
import de.dennisguse.opentracks.data.models.Track;
|
||||
import de.dennisguse.opentracks.data.models.TrackPoint;
|
||||
import kotlin.coroutines.Continuation;
|
||||
import kotlin.jvm.functions.Function2;
|
||||
import kotlinx.coroutines.BuildersKt;
|
||||
|
||||
/**
|
||||
* Exports a single track as an ExerciseSession to Health Connect.
|
||||
* Uses the Track.uuid as clientRecordId to prevent duplicates on re-export.
|
||||
*/
|
||||
//TODO add reasons for failures (if shown in the UI: must be localized)
|
||||
//TODO Export sensor data (permissions needed)
|
||||
//TODO Show progress: like ExportActivity? Or use a notification (aka handle it as a background process)
|
||||
public class HealthConnectWorker extends Worker {
|
||||
|
||||
private static final String TAG = HealthConnectWorker.class.getSimpleName();
|
||||
static final String WORK_TAG = "HealthConnectSync";
|
||||
|
||||
static final String TRACK_ID_KEY = "trackId";
|
||||
|
||||
public HealthConnectWorker(Context context, WorkerParameters params) {
|
||||
super(context, params);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
if (!HealthConnectUtils.isAvailable(getApplicationContext())) {
|
||||
Log.w(TAG, "Health Connect SDK not available");
|
||||
return Result.failure();
|
||||
}
|
||||
|
||||
//TODO If not all permissions are granted, export the data that was granted.
|
||||
if (!HealthConnectUtils.hasRequiredPermissions(getApplicationContext())) {
|
||||
Log.w(TAG, "Health Connect required permissions not granted");
|
||||
return Result.failure();
|
||||
}
|
||||
|
||||
Track.Id trackId = new Track.Id(getInputData().getLong(TRACK_ID_KEY, -1L));
|
||||
if (trackId.id() != -1L) {
|
||||
return exportTrack(trackId);
|
||||
}
|
||||
return exportAll();
|
||||
}
|
||||
|
||||
private Result exportTrack(Track.Id trackId) {
|
||||
try {
|
||||
export(getApplicationContext(), trackId);
|
||||
return Result.success();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return Result.retry();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Health Connect export failed for track " + trackId, e);
|
||||
return Result.failure();
|
||||
}
|
||||
}
|
||||
|
||||
private Result exportAll() {
|
||||
List<Track> allTracks = new ContentProviderUtils(getApplicationContext())
|
||||
.getTracks();
|
||||
|
||||
for (Track track : allTracks) {
|
||||
if (isStopped()) {
|
||||
Log.i(TAG, "Initial Health Connect sync stopped by WorkManager");
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
try {
|
||||
export(getApplicationContext(), track.id());
|
||||
} catch (InterruptedException e) {
|
||||
return Result.retry();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Initial Health Connect sync failed for track " + track.id().id(), e);
|
||||
return Result.failure();
|
||||
}
|
||||
}
|
||||
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
|
||||
private static ExportResult export(Context context, Track.Id trackId) throws InterruptedException {
|
||||
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context);
|
||||
Track track = contentProviderUtils.getTrack(trackId);
|
||||
if (track == null) {
|
||||
throw new RuntimeException("Track not found: " + trackId);
|
||||
}
|
||||
|
||||
ExerciseRoute exerciseRoute = createExerciseRoute(contentProviderUtils, trackId);
|
||||
|
||||
HealthConnectClient client = HealthConnectClient.getOrCreate(context);
|
||||
|
||||
ExerciseSessionRecord record = new ExerciseSessionRecord(
|
||||
track.startTime().toInstant(),
|
||||
track.startTime().getOffset(),
|
||||
track.stopTime().toInstant(),
|
||||
track.stopTime().getOffset(),
|
||||
Metadata.activelyRecorded(
|
||||
new Device(Device.TYPE_PHONE, Build.MANUFACTURER, Build.MODEL),
|
||||
track.uuid().toString()
|
||||
),
|
||||
toExerciseType(track.activityType()),
|
||||
track.name(),
|
||||
track.description(),
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
exerciseRoute);
|
||||
|
||||
List<ExerciseSessionRecord> records = Collections.singletonList(record);
|
||||
Object ignored = BuildersKt.runBlocking(
|
||||
kotlin.coroutines.EmptyCoroutineContext.INSTANCE,
|
||||
(Function2) (scope, cont) -> client.insertRecords(records, (Continuation) cont));
|
||||
|
||||
Log.i(TAG, "Exported track to Health Connect: " + track.name());
|
||||
return new ExportResult(track.name());
|
||||
}
|
||||
|
||||
private static ExerciseRoute createExerciseRoute(ContentProviderUtils contentProviderUtils, Track.Id trackId) {
|
||||
List<ExerciseRoute.Location> route = new ArrayList<>();
|
||||
|
||||
try (TrackPointIterator iterator = contentProviderUtils.getTrackPointIterator(trackId, null)) {
|
||||
while (iterator.hasNext()) {
|
||||
TrackPoint trackPoint = iterator.next();
|
||||
if (trackPoint.type() != TrackPoint.Type.TRACKPOINT) continue;
|
||||
|
||||
Position position = trackPoint.position();
|
||||
|
||||
route.add(new ExerciseRoute.Location(
|
||||
trackPoint.getTime(),
|
||||
position.latitude(),
|
||||
position.longitude(),
|
||||
position.horizontalAccuracy() != null ? Length.meters(position.horizontalAccuracy().toM()) : null,
|
||||
position.verticalAccuracy() != null ? Length.meters(position.verticalAccuracy().toM()) : null,
|
||||
position.altitude() != null ? Length.meters(position.altitude().toM()) : null)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new ExerciseRoute(route);
|
||||
}
|
||||
|
||||
private static int toExerciseType(ActivityType activityType) {
|
||||
return switch (activityType) {
|
||||
case RUNNING, STREET_RUNNING, TRAIL_RUNNING, TRACK_RUNNING -> ExerciseSessionRecord.EXERCISE_TYPE_RUNNING;
|
||||
case BIKING, CYCLING, ROAD_BIKING, MOUNTAIN_BIKING, TRACK_CYCLING -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING;
|
||||
case WALKING, SPEED_WALKING -> ExerciseSessionRecord.EXERCISE_TYPE_WALKING;
|
||||
case HIKING, OFF_TRAIL_HIKING, TRAIL_HIKING -> ExerciseSessionRecord.EXERCISE_TYPE_HIKING;
|
||||
case CROSS_COUNTRY_SKIING, SKIING -> ExerciseSessionRecord.EXERCISE_TYPE_SKIING;
|
||||
case SNOW_BOARDING -> ExerciseSessionRecord.EXERCISE_TYPE_SNOWBOARDING;
|
||||
case KAYAKING -> ExerciseSessionRecord.EXERCISE_TYPE_PADDLING;
|
||||
case ROWING -> ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE;
|
||||
case SWIMMING -> ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL;
|
||||
case SWIMMING_OPEN -> ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER;
|
||||
default -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT;
|
||||
};
|
||||
}
|
||||
|
||||
public record ExportResult(String trackName) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.dennisguse.opentracks.settings;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.health.connect.client.HealthConnectClient;
|
||||
import androidx.health.connect.client.PermissionController;
|
||||
|
||||
import de.dennisguse.opentracks.io.healthconnect.exporter.HealthConnectUtils;
|
||||
|
||||
public class HealthConnect {
|
||||
|
||||
public static class Settings extends AppCompatActivity {
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
startActivity(new Intent(HealthConnectClient.getHealthConnectSettingsAction()));
|
||||
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
public static class ConfigurePermissions extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (!HealthConnectUtils.isAvailable(this)) {
|
||||
finish();
|
||||
}
|
||||
|
||||
registerForActivityResult(
|
||||
PermissionController.createRequestPermissionResultContract(),
|
||||
grantedPermissions -> {
|
||||
// TODO User feedback via Toast.makeText()?
|
||||
finish();
|
||||
}
|
||||
).launch(HealthConnectUtils.PERMISSIONS);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ExportAll extends AppCompatActivity {
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
HealthConnectUtils.postExportAll(this);
|
||||
|
||||
finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
package de.dennisguse.opentracks.settings;
|
||||
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.RadioButton;
|
||||
import android.widget.RadioGroup;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.health.connect.client.HealthConnectClient;
|
||||
import androidx.health.connect.client.PermissionController;
|
||||
import androidx.work.WorkInfo;
|
||||
import androidx.work.WorkManager;
|
||||
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.android.material.datepicker.MaterialDatePicker;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import de.dennisguse.opentracks.AbstractActivity;
|
||||
import de.dennisguse.opentracks.R;
|
||||
import de.dennisguse.opentracks.databinding.ActivityHealthConnectSettingsBinding;
|
||||
import de.dennisguse.opentracks.io.file.exporter.ExportUtils;
|
||||
import de.dennisguse.opentracks.io.healthconnect.HealthConnectExporter;
|
||||
import de.dennisguse.opentracks.io.healthconnect.HealthConnectWorker;
|
||||
|
||||
public class HealthConnectSettingsActivity extends AbstractActivity {
|
||||
|
||||
private static final String TAG = HealthConnectSettingsActivity.class.getSimpleName();
|
||||
private static final long SYNC_ALL_TIME_MILLIS = -1L;
|
||||
private static final long UNSELECTED_TIME_MILLIS = Long.MIN_VALUE;
|
||||
|
||||
private ActivityHealthConnectSettingsBinding viewBinding;
|
||||
private ActivityResultLauncher<Set<String>> healthConnectPermissionLauncher;
|
||||
private boolean pendingEnableExport;
|
||||
private boolean pendingEnableDetailed;
|
||||
private boolean pendingShowInitialSyncDialog;
|
||||
private Set<String> pendingPermissions = Set.of();
|
||||
private boolean updatingUi;
|
||||
private long customInitialSyncStartTimeMillis = UNSELECTED_TIME_MILLIS;
|
||||
|
||||
private final SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener =
|
||||
(sharedPreferences, key) -> runOnUiThread(this::bindState);
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
setSupportActionBar(viewBinding.topAppBar);
|
||||
viewBinding.topAppBar.setNavigationOnClickListener(v -> getOnBackPressedDispatcher().onBackPressed());
|
||||
|
||||
healthConnectPermissionLauncher = registerForActivityResult(
|
||||
PermissionController.createRequestPermissionResultContract(),
|
||||
grantedPermissions -> {
|
||||
boolean exportGranted = grantedPermissions.contains(HealthConnectExporter.WRITE_EXERCISE_PERMISSION);
|
||||
boolean routeGranted = !pendingPermissions.contains(HealthConnectExporter.WRITE_EXERCISE_ROUTE_PERMISSION)
|
||||
|| grantedPermissions.contains(HealthConnectExporter.WRITE_EXERCISE_ROUTE_PERMISSION);
|
||||
boolean exportEnabled = pendingEnableExport && exportGranted;
|
||||
|
||||
PreferencesUtils.setBoolean(R.string.health_connect_export_enabled_key, exportEnabled);
|
||||
PreferencesUtils.setBoolean(
|
||||
R.string.health_connect_detailed_sync_key,
|
||||
pendingEnableDetailed && exportGranted && routeGranted
|
||||
);
|
||||
|
||||
if (!exportEnabled) {
|
||||
ExportUtils.cancelHealthConnectExport(this);
|
||||
}
|
||||
|
||||
bindState();
|
||||
|
||||
if (exportEnabled && pendingShowInitialSyncDialog) {
|
||||
showInitialSyncDialog();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
setTitle(R.string.health_connect_screen_title);
|
||||
bindActions();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
PreferencesUtils.registerOnSharedPreferenceChangeListenerSilent(preferenceChangeListener);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
PreferencesUtils.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener);
|
||||
super.onStop();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
bindState();
|
||||
}
|
||||
|
||||
private void bindActions() {
|
||||
viewBinding.healthConnectExportRow.setOnClickListener(v ->
|
||||
viewBinding.healthConnectExportSwitch.performClick());
|
||||
viewBinding.healthConnectDetailedRow.setOnClickListener(v -> {
|
||||
if (viewBinding.healthConnectDetailedSwitch.isEnabled()) {
|
||||
viewBinding.healthConnectDetailedSwitch.performClick();
|
||||
}
|
||||
});
|
||||
viewBinding.healthConnectManageDataRow.setOnClickListener(v ->
|
||||
openIntent(HealthConnectClient.getHealthConnectManageDataIntent(this)));
|
||||
viewBinding.healthConnectSettingsRow.setOnClickListener(v ->
|
||||
openIntent(new Intent(HealthConnectClient.getHealthConnectSettingsAction())));
|
||||
viewBinding.healthConnectManualSyncButton.setOnClickListener(v -> startManualSyncFlow());
|
||||
|
||||
viewBinding.healthConnectExportSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
|
||||
if (updatingUi) return;
|
||||
|
||||
if (isChecked) {
|
||||
requestPermissions(true, PreferencesUtils.shouldExportDetailedHealthConnectWorkout());
|
||||
} else {
|
||||
PreferencesUtils.setBoolean(R.string.health_connect_export_enabled_key, false);
|
||||
ExportUtils.cancelHealthConnectExport(this);
|
||||
bindState();
|
||||
}
|
||||
});
|
||||
|
||||
viewBinding.healthConnectDetailedSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
|
||||
if (updatingUi) return;
|
||||
|
||||
if (isChecked) {
|
||||
requestPermissions(true, true);
|
||||
} else {
|
||||
PreferencesUtils.setBoolean(R.string.health_connect_detailed_sync_key, false);
|
||||
bindState();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void bindState() {
|
||||
boolean available = HealthConnectExporter.isAvailable(this);
|
||||
boolean exportEnabled = available && PreferencesUtils.shouldExportToHealthConnect();
|
||||
boolean detailedEnabled = available && PreferencesUtils.shouldExportDetailedHealthConnectWorkout();
|
||||
|
||||
updatingUi = true;
|
||||
viewBinding.healthConnectExportSwitch.setChecked(exportEnabled);
|
||||
viewBinding.healthConnectDetailedSwitch.setChecked(detailedEnabled);
|
||||
updatingUi = false;
|
||||
|
||||
viewBinding.healthConnectExportSwitch.setEnabled(available);
|
||||
viewBinding.healthConnectDetailedSwitch.setEnabled(available && exportEnabled);
|
||||
viewBinding.healthConnectManualSyncButton.setEnabled(available && exportEnabled);
|
||||
|
||||
viewBinding.healthConnectManageDataRow.setEnabled(available);
|
||||
viewBinding.healthConnectSettingsRow.setEnabled(available);
|
||||
viewBinding.healthConnectManageDataRow.setAlpha(available ? 1f : 0.5f);
|
||||
viewBinding.healthConnectSettingsRow.setAlpha(available ? 1f : 0.5f);
|
||||
|
||||
viewBinding.healthConnectAvailabilityNotice.setVisibility(available ? View.GONE : View.VISIBLE);
|
||||
viewBinding.healthConnectExportSummary.setText(
|
||||
available ? R.string.health_connect_export_enabled_summary : R.string.health_connect_not_available
|
||||
);
|
||||
viewBinding.healthConnectDetailedSummary.setText(
|
||||
exportEnabled ? R.string.health_connect_detailed_sync_summary : R.string.health_connect_detailed_sync_requires_export
|
||||
);
|
||||
viewBinding.healthConnectManualSyncSummary.setText(
|
||||
exportEnabled ? R.string.health_connect_manual_sync_summary : R.string.health_connect_manual_sync_requires_export
|
||||
);
|
||||
viewBinding.healthConnectSyncStatusSummary.setText(PreferencesUtils.getHealthConnectLastSyncStatus());
|
||||
}
|
||||
|
||||
private void startManualSyncFlow() {
|
||||
if (!HealthConnectExporter.isAvailable(this) || !PreferencesUtils.shouldExportToHealthConnect()) {
|
||||
bindState();
|
||||
return;
|
||||
}
|
||||
|
||||
ListenableFuture<List<WorkInfo>> workInfosFuture =
|
||||
WorkManager.getInstance(this).getWorkInfosForUniqueWork(HealthConnectWorker.INITIAL_SYNC_WORK_NAME);
|
||||
workInfosFuture.addListener(() -> {
|
||||
try {
|
||||
boolean syncRunning = workInfosFuture.get().stream().anyMatch(workInfo -> !workInfo.getState().isFinished());
|
||||
runOnUiThread(() -> {
|
||||
if (syncRunning) {
|
||||
Toast.makeText(this, R.string.health_connect_sync_already_running, Toast.LENGTH_SHORT).show();
|
||||
} else {
|
||||
showInitialSyncDialog();
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Unable to determine Health Connect sync state", e);
|
||||
runOnUiThread(this::showInitialSyncDialog);
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(this));
|
||||
}
|
||||
|
||||
private void requestPermissions(boolean enableExport, boolean enableDetailed) {
|
||||
if (!HealthConnectExporter.isAvailable(this)) {
|
||||
bindState();
|
||||
return;
|
||||
}
|
||||
|
||||
pendingEnableExport = enableExport;
|
||||
pendingEnableDetailed = enableDetailed;
|
||||
pendingShowInitialSyncDialog = enableExport
|
||||
&& !PreferencesUtils.shouldExportToHealthConnect()
|
||||
&& !PreferencesUtils.hasHealthConnectInitialSyncConfigured()
|
||||
&& !PreferencesUtils.isHealthConnectInitialSyncCompleted();
|
||||
|
||||
LinkedHashSet<String> permissions = new LinkedHashSet<>();
|
||||
permissions.add(HealthConnectExporter.WRITE_EXERCISE_PERMISSION);
|
||||
if (enableDetailed) {
|
||||
permissions.add(HealthConnectExporter.WRITE_EXERCISE_ROUTE_PERMISSION);
|
||||
}
|
||||
pendingPermissions = permissions;
|
||||
|
||||
healthConnectPermissionLauncher.launch(permissions);
|
||||
}
|
||||
|
||||
private void showInitialSyncDialog() {
|
||||
View dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_health_connect_initial_sync, null);
|
||||
RadioGroup radioGroup = dialogView.findViewById(R.id.radio_group_sync_period);
|
||||
RadioButton radioPreset = dialogView.findViewById(R.id.radio_preset);
|
||||
Spinner spinnerPreset = dialogView.findViewById(R.id.spinner_sync_period);
|
||||
Button buttonSelectDate = dialogView.findViewById(R.id.button_select_date);
|
||||
|
||||
String[] options = new String[]{
|
||||
getString(R.string.health_connect_initial_sync_last_3_days),
|
||||
getString(R.string.health_connect_initial_sync_last_7_days),
|
||||
getString(R.string.health_connect_initial_sync_last_30_days),
|
||||
getString(R.string.health_connect_initial_sync_all),
|
||||
};
|
||||
ArrayAdapter<String> adapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, options);
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
spinnerPreset.setAdapter(adapter);
|
||||
spinnerPreset.setSelection(1);
|
||||
|
||||
customInitialSyncStartTimeMillis = UNSELECTED_TIME_MILLIS;
|
||||
buttonSelectDate.setText(R.string.health_connect_initial_sync_select_date);
|
||||
|
||||
radioGroup.setOnCheckedChangeListener((group, checkedId) -> {
|
||||
boolean presetSelected = checkedId == R.id.radio_preset;
|
||||
spinnerPreset.setEnabled(presetSelected);
|
||||
buttonSelectDate.setEnabled(checkedId == R.id.radio_custom_date);
|
||||
});
|
||||
radioPreset.setChecked(true);
|
||||
spinnerPreset.setEnabled(true);
|
||||
buttonSelectDate.setEnabled(false);
|
||||
buttonSelectDate.setOnClickListener(v -> showInitialSyncDatePicker(buttonSelectDate));
|
||||
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.health_connect_initial_sync_dialog_title)
|
||||
.setView(dialogView)
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) ->
|
||||
handleInitialSyncSelection(radioPreset.isChecked(), spinnerPreset.getSelectedItemPosition()))
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showInitialSyncDatePicker(Button buttonSelectDate) {
|
||||
MaterialDatePicker<Long> datePicker = MaterialDatePicker.Builder.datePicker()
|
||||
.setTitleText(R.string.health_connect_initial_sync_select_date)
|
||||
.setSelection(MaterialDatePicker.todayInUtcMilliseconds())
|
||||
.build();
|
||||
|
||||
datePicker.addOnPositiveButtonClickListener(selection -> {
|
||||
customInitialSyncStartTimeMillis = Instant.ofEpochMilli(selection)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli();
|
||||
buttonSelectDate.setText(describeInitialSyncSelection(customInitialSyncStartTimeMillis));
|
||||
});
|
||||
|
||||
datePicker.show(getSupportFragmentManager(), "health_connect_initial_sync_date");
|
||||
}
|
||||
|
||||
private void handleInitialSyncSelection(boolean presetSelected, int selectedPresetPosition) {
|
||||
if (presetSelected) {
|
||||
switch (selectedPresetPosition) {
|
||||
case 0 -> showInitialSyncConfirmation(daysAgoMillis(3));
|
||||
case 1 -> showInitialSyncConfirmation(daysAgoMillis(7));
|
||||
case 2 -> showInitialSyncConfirmation(daysAgoMillis(30));
|
||||
case 3 -> showInitialSyncConfirmation(SYNC_ALL_TIME_MILLIS);
|
||||
default -> showInitialSyncConfirmation(daysAgoMillis(7));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (customInitialSyncStartTimeMillis != UNSELECTED_TIME_MILLIS) {
|
||||
showInitialSyncConfirmation(customInitialSyncStartTimeMillis);
|
||||
return;
|
||||
}
|
||||
|
||||
showInitialSyncConfirmation(daysAgoMillis(7));
|
||||
}
|
||||
|
||||
private void showInitialSyncConfirmation(long startTimeMillis) {
|
||||
String selectionDescription = describeInitialSyncSelection(startTimeMillis);
|
||||
|
||||
PreferencesUtils.resetHealthConnectInitialSyncState(startTimeMillis);
|
||||
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.health_connect_sync_now_title)
|
||||
.setMessage(getString(R.string.health_connect_sync_now_message, selectionDescription))
|
||||
.setPositiveButton(R.string.health_connect_sync_now, (dialog, which) -> {
|
||||
PreferencesUtils.setHealthConnectLastSyncStatus(
|
||||
prefixedStatus(getString(R.string.health_connect_sync_status_initial_starting, selectionDescription))
|
||||
);
|
||||
ExportUtils.startHealthConnectInitialSync(this, startTimeMillis);
|
||||
})
|
||||
.setNegativeButton(R.string.health_connect_sync_later, (dialog, which) -> PreferencesUtils.setHealthConnectLastSyncStatus(
|
||||
prefixedStatus(getString(R.string.health_connect_sync_status_initial_pending, selectionDescription))
|
||||
))
|
||||
.show();
|
||||
}
|
||||
|
||||
private String describeInitialSyncSelection(long startTimeMillis) {
|
||||
if (startTimeMillis == SYNC_ALL_TIME_MILLIS) {
|
||||
return getString(R.string.health_connect_initial_sync_all);
|
||||
}
|
||||
|
||||
return DateFormat.getDateInstance(DateFormat.MEDIUM).format(new Date(startTimeMillis));
|
||||
}
|
||||
|
||||
private long daysAgoMillis(int days) {
|
||||
return Instant.now()
|
||||
.minusSeconds(days * 24L * 60L * 60L)
|
||||
.toEpochMilli();
|
||||
}
|
||||
|
||||
private String prefixedStatus(String message) {
|
||||
String timestamp = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(new Date());
|
||||
return timestamp + ": " + message;
|
||||
}
|
||||
|
||||
private void openIntent(Intent intent) {
|
||||
try {
|
||||
startActivity(intent);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
Toast.makeText(this, R.string.health_connect_not_available, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
protected View createRootView() {
|
||||
viewBinding = ActivityHealthConnectSettingsBinding.inflate(getLayoutInflater());
|
||||
return viewBinding.getRoot();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.dennisguse.opentracks.settings;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
|
||||
@@ -13,9 +12,9 @@ import androidx.preference.PreferenceFragmentCompat;
|
||||
import java.util.Map;
|
||||
|
||||
import de.dennisguse.opentracks.R;
|
||||
import de.dennisguse.opentracks.io.healthconnect.HealthConnectExporter;
|
||||
import de.dennisguse.opentracks.io.file.TrackFileFormat;
|
||||
import de.dennisguse.opentracks.io.file.TrackFilenameGenerator;
|
||||
import de.dennisguse.opentracks.io.healthconnect.exporter.HealthConnectUtils;
|
||||
import de.dennisguse.opentracks.util.IntentUtils;
|
||||
|
||||
|
||||
@@ -28,7 +27,11 @@ public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
|
||||
setExportTrackFileFormatOptions();
|
||||
setExportDirectorySummary();
|
||||
setFilenameTemplate();
|
||||
setHealthConnectPreference();
|
||||
|
||||
findPreference("healthConnectPermissions").setSummaryProvider(preference -> {
|
||||
//TODO This should be localized and prefixes trimmed.
|
||||
return String.join(",", HealthConnectUtils.getGrantedPermissions(getActivity()));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -42,35 +45,21 @@ public class ImportExportSettingsFragment extends PreferenceFragmentCompat {
|
||||
super.onResume();
|
||||
|
||||
setExportDirectorySummary();
|
||||
setHealthConnectPreference();
|
||||
findPreference(getString(R.string.post_workout_export_enabled_key)).setEnabled(PreferencesUtils.isDefaultExportDirectoryUri());
|
||||
|
||||
Preference instantExportEnabledPreference = findPreference(getString(R.string.post_workout_export_enabled_key));
|
||||
instantExportEnabledPreference.setEnabled(PreferencesUtils.isDefaultExportDirectoryUri());
|
||||
setHealthConnectPreference();
|
||||
}
|
||||
|
||||
private void setHealthConnectPreference() {
|
||||
Preference preference = findPreference(getString(R.string.health_connect_settings_key));
|
||||
if (preference == null) return;
|
||||
boolean healthConnectAvailable = HealthConnectUtils.isAvailable(requireContext());
|
||||
findPreference("healthConnectCategory")
|
||||
.setEnabled(healthConnectAvailable);
|
||||
|
||||
preference.setOnPreferenceClickListener(pref -> {
|
||||
startActivity(new Intent(requireContext(), HealthConnectSettingsActivity.class));
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!HealthConnectExporter.isAvailable(requireContext())) {
|
||||
preference.setSummary(R.string.health_connect_not_available);
|
||||
if (!healthConnectAvailable) {
|
||||
findPreference(getString(R.string.post_workout_export_health_connect_enabled_key))
|
||||
.setSummary(R.string.health_connect_not_available);
|
||||
return;
|
||||
}
|
||||
|
||||
if (PreferencesUtils.shouldExportToHealthConnect()) {
|
||||
preference.setSummary(
|
||||
PreferencesUtils.shouldExportDetailedHealthConnectWorkout()
|
||||
? R.string.health_connect_settings_summary_enabled_detailed
|
||||
: R.string.health_connect_settings_summary_enabled
|
||||
);
|
||||
} else {
|
||||
preference.setSummary(R.string.health_connect_settings_summary);
|
||||
}
|
||||
}
|
||||
|
||||
private void setExportTrackFileFormatOptions() {
|
||||
|
||||
@@ -679,79 +679,14 @@ public class PreferencesUtils {
|
||||
return entries;
|
||||
}
|
||||
|
||||
|
||||
public static boolean shouldInstantExportAfterWorkout() {
|
||||
public static boolean shouldInstantExportToFile() {
|
||||
final boolean INSTANT_POST_WORKOUT_EXPORT_DEFAULT = resources.getBoolean(R.bool.post_workout_export_enabled_default);
|
||||
return getBoolean(R.string.post_workout_export_enabled_key, INSTANT_POST_WORKOUT_EXPORT_DEFAULT) && isDefaultExportDirectoryUri();
|
||||
}
|
||||
|
||||
public static boolean shouldExportToHealthConnect() {
|
||||
final boolean DEFAULT = resources.getBoolean(R.bool.health_connect_export_enabled_default);
|
||||
return getBoolean(R.string.health_connect_export_enabled_key, DEFAULT);
|
||||
}
|
||||
|
||||
public static boolean shouldExportDetailedHealthConnectWorkout() {
|
||||
final boolean DEFAULT = resources.getBoolean(R.bool.health_connect_detailed_sync_default);
|
||||
return getBoolean(R.string.health_connect_detailed_sync_key, DEFAULT);
|
||||
}
|
||||
|
||||
public static String getHealthConnectLastSyncStatus() {
|
||||
return getString(R.string.health_connect_last_sync_status_key, resources.getString(R.string.health_connect_sync_status_never));
|
||||
}
|
||||
|
||||
public static void setHealthConnectLastSyncStatus(String value) {
|
||||
setString(R.string.health_connect_last_sync_status_key, value);
|
||||
}
|
||||
|
||||
public static boolean hasHealthConnectInitialSyncConfigured() {
|
||||
return getLong(R.string.health_connect_initial_sync_start_time_key, LONG_UNSET) != LONG_UNSET;
|
||||
}
|
||||
|
||||
public static long getHealthConnectInitialSyncStartTimeMillis() {
|
||||
return getLong(R.string.health_connect_initial_sync_start_time_key, LONG_UNSET);
|
||||
}
|
||||
|
||||
public static void setHealthConnectInitialSyncStartTimeMillis(long value) {
|
||||
setLong(R.string.health_connect_initial_sync_start_time_key, value);
|
||||
}
|
||||
|
||||
public static long getHealthConnectInitialSyncCursorStartTimeMillis() {
|
||||
return getLong(R.string.health_connect_initial_sync_cursor_start_time_key, LONG_UNSET);
|
||||
}
|
||||
|
||||
public static void setHealthConnectInitialSyncCursorStartTimeMillis(long value) {
|
||||
setLong(R.string.health_connect_initial_sync_cursor_start_time_key, value);
|
||||
}
|
||||
|
||||
public static long getHealthConnectInitialSyncCursorTrackId() {
|
||||
return getLong(R.string.health_connect_initial_sync_cursor_track_id_key, LONG_UNSET);
|
||||
}
|
||||
|
||||
public static void setHealthConnectInitialSyncCursorTrackId(long value) {
|
||||
setLong(R.string.health_connect_initial_sync_cursor_track_id_key, value);
|
||||
}
|
||||
|
||||
public static boolean isHealthConnectInitialSyncCompleted() {
|
||||
return getBoolean(R.string.health_connect_initial_sync_completed_key, resources.getBoolean(R.bool.health_connect_initial_sync_completed_default));
|
||||
}
|
||||
|
||||
public static void setHealthConnectInitialSyncCompleted(boolean value) {
|
||||
setBoolean(R.string.health_connect_initial_sync_completed_key, value);
|
||||
}
|
||||
|
||||
public static void resetHealthConnectInitialSyncState(long startTimeMillis) {
|
||||
setHealthConnectInitialSyncStartTimeMillis(startTimeMillis);
|
||||
clearHealthConnectInitialSyncCursor();
|
||||
setHealthConnectInitialSyncCompleted(false);
|
||||
}
|
||||
|
||||
public static void clearHealthConnectInitialSyncCursor() {
|
||||
setHealthConnectInitialSyncCursorStartTimeMillis(LONG_UNSET);
|
||||
setHealthConnectInitialSyncCursorTrackId(LONG_UNSET);
|
||||
}
|
||||
|
||||
public static boolean shouldRunHealthConnectInitialSync() {
|
||||
return shouldExportToHealthConnect() && hasHealthConnectInitialSyncConfigured() && !isHealthConnectInitialSyncCompleted();
|
||||
public static boolean shouldInstantExportToHealthConnect() {
|
||||
final boolean DEFAULT = resources.getBoolean(R.bool.post_workout_export_health_connect_enabled_default);
|
||||
return getBoolean(R.string.post_workout_export_health_connect_enabled_key, DEFAULT);
|
||||
}
|
||||
|
||||
public static TrackFilenameGenerator getTrackFileformatGenerator() {
|
||||
|
||||
Reference in New Issue
Block a user