From 0963e0dfbcaefda1c2c96635fe5d754dee22637e Mon Sep 17 00:00:00 2001 From: Gerhard Olsson Date: Sun, 13 Sep 2026 14:33:51 +0200 Subject: [PATCH 1/4] fix: ble hr deprecation fixes Update code with api-13 to remove deprecated calls. (suppress for = 5.0 + * Connects to a Bluetooth Low Energy module for Android versions >= 5.0 This implementation + * suppresses some deprecated, keep compatibility with versions before api-33/android-13 * * @author jonas */ @@ -78,28 +80,37 @@ public class AndroidBLEHRProvider extends BtHRBase implements HRProvider { new BluetoothGattCallback() { @Override - public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic arg0) { + @SuppressWarnings("deprecation") + public void onCharacteristicChanged( + BluetoothGatt gatt, BluetoothGattCharacteristic btChar) { + byte[] value = btChar.getValue(); + onCharacteristicChanged(gatt, btChar, value); + } + + @Override + public void onCharacteristicChanged( + BluetoothGatt gatt, BluetoothGattCharacteristic btChar, byte[] value) { try { - if (!checkBtGattOnlyLogError(gatt)) { + if (!checkBtGatt(gatt, true)) { return; } - if (!arg0.getUuid().equals(HEART_RATE_MEASUREMENT_CHARAC)) { - log("onCharacteristicChanged(" + arg0 + ") != HEART_RATE ??"); + if (!btChar.getUuid().equals(HEART_RATE_MEASUREMENT_CHARAC)) { + log("onCharacteristicChanged(" + btChar + ") != HEART_RATE ??"); return; } - int length = arg0.getValue().length; + int length = value.length; if (length == 0) { log("onCharacteristicChanged length = 0"); return; } int val; - if (isHeartRateInUINT16(arg0.getValue()[0])) { - val = arg0.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, 1); + if (isHeartRateInUINT16(value[0])) { + val = ((value[2] & 0xFF) << 8) | (value[1] & 0xFF); } else { - val = arg0.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, 1); + val = value[1] & 0xFF; } hrTimestamp = System.currentTimeMillis(); @@ -132,39 +143,45 @@ public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteris } } + @Override + @SuppressWarnings("deprecation") + public void onCharacteristicRead( + BluetoothGatt gatt, BluetoothGattCharacteristic btChar, int status) { + byte[] value = btChar.getValue(); + onCharacteristicRead(gatt, btChar, value, status); + } + @Override public void onCharacteristicRead( - BluetoothGatt gatt, BluetoothGattCharacteristic arg0, int status) { + BluetoothGatt gatt, BluetoothGattCharacteristic btChar, byte[] value, int status) { try { - log( - "onCharacteristicRead(): " - + gatt - + ", char: " - + arg0.getUuid() - + ", status: " - + status); + UUID charUuid = btChar.getUuid(); + log("onCharacteristicRead(): " + gatt + ", char: " + charUuid + ", status: " + status); - if (!checkBtGatt(gatt)) { + if (!checkBtGatt(gatt, false)) { return; } - UUID charUuid = arg0.getUuid(); if (charUuid.equals(FIRMWARE_REVISON_UUID)) { log("firmware => startHR()"); - // triggered from DummyReadForSecLevelCheck + // triggered from ReadDeviceInfoServices startHR(); } else if (mSupportPaired && charUuid.equals(HARDWARE_REVISON_UUID)) { // Some paired devices like Huami (MiBand) has no firmware uuid log("BLE hardware rev => startHR()"); - // triggered from DummyReadForSecLevelCheck + // triggered from ReadDeviceInfoServices startHR(); } else if (charUuid.equals(BATTERY_LEVEL_CHARAC)) { - log("batterylevel: " + arg0); - batteryLevel = arg0.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, 0); - log("Battery level: " + batteryLevel); + if (value != null && value.length >= 1) { + batteryLevel = value[0] & 0xFF; + log("Battery level: " + batteryLevel); + } else { + batteryLevel = HRProvider.BATTERY_LEVEL_UNAVAILABLE; + log("Battery level unavailable"); + } - log(" => startHR()"); - // triggered from DummyReadForSecLevelCheck + log("battery level => startHR()"); + // triggered from ReadDeviceInfoServices startHR(); } else { @@ -192,7 +209,7 @@ public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState + mIsConnected + ", mIsDisconnecting: " + mIsDisconnecting); - if (!checkBtGatt(gatt)) { + if (!checkBtGatt(gatt, false)) { log("checkBtGatt => return"); return; } @@ -231,9 +248,9 @@ public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState } @Override - public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor arg0, int status) { - - BluetoothGattCharacteristic mHRMcharac = arg0.getCharacteristic(); + public void onDescriptorRead( + BluetoothGatt gatt, BluetoothGattDescriptor btChar, int status, byte[] value) { + BluetoothGattCharacteristic mHRMcharac = btChar.getCharacteristic(); if (!enableNotification(gatt, true, mHRMcharac)) { reportConnectFailed("Failed to enable notification in onDescriptorRead"); } @@ -243,11 +260,11 @@ public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor arg0, i public void onServicesDiscovered(BluetoothGatt gatt, int status) { log("onServicesDiscoverd(): " + gatt + ", status: " + status); - if (!checkBtGatt(gatt)) { + if (!checkBtGatt(gatt, false)) { return; } - List list = btGatt.getServices(); + List list = gatt.getServices(); for (BluetoothGattService s : list) { log("Found service: " + s.getType() + ", " + s.getInstanceId() + ", " + s.getUuid()); for (BluetoothGattCharacteristic a : s.getCharacteristics()) { @@ -257,13 +274,13 @@ public void onServicesDiscovered(BluetoothGatt gatt, int status) { log(" serv: " + a.getUuid()); } - if (s.getUuid().equals(BATTERY_SERVICE)) { + if (s.getUuid().equals(BATTERY_SERVICE_UUID)) { hasBatteryService = true; } } - log(" => DummyRead"); - DummyReadForSecLevelCheck(gatt); + log(" => ReadDeviceInfoServices"); + ReadDeviceInfoServices(gatt); // if GATT_SUCCESS, continue in onCharacteristicRead // no report on error } @@ -271,7 +288,7 @@ public void onServicesDiscovered(BluetoothGatt gatt, int status) { /* * from Samsung HRPService.java */ - private void DummyReadForSecLevelCheck(BluetoothGatt btGatt) { + private void ReadDeviceInfoServices(BluetoothGatt btGatt) { if (btGatt == null) { return; } @@ -280,9 +297,13 @@ private void DummyReadForSecLevelCheck(BluetoothGatt btGatt) { return; } - BluetoothGattService disService = btGatt.getService(DIS_UUID); + BluetoothGattService disService = btGatt.getService(DEVICE_INFORMATION_SERVICE_UUID); if (disService == null) { - reportConnectFailed("Dis service not found"); + log("Device Information service not found"); + // This occurs for e.g. Garmin devices transmitting HR from a watch. + // Start HR (check HRP service) without reading characteristic (no trigger of + // onCharacteristicRead()) + startHR(); return; } BluetoothGattCharacteristic firmwareIdCharc = @@ -302,11 +323,12 @@ private void DummyReadForSecLevelCheck(BluetoothGatt btGatt) { } private boolean isHeartRateInUINT16(byte b) { + // Bluetooth SIG UUID 0x2A37 first bit in first byte describes the length return (b & 1) != 0; } private void startHR() { - BluetoothGattService mHRP = btGatt.getService(HRP_SERVICE); + BluetoothGattService mHRP = btGatt.getService(HEART_RATE_SERVICE_UUID); if (mHRP == null) { reportConnectFailed("HRP service not found!"); return; @@ -318,7 +340,8 @@ private void startHR() { reportConnectFailed("HEART RATE MEASUREMENT charateristic not found!"); return; } - BluetoothGattDescriptor mHRMccc = mHRMcharac.getDescriptor(CCC); + BluetoothGattDescriptor mHRMccc = + mHRMcharac.getDescriptor(CLIENT_CHARACTERISTIC_CONFIGURATION); if (mHRMccc == null) { reportConnectFailed("CCC for HEART RATE MEASUREMENT charateristic not found!"); return; @@ -330,7 +353,7 @@ private void startHR() { } private boolean readBatteryLevel() { - BluetoothGattService mBS = btGatt.getService(BATTERY_SERVICE); + BluetoothGattService mBS = btGatt.getService(BATTERY_SERVICE_UUID); if (mBS == null) { log("Battery service not found."); return false; @@ -392,7 +415,7 @@ private static BluetoothGattCharacteristic getHeartrateCharacteristic(BluetoothG if (gatt == null) { return null; } - BluetoothGattService mHRP = gatt.getService(HRP_SERVICE); + BluetoothGattService mHRP = gatt.getService(HEART_RATE_SERVICE_UUID); if (mHRP == null) { return null; } @@ -423,7 +446,9 @@ public void open(Handler handler, HRClient hrClient) { this.hrClient = hrClient; this.hrClientHandler = handler; if (btAdapter == null) { - btAdapter = BluetoothAdapter.getDefaultAdapter(); + @SuppressWarnings("deprecation") + BluetoothAdapter adapterDeprecated = BluetoothAdapter.getDefaultAdapter(); + btAdapter = adapterDeprecated; } hrClient.onOpenResult(btAdapter != null); @@ -468,18 +493,41 @@ private boolean enableNotification( return false; } - BluetoothGattDescriptor clientConfig = charac.getDescriptor(CCC); + BluetoothGattDescriptor clientConfig = + charac.getDescriptor(CLIENT_CHARACTERISTIC_CONFIGURATION); if (clientConfig == null) { log("clientConfig == null"); return false; } + boolean result; if (onoff) { - clientConfig.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + result = + gatt.writeDescriptor(clientConfig, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + == BluetoothStatusCodes.SUCCESS; + } else { + @SuppressWarnings("deprecation") + boolean setValueResult = + clientConfig.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); + @SuppressWarnings("deprecation") + boolean writeDescResult = gatt.writeDescriptor(clientConfig); + result = setValueResult && writeDescResult; + } } else { - clientConfig.setValue(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + result = + gatt.writeDescriptor(clientConfig, BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE) + == BluetoothStatusCodes.SUCCESS; + } else { + @SuppressWarnings("deprecation") + boolean setValueResult = + clientConfig.setValue(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE); + @SuppressWarnings("deprecation") + boolean writeDescResult = gatt.writeDescriptor(clientConfig); + result = setValueResult && writeDescResult; + } } - boolean result = gatt.writeDescriptor(clientConfig); if (result) { mNotificationsOn = onoff; } @@ -592,7 +640,9 @@ public void startScan() { mIsScanning = true; btScanner.startScan( Collections.singletonList( - new ScanFilter.Builder().setServiceUuid(new ParcelUuid(HRP_SERVICE)).build()), + new ScanFilter.Builder() + .setServiceUuid(new ParcelUuid(HEART_RATE_SERVICE_UUID)) + .build()), new ScanSettings.Builder().build(), mLeScanCallback); } @@ -636,7 +686,9 @@ private BluetoothGatt connectGatt(BluetoothDevice device, BluetoothGattCallback if (!checkPermission(Manifest.permission.BLUETOOTH_CONNECT, "connectGatt")) { return null; } - return device.connectGatt(context, false, callbacks); + @SuppressWarnings("deprecation") + BluetoothGatt gattDeprecated = device.connectGatt(context, false, callbacks); + return gattDeprecated; } private boolean connect(BluetoothGatt gatt) { @@ -843,7 +895,7 @@ boolean disableNotification(BluetoothGatt gatt) { return false; } - BluetoothGattService mHRP = gatt.getService(HRP_SERVICE); + BluetoothGattService mHRP = gatt.getService(HEART_RATE_SERVICE_UUID); if (mHRP == null) { log("disableNotification: HRP service not found!"); return false; @@ -851,7 +903,7 @@ boolean disableNotification(BluetoothGatt gatt) { BluetoothGattCharacteristic mHRMcharac = mHRP.getCharacteristic(HEART_RATE_MEASUREMENT_CHARAC); if (mHRMcharac == null) { - log("disableNotification: HEART RATE MEASUREMENT charateristic not found!"); + log("disableNotification: HEART RATE MEASUREMENT characteristic not found!"); return false; } @@ -943,16 +995,6 @@ public int getBatteryLevel() { return this.batteryLevel; } - @SuppressWarnings("BooleanMethodIsAlwaysInverted") - private boolean checkBtGatt(BluetoothGatt gatt) { - return checkBtGatt(gatt, false); - } - - @SuppressWarnings("BooleanMethodIsAlwaysInverted") - private boolean checkBtGattOnlyLogError(BluetoothGatt gatt) { - return checkBtGatt(gatt, true); - } - private synchronized boolean checkBtGatt(BluetoothGatt gatt, boolean onlyLogError) { if (btGatt == null) { if (!onlyLogError) { diff --git a/hrdevice/src/org/runnerup/hr/BtHRBase.java b/hrdevice/src/org/runnerup/hr/BtHRBase.java index 5bbce1dbb..3ae3f3b32 100644 --- a/hrdevice/src/org/runnerup/hr/BtHRBase.java +++ b/hrdevice/src/org/runnerup/hr/BtHRBase.java @@ -22,14 +22,17 @@ import java.util.UUID; abstract class BtHRBase implements HRProvider { - static final UUID HRP_SERVICE = UUID.fromString("0000180D-0000-1000-8000-00805f9b34fb"); - static final UUID BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb"); + static final UUID HEART_RATE_SERVICE_UUID = + UUID.fromString("0000180D-0000-1000-8000-00805f9b34fb"); + static final UUID BATTERY_SERVICE_UUID = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb"); static final UUID FIRMWARE_REVISON_UUID = UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb"); - static final UUID DIS_UUID = UUID.fromString("0000180a-0000-1000-8000-00805f9b34fb"); + static final UUID DEVICE_INFORMATION_SERVICE_UUID = + UUID.fromString("0000180a-0000-1000-8000-00805f9b34fb"); static final UUID HEART_RATE_MEASUREMENT_CHARAC = UUID.fromString("00002A37-0000-1000-8000-00805f9b34fb"); static final UUID BATTERY_LEVEL_CHARAC = UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb"); - static final UUID CCC = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); + static final UUID CLIENT_CHARACTERISTIC_CONFIGURATION = + UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); static final UUID HARDWARE_REVISON_UUID = UUID.fromString("00002a27-0000-1000-8000-00805f9b34fb"); HRProvider.HRClient hrClient; From 2b1b1e49f0b2db31695ffb8289149fbb6541a893 Mon Sep 17 00:00:00 2001 From: Gerhard Olsson Date: Sat, 12 Sep 2026 21:47:00 +0200 Subject: [PATCH 2/4] fix: analyzeReleaseR8Config warnings - Enabled depreciation warnings No update for wear, requires som GMS updates first - fix some gradle 10 syntax in builds.gradle and gradle.properties - use ivy repo for ant+ lib --- app/build.gradle | 14 +++----------- app/lint-baseline.xml | 13 +------------ build.gradle | 34 +++++++++++++++++++++------------- common/lint-baseline.xml | 2 +- gradle.properties | 9 --------- hrdevice/build.gradle | 4 ++-- hrdevice/lint-baseline.xml | 2 +- wear/lint-baseline.xml | 2 +- 8 files changed, 30 insertions(+), 50 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 03cbdea00..4ec65b24e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -100,7 +100,7 @@ android { lint { baseline = file('lint-baseline.xml') checkReleaseBuilds = true - if (mapboxEnabled) { + if (rootProject.ext.mapboxEnabled) { lintConfig = file('lint.xml') } else { lintConfig = file('lint-osmdroid.xml') @@ -131,7 +131,8 @@ android { repositories { google() - mavenCentral() //MapBox GraphView + mavenCentral() + maven { url = 'https://jitpack.io' } if (rootProject.ext.mapboxEnabled) { maven { url = 'https://api.mapbox.com/downloads/v2/releases/maven' @@ -183,15 +184,6 @@ dependencies { testImplementation "org.mockito:mockito-core:${rootProject.ext.mockitoVersion}" } -allprojects { - tasks.withType(JavaCompile).tap { - configureEach { - // options.compilerArgs << "-Xlint:deprecation"; - options.compilerArgs << "-Xlint:unchecked" - } - } -} - def props = new Properties() // For locally signed builds only, Google Play signs the bundle if (rootProject.file("release.properties").exists()) { diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 8a682b1ce..163c54afe 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,5 +1,5 @@ - + - - - - - + diff --git a/gradle.properties b/gradle.properties index b33955542..b31340008 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,16 +2,11 @@ # Build with ./gradlew app:minifyLatestReleaseWithProguard # required for com.jjoe64:graphview:4.2.2 android.enableJetifier=true -android.useAndroidX=true -org.gradle.caching = true -org.gradle.parallel = true -org.gradle.configureondemand = true org.gradle.vfs.watch = true org.gradle.unsafe.configuration-cache=true android.nonTransitiveRClass=true android.nonFinalResIds=true org.gradle.jvmargs=-Xmx4096m --XX=MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 # org.runnerup.free=true @@ -21,9 +16,5 @@ org.gradle.jvmargs=-Xmx4096m # Disable map (i.e. neither MapBox nor osmdroid is used) # org.runnerup.nomap=true -# Disable warnings due to the way ant+ is consumed -# Using flatDir should be avoided because it doesn't support any meta-data formats -android.sync.suppressAgpWarnings=FLAT_DIR_REPOSITORY_USED - # Enabled parallel sync for Gradle 9.4+ org.gradle.tooling.parallel=true diff --git a/hrdevice/build.gradle b/hrdevice/build.gradle index cdfa31ac3..84a7ef9cb 100644 --- a/hrdevice/build.gradle +++ b/hrdevice/build.gradle @@ -64,6 +64,6 @@ dependencies { implementation "androidx.preference:preference:${rootProject.ext.preference_version}" if (rootProject.ext.antPlusLibName) { - implementation(name: rootProject.ext.antPlusLibName, ext: 'aar') + implementation ":${rootProject.ext.antPlusLibName}@aar" } -} \ No newline at end of file +} diff --git a/hrdevice/lint-baseline.xml b/hrdevice/lint-baseline.xml index 5a1071ecf..e8c77f8fe 100644 --- a/hrdevice/lint-baseline.xml +++ b/hrdevice/lint-baseline.xml @@ -1,5 +1,5 @@ - + - + Date: Sun, 21 Jun 2026 23:16:54 +0200 Subject: [PATCH 3/4] feat: migrate charts --- app/assets/about.html | 2 +- app/build.gradle | 3 +- app/proguard.txt | 5 - app/res/layout/detail.xml | 8 +- app/res/values/dimens.xml | 1 + app/src/main/org/runnerup/util/DataPoint.java | 19 + app/src/main/org/runnerup/util/Formatter.java | 132 ++--- .../main/org/runnerup/util/GraphWrapper.java | 505 ++++++++++++------ .../org/runnerup/util/LineGraphSeries.java | 29 + .../org/runnerup/view/AccountActivity.java | 2 +- .../org/runnerup/view/HRSettingsActivity.java | 165 ++++-- .../main/org/runnerup/view/HRZonesBar.java | 81 ++- .../mapbox/org/runnerup/util/MapWrapper.java | 2 +- gradle.properties | 2 - 14 files changed, 613 insertions(+), 343 deletions(-) create mode 100644 app/src/main/org/runnerup/util/DataPoint.java create mode 100644 app/src/main/org/runnerup/util/LineGraphSeries.java diff --git a/app/assets/about.html b/app/assets/about.html index bef9a532d..d5d113047 100644 --- a/app/assets/about.html +++ b/app/assets/about.html @@ -12,7 +12,7 @@

RunnerUp

3rd party software

-

GraphView

+

MPAndroidChart

MapBox

Android ANT+ SDK

diff --git a/app/build.gradle b/app/build.gradle index 4ec65b24e..41f3c3c77 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -131,7 +131,6 @@ android { repositories { google() - mavenCentral() maven { url = 'https://jitpack.io' } if (rootProject.ext.mapboxEnabled) { maven { @@ -178,7 +177,7 @@ dependencies { } else { implementation 'org.osmdroid:osmdroid-android:6.1.20' } - latestImplementation 'com.jjoe64:graphview:4.2.2' + latestImplementation 'com.github.AppDevNext:AndroidChart:5.2.1' testImplementation "junit:junit:${rootProject.ext.junitVersion}" testImplementation "org.mockito:mockito-core:${rootProject.ext.mockitoVersion}" diff --git a/app/proguard.txt b/app/proguard.txt index 86fb88fbf..913b841fe 100644 --- a/app/proguard.txt +++ b/app/proguard.txt @@ -35,11 +35,6 @@ #ref to com.garmin.fit -dontwarn com.dsi.ant.** -############################ -# GraphView - --dontnote com.jjoe64.graphview.LegendRenderer - ############################ # other, unknown -dontnote Object diff --git a/app/res/layout/detail.xml b/app/res/layout/detail.xml index 826afc59c..7974a4cdc 100644 --- a/app/res/layout/detail.xml +++ b/app/res/layout/detail.xml @@ -190,21 +190,21 @@ android:layout_height="match_parent" /> - + android:orientation="vertical"> - + 88dp 16dp + 112dp 20dp 44dp diff --git a/app/src/main/org/runnerup/util/DataPoint.java b/app/src/main/org/runnerup/util/DataPoint.java new file mode 100644 index 000000000..4c723f532 --- /dev/null +++ b/app/src/main/org/runnerup/util/DataPoint.java @@ -0,0 +1,19 @@ +package org.runnerup.util; + +public class DataPoint { + private final double x; + private final double y; + + public DataPoint(double x, double y) { + this.x = x; + this.y = y; + } + + public double getX() { + return x; + } + + public double getY() { + return y; + } +} diff --git a/app/src/main/org/runnerup/util/Formatter.java b/app/src/main/org/runnerup/util/Formatter.java index c9b5ff2df..605786053 100644 --- a/app/src/main/org/runnerup/util/Formatter.java +++ b/app/src/main/org/runnerup/util/Formatter.java @@ -32,6 +32,7 @@ import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; +import java.util.Objects; import org.runnerup.R; import org.runnerup.common.util.Constants; import org.runnerup.workout.Dimension; @@ -163,13 +164,7 @@ public SpeedUnit getPreferredSpeedUnit() { sharedPreferences.getString( resources.getString(R.string.pref_speedunit), SpeedUnit.PACE.getValue()); assert speedUnit != null; // may not happen - switch (speedUnit) { - case Constants.SPEED_UNIT.SPEED: - return SpeedUnit.SPEED; - case Constants.SPEED_UNIT.PACE: - default: - return SpeedUnit.PACE; - } + return speedUnit.equals(Constants.SPEED_UNIT.SPEED) ? SpeedUnit.SPEED : SpeedUnit.PACE; } public double getUnitMeters() { @@ -181,45 +176,27 @@ public String getUnitString() { } public String format(Format target, Dimension dimension, double value) { - switch (dimension) { - case DISTANCE: - return formatDistance(target, Math.round(value)); - case TIME: - return formatElapsedTime(target, Math.round(value)); - case PACE: - return formatPace(target, value); - case HR: - return formatHeartRate(target, value); - case HRZ: - return formatHeartRateZone(target, value); - case SPEED: - return formatSpeed(target, value); - case CAD: - return formatCadence(target, value); - case TEMPERATURE: - return formatCadence(target, value); // TODO - case PRESSURE: - return formatCadence(target, value); // TODO - } - return ""; + return switch (dimension) { + case DISTANCE -> formatDistance(target, Math.round(value)); + case TIME -> formatElapsedTime(target, Math.round(value)); + case PACE -> formatPace(target, value); + case HR -> formatHeartRate(target, value); + case HRZ -> formatHeartRateZone(target, value); + case SPEED -> formatSpeed(target, value); + case CAD -> formatCadence(target, value); + case TEMPERATURE -> formatCadence(target, value); // TODO + case PRESSURE -> formatCadence(target, value); // TODO + }; } public String formatElapsedTime(Format target, long seconds) { - switch (target) { - case CUE: - case CUE_SHORT: - return cueElapsedTime(seconds, false); - case CUE_LONG: - return cueElapsedTime(seconds, true); - case TXT: - case TXT_SHORT: - return DateUtils.formatElapsedTime(seconds); - case TXT_LONG: - return txtElapsedTime(seconds); - case TXT_TIMESTAMP: - return formatTime(seconds); - } - return ""; + return switch (target) { + case CUE, CUE_SHORT -> cueElapsedTime(seconds, false); + case CUE_LONG -> cueElapsedTime(seconds, true); + case TXT, TXT_SHORT -> DateUtils.formatElapsedTime(seconds); + case TXT_LONG -> txtElapsedTime(seconds); + case TXT_TIMESTAMP -> formatTime(seconds); + }; } private String cueElapsedTime(long seconds, boolean includeDimension) { @@ -393,22 +370,6 @@ private String formatHeartRateZone(Format target, double hrZone) { return ""; } - /** - * Format pace from raw pace Most of RU handles pace separately instead of just storing speed and - * formatting - * - * @param target - * @param seconds_per_meter - * @return - */ - public String formatPace(Format target, double seconds_per_meter) { - double meters_per_second = - (seconds_per_meter == 0 || Double.isNaN(seconds_per_meter)) - ? Double.NaN - : 1 / seconds_per_meter; - return formatPaceSpeed(target, meters_per_second); - } - /** * Returns either a formatted value in minutes per kilometer or kilometer per hour depending on * the user's preference @@ -418,12 +379,12 @@ public String formatPace(Format target, double seconds_per_meter) { * @return display value */ public String formatVelocityByPreferredUnit(Format target, double meters_per_second) { - String paceTextUnit = - this.sharedPreferences.getString( - resources.getString(R.string.pref_speedunit), SpeedUnit.PACE.getValue()); - assert paceTextUnit != null; - if (paceTextUnit.contentEquals(SpeedUnit.PACE.getValue())) { - return this.formatPaceSpeed(target, meters_per_second); + return formatVelocity(target, meters_per_second, getPreferredSpeedUnit()); + } + + public String formatVelocity(Format target, double meters_per_second, SpeedUnit unit) { + if (unit == SpeedUnit.PACE) { + return this.formatPace(target, meters_per_second); } else { return this.formatSpeed(target, meters_per_second); } @@ -435,11 +396,11 @@ public String formatVelocityByPreferredUnit(Format target, double meters_per_sec * @return value */ public String formatVelocityLabel() { - String paceTextUnit = - this.sharedPreferences.getString( - resources.getString(R.string.pref_speedunit), SpeedUnit.PACE.getValue()); - assert paceTextUnit != null; - if (paceTextUnit.contentEquals(SpeedUnit.PACE.getValue())) { + return formatVelocityLabel(getPreferredSpeedUnit()); + } + + public String formatVelocityLabel(SpeedUnit unit) { + if (unit == SpeedUnit.PACE) { return this.resources.getString(org.runnerup.common.R.string.Pace); } else { return this.resources.getString(org.runnerup.common.R.string.Speed); @@ -453,32 +414,23 @@ public String formatVelocityLabel() { * @param meters_per_second speed in m/s * @return */ - public String formatPaceSpeed(Format target, double meters_per_second) { - switch (target) { - case CUE: - case CUE_SHORT: - case CUE_LONG: - return cuePace(meters_per_second); - case TXT: - case TXT_SHORT: - return txtPace(meters_per_second, false); - case TXT_LONG: - return txtPace(meters_per_second, true); - } - return ""; + public String formatPace(Format target, double meters_per_second) { + return switch (target) { + case CUE, CUE_SHORT, CUE_LONG -> cuePace(meters_per_second); + case TXT, TXT_SHORT -> txtPace(meters_per_second, false); + case TXT_LONG -> txtPace(meters_per_second, true); + default -> ""; + }; } /** * @return pace/speed unit string */ String getVelocityUnit() { // Resources resources, SharedPreferences sharedPreferences) { - switch (getPreferredSpeedUnit()) { - case SPEED: - return getSpeedUnit(); - case PACE: - default: - return getPaceUnit(); - } + SpeedUnit preferredSpeedUnit = getPreferredSpeedUnit(); + return Objects.requireNonNull(preferredSpeedUnit) == SpeedUnit.SPEED + ? getSpeedUnit() + : getPaceUnit(); } private String getPaceUnit() { diff --git a/app/src/main/org/runnerup/util/GraphWrapper.java b/app/src/main/org/runnerup/util/GraphWrapper.java index bc6bd98b6..8e446b9e5 100644 --- a/app/src/main/org/runnerup/util/GraphWrapper.java +++ b/app/src/main/org/runnerup/util/GraphWrapper.java @@ -21,18 +21,29 @@ import android.content.SharedPreferences; import android.content.res.Resources; import android.database.sqlite.SQLiteDatabase; +import android.graphics.Color; +import android.os.Build; import android.os.Handler; import android.os.Looper; import android.util.Log; import android.view.View; +import android.widget.CheckBox; import android.widget.LinearLayout; import android.widget.LinearLayout.LayoutParams; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.core.graphics.ColorUtils; import androidx.preference.PreferenceManager; -import com.jjoe64.graphview.DefaultLabelFormatter; -import com.jjoe64.graphview.GraphView; -import com.jjoe64.graphview.series.DataPoint; -import com.jjoe64.graphview.series.LineGraphSeries; +import info.appdev.charting.charts.LineChart; +import info.appdev.charting.components.AxisBase; +import info.appdev.charting.components.YAxis; +import info.appdev.charting.data.EntryFloat; +import info.appdev.charting.data.LineData; +import info.appdev.charting.data.LineDataSet; +import info.appdev.charting.formatter.IAxisValueFormatter; +import info.appdev.charting.highlight.Highlight; +import info.appdev.charting.interfaces.datasets.ILineDataSet; +import info.appdev.charting.listener.OnChartValueSelectedListener; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -45,10 +56,7 @@ import org.runnerup.workout.SpeedUnit; public class GraphWrapper implements Constants { - private final GraphView velocityGraphView; - private final GraphView hrGraphView; - private final GraphView elevationGraphView; - + private final LineChart chart; private final LinearLayout graphTab; private final HRZonesBar hrzonesBar; private final Formatter formatter; @@ -147,69 +155,9 @@ public String unit() { this.xAxis = timeXAxis; } - loadParam = new LoadParam(context, mDB, mID); - - velocityGraphView = new GraphView(context); - velocityGraphView.setTitle(formatter.formatVelocityLabel()); - velocityGraphView - .getGridLabelRenderer() - .setLabelFormatter( - new DefaultLabelFormatter() { - @Override - public String formatLabel(double value, boolean isValueX) { - if (isValueX) { - return xAxis.formatValue(value); - } else { - return formatter.formatVelocityByPreferredUnit(Formatter.Format.TXT_SHORT, value); - } - } - }); - velocityGraphView.getGridLabelRenderer().setVerticalAxisTitle(formatter.getVelocityUnit()); - velocityGraphView.getGridLabelRenderer().setHorizontalAxisTitle(xAxis.labelLong()); - // enable zoom - velocityGraphView.getViewport().setScalable(true); - velocityGraphView.getViewport().setScrollable(true); - - hrGraphView = new GraphView(context); - hrGraphView.setTitle(context.getString(org.runnerup.common.R.string.Heart_rate)); - hrGraphView.getGridLabelRenderer().setVerticalAxisTitle("bpm"); - hrGraphView.getGridLabelRenderer().setHorizontalAxisTitle(xAxis.labelLong()); - hrGraphView - .getGridLabelRenderer() - .setLabelFormatter( - new DefaultLabelFormatter() { - @Override - public String formatLabel(double value, boolean isValueX) { - if (isValueX) { - return xAxis.formatValue(value); - } else { - return formatter.formatHeartRate(Formatter.Format.TXT_SHORT, value); - } - } - }); - hrGraphView.getViewport().setScalable(true); - hrGraphView.getViewport().setScrollable(true); - - elevationGraphView = new GraphView(context); - elevationGraphView.setTitle(context.getString(org.runnerup.common.R.string.Elevation)); - elevationGraphView.getGridLabelRenderer().setVerticalAxisTitle(formatter.getElevationUnit()); - elevationGraphView.getGridLabelRenderer().setHorizontalAxisTitle(xAxis.labelLong()); - elevationGraphView - .getGridLabelRenderer() - .setLabelFormatter( - new DefaultLabelFormatter() { - @Override - public String formatLabel(double value, boolean isValueX) { - if (isValueX) { - return xAxis.formatValue(value); - } else { - return formatter.formatElevation(Formatter.Format.TXT_SHORT, value); - } - } - }); - elevationGraphView.getViewport().setScalable(true); - elevationGraphView.getViewport().setScrollable(true); + this.loadParam = new LoadParam(context, mDB, mID); + chart = new LineChart(context); hrzonesBar = new HRZonesBar(context); loadGraph(); } @@ -224,9 +172,7 @@ public void setUseDistanceAsX(boolean val) { } else { xAxis = timeXAxis; } - velocityGraphView.removeAllSeries(); - hrGraphView.removeAllSeries(); - elevationGraphView.removeAllSeries(); + chart.clear(); loadGraph(); } @@ -276,20 +222,14 @@ private GraphProducer doLoadGraphInBackground(LoadParam params) { private void onPostExecute(GraphProducer graphData) { if (graphData == null) return; - graphData.complete(velocityGraphView); - graphTab.removeView(velocityGraphView); - graphTab.removeView(hrGraphView); - graphTab.removeView(elevationGraphView); + graphData.complete(chart); + chart.invalidate(); + chart.setVisibility(View.VISIBLE); + graphTab.removeView(chart); - LayoutParams layout = - new LayoutParams( - LayoutParams.MATCH_PARENT, - (int) (250 * graphTab.getResources().getDisplayMetrics().density)); - - if (graphData.HasPaceInfo()) graphTab.addView(velocityGraphView, layout); - if (graphData.HasHRInfo()) graphTab.addView(hrGraphView, layout); - if (graphData.HasElevationInfo()) graphTab.addView(elevationGraphView, layout); + LayoutParams layout = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + graphTab.addView(chart, layout); hrzonesBarLayout.removeView(hrzonesBar); if (graphData.HasHRZHist()) { hrzonesBarLayout.setVisibility(View.VISIBLE); @@ -297,6 +237,18 @@ private void onPostExecute(GraphProducer graphData) { } else { hrzonesBarLayout.setVisibility(View.GONE); } + + // Add legend container to bottom of the view + LinearLayout legend = new LinearLayout(graphTab.getContext()); + legend.setOrientation(LinearLayout.HORIZONTAL); + android.widget.HorizontalScrollView scroll = + new android.widget.HorizontalScrollView(graphTab.getContext()); + scroll.addView(legend); + graphTab.addView(scroll, 0); + for (CheckBox cb : graphData.legendBoxes) { + legend.addView(cb); + } + if (!firstLoad) { graphTab.invalidate(); } else { @@ -349,12 +301,13 @@ class GraphProducer { final List elevationList; final HRZones hrCalc; final SpeedUnit preferred_speedunit; + final List legendBoxes = new ArrayList<>(); boolean first = true; int pos = 0; double sum_time = 0; double sum_distance = 0; double acc_time = 0; - double[] hrzHist = null; + double[] hrzHist = null; // no chart displayed double tot_avg_hr = 0; double avg_velocity = 0; double min_velocity = Double.MAX_VALUE; @@ -452,7 +405,6 @@ void addObservation( if (delta_distance > 0 && delta_time > 0) { showPace = true; if (altitude != null) showElevation = true; - ; } pos += 1; @@ -506,7 +458,51 @@ void emit(double tot_X) { } } - public void complete(final GraphView graphView) { + private void setAxisAndLegend( + LineDataSet dataSet, int color, IAxisValueFormatter valueFormatter) { + dataSet.setColor(color); + dataSet.setLineWidth(1.5f); + dataSet.setDrawValues(false); + dataSet.setDrawCircles(false); + + YAxis axis = null; + if (!chart.getAxisLeft().isEnabled()) { + axis = chart.getAxisLeft(); + dataSet.setAxisDependency(YAxis.AxisDependency.LEFT); + } else { + dataSet.setAxisDependency(YAxis.AxisDependency.RIGHT); + if (!chart.getAxisRight().isEnabled()) { + axis = chart.getAxisRight(); + } + } + if (axis != null) { + axis.setEnabled(true); + axis.setTextColor(color); + axis.setAxisLineColor(color); + axis.setTextSize(14f); + axis.setValueFormatter(valueFormatter); + } + + CheckBox cB = new CheckBox(graphTab.getContext()); + cB.setText(dataSet.getLabel()); + cB.setTextColor(color); + cB.setTextSize(12f); + cB.setChecked(true); + cB.setOnCheckedChangeListener( + (btn, checked) -> { + if (chart.getData() != null) { + ILineDataSet ds = + chart.getData().getDataSetByLabel(dataSet.getLabel(), false); + if (ds != null) { + ds.setVisible(checked); + chart.invalidate(); + } + } + }); + legendBoxes.add(cB); + } + + public void complete(LineChart chart) { if (velocityList.isEmpty()) { avg_velocity = 0; } else { @@ -515,9 +511,9 @@ public void complete(final GraphView graphView) { Log.d(getClass().getName(), "graph: " + velocityList.size() + " points"); boolean smoothData = - PreferenceManager.getDefaultSharedPreferences(graphView.getContext()) + PreferenceManager.getDefaultSharedPreferences(graphTab.getContext()) .getBoolean( - graphView + graphTab .getContext() .getResources() .getString(R.string.pref_pace_graph_smoothing), @@ -525,11 +521,11 @@ public void complete(final GraphView graphView) { if (!velocityList.isEmpty() && smoothData) { GraphFilter f = new GraphFilter(velocityList); final String defaultFilterList = - graphView.getContext().getResources().getString(R.string.mm31kz513sg5); + graphTab.getContext().getResources().getString(R.string.mm31kz513sg5); final String filterList = - PreferenceManager.getDefaultSharedPreferences(graphView.getContext()) + PreferenceManager.getDefaultSharedPreferences(graphTab.getContext()) .getString( - graphView + graphTab .getContext() .getResources() .getString(R.string.pref_pace_graph_smoothing_filters), @@ -567,75 +563,260 @@ public void complete(final GraphView graphView) { Log.d(getClass().getName(), s.toString()); f.complete(); } - LineGraphSeries velocityGraphViewData = - new LineGraphSeries<>(velocityList.toArray(new DataPoint[0])); - graphView.addSeries(velocityGraphViewData); // data - graphView.getViewport().setMinX(graphView.getViewport().getMinX(true)); - graphView.getViewport().setMaxX(graphView.getViewport().getMaxX(true)); - velocityGraphViewData.setOnDataPointTapListener( - (series, dataPoint) -> { - String msg = - String.format( - "%s: %s\n%s: %s", - xAxis.label(), - xAxis.formatLongValue(dataPoint.getX()), - formatter.formatVelocityLabel(), - formatter.formatVelocityByPreferredUnit( - Formatter.Format.TXT_LONG, dataPoint.getY())); - Toast.makeText(graphView.getContext(), msg, Toast.LENGTH_SHORT).show(); - }); - if (showHR) { - LineGraphSeries hrGraphViewData = - new LineGraphSeries<>(hrList.toArray(new DataPoint[0])); - hrGraphView.addSeries(hrGraphViewData); // data - hrGraphView.getViewport().setMinX(hrGraphView.getViewport().getMinX(true)); - hrGraphView.getViewport().setMaxX(hrGraphView.getViewport().getMaxX(true)); - hrGraphViewData.setOnDataPointTapListener( - (series, dataPoint) -> { - String msg = - String.format( - "%s: %s\n%s: %s", - xAxis.label(), - xAxis.formatLongValue(dataPoint.getX()), - graphView.getContext().getString(org.runnerup.common.R.string.Heart_rate), - formatter.formatHeartRate(Formatter.Format.TXT_LONG, dataPoint.getY())); - Toast.makeText(graphView.getContext(), msg, Toast.LENGTH_SHORT).show(); - }); - if (showHRZhist) { - StringBuilder s = new StringBuilder("HR Zones:"); - double sum = 0; - for (double aHrzHist : hrzHist) { - sum += aHrzHist; - } - if (sum > 0) { - for (int i = 0; i < hrzHist.length; i++) { - hrzHist[i] = hrzHist[i] / sum; - s.append(" ").append(hrzHist[i]); - } + if (showHRZhist && hrzHist != null) { + StringBuilder s = new StringBuilder("HR Zones:"); + double sum = 0; + for (double aHrzHist : hrzHist) { + sum += aHrzHist; + } + if (sum > 0) { + for (int i = 0; i < hrzHist.length; i++) { + hrzHist[i] = hrzHist[i] / sum; + s.append(" ").append(hrzHist[i]); } - Log.d(getClass().getName(), s.toString()); - hrzonesBar.pushHrzData(hrzHist); } + Log.d(getClass().getName(), s.toString()); + hrzonesBar.pushHrzData(hrzHist); + hrzonesBar.invalidate(); + } + + int chartBackColor = Color.BLACK; + if (chart.getBackground() instanceof android.graphics.drawable.ColorDrawable) { + chartBackColor = + ((android.graphics.drawable.ColorDrawable) chart.getBackground()).getColor(); + } + int chartForeColor = Color.WHITE; + if (chart.getForeground() instanceof android.graphics.drawable.ColorDrawable) { + chartForeColor = + ((android.graphics.drawable.ColorDrawable) chart.getForeground()).getColor(); + } + // Data mapping to MPAndroidChart + + // dataSets and axisFormatters are added in pairs + List> dataSets = new ArrayList<>(); + List axisFormatters = new ArrayList<>(); + chart.getAxisLeft().setEnabled(false); + chart.getAxisRight().setEnabled(false); + + if (useDistanceAsX && !velocityList.isEmpty()) { + List entries = new ArrayList<>(); + for (DataPoint p : velocityList) { + entries.add(new EntryFloat((float) p.getX(), (float) p.getY())); + } + LineDataSet velocityDataSet = + new LineDataSet<>( + entries, + formatter.formatVelocityLabel(preferred_speedunit) + + " (" + + formatter.getVelocityUnit() + + ")"); + axisFormatters.add( + 0, + new IAxisValueFormatter() { + @NonNull + @Override + public String getFormattedValue(float value, AxisBase axis) { + return formatter.formatVelocity( + Formatter.Format.TXT_SHORT, value, preferred_speedunit); + } + }); + setAxisAndLegend( + velocityDataSet, + ColorUtils.blendARGB(Color.BLUE, chartForeColor, 0.5f), + axisFormatters.get(axisFormatters.size() - 1)); + dataSets.add(velocityDataSet); + } + + if (showHR && !hrList.isEmpty()) { + List hrEntries = new ArrayList<>(); + for (DataPoint p : hrList) { + hrEntries.add(new EntryFloat((float) p.getX(), (float) p.getY())); + } + LineDataSet hrDataSet = + new LineDataSet<>( + hrEntries, + graphTab.getContext().getString(org.runnerup.common.R.string.Heart_rate) + + " (bpm)"); + axisFormatters.add( + new IAxisValueFormatter() { + @NonNull + @Override + public String getFormattedValue(float value, AxisBase axis) { + return formatter.formatHeartRate(Formatter.Format.TXT_SHORT, value); + } + }); + dataSets.add(hrDataSet); + setAxisAndLegend( + hrDataSet, + ColorUtils.blendARGB(Color.RED, chartForeColor, 0.5f), + axisFormatters.get(axisFormatters.size() - 1)); } - if (showElevation) { - LineGraphSeries elevationGraphViewData = - new LineGraphSeries<>(elevationList.toArray(new DataPoint[0])); - elevationGraphView.addSeries(elevationGraphViewData); // data - elevationGraphView.getViewport().setMinX(elevationGraphView.getViewport().getMinX(true)); - elevationGraphView.getViewport().setMaxX(elevationGraphView.getViewport().getMaxX(true)); - elevationGraphViewData.setOnDataPointTapListener( - (series, dataPoint) -> { - String msg = - String.format( - "%s: %s\n%s: %s", - xAxis.label(), - xAxis.formatLongValue(dataPoint.getX()), - graphView.getContext().getString(org.runnerup.common.R.string.Elevation), - formatter.formatElevation(Formatter.Format.TXT_LONG, dataPoint.getY())); - Toast.makeText(graphView.getContext(), msg, Toast.LENGTH_SHORT).show(); + + if (showElevation && !elevationList.isEmpty()) { + List plottedElevation = elevationList; + double[] inverseScaleParams = null; + if (dataSets.size() >= 2) { + // only one right axis supported, fake it by scaling the formatter series to previous + ILineDataSet targetSeries = dataSets.get(1); + double[] sourceMinMax = getMinMax(elevationList); + plottedElevation = + scaleToRange( + elevationList, + sourceMinMax[0], + sourceMinMax[1], + targetSeries.getYMin(), + targetSeries.getYMax()); + // Keep inverse mapping local so formatter can convert scaled values back. + inverseScaleParams = + new double[] { + targetSeries.getYMin(), targetSeries.getYMax(), sourceMinMax[0], sourceMinMax[1] + }; + } + List elevationEntries = new ArrayList<>(); + for (DataPoint p : plottedElevation) { + elevationEntries.add(new EntryFloat((float) p.getX(), (float) p.getY())); + } + LineDataSet elevationDataSet = + new LineDataSet<>( + elevationEntries, + graphTab.getContext().getString(org.runnerup.common.R.string.Elevation) + + " (" + + formatter.getElevationUnit() + + ")"); + elevationDataSet.setDrawFilled(true); + elevationDataSet.setFillDrawable( + new android.graphics.drawable.ColorDrawable( + ColorUtils.blendARGB(elevationDataSet.getColor(), chartBackColor, 0.5f))); + final double[] finalInverseScaleParams = inverseScaleParams; + axisFormatters.add( + new IAxisValueFormatter() { + @NonNull + @Override + public String getFormattedValue(float value, AxisBase axis) { + double displayValue = value; + if (finalInverseScaleParams != null) { + displayValue = + scaleValueToRange( + value, + finalInverseScaleParams[0], + finalInverseScaleParams[1], + finalInverseScaleParams[2], + finalInverseScaleParams[3]); + } + return formatter.formatElevation(Formatter.Format.TXT_SHORT, displayValue); + } }); + setAxisAndLegend( + elevationDataSet, + ColorUtils.setAlphaComponent(Color.GREEN, 64), + axisFormatters.get(axisFormatters.size() - 1)); + dataSets.add(elevationDataSet); + } + + chart.getLegend().setEnabled(false); + chart.getDescription().setEnabled(false); + chart.getXAxis().setTextColor(chartForeColor); + chart.getXAxis().setTextSize(14f); + chart.getXAxis().setPosition(info.appdev.charting.components.XAxis.XAxisPosition.BOTTOM); + IAxisValueFormatter xAxisFormatter = + new IAxisValueFormatter() { + @NonNull + @Override + public String getFormattedValue(float value, AxisBase axis) { + return xAxis.formatValue(value); + } + }; + chart.getXAxis().setValueFormatter(xAxisFormatter); + + chart.setOnChartValueSelectedListener( + new OnChartValueSelectedListener() { + @Override + public void onValueSelected(@NonNull EntryFloat e, @NonNull Highlight h) { + // Find out the dataset line that was tapped + int datasetIndex = h.getDataSetIndex(); + int axisIndex = axisFormatters.size() - 1 - datasetIndex; + if (datasetIndex >= axisFormatters.size() + || axisIndex >= axisFormatters.size() + || axisIndex < 0) { + return; + } + + // Display the results + assert chart.getData() != null; + String message = + chart.getData().getDataSetByIndex(datasetIndex).getLabel() + + ": " + + axisFormatters.get(axisIndex).getFormattedValue(e.getY(), null) + + "\n" + + xAxis.formatLongValue(e.getX()); + + Toast.makeText(chart.getContext(), message, Toast.LENGTH_SHORT).show(); + } + + @Override + public void onNothingSelected() {} + }); + + // reverse the order so first chart is rendered on top + List> chartSets = new ArrayList<>(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + chartSets = dataSets.reversed(); + } else { + for (var e : dataSets) { + chartSets.add(0, e); + } + } + chart.setData(new LineData(chartSets)); + } + + private double[] getMinMax(List series) { + double min = Double.POSITIVE_INFINITY; + double max = Double.NEGATIVE_INFINITY; + for (DataPoint point : series) { + double y = point.getY(); + if (y < min) { + min = y; + } + if (y > max) { + max = y; + } + } + if (!Double.isFinite(min) || !Double.isFinite(max)) { + return new double[] {0.0, 0.0}; + } + return new double[] {min, max}; + } + + private List scaleToRange( + List source, + double sourceMin, + double sourceMax, + double targetMin, + double targetMax) { + List scaled = new ArrayList<>(source.size()); + if (sourceMax - sourceMin <= 0) { + double mapped = targetMin + ((targetMax - targetMin) / 2.0); + for (DataPoint point : source) { + scaled.add(new DataPoint(point.getX(), mapped)); + } + return scaled; } + + for (DataPoint point : source) { + double mapped = scaleValueToRange(point.getY(), sourceMin, sourceMax, targetMin, targetMax); + scaled.add(new DataPoint(point.getX(), mapped)); + } + return scaled; + } + + private double scaleValueToRange( + double value, double sourceMin, double sourceMax, double targetMin, double targetMax) { + double sourceRange = sourceMax - sourceMin; + if (sourceRange <= 0) { + return targetMin + ((targetMax - targetMin) / 2.0); + } + return targetMin + ((value - sourceMin) * (targetMax - targetMin) / sourceRange); } private int[] getArgs(String s) { @@ -654,18 +835,6 @@ private int[] getArgs(String s) { } } - public boolean HasPaceInfo() { - return showPace; - } - - public boolean HasElevationInfo() { - return showElevation; - } - - public boolean HasHRInfo() { - return showHR; - } - public boolean HasHRZHist() { return showHR && showHRZhist; } diff --git a/app/src/main/org/runnerup/util/LineGraphSeries.java b/app/src/main/org/runnerup/util/LineGraphSeries.java new file mode 100644 index 000000000..fac7ca6b4 --- /dev/null +++ b/app/src/main/org/runnerup/util/LineGraphSeries.java @@ -0,0 +1,29 @@ +package org.runnerup.util; + +import java.util.ArrayList; +import java.util.List; + +public class LineGraphSeries { + private List data; + + public LineGraphSeries(T[] initialData) { + data = new ArrayList<>(); + for (T item : initialData) { + data.add(item); + } + } + + public void resetData(T[] newData) { + data.clear(); + for (T item : newData) { + data.add(item); + } + } + + public void appendData(T newDataPoint, boolean scrollToEnd, int maxSize) { + data.add(newDataPoint); + if (data.size() > maxSize) { + data.remove(0); + } + } +} diff --git a/app/src/main/org/runnerup/view/AccountActivity.java b/app/src/main/org/runnerup/view/AccountActivity.java index af96cfe7c..8b7f4b1bb 100644 --- a/app/src/main/org/runnerup/view/AccountActivity.java +++ b/app/src/main/org/runnerup/view/AccountActivity.java @@ -318,7 +318,7 @@ public void onClick(View v) { try { startActivity(intent); } catch (Exception e) { - Log.i(getClass().getName(), "No handler for file intent installed? " + e.getMessage()); + Log.e(getClass().getName(), "No handler for file intent installed", e); } } }; diff --git a/app/src/main/org/runnerup/view/HRSettingsActivity.java b/app/src/main/org/runnerup/view/HRSettingsActivity.java index 0e7df8ef2..951a74cff 100644 --- a/app/src/main/org/runnerup/view/HRSettingsActivity.java +++ b/app/src/main/org/runnerup/view/HRSettingsActivity.java @@ -43,18 +43,22 @@ import android.widget.BaseAdapter; import android.widget.Button; import android.widget.LinearLayout; +import android.widget.LinearLayout.LayoutParams; import android.widget.TextView; import androidx.activity.EdgeToEdge; +import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.core.view.WindowCompat; import androidx.preference.PreferenceManager; -import com.jjoe64.graphview.DefaultLabelFormatter; -import com.jjoe64.graphview.GraphView; -import com.jjoe64.graphview.series.DataPoint; -import com.jjoe64.graphview.series.LineGraphSeries; +import info.appdev.charting.charts.LineChart; +import info.appdev.charting.components.AxisBase; +import info.appdev.charting.data.EntryFloat; +import info.appdev.charting.data.LineData; +import info.appdev.charting.data.LineDataSet; +import info.appdev.charting.formatter.IAxisValueFormatter; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -90,10 +94,14 @@ public class HRSettingsActivity extends AppCompatActivity implements HRClient { private TextView tvBatteryLevel = null; private Formatter formatter = null; - private GraphView graphView = null; - private LineGraphSeries graphViewSeries = null; + private LineChart chart = null; + private LineDataSet dataSet = null; + private long lastTimestamp = 0; + private long timerStartTime = 0; + private float minHrSeen = Float.POSITIVE_INFINITY; + private float maxHrSeen = Float.NEGATIVE_INFINITY; private static final int GRAPH_HISTORY_SIZE = 180; - private static final double xInterval = 60; + private static final double X_INTERVAL_SECONDS = 60; private static final int REQUEST_BLUETOOTH_SETTINGS = 123; private static final int REQUEST_BLUETOOTH_ENABLE = 3002; @@ -147,33 +155,68 @@ public void onCreate(Bundle savedInstanceState) { formatter = new Formatter(this); { - graphView = new GraphView(this); - graphView.setTitle(getString(org.runnerup.common.R.string.Heart_rate)); - DataPoint[] empty = {}; - graphViewSeries = new LineGraphSeries<>(empty); - graphView.addSeries(graphViewSeries); - graphView.getViewport().setXAxisBoundsManual(true); - graphView.getViewport().setMinX(0); - graphView.getViewport().setMaxX(xInterval); - graphView.getViewport().setYAxisBoundsManual(true); - graphView.getViewport().setMinY(40); - graphView.getViewport().setMaxY(200); - graphView - .getGridLabelRenderer() - .setLabelFormatter( - new DefaultLabelFormatter() { + chart = new LineChart(this); + chart.getDescription().setEnabled(false); + + dataSet = + new LineDataSet<>( + new ArrayList<>(), getString(org.runnerup.common.R.string.Heart_rate) + " (bpm)"); + dataSet.setColor(android.graphics.Color.RED); + dataSet.setLineWidth(1.5f); + dataSet.setDrawValues(false); + dataSet.setDrawCircles(false); + chart.setData(new LineData(dataSet)); + + chart.getDescription().setEnabled(false); + chart.getLegend().setTextColor(android.graphics.Color.WHITE); + + int axisTextColor = tvHR.getCurrentTextColor(); + if (axisTextColor == 0) { + axisTextColor = android.graphics.Color.WHITE; + } + + chart.getAxisLeft().setEnabled(true); + chart.getAxisLeft().setTextColor(axisTextColor); + chart.getAxisLeft().setAxisLineColor(axisTextColor); + chart.getAxisLeft().setTextSize(12f); + chart.getAxisLeft().setAxisMinimum(40f); + chart.getAxisLeft().setAxisMaximum(200f); + chart + .getAxisLeft() + .setValueFormatter( + new IAxisValueFormatter() { + @NonNull + @Override + public String getFormattedValue(float value, AxisBase axis) { + return formatter.formatHeartRate(Formatter.Format.TXT_SHORT, value); + } + }); + + chart.getAxisRight().setEnabled(false); + chart.getAxisRight().setTextColor(axisTextColor); + + chart.getXAxis().setTextColor(axisTextColor); + chart.getXAxis().setAxisLineColor(axisTextColor); + chart.getXAxis().setTextSize(12f); + chart.getXAxis().setPosition(info.appdev.charting.components.XAxis.XAxisPosition.BOTTOM); + chart.getXAxis().setAxisMinimum(0f); + chart.getXAxis().setAxisMaximum((float) X_INTERVAL_SECONDS); + chart + .getXAxis() + .setValueFormatter( + new IAxisValueFormatter() { + @NonNull @Override - public String formatLabel(double value, boolean isValueX) { - if (isValueX) { - return formatter.formatElapsedTime(Formatter.Format.TXT_SHORT, (long) value); - } else { - return formatter.formatHeartRate(Formatter.Format.TXT_SHORT, value); - } + public String getFormattedValue(float value, AxisBase axis) { + return formatter.formatElapsedTime(Formatter.Format.TXT_SHORT, (long) value); } }); + chart.setMinOffset(8f); + chart.setExtraLeftOffset(8f); LinearLayout graphLayout = findViewById(R.id.hr_graph_layout); - graphLayout.addView(graphView); + graphLayout.addView( + chart, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); } load(); @@ -344,9 +387,36 @@ private void clear() { } private void clearGraph() { - DataPoint[] empty = {}; - graphViewSeries.resetData(empty); timerStartTime = 0; + lastTimestamp = 0; + minHrSeen = Float.POSITIVE_INFINITY; + maxHrSeen = Float.NEGATIVE_INFINITY; + if (dataSet != null) { + dataSet.clear(); + } + if (chart != null) { + chart.getXAxis().setAxisMinimum(0f); + chart.getXAxis().setAxisMaximum((float) X_INTERVAL_SECONDS); + chart.notifyDataSetChanged(); + chart.invalidate(); + } + } + + private void updateYAxisRange(float hrValue) { + minHrSeen = Math.min(minHrSeen, hrValue); + maxHrSeen = Math.max(maxHrSeen, hrValue); + + float min = minHrSeen - 5f; + float max = maxHrSeen + 5f; + + if (max - min < 20f) { + float center = (min + max) / 2f; + min = center - 10f; + max = center + 10f; + } + + chart.getAxisLeft().setAxisMinimum(min); + chart.getAxisLeft().setAxisMaximum(max); } private void updateView() { @@ -630,9 +700,6 @@ private void stopTimer() { hrReader = null; } - private long lastTimestamp = 0; - private long timerStartTime = 0; - private void readHR() { if (hrProvider == null) { return; @@ -647,23 +714,33 @@ private void readHR() { long hrValue = data.hrValue; if (timerStartTime == 0) { timerStartTime = age; - DataPoint[] empty = {}; - graphViewSeries.resetData(empty); + minHrSeen = Float.POSITIVE_INFINITY; + maxHrSeen = Float.NEGATIVE_INFINITY; + if (dataSet != null) { + dataSet.clear(); + } } tvHR.setText(String.format(Locale.getDefault(), "%d", hrValue)); if (age != lastTimestamp) { double x = (age - timerStartTime) / 1000.0; - graphViewSeries.appendData(new DataPoint(x, hrValue), true, GRAPH_HISTORY_SIZE); - lastTimestamp = age; + if (dataSet != null) { + float hr = (float) hrValue; + dataSet.addEntry(new EntryFloat((float) x, hr)); + updateYAxisRange(hr); + while (dataSet.getEntryCount() > GRAPH_HISTORY_SIZE) { + dataSet.removeEntry(0); + } - // graphView works weird with live data - graphView.getViewport().setMinY(graphViewSeries.getLowestValueY()); - graphView.getViewport().setMaxY(graphViewSeries.getHighestValueY()); - if (x > xInterval) { - graphView.getViewport().setMinX(x - xInterval); - graphView.getViewport().setMaxX(x); + if (x > X_INTERVAL_SECONDS) { + chart.getXAxis().setAxisMinimum((float) (x - X_INTERVAL_SECONDS)); + chart.getXAxis().setAxisMaximum((float) x); + } + + chart.notifyDataSetChanged(); + chart.invalidate(); } + lastTimestamp = age; } } diff --git a/app/src/main/org/runnerup/view/HRZonesBar.java b/app/src/main/org/runnerup/view/HRZonesBar.java index 1d2f404dd..e13c67120 100644 --- a/app/src/main/org/runnerup/view/HRZonesBar.java +++ b/app/src/main/org/runnerup/view/HRZonesBar.java @@ -21,11 +21,7 @@ import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; -import android.util.Log; import android.view.View; -import android.widget.LinearLayout; -import androidx.appcompat.app.AppCompatActivity; -import org.runnerup.R; public class HRZonesBar extends View { @@ -42,11 +38,12 @@ public class HRZonesBar extends View { Color.blue(colorHigh) - dColorLow[2] }; - private static final float borderSize = 10; // Border around the chart + private static final float borderSize = 6; // Border around the chart private static final float separatorSize = 2; // Separator between two zones - private static final int minBarHeight = 15; - private static final int maxBarHeight = 40; - private static final double chartSize = 0.8; + private static final int minBarHeight = 32; + private static final int maxBarHeight = 64; + private static final int preferredBarHeight = 48; + private static final float minChartWidth = 120f; private final Paint paint = new Paint(); private final Paint fontPaint = new Paint(); @@ -59,6 +56,20 @@ public HRZonesBar(Context ctx) { public void pushHrzData(double[] data) { this.hrzData = data; + requestLayout(); + invalidate(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int zoneCount = hrzData != null && hrzData.length > 0 ? hrzData.length : 6; + int desiredHeight = + (int) + Math.ceil( + 2 * borderSize + zoneCount * preferredBarHeight + (zoneCount - 1) * separatorSize); + int measuredWidth = resolveSize(getSuggestedMinimumWidth(), widthMeasureSpec); + int measuredHeight = resolveSize(desiredHeight, heightMeasureSpec); + setMeasuredDimension(measuredWidth, measuredHeight); } public void onDraw(Canvas canvas) { @@ -67,25 +78,21 @@ public void onDraw(Canvas canvas) { } // calculate bar height and chart offset - AppCompatActivity activity = (AppCompatActivity) getContext(); - LinearLayout buttons = activity.findViewById(R.id.buttons); - - int actualHeight = getHeight() - buttons.getHeight(); + int actualHeight = getHeight(); float calculatedBarHeight = (actualHeight - 2 * borderSize - (hrzData.length - 1) * separatorSize) / hrzData.length; // Height of the bar - calculatedBarHeight = calculatedBarHeight > maxBarHeight ? maxBarHeight : calculatedBarHeight; - int topOffset = getTop(); + calculatedBarHeight = Math.min(calculatedBarHeight, maxBarHeight); + calculatedBarHeight = Math.max(calculatedBarHeight, minBarHeight); + int topOffset = 0; float totalWidth = getWidth(); - if (totalWidth <= 0 || calculatedBarHeight < 10) { - Log.i(getClass().getName(), "Not enough space to display the heart-rate zone bar"); - activity.findViewById(R.id.hrzonesBarLayout).setVisibility(View.GONE); + if (totalWidth <= 0 || calculatedBarHeight <= 0) { return; } // Font size and style - int fontSize = (int) calculatedBarHeight / 2; + int fontSize = Math.max(15, (int) (calculatedBarHeight * 0.675f)); fontPaint.setTextSize(fontSize); fontPaint.setFlags(Paint.ANTI_ALIAS_FLAG); fontPaint.setColor(Color.WHITE); @@ -103,6 +110,20 @@ public void onDraw(Canvas canvas) { sum += aHrzData; } + float maxLabelWidth = 0; + for (int i = 0; i < hrzData.length; i++) { + String zoneName = getResources().getString(org.runnerup.common.R.string.Zone) + " " + i; + maxLabelWidth = Math.max(maxLabelWidth, fontPaint.measureText(zoneName)); + } + float percentTextWidth = fontPaint.measureText("100%"); + float chartWidth = Math.max(0f, totalWidth - maxLabelWidth - percentTextWidth - 5 * borderSize); + float requiredWidth = maxLabelWidth + percentTextWidth + minChartWidth + 5 * borderSize; + int requiredWidthPx = (int) Math.ceil(requiredWidth); + if (getMinimumWidth() != requiredWidthPx) { + setMinimumWidth(requiredWidthPx); + requestLayout(); + } + // do the drawing for (int i = 0; i < hrzData.length; i++) { int rectColor = @@ -113,26 +134,36 @@ public void onDraw(Canvas canvas) { paint.setColor(rectColor); // calculate per cent value of Zone duration - double hrzPart = hrzData[i] / sum; + double hrzPart = sum > 0 ? hrzData[i] / sum : 0.0; float percent = Math.round((float) hrzPart * 100); // calculate text and bar length String zoneName = getResources().getString(org.runnerup.common.R.string.Zone) + " " + i; - float textLen = fontPaint.measureText(zoneName); - float chartWidth = (float) ((totalWidth - textLen - 4 * borderSize) * chartSize); + String percentText = percent + "%"; float barLen = (float) (chartWidth * hrzPart); // elements x-offset float zoneOffset = borderSize; - float barOffset = zoneOffset + textLen + borderSize; + float barOffset = zoneOffset + maxLabelWidth + borderSize; float percentOffset = barOffset + chartWidth + borderSize; + float percentWidth = fontPaint.measureText(percentText); + float extraInsideInset = + percent >= 100 + ? fontPaint.measureText("100.0%") + : percent >= 10 ? fontPaint.measureText("20.0%") : fontPaint.measureText("2.0%"); + float percentInsideOffset = + Math.max( + barOffset + borderSize, + barOffset + chartWidth - percentWidth - borderSize + extraInsideInset); //noinspection IntegerDivisionInFloatingPointContext float y = topOffset + (i + 1) * borderSize + calculatedBarHeight * (i + 1) - fontSize / 2; // draw actual values and bars - if (calculatedBarHeight > minBarHeight) { - canvas.drawText(zoneName, zoneOffset, y, fontPaint); - canvas.drawText(percent + "%", percentOffset, y, fontPaint); + canvas.drawText(zoneName, zoneOffset, y, fontPaint); + if (percentOffset + percentWidth <= totalWidth - borderSize) { + canvas.drawText(percentText, percentOffset, y, fontPaint); + } else { + canvas.drawText(percentText, percentInsideOffset, y, fontPaint); } if (hrzPart >= 0) { diff --git a/app/src/mapbox/org/runnerup/util/MapWrapper.java b/app/src/mapbox/org/runnerup/util/MapWrapper.java index 2f090f633..55e748923 100644 --- a/app/src/mapbox/org/runnerup/util/MapWrapper.java +++ b/app/src/mapbox/org/runnerup/util/MapWrapper.java @@ -176,7 +176,7 @@ private Route loadRouteDataInBackground() { + "\n") + (formatter.formatDateTime(loc.getTime() / 1000) + "\n") + (loc.getSpeed() != null - ? formatter.formatPaceSpeed(TXT_LONG, loc.getSpeed()) + "\n" + ? formatter.formatVelocityByPreferredUnit(TXT_LONG, loc.getSpeed()) + "\n" : "") + (loc.getAltitude() != null ? formatter.formatElevation(TXT_LONG, loc.getAltitude()) + "\n" diff --git a/gradle.properties b/gradle.properties index b31340008..3482f75b4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,5 @@ # R8 is not displaying warnings for configuration issues, proguard may be required # Build with ./gradlew app:minifyLatestReleaseWithProguard -# required for com.jjoe64:graphview:4.2.2 -android.enableJetifier=true org.gradle.vfs.watch = true org.gradle.unsafe.configuration-cache=true android.nonTransitiveRClass=true From 8e9ac69af9c77d9f8b884942835b3fd30260b10d Mon Sep 17 00:00:00 2001 From: Gerhard Olsson Date: Sun, 13 Sep 2026 15:11:26 +0200 Subject: [PATCH 4/4] fixup! fix: ble hr deprecation fixes --- .../org/runnerup/hr/AndroidBLEHRProvider.java | 51 ++++++++----------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/hrdevice/src/org/runnerup/hr/AndroidBLEHRProvider.java b/hrdevice/src/org/runnerup/hr/AndroidBLEHRProvider.java index 39801f80f..a7a8410d9 100644 --- a/hrdevice/src/org/runnerup/hr/AndroidBLEHRProvider.java +++ b/hrdevice/src/org/runnerup/hr/AndroidBLEHRProvider.java @@ -101,8 +101,8 @@ public void onCharacteristicChanged( } int length = value.length; - if (length == 0) { - log("onCharacteristicChanged length = 0"); + if (length < 2 || length < 3 && isHeartRateInUINT16(value[0])) { + log("onCharacteristicChanged: insufficient data, length=" + length); return; } @@ -247,10 +247,17 @@ public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState } } + @Override + @SuppressWarnings("deprecation") + public void onDescriptorRead( + BluetoothGatt gatt, BluetoothGattDescriptor btDesc, int status) { + onDescriptorRead(gatt, btDesc, status, btDesc.getValue()); + } + @Override public void onDescriptorRead( - BluetoothGatt gatt, BluetoothGattDescriptor btChar, int status, byte[] value) { - BluetoothGattCharacteristic mHRMcharac = btChar.getCharacteristic(); + BluetoothGatt gatt, BluetoothGattDescriptor btDesc, int status, byte[] value) { + BluetoothGattCharacteristic mHRMcharac = btDesc.getCharacteristic(); if (!enableNotification(gatt, true, mHRMcharac)) { reportConnectFailed("Failed to enable notification in onDescriptorRead"); } @@ -501,32 +508,18 @@ private boolean enableNotification( } boolean result; - if (onoff) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - result = - gatt.writeDescriptor(clientConfig, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) - == BluetoothStatusCodes.SUCCESS; - } else { - @SuppressWarnings("deprecation") - boolean setValueResult = - clientConfig.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); - @SuppressWarnings("deprecation") - boolean writeDescResult = gatt.writeDescriptor(clientConfig); - result = setValueResult && writeDescResult; - } + byte[] notifValue = onoff + ? BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + : BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + result = gatt.writeDescriptor(clientConfig, notifValue) + == BluetoothStatusCodes.SUCCESS; } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - result = - gatt.writeDescriptor(clientConfig, BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE) - == BluetoothStatusCodes.SUCCESS; - } else { - @SuppressWarnings("deprecation") - boolean setValueResult = - clientConfig.setValue(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE); - @SuppressWarnings("deprecation") - boolean writeDescResult = gatt.writeDescriptor(clientConfig); - result = setValueResult && writeDescResult; - } + @SuppressWarnings("deprecation") + boolean setValueResult = clientConfig.setValue(notifValue); + @SuppressWarnings("deprecation") + boolean writeDescResult = gatt.writeDescriptor(clientConfig); + result = setValueResult && writeDescResult; } if (result) { mNotificationsOn = onoff;