diff --git a/src/main/java/de/dennisguse/opentracks/TrackRecordingActivity.java b/src/main/java/de/dennisguse/opentracks/TrackRecordingActivity.java index d0e56ff4d..7bf360409 100644 --- a/src/main/java/de/dennisguse/opentracks/TrackRecordingActivity.java +++ b/src/main/java/de/dennisguse/opentracks/TrackRecordingActivity.java @@ -67,6 +67,7 @@ public class TrackRecordingActivity extends AbstractActivity implements ChooseAc private static final String CURRENT_TAB_TAG_KEY = "current_tab_tag_key"; private Snackbar snackbar; + private Snackbar sensorWarningSnackbar; // The following are setFrequency in onCreate private ContentProviderUtils contentProviderUtils; @@ -86,6 +87,9 @@ public class TrackRecordingActivity extends AbstractActivity implements ChooseAc service.getGpsStatusObservable() .observe(TrackRecordingActivity.this, this::onGpsStatusChanged); + service.getSensorDisconnectedWarningObservable() + .observe(TrackRecordingActivity.this, this::onSensorDisconnectedWarningChanged); + if (!service.isRecording()) { finish(); return; @@ -408,4 +412,27 @@ public class TrackRecordingActivity extends AbstractActivity implements ChooseAc }); snackbar.show(); } + + private void onSensorDisconnectedWarningChanged(boolean show) { + if (show) { + if (sensorWarningSnackbar == null) { + sensorWarningSnackbar = Snackbar + .make(viewBinding.trackRecordingCoordinatorLayout, + getString(R.string.sensor_disconnected_warning), + Snackbar.LENGTH_INDEFINITE) + .setAction(getString(R.string.sensor_disconnected_switch_action), v -> { + if (trackRecordingServiceConnection != null && trackRecordingServiceConnection.getTrackRecordingService() != null) { + trackRecordingServiceConnection.getTrackRecordingService().setOverrideSensorWithGPS(true); + } + }); + } + if (!sensorWarningSnackbar.isShown()) { + sensorWarningSnackbar.show(); + } + } else { + if (sensorWarningSnackbar != null && sensorWarningSnackbar.isShown()) { + sensorWarningSnackbar.dismiss(); + } + } + } } \ No newline at end of file diff --git a/src/main/java/de/dennisguse/opentracks/data/statistics/SegmentStatisticUpdater.java b/src/main/java/de/dennisguse/opentracks/data/statistics/SegmentStatisticUpdater.java index 500d5ce1a..90f60179c 100644 --- a/src/main/java/de/dennisguse/opentracks/data/statistics/SegmentStatisticUpdater.java +++ b/src/main/java/de/dennisguse/opentracks/data/statistics/SegmentStatisticUpdater.java @@ -151,12 +151,14 @@ public class SegmentStatisticUpdater { } public void addMovingTime(TrackPoint trackPoint, TrackPoint lastTrackPoint) { - Duration movingDuration = Duration.between(lastTrackPoint.getTime(), trackPoint.getTime()); + addMovingTime(Duration.between(lastTrackPoint.getTime(), trackPoint.getTime())); + } - if (movingDuration.isNegative()) { - throw new RuntimeException("Moving time cannot be negative: " + lastTrackPoint.getTime() + " is after " + trackPoint.getTime()); + public void addMovingTime(Duration duration) { + if (duration.isNegative()) { + throw new RuntimeException("Moving time cannot be negative"); } - this.movingDuration = this.movingDuration.plus(movingDuration); + movingDuration = movingDuration.plus(duration); } private Speed getAverageMovingSpeed() { diff --git a/src/main/java/de/dennisguse/opentracks/data/statistics/TrackStatisticsUpdater.java b/src/main/java/de/dennisguse/opentracks/data/statistics/TrackStatisticsUpdater.java index 46ad43431..55fe47374 100644 --- a/src/main/java/de/dennisguse/opentracks/data/statistics/TrackStatisticsUpdater.java +++ b/src/main/java/de/dennisguse/opentracks/data/statistics/TrackStatisticsUpdater.java @@ -54,6 +54,7 @@ public class TrackStatisticsUpdater { private TrackPoint lastTrackPoint; private boolean isIdle; + private boolean forceZeroDistance; public TrackStatisticsUpdater(@NonNull TrackPoint trackPoint) { addTrackPoint(trackPoint); @@ -78,6 +79,7 @@ public class TrackStatisticsUpdater { this.lastTrackPoint = toCopy.lastTrackPoint; this.isIdle = toCopy.isIdle; + this.forceZeroDistance = toCopy.forceZeroDistance; resetAverageHeartRate(); addTrackPoint(tmp); @@ -94,6 +96,10 @@ public class TrackStatisticsUpdater { return isIdle; } + public void setForceZeroDistance(boolean forceZeroDistance) { + this.forceZeroDistance = forceZeroDistance; + } + public Statistics getCurrentSegment() { return currentSegment.getStatistics(); } @@ -147,22 +153,15 @@ public class TrackStatisticsUpdater { Distance movingDistance = null; if (trackPoint.sensorDistance() != null) { movingDistance = trackPoint.sensorDistance(); + } else if (forceZeroDistance) { + movingDistance = Distance.ZERO; } else if (lastTrackPoint != null && lastTrackPoint.position().hasLocation() && trackPoint.position().hasLocation()) { // GPS-based distance/speed movingDistance = trackPoint.distanceToPrevious(lastTrackPoint); } - if (movingDistance != null) { - currentSegment.addTotalDistance(movingDistance); - } - - if (!isIdle - && !trackPoint.isSegmentManualStart() - && lastTrackPoint != null) { - currentSegment.addMovingTime(trackPoint, lastTrackPoint); - } - + boolean isIdleTransition = false; if (trackPoint.isIdleTriggered()) { isIdle = true; } else if (isIdle) { @@ -170,6 +169,26 @@ public class TrackStatisticsUpdater { if (movingDistance != null && movingDistance.greaterOrEqualThan(PreferencesUtils.getRecordingDistanceInterval())) { isIdle = false; + isIdleTransition = true; + } + } + + if (movingDistance != null && !isIdle) { + currentSegment.addTotalDistance(movingDistance); + } + + if (!isIdle + && !trackPoint.isSegmentManualStart() + && lastTrackPoint != null) { + if (isIdleTransition) { + if (trackPoint.position().hasSpeed() && trackPoint.position().speed().toMPS() > 0) { + double timeSeconds = movingDistance.toM() / trackPoint.position().speed().toMPS(); + currentSegment.addMovingTime(Duration.ofMillis((long)(timeSeconds * 1000))); + } else { + currentSegment.addMovingTime(Duration.ofMillis((long)(movingDistance.toM() * 1000))); + } + } else { + currentSegment.addMovingTime(trackPoint, lastTrackPoint); } } diff --git a/src/main/java/de/dennisguse/opentracks/publicapi/DataProvider.java b/src/main/java/de/dennisguse/opentracks/publicapi/DataProvider.java index 1003dcbd9..eab156963 100644 --- a/src/main/java/de/dennisguse/opentracks/publicapi/DataProvider.java +++ b/src/main/java/de/dennisguse/opentracks/publicapi/DataProvider.java @@ -189,6 +189,17 @@ public class DataProvider { Log.i(TAG, "Starting dashboard activity with generic intent (package=" + targetPackage + ", class=" + targetClass + ")"); } + // Explicitly grant URI permissions to all packages that can resolve this intent. + // This works around issues where ClipData multiple URI grants fail on some Android versions. + android.content.pm.PackageManager packageManager = context.getPackageManager(); + java.util.List resolveInfos = packageManager.queryIntentActivities(intent, android.content.pm.PackageManager.MATCH_DEFAULT_ONLY); + for (android.content.pm.ResolveInfo resolveInfo : resolveInfos) { + String packageName = resolveInfo.activityInfo.packageName; + for (Uri uri : uris) { + context.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + } + try { context.startActivity(intent); } catch (ActivityNotFoundException e) { diff --git a/src/main/java/de/dennisguse/opentracks/sensors/SensorHandler.java b/src/main/java/de/dennisguse/opentracks/sensors/SensorHandler.java index 31433932d..0a7c6de8c 100644 --- a/src/main/java/de/dennisguse/opentracks/sensors/SensorHandler.java +++ b/src/main/java/de/dennisguse/opentracks/sensors/SensorHandler.java @@ -115,7 +115,8 @@ public abstract class SensorHandler implement return; } - SensorHandler.this.aggregator.resetAggregated(); + SensorHandler.this.aggregator = null; + sensorManager.onChange(); } @Override @@ -130,5 +131,6 @@ public abstract class SensorHandler implement @Override public void onDisconnected() { SensorHandler.this.aggregator = null; + sensorManager.onChange(); } } diff --git a/src/main/java/de/dennisguse/opentracks/sensors/SensorManager.java b/src/main/java/de/dennisguse/opentracks/sensors/SensorManager.java index b2a9ba1bc..7429b048a 100644 --- a/src/main/java/de/dennisguse/opentracks/sensors/SensorManager.java +++ b/src/main/java/de/dennisguse/opentracks/sensors/SensorManager.java @@ -239,6 +239,9 @@ public class SensorManager implements SharedPreferences.OnSharedPreferenceChange } public SensorDataSet getSensorDataSet(Instant now) { + if (!isStarted()) { + return new SensorDataSet(new SensorData<>(Position.of(now), ""), null, null, null, null, null, null, null); + } //We always need a Position with now SensorData gpsSensorData = gpsHandler.getSensorData(now); if (gpsSensorData != null && gpsSensorData.data() != null) { diff --git a/src/main/java/de/dennisguse/opentracks/sensors/sensorData/AggregatorCyclingCadence.java b/src/main/java/de/dennisguse/opentracks/sensors/sensorData/AggregatorCyclingCadence.java index f10fcace9..42ead636b 100644 --- a/src/main/java/de/dennisguse/opentracks/sensors/sensorData/AggregatorCyclingCadence.java +++ b/src/main/java/de/dennisguse/opentracks/sensors/sensorData/AggregatorCyclingCadence.java @@ -5,6 +5,7 @@ import android.util.Log; import androidx.annotation.NonNull; import java.time.Duration; +import java.time.Instant; import de.dennisguse.opentracks.data.models.Cadence; import de.dennisguse.opentracks.sensors.UintUtils; @@ -18,17 +19,20 @@ public class AggregatorCyclingCadence extends Aggregator current) { if (current.value() instanceof CyclingCadenceBluetooth.DirectCadenceData directCadenceData) { output = directCadenceData.cadence(); + lastValidUpdate = current.time(); return; } - computeRawCadence((CyclingCadenceBluetooth.CrankData) current.value()); + computeRawCadence((CyclingCadenceBluetooth.CrankData) current.value(), current.time()); } - private void computeRawCadence(@NonNull CyclingCadenceBluetooth.CrankData currentRaw) { + private void computeRawCadence(@NonNull CyclingCadenceBluetooth.CrankData currentRaw, Instant now) { CyclingCadenceBluetooth.CrankData previousRaw = getPreviousRaw(); if (previousRaw == null) return; @@ -36,6 +40,9 @@ public class AggregatorCyclingCadence extends Aggregator 0) { + resetOutdated(); + } return; } if (timeDiff.isNegative()) { @@ -52,9 +59,15 @@ public class AggregatorCyclingCadence extends Aggregator current) { if (previous == null) { + lastValidUpdate = current.time(); return; } @@ -31,6 +35,9 @@ public class AggregatorCyclingDistanceSpeed extends Aggregator 0) { + resetOutdated(); + } return; } if (timeDiff.isNegative()) { @@ -52,6 +59,7 @@ public class AggregatorCyclingDistanceSpeed extends Aggregator { + if (trackId != null) { + onIdle(); + } + }); + String address = de.dennisguse.opentracks.settings.PreferencesUtils.getString(de.dennisguse.opentracks.R.string.settings_sensor_bluetooth_cycling_speed_key, de.dennisguse.opentracks.sensors.driver.SensorType.NONE.getPreferenceValue()); + if (!de.dennisguse.opentracks.sensors.driver.SensorType.NONE.getPreferenceValue().equals(address)) { + handler.postDelayed(() -> { + if (trackId != null && !hasSensorBeenConnected && !isSensorDisconnectedWarningActive) { + isSensorDisconnectedWarningActive = true; + if (sensorDisconnectObserver != null) { + sensorDisconnectObserver.onSensorDisconnectedWarning(true); + } + } + }, 10000); + } + return trackId; } @@ -116,6 +151,23 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen reset(); + handler.post(() -> { + if (this.trackId != null) { + onIdle(); + } + }); + String address = de.dennisguse.opentracks.settings.PreferencesUtils.getString(de.dennisguse.opentracks.R.string.settings_sensor_bluetooth_cycling_speed_key, de.dennisguse.opentracks.sensors.driver.SensorType.NONE.getPreferenceValue()); + if (!de.dennisguse.opentracks.sensors.driver.SensorType.NONE.getPreferenceValue().equals(address)) { + handler.postDelayed(() -> { + if (trackId != null && !hasSensorBeenConnected && !isSensorDisconnectedWarningActive) { + isSensorDisconnectedWarningActive = true; + if (sensorDisconnectObserver != null) { + sensorDisconnectObserver.onSensorDisconnectedWarning(true); + } + } + }, 10000); + } + return true; } @@ -125,6 +177,12 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen trackId = null; trackStatisticsUpdater = null; + + idleTimeoutScheduledAt = null; + walkingStartedAt = null; + idleStartedAt = null; + longBreakRecorded = false; + handler.removeCallbacks(ON_LONG_BREAK); reset(); } @@ -144,18 +202,69 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen TrackBuilder trackBuilder = new TrackBuilder(track); trackBuilder.setStatistics(tmpTrackStatisticsUpdater.getTrackStatistics()); - - return new RecordingData(trackBuilder.getTrack(), TrackPointWithEGM2008, current.second, tmpTrackStatisticsUpdater.getCurrentSegment()); + + java.time.Instant now = java.time.Instant.now(); + String idleTimer = ""; + if (idleTimeoutScheduledAt != null && !"IDLE".equals(autoPauseStatus) && !"WALKING".equals(autoPauseStatus)) { + java.time.Duration d = java.time.Duration.between(now, idleTimeoutScheduledAt); + if (!d.isNegative()) idleTimer = de.dennisguse.opentracks.util.StringUtils.formatElapsedTime(d); + } + + String walkingTimer = ""; + if (walkingStartedAt != null && "WALKING".equals(autoPauseStatus)) { + java.time.Duration d = java.time.Duration.between(walkingStartedAt, now); + if (!d.isNegative()) walkingTimer = de.dennisguse.opentracks.util.StringUtils.formatElapsedTime(d); + } + + String markerTimer = ""; + if (idleStartedAt != null && "IDLE".equals(autoPauseStatus)) { + java.time.Duration d = java.time.Duration.between(idleStartedAt, now); + java.time.Duration markerDuration = de.dennisguse.opentracks.settings.PreferencesUtils.getMarkerIdleDuration(); + if (longBreakRecorded || d.compareTo(markerDuration) >= 0) { + d = markerDuration; + } + if (!d.isNegative()) markerTimer = de.dennisguse.opentracks.util.StringUtils.formatElapsedTime(d); + } + + return new RecordingData(trackBuilder.getTrack(), TrackPointWithEGM2008, current.second, tmpTrackStatisticsUpdater.getCurrentSegment(), autoPauseStatus, idleTimer, walkingTimer, markerTimer); } @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) public synchronized void onIdle() { Log.d(TAG, "Becoming idle"); - onNewTrackPoint(trackPointCreator.createIdle()); + TrackPoint idlePoint = trackPointCreator.createIdle(); + + boolean preventIdle = de.dennisguse.opentracks.settings.PreferencesUtils.shouldPreventIdleIfCadence(); + if (preventIdle && hasCadenceSensorBeenConnected) { + if (idlePoint.cadence() != null && idlePoint.cadence().value_rpm() > 0) { + scheduleNewIdleTimeout(); + return; + } + } + + if (!"IDLE".equals(autoPauseStatus)) { + idleStartedAt = java.time.Instant.now(); + longBreakRecorded = false; + java.time.Duration markerDuration = de.dennisguse.opentracks.settings.PreferencesUtils.getMarkerIdleDuration(); + handler.removeCallbacks(ON_LONG_BREAK); + handler.postDelayed(ON_LONG_BREAK, markerDuration.toMillis()); + } + autoPauseStatus = "IDLE"; + + onNewTrackPoint(idlePoint); idleObserver.onIdle(); } + private synchronized void onLongBreak() { + if ("IDLE".equals(autoPauseStatus) && idleStartedAt != null && !longBreakRecorded) { + longBreakRecorded = true; + TrackPoint trackPoint = trackPointCreator.createSegmentStartManual(); + insertTrackPoint(trackPoint, true); + idleObserver.onLongBreak(); + } + } + /** * @return TrackPoint was stored? */ @@ -167,9 +276,36 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen lastTrackPointUIWithAltitude = trackPoint; } + boolean sensorHasDistance = trackPoint.sensorDistance() != null; + if (sensorHasDistance) { + hasSensorBeenConnected = true; + if (isSensorDisconnectedWarningActive) { + isSensorDisconnectedWarningActive = false; + sensorDisconnectObserver.onSensorDisconnectedWarning(false); + sensorDisconnectObserver.onSensorConnected(); + } + } else if (hasSensorBeenConnected) { + if (!isSensorDisconnectedWarningActive) { + isSensorDisconnectedWarningActive = true; + sensorDisconnectObserver.onSensorDisconnectedWarning(true); + } + } + + if (trackStatisticsUpdater != null) { + String address = de.dennisguse.opentracks.settings.PreferencesUtils.getString(de.dennisguse.opentracks.R.string.settings_sensor_bluetooth_cycling_speed_key, de.dennisguse.opentracks.sensors.driver.SensorType.NONE.getPreferenceValue()); + boolean isSensorExpected = !de.dennisguse.opentracks.sensors.driver.SensorType.NONE.getPreferenceValue().equals(address); + trackStatisticsUpdater.setForceZeroDistance(hasSensorBeenConnected || (isSensorExpected && !gpsOverriden)); + } + if (trackPoint.type() == TrackPoint.Type.IDLE) { + longBreakRecorded = false; insertTrackPoint(trackPoint, true); handler.removeCallbacks(ON_IDLE); + + long durationMs = PreferencesUtils.getMarkerIdleDuration().toMillis(); + if (durationMs > 0) { + handler.postDelayed(ON_LONG_BREAK, durationMs); + } return true; } //Storing trackPoint @@ -200,11 +336,29 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen return true; } + if (trackPoint.cadence() != null) { + hasCadenceSensorBeenConnected = true; + } + + boolean isWalkingWithBike = false; + if (autoPauseOnCadence0 && hasCadenceSensorBeenConnected && autoPauseCadence0Speed != null) { + if (trackPoint.cadence() == null || trackPoint.cadence().getRPM() == 0) { + if (trackPoint.position().hasSpeed() && trackPoint.position().speed().lessThan(autoPauseCadence0Speed)) { + isWalkingWithBike = true; + } + } + } + Distance distanceToLastStoredTrackPoint; - if (trackPoint.position().hasLocation() && !lastStoredTrackPoint.position().hasLocation()) { - distanceToLastStoredTrackPoint = trackPoint.distanceToPreviousFromLocation(lastStoredTrackPointWithLocation); + if (isWalkingWithBike) { + distanceToLastStoredTrackPoint = Distance.ZERO; + autoPauseStatus = "WALKING"; } else { - distanceToLastStoredTrackPoint = trackPoint.distanceToPrevious(lastStoredTrackPoint); + if (trackPoint.position().hasLocation() && !lastStoredTrackPoint.position().hasLocation()) { + distanceToLastStoredTrackPoint = trackPoint.distanceToPreviousFromLocation(lastStoredTrackPointWithLocation); + } else { + distanceToLastStoredTrackPoint = trackPoint.distanceToPrevious(lastStoredTrackPoint); + } } if (distanceToLastStoredTrackPoint.greaterThan(maxRecordingDistance)) { @@ -212,6 +366,20 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen insertTrackPoint(trackPoint, true); scheduleNewIdleTimeout(); + if (!isWalkingWithBike) { + if ("IDLE".equals(autoPauseStatus)) { + handler.removeCallbacks(ON_LONG_BREAK); + } + autoPauseStatus = "REC"; + } else { + if (!"WALKING".equals(autoPauseStatus)) { + walkingStartedAt = java.time.Instant.now(); + if ("IDLE".equals(autoPauseStatus)) { + handler.removeCallbacks(ON_LONG_BREAK); + } + } + autoPauseStatus = "WALKING"; + } return true; } @@ -219,6 +387,20 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen insertTrackPoint(trackPoint, false); scheduleNewIdleTimeout(); + if (!isWalkingWithBike) { + if ("IDLE".equals(autoPauseStatus)) { + handler.removeCallbacks(ON_LONG_BREAK); + } + autoPauseStatus = "REC"; + } else { + if (!"WALKING".equals(autoPauseStatus)) { + walkingStartedAt = java.time.Instant.now(); + if ("IDLE".equals(autoPauseStatus)) { + handler.removeCallbacks(ON_LONG_BREAK); + } + } + autoPauseStatus = "WALKING"; + } return true; } @@ -235,6 +417,7 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen } handler.removeCallbacks(ON_IDLE); handler.postDelayed(ON_IDLE, idleDuration.toMillis()); + idleTimeoutScheduledAt = java.time.Instant.now().plus(idleDuration); } Statistics getTrackStatistics() { @@ -284,6 +467,13 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen lastStoredTrackPoint = null; lastStoredTrackPointWithLocation = null; + + hasSensorBeenConnected = false; + isSensorDisconnectedWarningActive = false; + hasCadenceSensorBeenConnected = false; + if (sensorDisconnectObserver != null) { + sensorDisconnectObserver.onSensorDisconnectedWarning(false); + } } @Override @@ -297,13 +487,39 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen if (PreferencesUtils.isKey(R.string.idle_duration_key, key)) { idleDuration = PreferencesUtils.getIdleDurationTimeout(); } + if (PreferencesUtils.isKey(R.string.settings_recording_auto_pause_on_cadence_0_key, key)) { + autoPauseOnCadence0 = PreferencesUtils.shouldAutoPauseOnCadence0(); + } + if (PreferencesUtils.isKey(R.string.settings_recording_auto_pause_on_cadence_0_speed_key, key)) { + autoPauseCadence0Speed = PreferencesUtils.getAutoPauseCadence0Speed(); + } } public TrackPoint getLastStoredTrackPointWithLocation() { return lastStoredTrackPointWithLocation; } + public void setOverrideSensorWithGPS(boolean override) { + if (override) { + hasSensorBeenConnected = false; + gpsOverriden = true; + if (isSensorDisconnectedWarningActive) { + isSensorDisconnectedWarningActive = false; + sensorDisconnectObserver.onSensorDisconnectedWarning(false); + } + if (trackStatisticsUpdater != null) { + trackStatisticsUpdater.setForceZeroDistance(false); + } + } + } + public interface IdleObserver { void onIdle(); + void onLongBreak(); + } + + public interface SensorDisconnectObserver { + void onSensorDisconnectedWarning(boolean show); + void onSensorConnected(); } } diff --git a/src/main/java/de/dennisguse/opentracks/services/TrackRecordingService.java b/src/main/java/de/dennisguse/opentracks/services/TrackRecordingService.java index 8ca4c5a4a..c28f3b224 100644 --- a/src/main/java/de/dennisguse/opentracks/services/TrackRecordingService.java +++ b/src/main/java/de/dennisguse/opentracks/services/TrackRecordingService.java @@ -44,7 +44,7 @@ import de.dennisguse.opentracks.services.announcement.VoiceAnnouncementManager; import de.dennisguse.opentracks.services.handlers.TrackPointCreator; import de.dennisguse.opentracks.settings.PreferencesUtils; -public class TrackRecordingService extends Service implements TrackPointCreator.Callback, SharedPreferences.OnSharedPreferenceChangeListener, TrackRecordingManager.IdleObserver { +public class TrackRecordingService extends Service implements TrackPointCreator.Callback, SharedPreferences.OnSharedPreferenceChangeListener, TrackRecordingManager.IdleObserver, TrackRecordingManager.SensorDisconnectObserver { private static final String TAG = TrackRecordingService.class.getSimpleName(); @@ -84,6 +84,7 @@ public class TrackRecordingService extends Service implements TrackPointCreator. private RecordingStatus recordingStatus; private MutableLiveData recordingStatusObservable; private MutableLiveData gpsStatusObservable; + private MutableLiveData sensorDisconnectedWarningObservable; private MutableLiveData recordingDataObservable; private Handler handler; @@ -104,10 +105,11 @@ public class TrackRecordingService extends Service implements TrackPointCreator. recordingStatusObservable = new MutableLiveData<>(); updateRecordingStatus(STATUS_DEFAULT); gpsStatusObservable = new MutableLiveData<>(STATUS_GPS_DEFAULT); + sensorDisconnectedWarningObservable = new MutableLiveData<>(false); recordingDataObservable = new MutableLiveData<>(RecordingData.NOT_RECORDING); trackPointCreator = new TrackPointCreator(this); - trackRecordingManager = new TrackRecordingManager(this, trackPointCreator, this, handler); + trackRecordingManager = new TrackRecordingManager(this, trackPointCreator, this, this, handler); voiceAnnouncementManager = new VoiceAnnouncementManager(this); notificationManager = new TrackRecordingServiceNotificationManager(this); @@ -138,6 +140,7 @@ public class TrackRecordingService extends Service implements TrackPointCreator. recordingStatusObservable = null; gpsStatusObservable = null; + sensorDisconnectedWarningObservable = null; recordingDataObservable = null; Log.d(TAG, "Destroyed"); @@ -315,6 +318,10 @@ public class TrackRecordingService extends Service implements TrackPointCreator. return gpsStatusObservable; } + public LiveData getSensorDisconnectedWarningObservable() { + return sensorDisconnectedWarningObservable; + } + public LiveData getRecordingDataObservable() { return recordingDataObservable; } @@ -337,6 +344,32 @@ public class TrackRecordingService extends Service implements TrackPointCreator. voiceAnnouncementManager.announceIdle(); } + @Override + public void onLongBreak() { + voiceAnnouncementManager.announceLongBreak(); + } + + @Override + public void onSensorDisconnectedWarning(boolean show) { + if (show) { + voiceAnnouncementManager.announceSensorDisconnected(); + } + if (sensorDisconnectedWarningObservable != null) { + sensorDisconnectedWarningObservable.postValue(show); + } + } + + @Override + public void onSensorConnected() { + voiceAnnouncementManager.announceSensorConnected(); + } + + public void setOverrideSensorWithGPS(boolean override) { + if (trackRecordingManager != null) { + trackRecordingManager.setOverrideSensorWithGPS(override); + } + } + @VisibleForTesting public void stopUpdateRecordingData() { handler.removeCallbacks(updateRecordingData); diff --git a/src/main/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementManager.java b/src/main/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementManager.java index af2e5b651..1c3988884 100644 --- a/src/main/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementManager.java +++ b/src/main/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementManager.java @@ -119,6 +119,30 @@ public class VoiceAnnouncementManager implements SharedPreferences.OnSharedPrefe voiceAnnouncement.speak(VoiceAnnouncementUtils.createIdle(context)); } + public void announceLongBreak() { + if (shouldNotAnnounce() || !PreferencesUtils.shouldVoiceAnnouncementLongBreak()) { + return; + } + + voiceAnnouncement.speak(VoiceAnnouncementUtils.createLongBreak(context)); + } + + public void announceSensorDisconnected() { + if (shouldNotAnnounce() || !PreferencesUtils.shouldVoiceAnnouncementSensorDisconnected()) { + return; + } + + voiceAnnouncement.speak(VoiceAnnouncementUtils.createSensorDisconnected(context)); + } + + public void announceSensorConnected() { + if (shouldNotAnnounce() || !PreferencesUtils.shouldVoiceAnnouncementSensorConnected()) { + return; + } + + voiceAnnouncement.speak(VoiceAnnouncementUtils.createSensorConnected(context)); + } + public void announceStatisticsIfNeeded(@NonNull RecordingData data) { if (shouldNotAnnounce()) { return; diff --git a/src/main/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtils.java b/src/main/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtils.java index bbce637e9..3fe16ce73 100644 --- a/src/main/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtils.java +++ b/src/main/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtils.java @@ -50,6 +50,21 @@ class VoiceAnnouncementUtils { .append(context.getString(R.string.voiceIdle)); } + static Spannable createLongBreak(Context context) { + return new SpannableStringBuilder() + .append(context.getString(R.string.voiceLongBreak)); + } + + static Spannable createSensorDisconnected(Context context) { + return new SpannableStringBuilder() + .append(context.getString(R.string.voiceSensorDisconnected)); + } + + static Spannable createSensorConnected(Context context) { + return new SpannableStringBuilder() + .append(context.getString(R.string.voiceSensorConnected)); + } + static Spannable createStatistics(Context context, Track track, SensorDataSet sensorDataSet, UnitSystem unitSystem, boolean isReportSpeed, @Nullable Statistics currentInterval, @Nullable SensorStatistics sensorStatistics) { Statistics trackStatistics = track.statistics(); diff --git a/src/main/java/de/dennisguse/opentracks/services/handlers/TrackPointCreator.java b/src/main/java/de/dennisguse/opentracks/services/handlers/TrackPointCreator.java index 696379cf0..8e449c65b 100644 --- a/src/main/java/de/dennisguse/opentracks/services/handlers/TrackPointCreator.java +++ b/src/main/java/de/dennisguse/opentracks/services/handlers/TrackPointCreator.java @@ -101,8 +101,11 @@ public class TrackPointCreator implements SharedPreferences.OnSharedPreferenceCh position = position.withCoordinates(lastStoredTrackPointWithLocation.position()); } - if (lastTrackPointUISpeed != null) + if (sensorDataSet.speed() != null) { + position = position.with(sensorDataSet.speed().data()); + } else if (lastTrackPointUISpeed != null) { position = position.with(lastTrackPointUISpeed.position().speed()); + } if (lastTrackPointUIAltitude != null) position = position.with(lastTrackPointUIAltitude.position().altitude()); diff --git a/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java b/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java index 2ef449f90..3ef618272 100644 --- a/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java +++ b/src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java @@ -38,11 +38,13 @@ import java.util.Arrays; import java.util.List; import java.util.Locale; import java.util.Optional; +import java.util.stream.Collectors; import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.data.models.ActivityType; import de.dennisguse.opentracks.data.models.Distance; import de.dennisguse.opentracks.data.models.DistanceFormatter; +import de.dennisguse.opentracks.data.models.Speed; import de.dennisguse.opentracks.data.models.HeartRate; import de.dennisguse.opentracks.data.models.HeartRateZones; import de.dennisguse.opentracks.data.models.Track; @@ -249,6 +251,10 @@ public class PreferencesUtils { setString(R.string.stats_units_key, unitSystem.getPreferenceId()); } + public static Duration getMarkerIdleDuration() { + return Duration.ofMinutes(getInt(R.string.marker_idle_duration_key, 3)); + } + //TODO Check if actually needed or can be superseeded by a flexible default in getUnit() public static void applyDefaultUnit() { if (getString(R.string.stats_units_key, "").isEmpty()) { @@ -342,6 +348,20 @@ public class PreferencesUtils { return getBoolean(R.string.voice_announce_idle_key, true); } + public static boolean shouldVoiceAnnouncementLongBreak() { + return getBoolean(R.string.voice_announce_long_break_key, true); + } + + + + public static boolean shouldVoiceAnnouncementSensorDisconnected() { + return getBoolean(R.string.voice_announce_sensor_disconnected_key, true); + } + + public static boolean shouldVoiceAnnouncementSensorConnected() { + return getBoolean(R.string.voice_announce_sensor_connected_key, true); + } + public static boolean shouldVoiceAnnounceTime() { return getBoolean(R.string.voice_announce_time_key, true); } @@ -682,6 +702,51 @@ public class PreferencesUtils { return entries; } + static String[] getMarkerIdleDurationEntries() { + String[] entryValues = resources.getStringArray(R.array.marker_idle_duration_values); + String[] entries = new String[entryValues.length]; + + final int defaultDuration = Integer.parseInt(resources.getString(R.string.marker_idle_duration_default)); + + for (int i = 0; i < entryValues.length; i++) { + int value = Integer.parseInt(entryValues[i]); + + if (value == defaultDuration) { + entries[i] = resources.getString(R.string.value_integer_minute_recommended, value); + } else { + entries[i] = resources.getString(R.string.value_integer_minute, value); + } + } + + return entries; + } + + public static boolean shouldAutoPauseOnCadence0() { + return getBoolean(R.string.settings_recording_auto_pause_on_cadence_0_key, false); + } + + public static boolean shouldPreventIdleIfCadence() { + return getBoolean(R.string.settings_recording_prevent_idle_if_cadence_key, true); + } + + public static Speed getAutoPauseCadence0Speed() { + final int DEFAULT = Integer.parseInt(resources.getString(R.string.auto_pause_cadence_0_speed_default)); + int value = getInt(R.string.settings_recording_auto_pause_on_cadence_0_speed_key, DEFAULT); + return Speed.ofKMH(value); + } + + static String[] getAutoPauseCadence0SpeedEntries() { + String[] entryValues = resources.getStringArray(R.array.auto_pause_cadence_0_speed_values); + String[] entries = new String[entryValues.length]; + + for (int i = 0; i < entryValues.length; i++) { + int value = Integer.parseInt(entryValues[i]); + entries[i] = value + " km/h"; + } + + return entries; + } + 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(); @@ -839,7 +904,14 @@ public class PreferencesUtils { new DataField(resources.getString(R.string.stats_custom_layout_cadence_key), false, false, false), new DataField(resources.getString(R.string.stats_custom_layout_power_key), false, false, false), new DataField(resources.getString(R.string.stats_custom_layout_clock_key), false, false, false), - new DataField(resources.getString(R.string.stats_custom_layout_temperature_key), false, false, false) + new DataField(resources.getString(R.string.stats_custom_layout_temperature_key), false, false, false), + new DataField(resources.getString(R.string.stats_custom_layout_auto_pause_status_key), false, false, false), + new DataField(resources.getString(R.string.stats_custom_layout_sensor_status_hr_key), false, false, false), + new DataField(resources.getString(R.string.stats_custom_layout_sensor_status_cadence_key), false, false, false), + new DataField(resources.getString(R.string.stats_custom_layout_sensor_status_speed_key), false, false, false), + new DataField(resources.getString(R.string.stats_custom_layout_debug_idle_timer_key), false, false, false), + new DataField(resources.getString(R.string.stats_custom_layout_debug_walking_timer_key), false, false, false), + new DataField(resources.getString(R.string.stats_custom_layout_debug_marker_timer_key), false, false, false) ); RecordingLayout defaultLayout = new RecordingLayout(resources.getString(R.string.stats_custom_layout_default_layout), 2); @@ -855,7 +927,7 @@ public class PreferencesUtils { String selectedLayout = getSelectedLayoutName(); String csvCustomLayouts = getString(R.string.stats_custom_layouts_key, buildDefaultLayout()); - List recordingLayouts = RecordingLayoutIO.fromCsv(csvCustomLayouts, resources); + List recordingLayouts = getAllCustomLayouts(); RecordingLayout fallback = !recordingLayouts.isEmpty() ? recordingLayouts.get(0) : getDefaultLayout(); @@ -901,7 +973,18 @@ public class PreferencesUtils { public static List getAllCustomLayouts() { String csvCustomLayouts = getString(R.string.stats_custom_layouts_key, buildDefaultLayout()); - return RecordingLayoutIO.fromCsv(csvCustomLayouts, resources); + List layouts = RecordingLayoutIO.fromCsv(csvCustomLayouts, resources); + List defaultFields = getDefaultLayout().getFields(); + + for (RecordingLayout layout : layouts) { + for (DataField df : defaultFields) { + boolean hasField = layout.getFields().stream().anyMatch(f -> f.getKey().equals(df.getKey())); + if (!hasField) { + layout.addField(new DataField(df.getKey(), false, false, df.isWide())); + } + } + } + return layouts; } public static void resetCustomLayoutPreferences() { diff --git a/src/main/java/de/dennisguse/opentracks/settings/RecordingSettingsFragment.java b/src/main/java/de/dennisguse/opentracks/settings/RecordingSettingsFragment.java index 7408de78f..71ac834de 100644 --- a/src/main/java/de/dennisguse/opentracks/settings/RecordingSettingsFragment.java +++ b/src/main/java/de/dennisguse/opentracks/settings/RecordingSettingsFragment.java @@ -57,5 +57,15 @@ public class RecordingSettingsFragment extends PreferenceFragmentCompat { ListPreference idleDuration = findPreference(getString(R.string.idle_duration_key)); idleDuration.setEntries(PreferencesUtils.getIdleDurationEntries()); + + ListPreference autoPauseCadence0Speed = findPreference(getString(R.string.settings_recording_auto_pause_on_cadence_0_speed_key)); + if (autoPauseCadence0Speed != null) { + autoPauseCadence0Speed.setEntries(PreferencesUtils.getAutoPauseCadence0SpeedEntries()); + } + + ListPreference markerIdleDuration = findPreference(getString(R.string.marker_idle_duration_key)); + if (markerIdleDuration != null) { + markerIdleDuration.setEntries(PreferencesUtils.getMarkerIdleDurationEntries()); + } } } diff --git a/src/main/java/de/dennisguse/opentracks/ui/customRecordingLayout/RecordingLayoutIO.java b/src/main/java/de/dennisguse/opentracks/ui/customRecordingLayout/RecordingLayoutIO.java index 0ebb5f0f8..7b8c31958 100644 --- a/src/main/java/de/dennisguse/opentracks/ui/customRecordingLayout/RecordingLayoutIO.java +++ b/src/main/java/de/dennisguse/opentracks/ui/customRecordingLayout/RecordingLayoutIO.java @@ -40,6 +40,13 @@ public class RecordingLayoutIO { Log.e(TAG, "Invalid CSV layout. It shouldn't happen: " + csvLine); return recordingLayout; } + + String key = fieldParts[0]; + if (key.equals(resources.getString(R.string.stats_custom_layout_sensor_status_key))) { + // Ignore deprecated sensor_status key to safely remove it from user's custom layout + continue; + } + recordingLayout.addField(fromCSV(fieldParts, resources)); } return recordingLayout; diff --git a/src/main/java/de/dennisguse/opentracks/ui/customRecordingLayout/SettingsCustomLayoutEditAdapter.java b/src/main/java/de/dennisguse/opentracks/ui/customRecordingLayout/SettingsCustomLayoutEditAdapter.java index 3d62ca203..841324b84 100644 --- a/src/main/java/de/dennisguse/opentracks/ui/customRecordingLayout/SettingsCustomLayoutEditAdapter.java +++ b/src/main/java/de/dennisguse/opentracks/ui/customRecordingLayout/SettingsCustomLayoutEditAdapter.java @@ -77,7 +77,7 @@ public class SettingsCustomLayoutEditAdapter extends RecyclerView.AdapterNowy segment jest tworzony, jeśli odległość między dwiema lokalizacjami jest większa niż %1$s Maksymalna odległość Rejestrowanie odcinka + Minimalny czas długiej przerwy Rejestrowanie interwału Dokładność GPS-u Data (ISO 8601) @@ -281,6 +282,7 @@ limitations under the License. %1$d m (zalecane) %1$d mil/h %1$d min + %1$d min (zalecane) %1$d s Brak Wyłącz @@ -443,6 +445,8 @@ limitations under the License. Przedział czasu Tętno Zegar + Status auto-pauzy + Status czujników Długość okrążenia Zresetować twoje układy\? Kolarstwo @@ -523,6 +527,10 @@ limitations under the License. Tempo/prędkość okrążenia %1$d ścieżki Przerwa + Długa przerwa (filiżanka) + Czujniki + Odłączono czujnik + Podłączono czujnik Nagrywanie Zmiana motywu może wymagać ręcznego restartu. Użyj ręcznie zdefiniowanych Dynamicznych Kolorów (tylko Android 12+; wymaga ręcznego restartu) @@ -585,4 +593,15 @@ limitations under the License. API panelu OpenTracks %1$d z %2$d: %3$s Użyj wbudowanego głośnika + Czujnik rozłączony. Kontynuować z GPS? + Przełącz + Zatrzymaj przy prowadzeniu roweru + Uruchom autopauzę jeżeli kadencja wynosi 0 a prędkość wskazuje na chód + Maksymalna prędkość prowadzenia + Prędkość poniżej której wykrywane jest prowadzenie roweru (gdy kadencja to 0) + Zapobiegaj pauzie podczas pedałowania + Nie włączaj trybu postoju jeżeli pedałujesz, nawet przy braku ruchu + Rozpoczęto długą przerwę. + Odłączono czujnik. + Podłączono czujnik. diff --git a/src/main/res/values/settings.xml b/src/main/res/values/settings.xml index 5d9c2038e..3c1323a95 100644 --- a/src/main/res/values/settings.xml +++ b/src/main/res/values/settings.xml @@ -128,6 +128,14 @@ idleSpeedDuration 10 0 + + 4 + 6 + 8 + 10 + 12 + + @string/idle_duration_off 5 @@ -139,6 +147,17 @@ 120 + 3 + + 1 + 2 + @string/marker_idle_duration_default + 5 + 10 + 15 + 30 + + statsRate @string/stats_rate_speed_or_pace_default @@ -243,6 +262,12 @@ true voice_announce_idle_key false + voice_announce_long_break_key + true + voice_announce_sensor_disconnected_key + true + voice_announce_sensor_connected_key + true voiceAnnounceTotalDistance true @@ -379,6 +404,14 @@ average_cadence power time + auto_pause_status + sensor_status + sensor_status_hr + sensor_status_cadence + sensor_status_speed + debug_idle_timer + debug_walking_timer + debug_marker_timer 0 diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index c36234e09..dc51c72ef 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -331,6 +331,10 @@ limitations under the License. Cycling Sensors, Wheel Circumference, Running Sensors Idle Announcements Idle + Long break + Sensors + Sensor disconnected + Sensor connected Statistics Announcements Time, Distance, Voice Speed Import and Export @@ -369,7 +373,9 @@ limitations under the License. A new segment is created if the distance between two locations is greater than %1$s Max distance Recording distance interval - Recording time interval + marker_idle_duration + Marker idle duration + recording_min_recording_interval_key Location is ignored if its accuracy is greater than %1$s GPS accuracy Date (ISO 8601) @@ -478,6 +484,14 @@ limitations under the License. Cadence Power Clock + Auto-Pause Status + Sensor HR + Sensor Cadence + Sensor Speed + Idle Timer + Walking Timer + Marker Timer + Track %1$d Location accuracy: %1$s @@ -536,6 +550,7 @@ limitations under the License. %1$.1f mi/h (recommended) %1$.1f knots (recommended) %1$d min + %1$d min (recommended) %1$d s None Off @@ -543,6 +558,9 @@ limitations under the License. Smallest (recommended) %1$d s (recommended) Becoming idle. + Long break recorded. + Sensor disconnected. + Sensor connected. {n, plural, =1 {1 hour} @@ -705,4 +723,16 @@ limitations under the License. Lap power Power {x, plural, =1 {1 watt} other {# watts} } Power + preventIdleIfCadence + Prevent auto-pause if pedaling + Do not enter idle mode while pedaling even if not moving + Sensor disconnected. Continue with GPS? + Switch + autoPauseCadence0 + Auto-pause when carrying bike + Pause recording if cadence is 0 and speed is low + autoPauseCadence0Speed + Auto-pause max speed + Speed below which walking with bike is detected (if cadence is 0) + 8 diff --git a/src/main/res/xml/settings_announcements.xml b/src/main/res/xml/settings_announcements.xml index 3adf5fa29..919270f62 100644 --- a/src/main/res/xml/settings_announcements.xml +++ b/src/main/res/xml/settings_announcements.xml @@ -22,6 +22,21 @@ android:defaultValue="@bool/voice_announce_idle_default" android:key="@string/voice_announce_idle_key" android:title="@string/settings_announcements_idle_title" /> + + + + + + diff --git a/src/main/res/xml/settings_recording.xml b/src/main/res/xml/settings_recording.xml index 2a16d3041..28c4d003f 100644 --- a/src/main/res/xml/settings_recording.xml +++ b/src/main/res/xml/settings_recording.xml @@ -4,6 +4,25 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:title="@string/settings_recording_title"> + + + + + + + + \ No newline at end of file