feat: prioritize Bluetooth sensor data over GPS

- Restructured sensor logic to give absolute priority to Bluetooth sensors, largely ignoring GPS data for distance and speed calculations.
- Implemented downstream UI expansions, custom recording layouts, and new settings to reflect and control the new sensor-first flow.
- Added MediaNotificationListenerService and integrated media playback tracking within statistics.
- Modified VoiceAnnouncementUtils to harmonize announcements with the updated architecture.
- Added 'About This Fork' section to the README.

Note: These modifications were co-created with Antigravity AI.
This commit is contained in:
2026-09-11 14:02:57 +02:00
parent 00be0b565c
commit 7c6141f98c
50 changed files with 4338 additions and 62 deletions
@@ -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<RecordingLayout> recordingLayouts = RecordingLayoutIO.fromCsv(csvCustomLayouts, resources);
List<RecordingLayout> recordingLayouts = getAllCustomLayouts();
RecordingLayout fallback = !recordingLayouts.isEmpty() ? recordingLayouts.get(0) : getDefaultLayout();
@@ -901,7 +974,18 @@ public class PreferencesUtils {
public static List<RecordingLayout> getAllCustomLayouts() {
String csvCustomLayouts = getString(R.string.stats_custom_layouts_key, buildDefaultLayout());
return RecordingLayoutIO.fromCsv(csvCustomLayouts, resources);
List<RecordingLayout> layouts = RecordingLayoutIO.fromCsv(csvCustomLayouts, resources);
List<DataField> 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() {