diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 000000000..4a53bee8c --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 000000000..3d55fdfe3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 000000000..c61ea3346 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 000000000..f8051a6f9 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 000000000..16660f1d8 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 8e055d2e4..7513bbbdc 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,18 @@ Translations are hosted on +## ⚠️ About This Fork + +This repository is a customized fork of the official OpenTracks application. + +**Core Purpose:** +The primary goal of this fork is to fundamentally change how Bluetooth sensors are prioritized over GPS data. In this modified version, Bluetooth sensor data is given absolute priority and actively ignores/overrides GPS signals in most scenarios. This ensures that cadence, speed, and distance measurements strictly rely on hardware sensors rather than falling back to or being averaged with GPS data. + +**Consequential Changes:** +As a direct result of these sensor priority changes, several downstream modifications were implemented to support the new data flow: +* **UI & Settings:** Expanded settings menus and customized recording layouts to better expose the raw sensor data and give the user finer control. +* **Media & Voice Announcements:** Integrated media playback controls directly into the statistics view and overhauled voice announcements to handle audio focus and integrate with the new sensor-first approach. + ## Screenshots
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 000000000..baa28d154 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/moje_zmiany_opentracks.patch b/moje_zmiany_opentracks.patch new file mode 100644 index 000000000..ba2e04e01 --- /dev/null +++ b/moje_zmiany_opentracks.patch @@ -0,0 +1,1432 @@ +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 diff --git a/patch_pref.sh b/patch_pref.sh new file mode 100644 index 000000000..60db2cd4d --- /dev/null +++ b/patch_pref.sh @@ -0,0 +1,14 @@ +cat << 'EOC' > src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java.patch +--- src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java ++++ src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java +@@ -351,6 +351,11 @@ + public static boolean shouldVoiceAnnouncementLongBreak() { + return getBoolean(R.string.settings_announcements_long_break_key, true); + } ++ ++ public static long getLongBreakDuration() { ++ int minutes = getInt(R.string.settings_long_break_duration_key, 3); ++ return minutes * 60 * 1000L; ++ } +EOC +patch -p0 < src/main/java/de/dennisguse/opentracks/settings/PreferencesUtils.java.patch diff --git a/patch_strings.sh b/patch_strings.sh new file mode 100644 index 000000000..f6019e325 --- /dev/null +++ b/patch_strings.sh @@ -0,0 +1,13 @@ +cat << 'EOC' > src/main/res/values/strings.xml.patch +--- src/main/res/values/strings.xml ++++ src/main/res/values/strings.xml +@@ -1071,6 +1071,8 @@ + + settings_announcements_long_break_key + Long break ++ settings_long_break_duration_key ++ Long break duration (minutes) + + +EOC +patch -p0 < src/main/res/values/strings.xml.patch diff --git a/patch_track_stats.sh b/patch_track_stats.sh new file mode 100644 index 000000000..7138f2829 --- /dev/null +++ b/patch_track_stats.sh @@ -0,0 +1 @@ +cat src/main/java/de/dennisguse/opentracks/data/statistics/TrackStatisticsUpdater.java | grep -n "if (movingDistance != null) {" -B 2 -A 30 diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..008e284e5 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,4 @@ +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} +rootProject.name = 'OpenTracks' \ No newline at end of file diff --git a/src/androidTest/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtilsTest.java b/src/androidTest/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtilsTest.java index cf4aab14f..681081b28 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtilsTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtilsTest.java @@ -92,7 +92,10 @@ public class VoiceAnnouncementUtilsTest { new SensorData<>(HeartRate.of(60), "unused"), null, null, null, - null + null, + null, + null, + null ); // when @@ -132,7 +135,10 @@ public class VoiceAnnouncementUtilsTest { new SensorData<>(HeartRate.of(60), "unused"), null, null, null, - null + null, + null, + null, + null ); // when @@ -172,7 +178,10 @@ public class VoiceAnnouncementUtilsTest { new SensorData<>(HeartRate.of(60), "unused"), null, null, null, - null + null, + null, + null, + null ); // when @@ -212,7 +221,10 @@ public class VoiceAnnouncementUtilsTest { new SensorData<>(HeartRate.of(60), "unused"), null, null, null, - null + null, + null, + null, + null ); // when @@ -254,7 +266,10 @@ public class VoiceAnnouncementUtilsTest { new SensorData<>(HeartRate.of(60), "unused"), null, null, null, - null + null, + null, + null, + null ); // when @@ -276,7 +291,10 @@ public class VoiceAnnouncementUtilsTest { new SensorData<>(HeartRate.of(60), "unused"), null, null, null, - null + null, + null, + null, + null ); // when @@ -316,7 +334,10 @@ public class VoiceAnnouncementUtilsTest { new SensorData<>(HeartRate.of(60), "unused"), null, null, null, - null + null, + null, + null, + null ); // when @@ -338,7 +359,10 @@ public class VoiceAnnouncementUtilsTest { new SensorData<>(HeartRate.of(60), "unused"), null, null, null, - null + null, + null, + null, + null ); // when @@ -378,7 +402,10 @@ public class VoiceAnnouncementUtilsTest { new SensorData<>(HeartRate.of(60), "unused"), null, null, null, - null + null, + null, + null, + null ); // when @@ -418,7 +445,10 @@ public class VoiceAnnouncementUtilsTest { new SensorData<>(HeartRate.of(60), "unused"), null, null, null, - null + null, + null, + null, + null ); // when @@ -458,7 +488,10 @@ public class VoiceAnnouncementUtilsTest { new SensorData<>(HeartRate.of(60), "unused"), null, null, null, - null + null, + null, + null, + null ); // when @@ -498,7 +531,10 @@ public class VoiceAnnouncementUtilsTest { new SensorData<>(HeartRate.of(60), "unused"), null, null, null, - null + null, + null, + null, + null ); // when @@ -520,7 +556,10 @@ public class VoiceAnnouncementUtilsTest { new SensorData<>(HeartRate.of(60), "unused"), null, null, null, - null + null, + null, + null, + null ); // when @@ -560,7 +599,10 @@ public class VoiceAnnouncementUtilsTest { new SensorData<>(HeartRate.of(60), "unused"), null, null, null, - null + null, + null, + null, + null ); // when @@ -582,7 +624,10 @@ public class VoiceAnnouncementUtilsTest { new SensorData<>(HeartRate.of(60), "unused"), null, null, null, - null + null, + null, + null, + null ); // when @@ -610,7 +655,10 @@ public class VoiceAnnouncementUtilsTest { new SensorData<>(HeartRate.of(60), "unused"), null, null, null, - null + null, + null, + null, + null ); // when @@ -644,7 +692,10 @@ public class VoiceAnnouncementUtilsTest { new SensorData<>(HeartRate.of(60), "unused"), null, null, null, - null + null, + null, + null, + null ); // when diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 3b1339e6b..5f0fc36a4 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -421,6 +421,16 @@ limitations under the License. android:icon="@drawable/ic_logo_color_24dp" android:label="@string/recording_service" /> + + + + + + { + 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..cef383846 100644 --- a/src/main/java/de/dennisguse/opentracks/data/statistics/TrackStatisticsUpdater.java +++ b/src/main/java/de/dennisguse/opentracks/data/statistics/TrackStatisticsUpdater.java @@ -143,7 +143,6 @@ public class TrackStatisticsUpdater { } { - // Update total distance Distance movingDistance = null; if (trackPoint.sensorDistance() != null) { movingDistance = trackPoint.sensorDistance(); @@ -153,16 +152,7 @@ public class TrackStatisticsUpdater { // 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 +160,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..7e2c17c0f 100644 --- a/src/main/java/de/dennisguse/opentracks/sensors/SensorHandler.java +++ b/src/main/java/de/dennisguse/opentracks/sensors/SensorHandler.java @@ -9,6 +9,7 @@ import androidx.annotation.VisibleForTesting; import java.time.Instant; +import de.dennisguse.opentracks.data.models.BatteryLevel; import de.dennisguse.opentracks.sensors.driver.Driver; import de.dennisguse.opentracks.sensors.driver.DriverObserver; import de.dennisguse.opentracks.sensors.driver.SensorType; @@ -28,6 +29,9 @@ public abstract class SensorHandler implement protected Aggregator aggregator; + @Nullable + private Integer batteryLevel = null; + protected SensorHandler(SensorManager sensorManager) { this.sensorManager = sensorManager; } @@ -115,7 +119,8 @@ public abstract class SensorHandler implement return; } - SensorHandler.this.aggregator.resetAggregated(); + SensorHandler.this.aggregator = null; + sensorManager.onChange(); } @Override @@ -130,5 +135,18 @@ public abstract class SensorHandler implement @Override public void onDisconnected() { SensorHandler.this.aggregator = null; + batteryLevel = null; + sensorManager.onChange(); + } + + @Override + public void onBatteryLevelReceived(int percentage) { + batteryLevel = percentage; + sensorManager.onChange(); + } + + @Nullable + public Integer getBatteryLevel() { + return batteryLevel; } } diff --git a/src/main/java/de/dennisguse/opentracks/sensors/SensorManager.java b/src/main/java/de/dennisguse/opentracks/sensors/SensorManager.java index b2a9ba1bc..b0c9d9a2b 100644 --- a/src/main/java/de/dennisguse/opentracks/sensors/SensorManager.java +++ b/src/main/java/de/dennisguse/opentracks/sensors/SensorManager.java @@ -12,6 +12,8 @@ import androidx.annotation.VisibleForTesting; import java.time.Instant; +import de.dennisguse.opentracks.data.models.AltitudeGainLoss; +import de.dennisguse.opentracks.data.models.BatteryLevel; import de.dennisguse.opentracks.data.models.Cadence; import de.dennisguse.opentracks.data.models.Distance; import de.dennisguse.opentracks.data.models.Position; @@ -239,6 +241,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, null, null, null); + } //We always need a Position with now SensorData gpsSensorData = gpsHandler.getSensorData(now); if (gpsSensorData != null && gpsSensorData.data() != null) { @@ -247,6 +252,13 @@ public class SensorManager implements SharedPreferences.OnSharedPreferenceChange gpsSensorData = new SensorData<>(Position.of(now), ""); } + BatteryLevel cadenceBattery = cyclingCadenceHandler.getBatteryLevel() != null + ? BatteryLevel.of(cyclingCadenceHandler.getBatteryLevel()) : null; + BatteryLevel speedBattery = cyclingDistanceSpeedHandler.getBatteryLevel() != null + ? BatteryLevel.of(cyclingDistanceSpeedHandler.getBatteryLevel()) : null; + BatteryLevel heartRateBattery = heartRateHandler.getBatteryLevel() != null + ? BatteryLevel.of(heartRateHandler.getBatteryLevel()) : null; + return new SensorDataSet( gpsSensorData, getSpeed(now), @@ -255,7 +267,10 @@ public class SensorManager implements SharedPreferences.OnSharedPreferenceChange temperatureHandler.getSensorData(now), getCadence(now), cyclingPowerHandler.getSensorData(now), - altitudeChangeHandler.getSensorData(now) + altitudeChangeHandler.getSensorData(now), + cadenceBattery, + speedBattery, + heartRateBattery ); } diff --git a/src/main/java/de/dennisguse/opentracks/sensors/driver/BluetoothDriver.java b/src/main/java/de/dennisguse/opentracks/sensors/driver/BluetoothDriver.java index ad30977ef..72b0465ff 100644 --- a/src/main/java/de/dennisguse/opentracks/sensors/driver/BluetoothDriver.java +++ b/src/main/java/de/dennisguse/opentracks/sensors/driver/BluetoothDriver.java @@ -35,6 +35,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import de.dennisguse.opentracks.data.models.BatteryLevel; import de.dennisguse.opentracks.sensors.BluetoothUtils; import de.dennisguse.opentracks.sensors.ServiceMeasurementUUID; @@ -53,6 +54,9 @@ public class BluetoothDriver implements Driver { private final BluetoothAdapter bluetoothAdapter; private BluetoothGatt bluetoothGatt; + private Context context; + private Handler handler; + private String address; private final BluetoothGattCallback connectCallback = new BluetoothGattCallback() { @Override public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { @@ -68,10 +72,21 @@ public class BluetoothDriver implements Driver { case BluetoothProfile.STATE_DISCONNECTED -> { //This is also triggered, if no connection was established (ca. 30s) Log.i(TAG, gatt.getDevice() + ": disconnected from sensor: trying to reconnect"); - if (gatt.connect()) { - Log.e(TAG, gatt.getDevice() + ": could not trigger reconnect for sensor"); - } observer.onConnectionLost(); + + synchronized (BluetoothDriver.this) { + gatt.close(); + if (bluetoothGatt == gatt) { + bluetoothGatt = null; + } + + if (context != null && handler != null && address != null) { + final Context ctx = context; + final Handler hnd = handler; + final String addr = address; + hnd.post(() -> connect(ctx, hnd, addr)); + } + } } } } @@ -116,9 +131,39 @@ public class BluetoothDriver implements Driver { Log.e(TAG, "CLIENT_CHARACTERISTIC_CONFIG_UUID descriptor could not be written"); } + // NOTE: battery read is triggered in onDescriptorWrite, after this write completes. + // Attempting readCharacteristic here would fail silently because GATT + // allows only one pending operation at a time. + observer.onConnected(device.getAddress(), device.getName()); } + @Override + public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + // After the notification descriptor write completes, try to read the battery level. + // This is the correct moment — GATT is idle and can process a new operation. + BluetoothGattService batteryService = gatt.getService(BluetoothUtils.BATTERY.serviceUUID()); + if (batteryService != null) { + BluetoothGattCharacteristic batteryChar = batteryService.getCharacteristic(BluetoothUtils.BATTERY.measurementUUID()); + if (batteryChar != null) { + boolean queued = gatt.readCharacteristic(batteryChar); + Log.d(TAG, gatt.getDevice() + ": battery read queued=" + queued); + } + } + } + + @Override + public void onCharacteristicRead(@NonNull BluetoothGatt gatt, @NonNull BluetoothGattCharacteristic characteristic, int status) { + if (status == BluetoothGatt.GATT_SUCCESS + && characteristic.getUuid().equals(BluetoothUtils.BATTERY.measurementUUID())) { + BatteryLevel level = BluetoothUtils.parseBatteryLevel(characteristic); + if (level != null) { + Log.d(TAG, gatt.getDevice() + ": battery level " + level.percentage() + "%"); + observer.onBatteryLevelReceived(level.percentage()); + } + } + } + @Override public void onCharacteristicChanged(BluetoothGatt gatt, @NonNull BluetoothGattCharacteristic characteristic) { UUID serviceUUID = characteristic.getService().getUuid(); @@ -169,6 +214,10 @@ public class BluetoothDriver implements Driver { Log.d(TAG, device + ": trying to connect"); + this.context = context; + this.handler = handler; + this.address = address; + bluetoothGatt = device.connectGatt(context, false, connectCallback, BluetoothDevice.TRANSPORT_AUTO, 0, handler); } @@ -184,6 +233,10 @@ public class BluetoothDriver implements Driver { Log.i(TAG, bluetoothGatt.getDevice() + ": disconnect finished"); bluetoothGatt = null; + this.context = null; + this.handler = null; + this.address = null; + observer.onDisconnected(); } diff --git a/src/main/java/de/dennisguse/opentracks/sensors/driver/DriverObserver.java b/src/main/java/de/dennisguse/opentracks/sensors/driver/DriverObserver.java index 538c5f48b..799358555 100644 --- a/src/main/java/de/dennisguse/opentracks/sensors/driver/DriverObserver.java +++ b/src/main/java/de/dennisguse/opentracks/sensors/driver/DriverObserver.java @@ -28,4 +28,10 @@ public interface DriverObserver { * For example, the user deactivated the internal GPS. */ default void onSensorDeactivated() {} + + /** + * The Driver read the battery level from the Battery Service (0x180F). + * Only called if the device exposes the Battery Service. + */ + default void onBatteryLevelReceived(int percentage) {} } 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 power, @Nullable - SensorData altitudeGainLoss + SensorData altitudeGainLoss, + @Nullable + BatteryLevel cadenceBattery, + @Nullable + BatteryLevel speedBattery, + @Nullable + BatteryLevel heartRateBattery ) { } diff --git a/src/main/java/de/dennisguse/opentracks/services/MediaNotificationListenerService.java b/src/main/java/de/dennisguse/opentracks/services/MediaNotificationListenerService.java new file mode 100644 index 000000000..08435a8fc --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/services/MediaNotificationListenerService.java @@ -0,0 +1,174 @@ +package de.dennisguse.opentracks.services; + +import android.content.ComponentName; +import android.media.MediaMetadata; +import android.media.session.MediaController; +import android.media.session.MediaSessionManager; +import android.media.session.PlaybackState; +import android.service.notification.NotificationListenerService; +import android.util.Log; +import android.view.KeyEvent; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import java.util.List; + +public class MediaNotificationListenerService extends NotificationListenerService { + + private static final String TAG = MediaNotificationListenerService.class.getSimpleName(); + + private MediaSessionManager mediaSessionManager; + private MediaController currentController; + + private static final MutableLiveData mediaDataLiveData = new MutableLiveData<>(new MediaData(null, null, false, false)); + private static MediaNotificationListenerService instance; + + public static LiveData getMediaDataObservable() { + return mediaDataLiveData; + } + + public static MediaNotificationListenerService getInstance() { + return instance; + } + + private final MediaController.Callback mediaControllerCallback = new MediaController.Callback() { + @Override + public void onPlaybackStateChanged(PlaybackState state) { + updateMediaData(); + } + + @Override + public void onMetadataChanged(MediaMetadata metadata) { + updateMediaData(); + } + }; + + private final MediaSessionManager.OnActiveSessionsChangedListener sessionsChangedListener = controllers -> { + Log.d(TAG, "Active sessions changed"); + findActiveMediaController(controllers); + }; + + @Override + public void onCreate() { + super.onCreate(); + instance = this; + mediaSessionManager = (MediaSessionManager) getSystemService(MEDIA_SESSION_SERVICE); + } + + @Override + public void onListenerConnected() { + super.onListenerConnected(); + Log.d(TAG, "Listener connected"); + mediaDataLiveData.postValue(new MediaData(null, null, false, true)); + try { + ComponentName componentName = new ComponentName(this, MediaNotificationListenerService.class); + mediaSessionManager.addOnActiveSessionsChangedListener(sessionsChangedListener, componentName); + findActiveMediaController(mediaSessionManager.getActiveSessions(componentName)); + } catch (SecurityException e) { + Log.e(TAG, "SecurityException while accessing MediaSessionManager", e); + } + } + + @Override + public void onListenerDisconnected() { + super.onListenerDisconnected(); + Log.d(TAG, "Listener disconnected"); + mediaDataLiveData.postValue(new MediaData(null, null, false, false)); + if (mediaSessionManager != null) { + mediaSessionManager.removeOnActiveSessionsChangedListener(sessionsChangedListener); + } + if (currentController != null) { + currentController.unregisterCallback(mediaControllerCallback); + currentController = null; + } + instance = null; + } + + private void findActiveMediaController(List controllers) { + if (controllers == null || controllers.isEmpty()) { + if (currentController != null) { + currentController.unregisterCallback(mediaControllerCallback); + currentController = null; + } + updateMediaData(); + return; + } + + MediaController newController = controllers.get(0); + for (MediaController controller : controllers) { + PlaybackState state = controller.getPlaybackState(); + if (state != null && (state.getState() == PlaybackState.STATE_PLAYING || state.getState() == PlaybackState.STATE_BUFFERING)) { + newController = controller; + break; + } + } + + if (currentController != null && currentController.getSessionToken().equals(newController.getSessionToken())) { + // Already tracking this controller + updateMediaData(); + return; + } + + if (currentController != null) { + currentController.unregisterCallback(mediaControllerCallback); + } + + currentController = newController; + currentController.registerCallback(mediaControllerCallback); + updateMediaData(); + } + + private void updateMediaData() { + if (currentController == null) { + mediaDataLiveData.postValue(new MediaData(null, null, false, true)); + return; + } + + MediaMetadata metadata = currentController.getMetadata(); + PlaybackState playbackState = currentController.getPlaybackState(); + + String title = null; + String artist = null; + if (metadata != null) { + title = metadata.getString(MediaMetadata.METADATA_KEY_TITLE); + artist = metadata.getString(MediaMetadata.METADATA_KEY_ARTIST); + } + + boolean isPlaying = false; + if (playbackState != null) { + isPlaying = playbackState.getState() == PlaybackState.STATE_PLAYING || + playbackState.getState() == PlaybackState.STATE_BUFFERING; + } + + mediaDataLiveData.postValue(new MediaData(title, artist, isPlaying, true)); + } + + public void sendMediaCommand(int keyCode) { + if (currentController != null) { + currentController.dispatchMediaButtonEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyCode)); + currentController.dispatchMediaButtonEvent(new KeyEvent(KeyEvent.ACTION_UP, keyCode)); + } else { + // Fallback if no active controller (will broadcast to the system) + android.media.AudioManager audioManager = (android.media.AudioManager) getSystemService(AUDIO_SERVICE); + if (audioManager != null) { + audioManager.dispatchMediaKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyCode)); + audioManager.dispatchMediaKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, keyCode)); + } + } + } + + public static class MediaData { + public final String title; + public final String artist; + public final boolean isPlaying; + public final boolean isPermissionGranted; + + public MediaData(String title, String artist, boolean isPlaying, boolean isPermissionGranted) { + this.title = title; + this.artist = artist; + this.isPlaying = isPlaying; + this.isPermissionGranted = isPermissionGranted; + } + } +} diff --git a/src/main/java/de/dennisguse/opentracks/services/RecordingData.java b/src/main/java/de/dennisguse/opentracks/services/RecordingData.java index b85449e9f..34b409688 100644 --- a/src/main/java/de/dennisguse/opentracks/services/RecordingData.java +++ b/src/main/java/de/dennisguse/opentracks/services/RecordingData.java @@ -16,9 +16,13 @@ public record RecordingData( */ TrackPoint latestTrackPoint, SensorDataSet sensorDataSet, - Statistics currentSegment) { + Statistics currentSegment, + String autoPauseStatus, + String idleTimer, + String walkingTimer, + String markerTimer) { - public static final RecordingData NOT_RECORDING = new RecordingData(null, null, null, null); + public static final RecordingData NOT_RECORDING = new RecordingData(null, null, null, null, "", "", "", ""); public Statistics trackStatistics() { return track.statistics(); diff --git a/src/main/java/de/dennisguse/opentracks/services/TrackRecordingManager.java b/src/main/java/de/dennisguse/opentracks/services/TrackRecordingManager.java index 3a4197aee..0988c5171 100644 --- a/src/main/java/de/dennisguse/opentracks/services/TrackRecordingManager.java +++ b/src/main/java/de/dennisguse/opentracks/services/TrackRecordingManager.java @@ -36,10 +36,12 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen private static final AltitudeCorrectionManager ALTITUDE_CORRECTION_MANAGER = new AltitudeCorrectionManager(); private final Runnable ON_IDLE = this::onIdle; + private final Runnable ON_LONG_BREAK = this::onLongBreak; private final ContentProviderUtils contentProviderUtils; private final Context context; private final IdleObserver idleObserver; + private final SensorDisconnectObserver sensorDisconnectObserver; private final Handler handler; @@ -59,9 +61,25 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen private TrackPoint lastStoredTrackPoint; private TrackPoint lastStoredTrackPointWithLocation; - TrackRecordingManager(Context context, TrackPointCreator trackPointCreator, IdleObserver idleObserver, Handler handler) { + private boolean hasSensorBeenConnected = false; + private boolean isSensorDisconnectedWarningActive = false; + + private boolean autoPauseOnCadence0; + private de.dennisguse.opentracks.data.models.Speed autoPauseCadence0Speed; + private boolean hasCadenceSensorBeenConnected = false; + private String autoPauseStatus = "N/A"; + + private java.time.Instant idleTimeoutScheduledAt; + private java.time.Instant walkingStartedAt; + private java.time.Instant idleStartedAt; + private boolean longBreakRecorded = false; + + private boolean gpsOverriden = false; + + TrackRecordingManager(Context context, TrackPointCreator trackPointCreator, IdleObserver idleObserver, SensorDisconnectObserver sensorDisconnectObserver, Handler handler) { this.context = context; this.idleObserver = idleObserver; + this.sensorDisconnectObserver = sensorDisconnectObserver; this.trackPointCreator = trackPointCreator; this.handler = handler; contentProviderUtils = new ContentProviderUtils(context); @@ -97,6 +115,23 @@ public class TrackRecordingManager implements SharedPreferences.OnSharedPreferen onNewTrackPoint(segmentStartTrackPoint); + handler.post(() -> { + 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,32 +202,115 @@ 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? */ synchronized boolean onNewTrackPoint(@NonNull TrackPoint trackPoint) { - if (trackPoint.position().hasSpeed()) { - lastTrackPointUIWithSpeed = trackPoint; - } if (trackPoint.position().hasAltitude()) { 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); + } + } + + 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); + boolean strictSensorMode = isSensorExpected && !sensorHasDistance && !gpsOverriden; + if (strictSensorMode) { + if (trackPoint.position().hasSpeed()) { + trackPoint = trackPoint.with(trackPoint.position().with(de.dennisguse.opentracks.data.models.Speed.ZERO)); + } + } + + if (trackPoint.position().hasSpeed()) { + lastTrackPointUIWithSpeed = trackPoint; + } + + 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 +341,37 @@ 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().toMPS() > 0 && trackPoint.position().speed().lessThan(autoPauseCadence0Speed)) { + isWalkingWithBike = true; + } + } + } + + if (!isWalkingWithBike && "WALKING".equals(autoPauseStatus)) { + autoPauseStatus = "REC"; + walkingStartedAt = null; + scheduleNewIdleTimeout(); + } + Distance distanceToLastStoredTrackPoint; - if (trackPoint.position().hasLocation() && !lastStoredTrackPoint.position().hasLocation()) { - distanceToLastStoredTrackPoint = trackPoint.distanceToPreviousFromLocation(lastStoredTrackPointWithLocation); + if (isWalkingWithBike || strictSensorMode) { + distanceToLastStoredTrackPoint = Distance.ZERO; + if (isWalkingWithBike) { + 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 +379,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 +400,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 +430,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 +480,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 +500,36 @@ 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); + } + } + } + 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..c07537124 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,15 @@ 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), + new DataField(resources.getString(R.string.stats_custom_layout_media_key), false, false, true) ); RecordingLayout defaultLayout = new RecordingLayout(resources.getString(R.string.stats_custom_layout_default_layout), 2); @@ -855,7 +928,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 +974,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..36fef9c45 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; @@ -50,11 +57,24 @@ public class RecordingLayoutIO { } private static DataField fromCSV(String[] fieldParts, @NonNull Resources resources) { + boolean isWide; + String key = fieldParts[0]; + + // Force isWide to true for specific keys that must always be wide + if (key.equals(resources.getString(R.string.stats_custom_layout_coordinates_key)) || + key.equals(resources.getString(R.string.stats_custom_layout_media_key))) { + isWide = true; + } else if (fieldParts.length > 3) { + isWide = YES_VALUE.equals(fieldParts[3]); + } else { + isWide = false; + } + return new DataField( - fieldParts[0], + key, YES_VALUE.equals(fieldParts[1]), YES_VALUE.equals(fieldParts[2]), - fieldParts[0].equals(resources.getString(R.string.stats_custom_layout_coordinates_key))); + isWide); } static String toCsv(DataField datafield) { 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.Adapter { + + @Override + protected StatsMediaItemBinding createViewBinding(LayoutInflater inflater) { + return StatsMediaItemBinding.inflate(inflater); + } + + @Override + public void configureUI(DataField dataField) { + getBinding().statsMediaPrev.setOnClickListener(v -> { + MediaNotificationListenerService service = MediaNotificationListenerService.getInstance(); + if (service != null) { + service.sendMediaCommand(KeyEvent.KEYCODE_MEDIA_PREVIOUS); + } + }); + + getBinding().statsMediaNext.setOnClickListener(v -> { + MediaNotificationListenerService service = MediaNotificationListenerService.getInstance(); + if (service != null) { + service.sendMediaCommand(KeyEvent.KEYCODE_MEDIA_NEXT); + } + }); + + getBinding().statsMediaPlayPause.setOnClickListener(v -> { + MediaNotificationListenerService service = MediaNotificationListenerService.getInstance(); + if (service != null) { + service.sendMediaCommand(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE); + } + }); + + getBinding().statsMediaPermissionButton.setOnClickListener(v -> { + Intent intent = new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + getContext().startActivity(intent); + }); + + if (getContext() instanceof LifecycleOwner) { + MediaNotificationListenerService.getMediaDataObservable().observe((LifecycleOwner) getContext(), mediaData -> { + if (mediaData.isPermissionGranted) { + getBinding().statsMediaPermissionButton.setVisibility(View.GONE); + getBinding().statsMediaControls.setVisibility(View.VISIBLE); + getBinding().statsMediaTitle.setVisibility(View.VISIBLE); + getBinding().statsMediaArtist.setVisibility(View.VISIBLE); + + getBinding().statsMediaTitle.setText(mediaData.title != null ? mediaData.title : getContext().getString(R.string.media_title_default)); + getBinding().statsMediaArtist.setText(mediaData.artist != null ? mediaData.artist : getContext().getString(R.string.media_artist_default)); + + if (mediaData.isPlaying) { + getBinding().statsMediaPlayPause.setImageResource(R.drawable.ic_baseline_pause_24); // Needs to exist, or fallback to something else + } else { + getBinding().statsMediaPlayPause.setImageResource(R.drawable.ic_baseline_play_arrow_24); + } + } else { + getBinding().statsMediaPermissionButton.setVisibility(View.VISIBLE); + getBinding().statsMediaControls.setVisibility(View.GONE); + getBinding().statsMediaTitle.setVisibility(View.GONE); + getBinding().statsMediaArtist.setVisibility(View.GONE); + } + }); + } + } + + @Override + public void onChanged(UnitSystem unitSystem, RecordingData data) { + // Not used, as we observe LiveData directly + } +} diff --git a/src/main/res/drawable/ic_baseline_pause_24.xml b/src/main/res/drawable/ic_baseline_pause_24.xml new file mode 100644 index 000000000..de759cf57 --- /dev/null +++ b/src/main/res/drawable/ic_baseline_pause_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/drawable/ic_baseline_play_arrow_24.xml b/src/main/res/drawable/ic_baseline_play_arrow_24.xml new file mode 100644 index 000000000..370d19ad2 --- /dev/null +++ b/src/main/res/drawable/ic_baseline_play_arrow_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/drawable/ic_baseline_skip_next_24.xml b/src/main/res/drawable/ic_baseline_skip_next_24.xml new file mode 100644 index 000000000..8e7d7496d --- /dev/null +++ b/src/main/res/drawable/ic_baseline_skip_next_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/drawable/ic_baseline_skip_previous_24.xml b/src/main/res/drawable/ic_baseline_skip_previous_24.xml new file mode 100644 index 000000000..e82b6f189 --- /dev/null +++ b/src/main/res/drawable/ic_baseline_skip_previous_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/layout/stats_media_item.xml b/src/main/res/layout/stats_media_item.xml new file mode 100644 index 000000000..505b2cf44 --- /dev/null +++ b/src/main/res/layout/stats_media_item.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + +