forked from upstream-mirrors/OpenTracks
Health Connect: minimal implementation for writing route data.
This commit is contained in:
@@ -129,8 +129,8 @@ limitations under the License.
|
||||
|
||||
<activity android:name=".AboutActivity" />
|
||||
|
||||
<activity android:name=".settings.HealthConnectSettingsActivity" />
|
||||
|
||||
<!-- Permissions -->
|
||||
<!-- until Android 13 -->
|
||||
<activity
|
||||
android:name=".HealthConnectPermissionsRationaleActivity"
|
||||
android:exported="true">
|
||||
@@ -138,7 +138,7 @@ limitations under the License.
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- for Android 14 -->
|
||||
<activity-alias
|
||||
android:name="ViewPermissionUsageActivity"
|
||||
android:exported="true"
|
||||
@@ -150,9 +150,11 @@ limitations under the License.
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity
|
||||
android:name=".HelpActivity"
|
||||
android:label="@string/menu_help" />
|
||||
<activity android:name=".settings.HealthConnect$Settings" />
|
||||
<activity android:name=".settings.HealthConnect$ConfigurePermissions" />
|
||||
<activity android:name=".settings.HealthConnect$ExportAll" />
|
||||
|
||||
<activity android:name=".HelpActivity" />
|
||||
|
||||
<activity android:name=".io.file.importer.DirectoryChooserActivity$ImportDirectoryChooserActivity" />
|
||||
|
||||
|
||||
+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() {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M16.5,3c-1.74,0 -3.41,0.81 -4.5,2.09C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.54L12,21.35l1.45,-1.32C18.6,15.36 22,12.28 22,8.5C22,5.42 19.58,3 16.5,3zM12.1,18.55l-0.1,0.1l-0.1,-0.1C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5c1.54,0 3.04,0.99 3.57,2.36h1.87C13.46,5.99 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5c0,2.89 -3.14,5.74 -7.9,10.05z" />
|
||||
</vector>
|
||||
@@ -1,10 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M20,6H4V4h16v2zm0,2H4c-1.1,0 -2,0.9 -2,2v8c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2v-8c0,-1.1 -0.9,-2 -2,-2zm0,10H4v-8h16v8zm-10,-7h8v2h-8v-2zm0,3h8v2h-8v-2zM6,11h2v5H6v-5z" />
|
||||
</vector>
|
||||
@@ -1,10 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,4V1L8,5l4,4V6c3.31,0 6,2.69 6,6c0,1.01 -0.25,1.97 -0.69,2.8l1.46,1.46C19.54,14.97 20,13.53 20,12c0,-4.42 -3.58,-8 -8,-8zm-6.77,1.74C4.46,7.03 4,8.47 4,10c0,4.42 3.58,8 8,8v3l4,-4l-4,-4v3c-3.31,0 -6,-2.69 -6,-6c0,-1.01 0.25,-1.97 0.69,-2.8L5.23,5.74z" />
|
||||
</vector>
|
||||
@@ -11,23 +11,22 @@
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/health_connect_activity_toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:navigationIcon="@drawable/ic_help_24dp"
|
||||
app:title="@string/health_connect_permissions_rationale_title" />
|
||||
app:title="@string/app_name" />
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingBottom="?attr/actionBarSize"
|
||||
android:padding="20dp"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="?attr/textAppearanceHeadline6"
|
||||
@@ -41,27 +40,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/health_connect_permissions_rationale_summary" />
|
||||
|
||||
<TextView
|
||||
style="?attr/textAppearanceBody1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="@string/health_connect_permissions_rationale_usage" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/health_connect_permissions_rationale_app_url"
|
||||
style="?attr/textAppearanceBody1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:autoLink="web"
|
||||
android:linksClickable="true" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<include
|
||||
android:id="@+id/bottom_app_bar_layout"
|
||||
layout="@layout/bottomappbar" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/top_app_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:navigationIcon="@drawable/ic_baseline_arrow_back_24"
|
||||
app:title="@string/health_connect_screen_title" />
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
style="?attr/textAppearanceHeadline6"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/health_connect_section_general" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/health_connect_availability_notice"
|
||||
style="?attr/textAppearanceBody2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/health_connect_not_available"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/health_connect_export_row"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="72dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/ic_favorite_border_24" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="?attr/textAppearanceSubtitle1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/health_connect_export_enabled_title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/health_connect_export_summary"
|
||||
style="?attr/textAppearanceBody2"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="@string/health_connect_export_enabled_summary" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/health_connect_export_switch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/health_connect_manage_data_row"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="64dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/ic_storage_24" />
|
||||
|
||||
<TextView
|
||||
style="?attr/textAppearanceSubtitle1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="20dp"
|
||||
android:text="@string/health_connect_manage_data_title" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/health_connect_settings_row"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="72dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/ic_settings_24dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="?attr/textAppearanceSubtitle1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/health_connect_open_settings_title" />
|
||||
|
||||
<TextView
|
||||
style="?attr/textAppearanceBody2"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="@string/health_connect_open_settings_summary" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="top"
|
||||
android:minHeight="64dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/ic_baseline_info_24" />
|
||||
|
||||
<TextView
|
||||
style="?attr/textAppearanceBody2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/health_connect_disable_note" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
style="@style/HorizontalLine"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="20dp" />
|
||||
|
||||
<TextView
|
||||
style="?attr/textAppearanceHeadline6"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/health_connect_section_sync" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="72dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/ic_sync_24" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="?attr/textAppearanceSubtitle1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/health_connect_sync_status_title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/health_connect_sync_status_summary"
|
||||
style="?attr/textAppearanceBody2"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="@string/health_connect_sync_status_never" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="top"
|
||||
android:minHeight="72dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/ic_sync_24" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="?attr/textAppearanceSubtitle1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/health_connect_manual_sync_title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/health_connect_manual_sync_summary"
|
||||
style="?attr/textAppearanceBody2"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="@string/health_connect_manual_sync_summary" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/health_connect_manual_sync_button"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/health_connect_manual_sync_button" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/health_connect_detailed_row"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="72dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/ic_map_24dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="?attr/textAppearanceSubtitle1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/health_connect_detailed_sync_title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/health_connect_detailed_summary"
|
||||
style="?attr/textAppearanceBody2"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="@string/health_connect_detailed_sync_summary" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/health_connect_detailed_switch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="top"
|
||||
android:minHeight="64dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/ic_baseline_info_24" />
|
||||
|
||||
<TextView
|
||||
style="?attr/textAppearanceBody2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/health_connect_sync_note" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -1,52 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="8dp">
|
||||
|
||||
<TextView
|
||||
style="?attr/textAppearanceBody2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/health_connect_initial_sync_dialog_message" />
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/radio_group_sync_period"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/radio_preset"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/health_connect_initial_sync_preset" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinner_sync_period"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="32dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/radio_custom_date"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/health_connect_initial_sync_custom_date" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_select_date"
|
||||
style="?android:attr/buttonBarButtonStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="32dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@string/health_connect_initial_sync_select_date" />
|
||||
</RadioGroup>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -456,7 +456,7 @@ limitations under the License.
|
||||
<string name="settings_recording_title">التسجيل</string>
|
||||
<string name="settings_public_api_enabled_title">واجهة برمجة التطبيقات العامة</string>
|
||||
<string name="settings_public_api_disabled_toast">واجهة برمجة التطبيقات العامة لـ المسارات المفتوحة معطلة: يمكن تفعيلها في الإعدادات.</string>
|
||||
<string name="settings_export_section">التصدير</string>
|
||||
<string name="settings_export_as_file_section">التصدير</string>
|
||||
<string name="instant_export_enabled_summary">تصدير المسار إلى التخزين بعد انتهاء التسجيل</string>
|
||||
<string name="create_marker_error">غير قادر على إنشاء علامة. حاول مرة أخرى.</string>
|
||||
<string name="sensor_state_heart_rate_avg">متوسط معدل ضربات القلب</string>
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
<string name="settings_gps_summary">Intervalo de tiempo, Intervalo de distancia, Distancia máxima, Precisión</string>
|
||||
<string name="settings_sensors_title">Sensores</string>
|
||||
<string name="settings_sensors_summary">Sensores de ciclismo, Circunferencia de la rueda, Sensores de carrera</string>
|
||||
<string name="settings_export_section">Exportar</string>
|
||||
<string name="settings_export_as_file_section">Exportar</string>
|
||||
<string name="activity_type_swimming">Natación</string>
|
||||
<string name="activity_type_swimming_open">Natación en aguas abiertas</string>
|
||||
<string name="activity_type_workout">Entrenamiento</string>
|
||||
|
||||
@@ -456,7 +456,7 @@
|
||||
<string name="settings_stats_units_imperial_meter">Imperiale (mi, m)</string>
|
||||
<string name="settings_stats_units_metric">Metrica (km, m)</string>
|
||||
<string name="settings_import_section">Impurtà</string>
|
||||
<string name="settings_export_section">Espurtà</string>
|
||||
<string name="settings_export_as_file_section">Espurtà</string>
|
||||
<string name="settings_public_api_package_title">Nome di u pacchettu per l’API publica</string>
|
||||
<string name="settings_public_api_enabled_summary_off">Solu OpenTracks pò lancià è piantà l’arregistramenti.</string>
|
||||
<string name="settings_public_api_enabled_summary_on">D’altre appiecazioni installate ponu lancià o piantà l’arregistramenti.</string>
|
||||
|
||||
@@ -444,7 +444,7 @@ limitations under the License.
|
||||
<string name="settings_cycling_sensor">Cyklistika</string>
|
||||
<string name="settings_running_sensor">Běh</string>
|
||||
<string name="settings_import_section">Importovat</string>
|
||||
<string name="settings_export_section">Exportovat</string>
|
||||
<string name="settings_export_as_file_section">Exportovat</string>
|
||||
<string name="stats_sensors_power">Napájení</string>
|
||||
<string name="permission_bluetooth_failed_rejected">V nastavení systému je třeba ručně povolit aplikaci OpenTracks přístup k blízkým zařízením.</string>
|
||||
<string name="value_float_mile_hour">%1$.1f mi/h</string>
|
||||
|
||||
@@ -471,7 +471,7 @@ limitations under the License.
|
||||
<string name="custom_layout_select_layout">Vælg layout</string>
|
||||
<string name="settings_running_sensor">Kører</string>
|
||||
<string name="settings_import_section">Importere</string>
|
||||
<string name="settings_export_section">Eksport</string>
|
||||
<string name="settings_export_as_file_section">Eksport</string>
|
||||
<string name="stats_clock">Ur</string>
|
||||
<string name="value_float_mile_hour_recommended">%1$.1f mi/h (anbefalet)</string>
|
||||
<string name="value_float_mile_hour">%1$.1f mi/h</string>
|
||||
|
||||
@@ -511,7 +511,7 @@ Meldet das GPS-Gerät ungenaue Daten (z.B. Standort, Geschwindigkeit, Höhe), ka
|
||||
<string name="settings_sensor_bluetooth_service_filter_on">Zeige nur Bluetooth-Geräte, die benötigte Dienste bekanntmachen</string>
|
||||
<string name="settings_public_api_enabled_summary_on">Andere installierte Anwendungen können Aufzeichnungen starten oder stoppen.</string>
|
||||
<string name="settings_public_api_disabled_toast">OpenTracks öffentliche API ist deaktiviert: es kann in den Einstellungen aktiviert werden.</string>
|
||||
<string name="settings_export_section">Export</string>
|
||||
<string name="settings_export_as_file_section">Export</string>
|
||||
<string name="settings_public_api_enabled_summary_off">Nur OpenTracks kann Aufzeichnungen starten und stoppen.</string>
|
||||
<string name="settings_import_section">Import</string>
|
||||
<string name="settings_api_title">Öffentliche API</string>
|
||||
|
||||
@@ -503,7 +503,7 @@ limitations under the License.
|
||||
<string name="permission_bluetooth_failed_rejected">Es necesario conceder manualmente a OpenTracks el acceso a los Dispositivos Cercanos en la configuración del sistema.</string>
|
||||
<string name="settings_running_sensor">Correr</string>
|
||||
<string name="settings_import_section">Importar</string>
|
||||
<string name="settings_export_section">Exportar</string>
|
||||
<string name="settings_export_as_file_section">Exportar</string>
|
||||
<string name="generic_today">Hoy</string>
|
||||
<string name="generic_yesterday">Ayer</string>
|
||||
<string name="menu_add_custom_layout">Nuevo diseño</string>
|
||||
|
||||
@@ -442,7 +442,7 @@ limitations under the License.
|
||||
<string name="settings_reset_summary">Lähtesta seadistused vaikeväärtusteks</string>
|
||||
<string name="settings_recording_customize_layout_title">Kohanda oma salvestuse paigutust</string>
|
||||
<string name="settings_recording_customize_layout_select_columns">Andmeväljad rea kohta</string>
|
||||
<string name="settings_export_section">Eksport</string>
|
||||
<string name="settings_export_as_file_section">Eksport</string>
|
||||
<string name="settings_announcements_totaltime_frequency">Ajavahemik</string>
|
||||
<string name="settings_layout_reset">Lähtesta kohandatud paigutus</string>
|
||||
<string name="settings_layout_reset_confirm_title">Kas lähtestad oma paigutused?</string>
|
||||
|
||||
@@ -458,7 +458,7 @@ GPS gailuak datu okerrak salatzen baditu (adibidez, kokapena, abiadura, kota), O
|
||||
<string name="settings_layout_reset_done">Diseinuaren ezarpen guztiak balio lehenetsietara itzuli dira</string>
|
||||
<string name="settings_all_reset">Berrezarri</string>
|
||||
<string name="settings_import_section">Inportatu</string>
|
||||
<string name="settings_export_section">Esportatu</string>
|
||||
<string name="settings_export_as_file_section">Esportatu</string>
|
||||
<string name="stats_coordinates">Latitudea /Longitudea</string>
|
||||
<string name="stats_clock">Erlojua</string>
|
||||
<string name="value_float_mile_hour">%1$.1f mi/h</string>
|
||||
|
||||
@@ -490,7 +490,7 @@ limitations under the License.
|
||||
<string name="stats_sensors_power">Teho</string>
|
||||
<string name="no_compatible_file_manager_installed">Yhteensopiva tiedostojen hallinta puuttuu</string>
|
||||
<string name="settings_export_filename_title">Tiedostonimen muoto</string>
|
||||
<string name="settings_export_section">Vienti</string>
|
||||
<string name="settings_export_as_file_section">Vienti</string>
|
||||
<string name="settings_recording_customize_layout_title">Muokkaa tallennuksen asettelua</string>
|
||||
<string name="settings_sensor_bluetooth_service_filter_on">Näytä vain Bluetooth-laitteet, jotka ilmoittavat tarvittavat palvelut</string>
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">Tallennettuja tietoja ei jaeta automaattisesti muiden sovellusten kanssa.</string>
|
||||
|
||||
@@ -505,7 +505,7 @@ Si le dispositif GPS signale des données inexactes (par exemple, la localisatio
|
||||
<string name="settings_cycling_sensor">Cyclisme</string>
|
||||
<string name="settings_running_sensor">Course à pied</string>
|
||||
<string name="settings_import_section">Importer</string>
|
||||
<string name="settings_export_section">Exporter</string>
|
||||
<string name="settings_export_as_file_section">Exporter</string>
|
||||
<string name="settings_reset_summary">Réinitialiser les paramètres aux valeurs par défaut</string>
|
||||
<string name="settings_activity_defaults_category">Valeurs par défaut de l\'activité</string>
|
||||
<string name="settings_sensors_summary">Capteurs de cyclisme, Circonférence de roue, Capteurs de course à pied</string>
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
<string name="settings_public_api_enabled_summary_off">Ní féidir ach le OpenTracks taifeadtaí a thosú agus a stopadh.</string>
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">Ní dhéanfar sonraí taifeadta a roinnt go huathoibríoch le feidhmchláir eile.</string>
|
||||
<string name="settings_import_section">Iompórtáil</string>
|
||||
<string name="settings_export_section">Easpórtáil</string>
|
||||
<string name="settings_export_as_file_section">Easpórtáil</string>
|
||||
<string name="share_track_share_file_body">Sílim go mb\'fhéidir go mbeadh suim agat sa rian seo.
|
||||
\n
|
||||
\n%1$s</string>
|
||||
|
||||
@@ -443,7 +443,7 @@ limitations under the License.
|
||||
<string name="settings_cycling_sensor">Ciclismo</string>
|
||||
<string name="settings_running_sensor">Correr</string>
|
||||
<string name="settings_import_section">Importar</string>
|
||||
<string name="settings_export_section">Exportar</string>
|
||||
<string name="settings_export_as_file_section">Exportar</string>
|
||||
<string name="settings_announcements_distance_frequency">Lonxitude da volta</string>
|
||||
<string name="permission_bluetooth_failed">OpenTracks require permiso para usar o Bluetooth.</string>
|
||||
<string name="permission_bluetooth_failed_rejected">Tes que permitirlle a OpenTracks nos axustes do sistema que acceda a Dispositivos Próximos.</string>
|
||||
|
||||
@@ -305,7 +305,7 @@ limitations under the License.
|
||||
<string name="waypoint_type_water_fountain">fontana</string>
|
||||
<string name="waypoint_type_waypoint">točka na ruti</string>
|
||||
<string name="settings_import_section">Uvoz</string>
|
||||
<string name="settings_export_section">Izvoz</string>
|
||||
<string name="settings_export_as_file_section">Izvoz</string>
|
||||
<string name="settings_prevent_reimport_tracks_title">Spriječi ponovni uvoz ruta</string>
|
||||
<string name="menu_export_all_one_file">Izvezi sve (jedna datoteka za sve rute)</string>
|
||||
<string name="menu_select_layout">Odaberi raspored</string>
|
||||
|
||||
@@ -467,7 +467,7 @@ limitations under the License.
|
||||
<string name="average_heart_rate">Átlagos pulzusszám</string>
|
||||
<string name="settings_sensor_bluetooth_service_filter_on">Csak azokat a Bluetooth-eszközöket jelenítse meg, amelyek szükséges szolgáltatásokat kérnek</string>
|
||||
<string name="settings_import_section">Importálás</string>
|
||||
<string name="settings_export_section">Exportálás</string>
|
||||
<string name="settings_export_as_file_section">Exportálás</string>
|
||||
<string name="settings_layout_reset_confirm_title">Az elrendezések visszaállítása?</string>
|
||||
<string name="settings_layout_reset_done">Az összes elrendezési beállítás visszaállt az alapértelmezett értékekre</string>
|
||||
<string name="custom_layout_list_layout_removed">Az elrendezést eltávolították</string>
|
||||
|
||||
@@ -448,7 +448,7 @@ Infatti, un\'applicazione deve supportare <i>ACTION_VIEW</i> con MIME <i>applica
|
||||
<string name="settings_cycling_sensor">Ciclismo</string>
|
||||
<string name="settings_running_sensor">Corsa</string>
|
||||
<string name="settings_import_section">Importa</string>
|
||||
<string name="settings_export_section">Esporta</string>
|
||||
<string name="settings_export_as_file_section">Esporta</string>
|
||||
<string name="stats_coordinates">Latitudine/Longitudine</string>
|
||||
<string name="field_remove_from_layout">Rimuovi</string>
|
||||
<string name="field_set_primary">Primario</string>
|
||||
|
||||
@@ -333,7 +333,7 @@
|
||||
<string name="settings_public_api_dashboard_enabled_summary_on">ರೆಕಾರ್ಡಿಂಗ್ ಅನ್ನು ಪ್ರಾರಂಭಿಸಿದ ಅಪ್ಲಿಕೇಶನ್ ರೆಕಾರ್ಡ್ ಮಾಡಿದ ಡೇಟಾವನ್ನು ಸಹ ಪ್ರವೇಶಿಸಬಹುದು.</string>
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">ರೆಕಾರ್ಡ್ ಮಾಡಲಾದ ಡೇಟಾವನ್ನು ಇತರ ಅಪ್ಲಿಕೇಶನ್ಗಳೊಂದಿಗೆ ಸ್ವಯಂಚಾಲಿತವಾಗಿ ಹಂಚಿಕೊಳ್ಳಲಾಗುವುದಿಲ್ಲ.</string>
|
||||
<string name="settings_import_section">ಆಮದು</string>
|
||||
<string name="settings_export_section">ರಫ್ತು</string>
|
||||
<string name="settings_export_as_file_section">ರಫ್ತು</string>
|
||||
<string name="settings_prevent_reimport_tracks_title">ಟ್ರ್ಯಾಕ್ಗಳ ಮರು-ಆಮದು ತಡೆಯಿರಿ</string>
|
||||
<string name="share_track_share_file_body">ನೀವು ಈ ಟ್ರ್ಯಾಕ್ನಲ್ಲಿ ಆಸಕ್ತಿ ಹೊಂದಿರಬಹುದು ಎಂದು ನಾನು ಭಾವಿಸುತ್ತೇನೆ.
|
||||
\n
|
||||
|
||||
@@ -437,7 +437,7 @@ limitations under the License.
|
||||
<string name="settings_cycling_sensor">Sykling</string>
|
||||
<string name="settings_running_sensor">Løping</string>
|
||||
<string name="settings_import_section">Import</string>
|
||||
<string name="settings_export_section">Eksport</string>
|
||||
<string name="settings_export_as_file_section">Eksport</string>
|
||||
<string name="value_float_kilometer_hour">%1$.1f km/t</string>
|
||||
<string name="value_float_kilometer_hour_recommended">%1$.1f km/t (anbefales)</string>
|
||||
<string name="value_float_mile_hour">%1$.1f mi/h</string>
|
||||
|
||||
@@ -465,7 +465,7 @@ Als het GPS-apparaat onnauwkeurige gegevens rapporteert (bijv. locatie, snelheid
|
||||
<string name="settings_cycling_sensor">Fietsen</string>
|
||||
<string name="settings_running_sensor">Hardlopen</string>
|
||||
<string name="settings_import_section">Invoer</string>
|
||||
<string name="settings_export_section">Uitvoer</string>
|
||||
<string name="settings_export_as_file_section">Uitvoer</string>
|
||||
<string name="settings_generic_sensor">Generiek</string>
|
||||
<string name="export_dir_not_writable">" (geen schrijfrecht!)"</string>
|
||||
<string name="value_float_kilometer_hour_recommended">%1$.1f km/u (aanbevolen)</string>
|
||||
|
||||
@@ -450,7 +450,7 @@ limitations under the License.
|
||||
<string name="settings_cycling_sensor">Kolarstwo</string>
|
||||
<string name="settings_running_sensor">Bieganie</string>
|
||||
<string name="settings_import_section">Import</string>
|
||||
<string name="settings_export_section">Eksport</string>
|
||||
<string name="settings_export_as_file_section">Eksport</string>
|
||||
<string name="settings_generic_sensor">Domyślny</string>
|
||||
<string name="description_speed_source_gps">GPS</string>
|
||||
<string name="permission_bluetooth_failed">OpenTracks wymaga uprawnień do urzycia Bluetooth.</string>
|
||||
|
||||
@@ -410,7 +410,7 @@
|
||||
<string name="settings_cycling_sensor">Ciclismo</string>
|
||||
<string name="settings_running_sensor">Correndo</string>
|
||||
<string name="settings_import_section">Importar</string>
|
||||
<string name="settings_export_section">Exportar</string>
|
||||
<string name="settings_export_as_file_section">Exportar</string>
|
||||
<string name="value_float_kilometer_hour">%1$.1f km/h</string>
|
||||
<string name="value_float_kilometer_hour_recommended">%1$.1f km/h (Recomendado)</string>
|
||||
<string name="stats_clock">Relógio</string>
|
||||
|
||||
@@ -451,7 +451,7 @@
|
||||
<string name="settings_public_api_dashboard_enabled_summary_on">Uma aplicação que iniciou uma gravação também pode acessar os dados gravados.</string>
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">Os dados gravados não serão compartilhados automaticamente com outras aplicações.</string>
|
||||
<string name="settings_import_section">Importar</string>
|
||||
<string name="settings_export_section">Exportar</string>
|
||||
<string name="settings_export_as_file_section">Exportar</string>
|
||||
<string name="settings_prevent_reimport_tracks_title">Impedir a reimportação de trilhas</string>
|
||||
<string name="stats_loss">Perda</string>
|
||||
<string name="stats_coordinates">Latitude/Longitude</string>
|
||||
|
||||
@@ -466,7 +466,7 @@ limitations under the License.
|
||||
<string name="settings_public_api_enabled_summary_off">Apenas o OpenTracks pode iniciar e parar gravações.</string>
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">Os dados gravados não serão compartilhados automaticamente com outras aplicações.</string>
|
||||
<string name="settings_import_section">Importar</string>
|
||||
<string name="settings_export_section">Exportar</string>
|
||||
<string name="settings_export_as_file_section">Exportar</string>
|
||||
<string name="settings_prevent_reimport_tracks_title">Impedir a reimportação de trilhas</string>
|
||||
<string name="stats_loss">Perda</string>
|
||||
<string name="stats_coordinates">Latitude/Longitude</string>
|
||||
|
||||
@@ -498,7 +498,7 @@ limitations under the License.
|
||||
<string name="current_heart_rate">Текущий пульс</string>
|
||||
<string name="generic_filter">Фильтр</string>
|
||||
<string name="generic_from">с</string>
|
||||
<string name="settings_export_section">Экспорт</string>
|
||||
<string name="settings_export_as_file_section">Экспорт</string>
|
||||
<string name="settings_import_section">Импорт</string>
|
||||
<string name="settings_api_title">Публичный API</string>
|
||||
<string name="settings_public_api_disabled_toast">Публичный API OpenTracks отключен: он может быть включен в настройках.</string>
|
||||
|
||||
@@ -381,7 +381,7 @@ limitations under the License.
|
||||
<string name="settings_running_sensor">Spúšťanie</string>
|
||||
<string name="settings_cycling_sensor">Cyklistika</string>
|
||||
<string name="custom_layout_list_layout_removed">Rozloženie bolo odstránené</string>
|
||||
<string name="settings_export_section">Exportujte</string>
|
||||
<string name="settings_export_as_file_section">Exportujte</string>
|
||||
<string name="settings_import_section">Importovať</string>
|
||||
<string name="track_recording_notification_accuracy">Presnosť polohy: %1$s</string>
|
||||
<string name="stats_clock">Hodiny</string>
|
||||
|
||||
@@ -352,7 +352,7 @@ limitations under the License.
|
||||
<string name="import_parser_error">Napaka analizatorja: %1$s</string>
|
||||
<string name="settings_sensor_cycling_cadence">Kadenca</string>
|
||||
<string name="menu_select_layout">Izberite postavitev</string>
|
||||
<string name="settings_export_section">Izvoz</string>
|
||||
<string name="settings_export_as_file_section">Izvoz</string>
|
||||
<string name="stats_sensors_cadence">Kadenca</string>
|
||||
<string name="stats_sensors_heart_rate">Srčni utrip</string>
|
||||
<string name="settings_ui_title">Uporabniški vmesnik</string>
|
||||
|
||||
@@ -338,7 +338,7 @@
|
||||
<string name="settings_public_api_dashboard_enabled_summary_on">பதிவைத் தொடங்கிய பயன்பாடு பதிவு செய்யப்பட்ட தரவையும் அணுகலாம்.</string>
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">பதிவுசெய்யப்பட்ட தரவு மற்ற பயன்பாடுகளுடன் தானாக பகிரப்படாது.</string>
|
||||
<string name="settings_import_section">இறக்குமதி</string>
|
||||
<string name="settings_export_section">ஏற்றுமதி</string>
|
||||
<string name="settings_export_as_file_section">ஏற்றுமதி</string>
|
||||
<string name="share_track_share_file_body">இந்த பாதையில் நீங்கள் ஆர்வமாக இருக்கலாம் என்று நினைக்கிறேன்.\n\n %1$s</string>
|
||||
<string name="settings_prevent_reimport_tracks_title">தடங்களை மீண்டும் இறக்குமதி செய்வதைத் தடுக்கவும்</string>
|
||||
<string name="share_track_subject">நான் உங்களுடன் ஒரு பாதையைப் பகிர்ந்து கொள்ள விரும்புகிறேன்</string>
|
||||
|
||||
@@ -438,7 +438,7 @@ limitations under the License.
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">Kaydedilen veriler diğer uygulamalarla otomatik olarak paylaşılmayacaktır.</string>
|
||||
<!-- Settings import/export -->
|
||||
<string name="settings_import_section">İçe Aktar</string>
|
||||
<string name="settings_export_section">Dışa Aktar</string>
|
||||
<string name="settings_export_as_file_section">Dışa Aktar</string>
|
||||
<string name="settings_prevent_reimport_tracks_title">Parkurların yeniden içe aktarılmasını engelle</string>
|
||||
<!-- Share Track -->
|
||||
<string name="share_track_share_file_body">Bu parkurun ilginizi çekebileceğini düşündüm.\n\n%1$s</string>
|
||||
|
||||
@@ -425,7 +425,7 @@ limitations under the License.
|
||||
<string name="field_remove_from_layout">Видалити</string>
|
||||
<string name="stats_clock">Годинник</string>
|
||||
<string name="settings_recording_customize_layout_select_columns">Поля даних у рядку</string>
|
||||
<string name="settings_export_section">Експорт</string>
|
||||
<string name="settings_export_as_file_section">Експорт</string>
|
||||
<string name="field_set_secondary">Вторинний</string>
|
||||
<string name="settings_all_reset">Скидання</string>
|
||||
<string name="generic_filter">Фільтр</string>
|
||||
|
||||
@@ -455,7 +455,7 @@ limitations under the License.
|
||||
<string name="settings_cycling_sensor">Đạp xe</string>
|
||||
<string name="settings_running_sensor">Chạy</string>
|
||||
<string name="settings_import_section">Nhập</string>
|
||||
<string name="settings_export_section">Xuất</string>
|
||||
<string name="settings_export_as_file_section">Xuất</string>
|
||||
<string name="settings_generic_sensor">Chung chung</string>
|
||||
<string name="value_float_mile_hour">%1$.1f mi/h</string>
|
||||
<string name="value_float_mile_hour_recommended">%1$.1f mi/h (khuyến khích)</string>
|
||||
|
||||
@@ -393,7 +393,7 @@ limitations under the License.
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">記錄的資料將不會自動與其他應用程式共用。</string>
|
||||
<!-- Settings import/export -->
|
||||
<string name="settings_import_section">匯入</string>
|
||||
<string name="settings_export_section">匯出</string>
|
||||
<string name="settings_export_as_file_section">匯出</string>
|
||||
<string name="settings_prevent_reimport_tracks_title">防止重新匯入蹤跡</string>
|
||||
<!-- Share Track -->
|
||||
<string name="share_track_share_file_body">我想您應該會對這個路線感興趣。
|
||||
|
||||
@@ -539,7 +539,7 @@ limitations under the License.
|
||||
<string name="settings_public_api_disabled_toast">OpenTracks 公共 API 已禁用:可以在设置中启用。</string>
|
||||
<string name="settings_public_api_dashboard_enabled_title">自动数据传输</string>
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">记录的数据不会自动与其他应用程序共享。</string>
|
||||
<string name="settings_export_section">导出</string>
|
||||
<string name="settings_export_as_file_section">导出</string>
|
||||
<string name="stats_loss">下降</string>
|
||||
<string name="stats_coordinates">纬度/经度</string>
|
||||
<string name="track_pace_notification">配速:%1$s</string>
|
||||
|
||||
@@ -58,6 +58,9 @@
|
||||
<string name="export_filename_format_key" translatable="false">instantExportFilename</string>
|
||||
<string name="export_filename_format_default" translatable="false">{date}_{time}_{name}</string>
|
||||
|
||||
<string name="post_workout_export_health_connect_enabled_key" translatable="false">instantExportHealthConnectEnabled</string>
|
||||
<bool name="post_workout_export_health_connect_enabled_default" translatable="false">false</bool>
|
||||
|
||||
<!-- Stats layout customization default layout -->
|
||||
<string name="settings_layout_reset_key" translatable="false">settingsLayoutReset</string>
|
||||
|
||||
@@ -271,18 +274,6 @@
|
||||
<string name="import_prevent_reimport_key" translatable="false">preventReimportTrackKey</string>
|
||||
<bool name="import_prevent_reimport_default">true</bool>
|
||||
|
||||
<string name="health_connect_export_enabled_key" translatable="false">healthConnectExportEnabled</string>
|
||||
<bool name="health_connect_export_enabled_default" translatable="false">false</bool>
|
||||
<string name="health_connect_detailed_sync_key" translatable="false">healthConnectDetailedSync</string>
|
||||
<bool name="health_connect_detailed_sync_default" translatable="false">false</bool>
|
||||
<string name="health_connect_last_sync_status_key" translatable="false">healthConnectLastSyncStatus</string>
|
||||
<string name="health_connect_settings_key" translatable="false">healthConnectSettings</string>
|
||||
<string name="health_connect_initial_sync_start_time_key" translatable="false">healthConnectInitialSyncStartTime</string>
|
||||
<string name="health_connect_initial_sync_cursor_start_time_key" translatable="false">healthConnectInitialSyncCursorStartTime</string>
|
||||
<string name="health_connect_initial_sync_cursor_track_id_key" translatable="false">healthConnectInitialSyncCursorTrackId</string>
|
||||
<string name="health_connect_initial_sync_completed_key" translatable="false">healthConnectInitialSyncCompleted</string>
|
||||
<bool name="health_connect_initial_sync_completed_default" translatable="false">false</bool>
|
||||
|
||||
<string name="settings_api_key" translatable="false">settingsAPI</string>
|
||||
<string name="publicapi_package_key" translatable="false">apiPackageKey</string>
|
||||
<string name="publicapi_enabled_key" translatable="false">apiEnabledKey</string>
|
||||
|
||||
@@ -280,6 +280,7 @@ limitations under the License.
|
||||
<string name="menu_edit">Edit</string>
|
||||
<string name="menu_export_all">Export all (one file per track)</string>
|
||||
<string name="menu_export_all_one_file">Export all (one file for all tracks)</string>
|
||||
<string name="menu_export_all_to_health_connect">Export all</string>
|
||||
<string name="menu_help">Help</string>
|
||||
<string name="menu_import_all">Import all</string>
|
||||
<string name="menu_insert_marker">Insert marker</string>
|
||||
@@ -448,7 +449,8 @@ limitations under the License.
|
||||
<string name="settings_public_api_dashboard_enabled_summary_off">Recorded data will not be shared automatically with other applications.</string>
|
||||
<!-- Settings import/export -->
|
||||
<string name="settings_import_section">Import</string>
|
||||
<string name="settings_export_section">Export</string>
|
||||
<string name="settings_export_as_file_section">Export as file</string>
|
||||
<string name="settings_export_to_health_connect">Export to Health Connect</string>
|
||||
<string name="settings_prevent_reimport_tracks_title">Prevent re-import of tracks</string>
|
||||
<!-- Share Track -->
|
||||
<string name="share_track_share_file_body">I think you might be interested in this track.\n\n%1$s</string>
|
||||
@@ -679,57 +681,13 @@ limitations under the License.
|
||||
<string name="hold_to_stop">Hold to stop</string>
|
||||
<string name="instant_export_enabled_summary">Export track to storage after recording is finished</string>
|
||||
<string name="instant_export_enabled_title">Instant post-workout export</string>
|
||||
<string name="health_connect_export_enabled_title">Export to Health Connect</string>
|
||||
<string name="health_connect_export_enabled_summary">Export activity to Health Connect after recording is finished</string>
|
||||
<string name="health_connect_not_available">Health Connect is not available on this device</string>
|
||||
<string name="health_connect_screen_title">Health Connect</string>
|
||||
<string name="health_connect_settings_summary">Configure export and synchronization with Health Connect</string>
|
||||
<string name="health_connect_settings_summary_enabled">Health Connect export is enabled</string>
|
||||
<string name="health_connect_settings_summary_enabled_detailed">Health Connect export and detailed GPS route sync are enabled</string>
|
||||
<string name="health_connect_section_general">General settings</string>
|
||||
<string name="health_connect_manage_data_title">Manage Health Connect data</string>
|
||||
<string name="health_connect_open_settings_title">Health Connect settings</string>
|
||||
<string name="health_connect_open_settings_summary">Open Android Health Connect settings</string>
|
||||
<string name="health_connect_disable_note">To fully disconnect Health Connect, remove OpenTracks permissions in the Health Connect app.</string>
|
||||
<string name="health_connect_section_sync">Synchronization</string>
|
||||
<string name="health_connect_sync_status_title">Sync status</string>
|
||||
<string name="health_connect_sync_status_never">No Health Connect export yet.</string>
|
||||
<string name="health_connect_sync_already_running">A Health Connect sync is already running.</string>
|
||||
<string name="health_connect_manual_sync_title">Manual sync</string>
|
||||
<string name="health_connect_manual_sync_summary">Choose a time range and sync existing workouts to Health Connect.</string>
|
||||
<string name="health_connect_manual_sync_requires_export">Enable Health Connect export to manually sync existing workouts.</string>
|
||||
<string name="health_connect_manual_sync_button">Sync now</string>
|
||||
<string name="health_connect_detailed_sync_title">Detailed workout sync</string>
|
||||
<string name="health_connect_detailed_sync_summary">Sync the recorded GPS route with workouts. This provides richer workout data but may take longer.</string>
|
||||
<string name="health_connect_detailed_sync_requires_export">Enable Health Connect export to include detailed route data.</string>
|
||||
<string name="health_connect_sync_note">Depending on the amount of data, a Health Connect export might take a while.</string>
|
||||
<string name="health_connect_sync_status_success">Finished Health Connect export for %1$s%2$s</string>
|
||||
<string name="health_connect_sync_status_failure">Health Connect export failed: %1$s</string>
|
||||
<string name="health_connect_sync_status_route_suffix"> with GPS route</string>
|
||||
<string name="health_connect_initial_sync_dialog_title">Initial sync</string>
|
||||
<string name="health_connect_initial_sync_dialog_message">Choose how much existing workout history OpenTracks should sync to Health Connect.</string>
|
||||
<string name="health_connect_initial_sync_preset">Use a preset time range</string>
|
||||
<string name="health_connect_initial_sync_last_3_days">Last 3 days</string>
|
||||
<string name="health_connect_initial_sync_last_7_days">Last 7 days</string>
|
||||
<string name="health_connect_initial_sync_last_30_days">Last 30 days</string>
|
||||
<string name="health_connect_initial_sync_all">All recorded workouts</string>
|
||||
<string name="health_connect_initial_sync_custom_date">Choose date…</string>
|
||||
<string name="health_connect_initial_sync_select_date">Choose start date</string>
|
||||
<string name="health_connect_sync_now_title">Sync now</string>
|
||||
<string name="health_connect_sync_now_message">Sync existing workouts from %1$s to Health Connect now?</string>
|
||||
<string name="health_connect_sync_now">Sync now</string>
|
||||
<string name="health_connect_sync_later">Sync later</string>
|
||||
<string name="health_connect_sync_status_initial_pending">Initial Health Connect sync is ready for %1$s.</string>
|
||||
<string name="health_connect_sync_status_initial_starting">Starting initial Health Connect sync for %1$s.</string>
|
||||
<string name="health_connect_sync_status_initial_progress">Initial Health Connect sync %1$d of %2$d: %3$s</string>
|
||||
<string name="health_connect_sync_status_initial_success">Finished initial Health Connect sync for %1$d workout(s)</string>
|
||||
<string name="health_connect_sync_status_initial_no_tracks">No recorded workouts found for initial Health Connect sync.</string>
|
||||
<string name="health_connect_sync_status_initial_stopped">Initial Health Connect sync stopped because export was disabled.</string>
|
||||
<string name="health_connect_sync_status_initial_failure">Initial Health Connect sync failed for %1$s: %2$s</string>
|
||||
<string name="health_connect_permissions_rationale_title">Health Connect access</string>
|
||||
<string name="health_connect_open_settings_summary">Open the Health Connect settings</string>
|
||||
<string name="health_connect_permissions_rationale_title">Health Connect permissions</string>
|
||||
<string name="health_connect_permissions_rationale_heading">How OpenTracks uses Health Connect</string>
|
||||
<string name="health_connect_permissions_rationale_summary">OpenTracks only writes finished activities to Health Connect when you explicitly enable Health Connect export in settings.</string>
|
||||
<string name="health_connect_permissions_rationale_usage">The requested Health Connect permission allows OpenTracks to export your recorded workouts. You can disable export again at any time in OpenTracks settings.</string>
|
||||
<string name="health_connect_permissions_rationale_summary">If enabled in the settings, OpenTracks writes exercise to Health Connect. OpenTracks does not read data from Health Connect.</string>
|
||||
<string name="settings_default_export_uri_title">Track export directory</string>
|
||||
<!-- Fields -->
|
||||
<string name="field_set_primary">Primary</string>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
android:title="@string/settings_prevent_reimport_tracks_title" />
|
||||
</PreferenceCategory>
|
||||
|
||||
<PreferenceCategory android:title="@string/settings_export_section">
|
||||
<PreferenceCategory android:title="@string/settings_export_as_file_section">
|
||||
<Preference
|
||||
android:title="@string/menu_export_all">
|
||||
<intent
|
||||
@@ -51,9 +51,38 @@
|
||||
android:key="@string/export_filename_format_key"
|
||||
android:title="@string/settings_export_filename_title"
|
||||
android:defaultValue="@string/export_filename_format_default" />
|
||||
</PreferenceCategory>
|
||||
|
||||
<PreferenceCategory
|
||||
android:title="@string/settings_export_to_health_connect"
|
||||
android:key="healthConnectCategory">
|
||||
<Preference
|
||||
android:key="@string/health_connect_settings_key"
|
||||
android:summary="@string/health_connect_settings_summary"
|
||||
android:title="@string/health_connect_screen_title" />
|
||||
android:persistent="false"
|
||||
android:summary="@string/health_connect_open_settings_summary"
|
||||
android:title="@string/health_connect_open_settings_title">
|
||||
<intent
|
||||
android:targetClass="de.dennisguse.opentracks.settings.HealthConnect$Settings"
|
||||
android:targetPackage="@string/applicationId" />
|
||||
</Preference>
|
||||
|
||||
<Preference
|
||||
android:title="@string/health_connect_permissions_rationale_title"
|
||||
android:key="healthConnectPermissions">
|
||||
<intent
|
||||
android:targetClass="de.dennisguse.opentracks.settings.HealthConnect$ConfigurePermissions"
|
||||
android:targetPackage="@string/applicationId" />
|
||||
</Preference>
|
||||
|
||||
<Preference android:title="@string/menu_export_all_to_health_connect">
|
||||
<intent
|
||||
android:targetClass="de.dennisguse.opentracks.settings.HealthConnect$ExportAll"
|
||||
android:targetPackage="@string/applicationId" />
|
||||
</Preference>
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:defaultValue="@bool/post_workout_export_health_connect_enabled_default"
|
||||
android:key="@string/post_workout_export_health_connect_enabled_key"
|
||||
android:summary="@string/health_connect_export_enabled_summary"
|
||||
android:title="@string/instant_export_enabled_title" />
|
||||
</PreferenceCategory>
|
||||
</PreferenceScreen>
|
||||
|
||||
Reference in New Issue
Block a user