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 03cbdea00..41f3c3c77 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,7 @@ android {
repositories {
google()
- mavenCentral() //MapBox GraphView
+ maven { url = 'https://jitpack.io' }
if (rootProject.ext.mapboxEnabled) {
maven {
url = 'https://api.mapbox.com/downloads/v2/releases/maven'
@@ -177,21 +177,12 @@ 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}"
}
-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 @@
-
+
-
-
-
-
-
+ 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/build.gradle b/build.gradle
index bcfcf3791..c95f9da40 100644
--- a/build.gradle
+++ b/build.gradle
@@ -9,7 +9,7 @@ buildscript {
}
plugins {
- id "com.diffplug.spotless" version "8.6.0"
+ id "com.diffplug.spotless" version "8.10.2"
}
repositories {
@@ -107,29 +107,37 @@ project.ext {
}
}
-// Special setup for antPlusLib, not allowed to copy the .aar and create mavenLocal
-// Raises warning:
-// AGPBI: {"kind":"warning","text":"Using flatDir should be avoided because it doesn't support any meta-data formats.","sources":[{}]}
allprojects {
repositories {
+ google()
+ mavenCentral()
if (rootProject.ext.antPlusLibName) {
- flatDir { dirs rootProject.ext.antPlusLibPath }
+ ivy {
+ url = uri(rootProject.ext.antPlusLibPath)
+ patternLayout {
+ artifact '[artifact].[ext]'
+ }
+ metadataSources {
+ artifact()
+ }
+ }
+ }
+ }
+ tasks.withType(JavaCompile).tap {
+ configureEach {
+ if (project.name != 'wear') {
+ options.compilerArgs << "-Xlint:deprecation";
+ }
+ options.compilerArgs << "-Xlint:unchecked"
}
}
}
spotless {
- lineEndings 'UNIX'
+ lineEndings = 'UNIX'
java {
googleJavaFormat()
target '**/*.java'
targetExclude '**/ANT-Android-SDKs/**', '**/build/**'
}
}
-
-allprojects {
- repositories {
- google()
- mavenCentral()
- }
-}
\ No newline at end of file
diff --git a/common/lint-baseline.xml b/common/lint-baseline.xml
index 355b2f37d..3d489671b 100644
--- a/common/lint-baseline.xml
+++ b/common/lint-baseline.xml
@@ -1,4 +1,4 @@
-
+
diff --git a/gradle.properties b/gradle.properties
index b33955542..3482f75b4 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,17 +1,10 @@
# 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
-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 +14,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 @@
-
+
= 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;
- if (length == 0) {
- log("onCharacteristicChanged length = 0");
+ int length = value.length;
+ if (length < 2 || length < 3 && isHeartRateInUINT16(value[0])) {
+ log("onCharacteristicChanged: insufficient data, length=" + length);
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,16 @@ public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState
}
@Override
- public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor arg0, int status) {
+ @SuppressWarnings("deprecation")
+ public void onDescriptorRead(
+ BluetoothGatt gatt, BluetoothGattDescriptor btDesc, int status) {
+ onDescriptorRead(gatt, btDesc, status, btDesc.getValue());
+ }
- BluetoothGattCharacteristic mHRMcharac = arg0.getCharacteristic();
+ @Override
+ public void onDescriptorRead(
+ BluetoothGatt gatt, BluetoothGattDescriptor btDesc, int status, byte[] value) {
+ BluetoothGattCharacteristic mHRMcharac = btDesc.getCharacteristic();
if (!enableNotification(gatt, true, mHRMcharac)) {
reportConnectFailed("Failed to enable notification in onDescriptorRead");
}
@@ -243,11 +267,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 +281,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 +295,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 +304,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 +330,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 +347,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 +360,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 +422,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 +453,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 +500,27 @@ 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;
}
- if (onoff) {
- clientConfig.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
+ boolean result;
+ 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 {
- clientConfig.setValue(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE);
+ @SuppressWarnings("deprecation")
+ boolean setValueResult = clientConfig.setValue(notifValue);
+ @SuppressWarnings("deprecation")
+ boolean writeDescResult = gatt.writeDescriptor(clientConfig);
+ result = setValueResult && writeDescResult;
}
- boolean result = gatt.writeDescriptor(clientConfig);
if (result) {
mNotificationsOn = onoff;
}
@@ -592,7 +633,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 +679,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 +888,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 +896,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 +988,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;
diff --git a/wear/lint-baseline.xml b/wear/lint-baseline.xml
index 19afff74f..756c2ec88 100644
--- a/wear/lint-baseline.xml
+++ b/wear/lint-baseline.xml
@@ -1,5 +1,5 @@
-
+