Add Braun iCheck 7 BPW4500 blood pressure monitor support

Add full Gadgetbridge integration for the Braun iCheck 7 BPW4500
blood pressure monitor using its proprietary BLE protocol (Kaz USA).

New files:
- BraunBPW4500DeviceCoordinator: device detection via BLE name "BPW4500",
  WHO blood pressure classification, GenericBloodPressureSampleProvider
- BraunBPW4500DeviceSupport: BLE communication, 19-byte measurement
  packet parsing (systolic, diastolic), RTC sync
- DashboardBloodPressureWidget: dashboard gauge with WHO color coding
- BloodPressureChartFragment: line chart, measurement list,
  PDF and CSV export with user profile info

Modified files:
- DeviceType: register BRAUN_BPW4500
- DashboardFragment: register bloodpressure widget
- DefaultChartsProvider: register BloodPressureChartFragment
- arrays.xml: add blood pressure to dashboard and charts tab lists
- strings.xml: add device name, bp_systolic, bp_diastolic strings
- shared_paths.xml: add pdf/csv cache paths for FileProvider export

Known limitation: offline stored measurements cannot be retrieved
reliably as the device transmits history data before GATT service
discovery completes in Gadgetbridge.

fixed chart monthly/weekly view

remodeled blood pressure fragments

fix remaining review comments
This commit is contained in:
Christian Breiteneder
2026-03-21 17:34:40 +01:00
parent 47b2412663
commit 6a1ef81a05
21 changed files with 2148 additions and 1 deletions
@@ -72,6 +72,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardCalori
import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardDistanceWidget;
import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardGoalsWidget;
import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardHrvWidget;
import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardBloodPressureWidget;
import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardPaiWidget;
import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardSleepScoreWidget;
import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardSleepWidget;
@@ -310,6 +311,9 @@ public class DashboardFragment extends Fragment implements MenuProvider {
case "hrv":
widget = DashboardHrvWidget.newInstance(dashboardData);
break;
case "bloodpressure":
widget = DashboardBloodPressureWidget.newInstance(dashboardData);
break;
case "vo2max_running":
widget = DashboardVO2MaxRunningWidget.newInstance(dashboardData);
break;
@@ -0,0 +1,366 @@
/*
Copyright (C) 2026 Christian Breiteneder
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package nodomain.freeyourgadget.gadgetbridge.activities.charts;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.github.mikephil.charting.charts.Chart;
import com.github.mikephil.charting.charts.LineChart;
import com.github.mikephil.charting.components.LegendEntry;
import com.github.mikephil.charting.components.LimitLine;
import com.github.mikephil.charting.components.XAxis;
import com.github.mikephil.charting.components.YAxis;
import com.github.mikephil.charting.data.Entry;
import com.github.mikephil.charting.data.LineData;
import com.github.mikephil.charting.data.LineDataSet;
import com.github.mikephil.charting.formatter.ValueFormatter;
import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.BloodPressureSample;
import nodomain.freeyourgadget.gadgetbridge.util.Accumulator;
import nodomain.freeyourgadget.gadgetbridge.util.BloodPressureExportHelper;
public class BloodPressureChartFragment extends AbstractChartFragment<BloodPressureChartFragment.BloodPressureChartsData> {
protected static final Logger LOG = LoggerFactory.getLogger(BloodPressureChartFragment.class);
static int DATA_INVALID = -1;
private int BACKGROUND_COLOR;
private int CHART_TEXT_COLOR;
private int TEXT_COLOR;
private int LEGEND_TEXT_COLOR;
private TimestampTranslation tsTranslation;
private TextView mDateView;
private LineChart mChart;
private TextView mSystolicLast;
private TextView mDiastolicLast;
private TextView mAverage;
private TextView mMeasurementCount;
private LinearLayout mManualMeasurements;
private LinearLayout mManualMeasurementsList;
private BloodPressureChartsData currentData;
@Override
protected void init() {
BACKGROUND_COLOR = GBApplication.getBackgroundColor(requireContext());
LEGEND_TEXT_COLOR = TEXT_COLOR = GBApplication.getTextColor(requireContext());
CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
}
@Override
public View onCreateView(final LayoutInflater inflater,
final ViewGroup container,
final Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_blood_pressure_chart, container, false);
mDateView = rootView.findViewById(R.id.date_view);
mChart = rootView.findViewById(R.id.blood_pressure_line_chart);
mSystolicLast = rootView.findViewById(R.id.bp_systolic_last);
mDiastolicLast = rootView.findViewById(R.id.bp_diastolic_last);
mAverage = rootView.findViewById(R.id.bp_average);
mMeasurementCount = rootView.findViewById(R.id.bp_measurement_count);
mManualMeasurements = rootView.findViewById(R.id.manualMeasurements);
mManualMeasurementsList = rootView.findViewById(R.id.manualMeasurementsList);
mManualMeasurements.setVisibility(View.GONE);
setupLineChart();
FloatingActionButton exportFab = rootView.findViewById(R.id.bp_export_fab);
exportFab.setOnClickListener(v -> showExportDialog());
refresh();
return rootView;
}
@Override
protected BloodPressureChartsData refreshInBackground(final ChartsHost chartsHost, final DBHandler db, final GBDevice device) {
Calendar day = Calendar.getInstance();
day.setTime(getEndDate());
day.set(Calendar.HOUR_OF_DAY, 0);
day.set(Calendar.MINUTE, 0);
day.set(Calendar.SECOND, 0);
int startTs = (int) (day.getTimeInMillis() / 1000);
int endTs = startTs + 24 * 60 * 60 - 1;
tsTranslation = new TimestampTranslation();
tsTranslation.shorten(startTs);
String formattedDate = new SimpleDateFormat("E, MMM dd", Locale.getDefault()).format(chartsHost.getEndDate());
mDateView.setText(formattedDate);
return fetchBloodPressureData(db, device, startTs, endTs);
}
protected LineDataSet createDataSet(final List<Entry> values, String label, int color) {
final LineDataSet lineDataSet = new LineDataSet(values, label);
lineDataSet.setColor(color);
lineDataSet.setDrawCircles(true);
lineDataSet.setCircleColor(color);
lineDataSet.setCircleRadius(3f);
lineDataSet.setDrawCircleHole(false);
lineDataSet.setLineWidth(2.2f);
lineDataSet.setFillAlpha(255);
lineDataSet.setValueTextColor(TEXT_COLOR);
lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT);
lineDataSet.setValueFormatter(new ValueFormatter() {
@Override
public String getFormattedValue(float value) {
return String.format(Locale.ROOT, "%d", (int) value);
}
});
return lineDataSet;
}
@Override
protected void updateChartsnUIThread(BloodPressureChartsData data) {
currentData = data;
mManualMeasurementsList.removeAllViews();
mManualMeasurements.setVisibility(View.GONE);
final String emptyValue = requireContext().getString(R.string.stats_empty_value);
mSystolicLast.setText(data.systolicLast > 0 ? String.valueOf(data.systolicLast) : emptyValue);
mDiastolicLast.setText(data.diastolicLast > 0 ? String.valueOf(data.diastolicLast) : emptyValue);
if (data.systolicAvg > 0 && data.diastolicAvg > 0) {
mAverage.setText(getString(R.string.blood_pressure_avg_format, data.systolicAvg, data.diastolicAvg));
} else {
mAverage.setText(emptyValue);
}
mMeasurementCount.setText(String.valueOf(data.measurementCount));
mChart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317
mChart.getAxisLeft().removeAllLimitLines();
Date date = new Date((long) getTSEnd() * 1000);
String formattedDate = new SimpleDateFormat("E, MMM dd", Locale.getDefault()).format(date);
mDateView.setText(formattedDate);
final int systolicColor = ContextCompat.getColor(requireContext(), R.color.blood_pressure_systolic_color);
final int diastolicColor = ContextCompat.getColor(requireContext(), R.color.blood_pressure_diastolic_color);
final List<LegendEntry> legendEntries = new ArrayList<>(2);
final LegendEntry systolicEntry = new LegendEntry();
systolicEntry.label = getString(R.string.blood_pressure_systolic);
systolicEntry.formColor = systolicColor;
legendEntries.add(systolicEntry);
final LegendEntry diastolicEntry = new LegendEntry();
diastolicEntry.label = getString(R.string.blood_pressure_diastolic);
diastolicEntry.formColor = diastolicColor;
legendEntries.add(diastolicEntry);
mChart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
mChart.getLegend().setCustom(legendEntries);
final List<ILineDataSet> lineDataSets = new ArrayList<>();
final List<Entry> systolicEntries = new ArrayList<>();
final List<Entry> diastolicEntries = new ArrayList<>();
for (final BloodPressureSample sample : data.samples) {
int ts = (int) (sample.getTimestamp() / 1000L);
int tsShorten = tsTranslation.shorten(ts);
if (sample.getBpSystolic() > 0) {
systolicEntries.add(new Entry(tsShorten, sample.getBpSystolic()));
}
if (sample.getBpDiastolic() > 0) {
diastolicEntries.add(new Entry(tsShorten, sample.getBpDiastolic()));
}
}
if (!systolicEntries.isEmpty()) {
lineDataSets.add(createDataSet(systolicEntries, getString(R.string.blood_pressure_systolic), systolicColor));
}
if (!diastolicEntries.isEmpty()) {
lineDataSets.add(createDataSet(diastolicEntries, getString(R.string.blood_pressure_diastolic), diastolicColor));
}
mChart.getXAxis().setValueFormatter(new SampleXLabelFormatter(tsTranslation, "HH:mm"));
if (!lineDataSets.isEmpty()) {
final LineData lineData = new LineData(lineDataSets);
mChart.setData(lineData);
}
if (data.systolicAvg > 0 && GBApplication.getPrefs().getBoolean("charts_show_average", true)) {
final LimitLine avgLine = new LimitLine(data.systolicAvg);
avgLine.setLineColor(Color.GRAY);
avgLine.setLineWidth(1.5f);
avgLine.enableDashedLine(15f, 10f, 0f);
mChart.getAxisLeft().addLimitLine(avgLine);
}
}
@Override
public String getTitle() {
return requireContext().getString(R.string.blood_pressure);
}
private void showExportDialog() {
if (currentData == null || currentData.samples.isEmpty()) {
return;
}
String dateLabel = mDateView.getText().toString();
String[] options = {"PDF", "CSV"};
new AlertDialog.Builder(requireContext())
.setTitle(R.string.appmanager_app_share)
.setItems(options, (dialog, which) -> {
if (which == 0) {
BloodPressureExportHelper.exportPdf(requireContext(), currentData.samples, dateLabel, getWhiteChartBitmap());
} else {
BloodPressureExportHelper.exportCsv(requireContext(), currentData.samples);
}
})
.show();
}
private Bitmap getWhiteChartBitmap() {
// Temporarily switch to light colors for PDF export
mChart.setBackgroundColor(0xFFFFFFFF);
mChart.getXAxis().setTextColor(0xFF000000);
mChart.getAxisLeft().setTextColor(0xFF000000);
mChart.getAxisRight().setAxisLineColor(0xFF000000);
mChart.getLegend().setTextColor(0xFF000000);
mChart.invalidate();
Bitmap bitmap = mChart.getChartBitmap();
// Restore original colors
mChart.setBackgroundColor(BACKGROUND_COLOR);
mChart.getXAxis().setTextColor(CHART_TEXT_COLOR);
mChart.getAxisLeft().setTextColor(CHART_TEXT_COLOR);
mChart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
mChart.invalidate();
return bitmap;
}
private void setupLineChart() {
mChart.setBackgroundColor(BACKGROUND_COLOR);
mChart.getDescription().setText("");
final XAxis xAxisBottom = mChart.getXAxis();
xAxisBottom.setPosition(XAxis.XAxisPosition.BOTTOM);
xAxisBottom.setDrawLabels(true);
xAxisBottom.setDrawGridLines(false);
xAxisBottom.setEnabled(true);
xAxisBottom.setDrawLimitLinesBehindData(true);
xAxisBottom.setTextColor(CHART_TEXT_COLOR);
xAxisBottom.setAxisMinimum(0f);
xAxisBottom.setAxisMaximum(86400f);
xAxisBottom.setLabelCount(7, true);
final YAxis yAxisLeft = mChart.getAxisLeft();
yAxisLeft.setDrawGridLines(true);
yAxisLeft.setAxisMaximum(200f);
yAxisLeft.setAxisMinimum(40f);
yAxisLeft.setDrawTopYLabelEntry(false);
yAxisLeft.setTextColor(CHART_TEXT_COLOR);
yAxisLeft.setEnabled(true);
final YAxis yAxisRight = mChart.getAxisRight();
yAxisRight.setEnabled(true);
yAxisRight.setDrawLabels(false);
yAxisRight.setDrawGridLines(false);
yAxisRight.setDrawAxisLine(true);
}
@Override
protected void setupLegend(final Chart<?> chart) {}
@Override
protected void renderCharts() {
mChart.invalidate();
}
private List<? extends BloodPressureSample> getSamples(final DBHandler db, final GBDevice device, int startTs, int endTs) {
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
final TimeSampleProvider<? extends BloodPressureSample> sampleProvider = coordinator.getBloodPressureSampleProvider(device, db.getDaoSession());
return sampleProvider.getAllSamples(startTs * 1000L, endTs * 1000L);
}
private BloodPressureChartsData fetchBloodPressureData(DBHandler db, GBDevice device, int startTs, int endTs) {
List<? extends BloodPressureSample> samples = getSamples(db, device, startTs, endTs);
final Accumulator systolicAccumulator = new Accumulator();
final Accumulator diastolicAccumulator = new Accumulator();
int systolicLast = DATA_INVALID;
int diastolicLast = DATA_INVALID;
for (final BloodPressureSample sample : samples) {
if (sample.getBpSystolic() > 0) {
systolicAccumulator.add(sample.getBpSystolic());
systolicLast = sample.getBpSystolic();
}
if (sample.getBpDiastolic() > 0) {
diastolicAccumulator.add(sample.getBpDiastolic());
diastolicLast = sample.getBpDiastolic();
}
}
final int systolicAvg = systolicAccumulator.getCount() > 0 ? (int) Math.round(systolicAccumulator.getAverage()) : DATA_INVALID;
final int diastolicAvg = diastolicAccumulator.getCount() > 0 ? (int) Math.round(diastolicAccumulator.getAverage()) : DATA_INVALID;
final int measurementCount = samples.size();
return new BloodPressureChartsData(samples, systolicAvg, diastolicAvg, systolicLast, diastolicLast, measurementCount);
}
protected static class BloodPressureChartsData extends ChartsData {
public List<? extends BloodPressureSample> samples;
public final int systolicAvg;
public final int diastolicAvg;
public final int systolicLast;
public final int diastolicLast;
public final int measurementCount;
public BloodPressureChartsData(List<? extends BloodPressureSample> samples, int systolicAvg, int diastolicAvg,
int systolicLast, int diastolicLast, int measurementCount) {
this.samples = samples;
this.systolicAvg = systolicAvg;
this.diastolicAvg = diastolicAvg;
this.systolicLast = systolicLast;
this.diastolicLast = diastolicLast;
this.measurementCount = measurementCount;
}
}
}
@@ -0,0 +1,43 @@
/*
Copyright (C) 2026 Christian Breiteneder
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package nodomain.freeyourgadget.gadgetbridge.activities.charts;
import android.os.Bundle;
import androidx.viewpager2.adapter.FragmentStateAdapter;
import nodomain.freeyourgadget.gadgetbridge.adapter.BloodPressureFragmentAdapter;
public class BloodPressureCollectionFragment extends AbstractCollectionFragment {
public BloodPressureCollectionFragment() {
}
public static BloodPressureCollectionFragment newInstance(final boolean allowSwipe) {
final BloodPressureCollectionFragment fragment = new BloodPressureCollectionFragment();
final Bundle args = new Bundle();
args.putBoolean(ARG_ALLOW_SWIPE, allowSwipe);
fragment.setArguments(args);
return fragment;
}
@Override
public FragmentStateAdapter getFragmentAdapter() {
return new BloodPressureFragmentAdapter(this);
}
}
@@ -0,0 +1,448 @@
/*
Copyright (C) 2026 Christian Breiteneder
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package nodomain.freeyourgadget.gadgetbridge.activities.charts;
import android.graphics.Bitmap;
import android.graphics.Paint;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.github.mikephil.charting.charts.Chart;
import com.github.mikephil.charting.charts.CombinedChart;
import com.github.mikephil.charting.components.LegendEntry;
import com.github.mikephil.charting.components.XAxis;
import com.github.mikephil.charting.components.YAxis;
import com.github.mikephil.charting.data.CandleData;
import com.github.mikephil.charting.data.CandleDataSet;
import com.github.mikephil.charting.data.CandleEntry;
import com.github.mikephil.charting.data.CombinedData;
import com.github.mikephil.charting.formatter.ValueFormatter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.BloodPressureSample;
import nodomain.freeyourgadget.gadgetbridge.util.Accumulator;
import nodomain.freeyourgadget.gadgetbridge.util.BloodPressureExportHelper;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
public class BloodPressurePeriodFragment extends AbstractChartFragment<BloodPressurePeriodFragment.BloodPressurePeriodData> {
protected static final Logger LOG = LoggerFactory.getLogger(BloodPressurePeriodFragment.class);
static int SEC_PER_DAY = 24 * 60 * 60;
static int DATA_INVALID = -1;
private int BACKGROUND_COLOR;
private int CHART_TEXT_COLOR;
private int LEGEND_TEXT_COLOR;
private int SYSTOLIC_COLOR;
private int DIASTOLIC_COLOR;
private TextView mDateView;
private TextView mSystolicLast;
private TextView mDiastolicLast;
private TextView mAverage;
private TextView mMeasurementCount;
private CombinedChart mChart;
private int TOTAL_DAYS;
private List<? extends BloodPressureSample> allSamples;
@Override
protected boolean isSingleDay() {
return false;
}
public static BloodPressurePeriodFragment newInstance(int totalDays) {
BloodPressurePeriodFragment fragment = new BloodPressurePeriodFragment();
Bundle args = new Bundle();
args.putInt("totalDays", totalDays);
fragment.setArguments(args);
return fragment;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
TOTAL_DAYS = getArguments() != null ? getArguments().getInt("totalDays") : 7;
}
@Override
protected void init() {
BACKGROUND_COLOR = GBApplication.getBackgroundColor(requireContext());
LEGEND_TEXT_COLOR = GBApplication.getTextColor(requireContext());
CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
SYSTOLIC_COLOR = ContextCompat.getColor(requireContext(), R.color.blood_pressure_systolic_color);
DIASTOLIC_COLOR = ContextCompat.getColor(requireContext(), R.color.blood_pressure_diastolic_color);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_blood_pressure_period, container, false);
rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) ->
getChartsHost().enableSwipeRefresh(scrollY == 0)
);
mDateView = rootView.findViewById(R.id.date_view);
mSystolicLast = rootView.findViewById(R.id.bp_systolic_last);
mDiastolicLast = rootView.findViewById(R.id.bp_diastolic_last);
mAverage = rootView.findViewById(R.id.bp_average);
mMeasurementCount = rootView.findViewById(R.id.bp_measurement_count);
mChart = rootView.findViewById(R.id.blood_pressure_chart);
setupChart();
FloatingActionButton exportFab = rootView.findViewById(R.id.bp_export_fab);
exportFab.setOnClickListener(v -> showExportDialog());
refresh();
setupLegend(mChart);
return rootView;
}
@Override
public String getTitle() {
return getString(R.string.blood_pressure);
}
private int getStartTs() {
Calendar day = Calendar.getInstance();
day.setTime(getEndDate());
day.set(Calendar.HOUR_OF_DAY, 0);
day.set(Calendar.MINUTE, 0);
day.set(Calendar.SECOND, 0);
return (int) (day.getTimeInMillis() / 1000) - SEC_PER_DAY * (TOTAL_DAYS - 1);
}
private BloodPressureDayData fetchDataForDay(DBHandler db, GBDevice device, int startTs) {
int endTs = startTs + SEC_PER_DAY - 1;
List<? extends BloodPressureSample> samples = getSamples(db, device, startTs, endTs);
final Accumulator systolicAcc = new Accumulator();
final Accumulator diastolicAcc = new Accumulator();
for (final BloodPressureSample sample : samples) {
if (sample.getBpSystolic() > 0) {
systolicAcc.add(sample.getBpSystolic());
}
if (sample.getBpDiastolic() > 0) {
diastolicAcc.add(sample.getBpDiastolic());
}
}
final int systolicAvg = systolicAcc.getCount() > 0 ? (int) Math.round(systolicAcc.getAverage()) : DATA_INVALID;
final int systolicMin = systolicAcc.getCount() > 0 ? (int) Math.round(systolicAcc.getMin()) : DATA_INVALID;
final int systolicMax = systolicAcc.getCount() > 0 ? (int) Math.round(systolicAcc.getMax()) : DATA_INVALID;
final int diastolicAvg = diastolicAcc.getCount() > 0 ? (int) Math.round(diastolicAcc.getAverage()) : DATA_INVALID;
final int diastolicMin = diastolicAcc.getCount() > 0 ? (int) Math.round(diastolicAcc.getMin()) : DATA_INVALID;
final int diastolicMax = diastolicAcc.getCount() > 0 ? (int) Math.round(diastolicAcc.getMax()) : DATA_INVALID;
return new BloodPressureDayData(systolicAvg, systolicMin, systolicMax, diastolicAvg, diastolicMin, diastolicMax, samples.size());
}
@Override
protected BloodPressurePeriodData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
final int startTs = getStartTs();
final int endTs = startTs + SEC_PER_DAY * TOTAL_DAYS - 1;
List<BloodPressureDayData> result = new ArrayList<>();
for (int i = 0; i < TOTAL_DAYS; i++) {
BloodPressureDayData dayData = fetchDataForDay(db, device, startTs + i * SEC_PER_DAY);
result.add(dayData);
}
allSamples = getSamples(db, device, startTs, endTs);
return new BloodPressurePeriodData(result);
}
private List<? extends BloodPressureSample> getSamples(DBHandler db, GBDevice device, int startTs, int endTs) {
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
final TimeSampleProvider<? extends BloodPressureSample> sampleProvider = coordinator.getBloodPressureSampleProvider(device, db.getDaoSession());
return sampleProvider.getAllSamples(startTs * 1000L, endTs * 1000L);
}
@Override
protected void updateChartsnUIThread(BloodPressurePeriodData data) {
final int startTs = getStartTs();
mDateView.setText(DateTimeUtils.formatDaysUntil(TOTAL_DAYS, getTSEnd()));
final Accumulator systolicAvgAcc = new Accumulator();
final Accumulator diastolicAvgAcc = new Accumulator();
final ArrayList<CandleEntry> systolicCandleEntries = new ArrayList<>();
final ArrayList<CandleEntry> diastolicCandleEntries = new ArrayList<>();
for (int i = 0; i < data.days.size(); i++) {
final BloodPressureDayData dayData = data.days.get(i);
if (dayData.systolicMin > 0 && dayData.systolicMax > 0) {
systolicAvgAcc.add(dayData.systolicAvg);
systolicCandleEntries.add(new CandleEntry(i, dayData.systolicMax, dayData.systolicMin, dayData.systolicMin, dayData.systolicMax));
}
if (dayData.diastolicMin > 0 && dayData.diastolicMax > 0) {
diastolicAvgAcc.add(dayData.diastolicAvg);
diastolicCandleEntries.add(new CandleEntry(i, dayData.diastolicMax, dayData.diastolicMin, dayData.diastolicMin, dayData.diastolicMax));
}
}
final String emptyValue = requireContext().getString(R.string.stats_empty_value);
final int systolicAvg = systolicAvgAcc.getCount() > 0 ? (int) Math.round(systolicAvgAcc.getAverage()) : DATA_INVALID;
final int diastolicAvg = diastolicAvgAcc.getCount() > 0 ? (int) Math.round(diastolicAvgAcc.getAverage()) : DATA_INVALID;
// Last day with valid data
int systolicLast = DATA_INVALID;
int diastolicLast = DATA_INVALID;
int totalMeasurements = 0;
for (int i = data.days.size() - 1; i >= 0; i--) {
final BloodPressureDayData dayData = data.days.get(i);
if (dayData.systolicAvg > 0 && systolicLast == DATA_INVALID) {
systolicLast = dayData.systolicAvg;
}
if (dayData.diastolicAvg > 0 && diastolicLast == DATA_INVALID) {
diastolicLast = dayData.diastolicAvg;
}
totalMeasurements += dayData.measurementCount;
}
mSystolicLast.setText(systolicLast > 0 ? String.valueOf(systolicLast) : emptyValue);
mDiastolicLast.setText(diastolicLast > 0 ? String.valueOf(diastolicLast) : emptyValue);
if (systolicAvg > 0 && diastolicAvg > 0) {
mAverage.setText(getString(R.string.blood_pressure_avg_format, systolicAvg, diastolicAvg));
} else {
mAverage.setText(emptyValue);
}
mMeasurementCount.setText(String.valueOf(totalMeasurements));
mChart.getXAxis().setValueFormatter(createDayFormatter(startTs));
final CombinedData combinedData = new CombinedData();
// Systolic candle data (range bars)
if (!systolicCandleEntries.isEmpty()) {
CandleDataSet systolicCandleDataSet = new CandleDataSet(systolicCandleEntries, getString(R.string.blood_pressure_systolic));
systolicCandleDataSet.setDrawValues(false);
systolicCandleDataSet.setDrawIcons(false);
systolicCandleDataSet.setAxisDependency(YAxis.AxisDependency.LEFT);
systolicCandleDataSet.setShadowColor(SYSTOLIC_COLOR);
systolicCandleDataSet.setShadowWidth(2f);
systolicCandleDataSet.setDecreasingColor(SYSTOLIC_COLOR);
systolicCandleDataSet.setDecreasingPaintStyle(Paint.Style.FILL);
systolicCandleDataSet.setIncreasingColor(SYSTOLIC_COLOR);
systolicCandleDataSet.setIncreasingPaintStyle(Paint.Style.FILL);
systolicCandleDataSet.setNeutralColor(SYSTOLIC_COLOR);
systolicCandleDataSet.setBarSpace(0.15f);
systolicCandleDataSet.setShowCandleBar(true);
// Diastolic candle data as second set
if (!diastolicCandleEntries.isEmpty()) {
CandleDataSet diastolicCandleDataSet = new CandleDataSet(diastolicCandleEntries, getString(R.string.blood_pressure_diastolic));
diastolicCandleDataSet.setDrawValues(false);
diastolicCandleDataSet.setDrawIcons(false);
diastolicCandleDataSet.setAxisDependency(YAxis.AxisDependency.LEFT);
diastolicCandleDataSet.setShadowColor(DIASTOLIC_COLOR);
diastolicCandleDataSet.setShadowWidth(2f);
diastolicCandleDataSet.setDecreasingColor(DIASTOLIC_COLOR);
diastolicCandleDataSet.setDecreasingPaintStyle(Paint.Style.FILL);
diastolicCandleDataSet.setIncreasingColor(DIASTOLIC_COLOR);
diastolicCandleDataSet.setIncreasingPaintStyle(Paint.Style.FILL);
diastolicCandleDataSet.setNeutralColor(DIASTOLIC_COLOR);
diastolicCandleDataSet.setBarSpace(0.15f);
diastolicCandleDataSet.setShowCandleBar(true);
combinedData.setData(new CandleData(systolicCandleDataSet, diastolicCandleDataSet));
} else {
combinedData.setData(new CandleData(systolicCandleDataSet));
}
}
mChart.setData(combinedData);
}
private void showExportDialog() {
if (allSamples == null || allSamples.isEmpty()) {
return;
}
String dateLabel = mDateView.getText().toString();
String[] options = {"PDF", "CSV"};
new AlertDialog.Builder(requireContext())
.setTitle(R.string.appmanager_app_share)
.setItems(options, (dialog, which) -> {
if (which == 0) {
BloodPressureExportHelper.exportPdf(requireContext(), allSamples, dateLabel, getWhiteChartBitmap());
} else {
BloodPressureExportHelper.exportCsv(requireContext(), allSamples);
}
})
.show();
}
private ValueFormatter createDayFormatter(final int startTs) {
final String fmt = TOTAL_DAYS == 7 ? "EEE" : "dd";
final SimpleDateFormat formatDay = new SimpleDateFormat(fmt, Locale.getDefault());
return new ValueFormatter() {
@Override
public String getFormattedValue(float value) {
int dayIndex = Math.round(value);
if (dayIndex < 0 || dayIndex >= TOTAL_DAYS) {
return "";
}
int ts = startTs + SEC_PER_DAY * dayIndex;
return formatDay.format(new Date(ts * 1000L));
}
};
}
private Bitmap getWhiteChartBitmap() {
// Temporarily switch to light colors for PDF export
mChart.setBackgroundColor(0xFFFFFFFF);
mChart.getXAxis().setTextColor(0xFF000000);
mChart.getAxisLeft().setTextColor(0xFF000000);
mChart.getAxisRight().setAxisLineColor(0xFF000000);
mChart.getLegend().setTextColor(0xFF000000);
mChart.invalidate();
Bitmap bitmap = mChart.getChartBitmap();
// Restore original colors
mChart.setBackgroundColor(BACKGROUND_COLOR);
mChart.getXAxis().setTextColor(CHART_TEXT_COLOR);
mChart.getAxisLeft().setTextColor(CHART_TEXT_COLOR);
mChart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
mChart.invalidate();
return bitmap;
}
private void setupChart() {
mChart.setBackgroundColor(BACKGROUND_COLOR);
mChart.getDescription().setEnabled(false);
mChart.setDrawOrder(new CombinedChart.DrawOrder[]{
CombinedChart.DrawOrder.CANDLE
});
if (TOTAL_DAYS <= 7) {
mChart.setTouchEnabled(false);
mChart.setPinchZoom(false);
}
mChart.setDoubleTapToZoomEnabled(false);
final XAxis xAxisBottom = mChart.getXAxis();
xAxisBottom.setPosition(XAxis.XAxisPosition.BOTTOM);
xAxisBottom.setDrawLabels(true);
xAxisBottom.setDrawGridLines(false);
xAxisBottom.setEnabled(true);
xAxisBottom.setDrawLimitLinesBehindData(true);
xAxisBottom.setTextColor(CHART_TEXT_COLOR);
xAxisBottom.setGranularity(1f);
xAxisBottom.setGranularityEnabled(true);
xAxisBottom.setAxisMinimum(-0.5f);
xAxisBottom.setAxisMaximum(TOTAL_DAYS - 0.5f);
final YAxis yAxisLeft = mChart.getAxisLeft();
yAxisLeft.setDrawGridLines(true);
yAxisLeft.setAxisMaximum(200f);
yAxisLeft.setAxisMinimum(40f);
yAxisLeft.setDrawTopYLabelEntry(true);
yAxisLeft.setTextColor(CHART_TEXT_COLOR);
yAxisLeft.setEnabled(true);
yAxisLeft.setGranularity(10f);
yAxisLeft.setGranularityEnabled(true);
final YAxis yAxisRight = mChart.getAxisRight();
yAxisRight.setEnabled(true);
yAxisRight.setDrawLabels(false);
yAxisRight.setDrawGridLines(false);
yAxisRight.setDrawAxisLine(true);
}
@Override
protected void setupLegend(Chart<?> chart) {
List<LegendEntry> legendEntries = new ArrayList<>(2);
LegendEntry systolicEntry = new LegendEntry();
systolicEntry.label = getString(R.string.blood_pressure_systolic);
systolicEntry.formColor = SYSTOLIC_COLOR;
legendEntries.add(systolicEntry);
LegendEntry diastolicEntry = new LegendEntry();
diastolicEntry.label = getString(R.string.blood_pressure_diastolic);
diastolicEntry.formColor = DIASTOLIC_COLOR;
legendEntries.add(diastolicEntry);
mChart.getLegend().setCustom(legendEntries);
mChart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
mChart.getLegend().setWordWrapEnabled(true);
}
@Override
protected void renderCharts() {
mChart.invalidate();
}
protected static class BloodPressurePeriodData extends ChartsData {
public List<BloodPressureDayData> days;
protected BloodPressurePeriodData(List<BloodPressureDayData> days) {
this.days = days;
}
}
protected static class BloodPressureDayData extends ChartsData {
public int systolicAvg;
public int systolicMin;
public int systolicMax;
public int diastolicAvg;
public int diastolicMin;
public int diastolicMax;
public int measurementCount;
protected BloodPressureDayData(int systolicAvg, int systolicMin, int systolicMax,
int diastolicAvg, int diastolicMin, int diastolicMax,
int measurementCount) {
this.systolicAvg = systolicAvg;
this.systolicMin = systolicMin;
this.systolicMax = systolicMax;
this.diastolicAvg = diastolicAvg;
this.diastolicMin = diastolicMin;
this.diastolicMax = diastolicMax;
this.measurementCount = measurementCount;
}
}
}
@@ -67,6 +67,9 @@ open class DefaultChartsProvider : DeviceChartsProvider {
if (coordinator.supportsTemperatureMeasurement(device)) {
supportedCharts.add("temperature")
}
if (coordinator.supportsBloodPressureMeasurement(device)) {
supportedCharts.add("bloodpressure")
}
if (coordinator.supportsWeightMeasurement(device)) {
supportedCharts.add("weight")
}
@@ -110,6 +113,7 @@ open class DefaultChartsProvider : DeviceChartsProvider {
"livestats" -> context.getString(R.string.liveactivity_live_activity)
"spo2" -> context.getString(R.string.pref_header_spo2)
"temperature" -> context.getString(R.string.menuitem_temperature)
"bloodpressure" -> context.getString(R.string.blood_pressure)
"cycling" -> context.getString(R.string.title_cycling)
"weight" -> context.getString(R.string.menuitem_weight)
"calories" -> context.getString(R.string.calories)
@@ -144,6 +148,7 @@ open class DefaultChartsProvider : DeviceChartsProvider {
TemperatureDailyFragment() else TemperatureChartFragment()
}
"bloodpressure" -> BloodPressureCollectionFragment.newInstance(allowSwipe)
"cycling" -> CyclingChartFragment()
"weight" -> WeightChartFragment()
"calories" -> CaloriesCollectionFragment.newInstance(allowSwipe)
@@ -0,0 +1,119 @@
/*
Copyright (C) 2026 Christian Breiteneder
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
import android.os.Bundle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.BloodPressureSample;
public class DashboardBloodPressureWidget extends AbstractGaugeWidget {
private static final Logger LOG = LoggerFactory.getLogger(DashboardBloodPressureWidget.class);
public DashboardBloodPressureWidget() {
super(R.string.blood_pressure, "bloodpressure");
}
public static DashboardBloodPressureWidget newInstance(final DashboardFragment.DashboardData dashboardData) {
final DashboardBloodPressureWidget fragment = new DashboardBloodPressureWidget();
final Bundle args = new Bundle();
args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
fragment.setArguments(args);
return fragment;
}
@Override
protected boolean isSupportedBy(final GBDevice device) {
return device.getDeviceCoordinator().supportsBloodPressureMeasurement(device);
}
@Override
protected void populateData(final DashboardFragment.DashboardData dashboardData) {
final List<GBDevice> devices = getSupportedDevices(dashboardData);
int latestSystolic = 0;
int latestDiastolic = 0;
long latestTimestamp = 0;
try (DBHandler dbHandler = GBApplication.acquireDbReadOnly()) {
for (final GBDevice dev : devices) {
final var provider = dev.getDeviceCoordinator()
.getBloodPressureSampleProvider(dev, dbHandler.getDaoSession());
if (provider == null) continue;
final List<? extends BloodPressureSample> samples = provider.getAllSamples(
dashboardData.timeFrom * 1000L,
dashboardData.timeTo * 1000L
);
if (!samples.isEmpty()) {
final BloodPressureSample latest = samples.get(samples.size() - 1);
if (latest.getTimestamp() > latestTimestamp) {
latestTimestamp = latest.getTimestamp();
latestSystolic = latest.getBpSystolic();
latestDiastolic = latest.getBpDiastolic();
}
}
}
} catch (final Exception e) {
LOG.error("Could not get blood pressure samples", e);
}
dashboardData.put("bp_systolic", latestSystolic);
dashboardData.put("bp_diastolic", latestDiastolic);
}
@Override
protected void draw(final DashboardFragment.DashboardData dashboardData) {
final Integer systolic = (Integer) dashboardData.get("bp_systolic");
final Integer diastolic = (Integer) dashboardData.get("bp_diastolic");
if (systolic != null && systolic > 0 && diastolic != null && diastolic > 0) {
setText(systolic + "/" + diastolic);
// WHO classification: color and gauge position based on systolic/diastolic values
final int color;
final float gaugeValue;
if (systolic < 120 && diastolic < 80) {
color = android.graphics.Color.rgb(76, 175, 80); // green Normal
gaugeValue = 0.25f;
} else if (systolic < 130 && diastolic < 80) {
color = android.graphics.Color.rgb(139, 195, 74); // lime Elevated
gaugeValue = 0.45f;
} else if (systolic < 140 || diastolic < 90) {
color = android.graphics.Color.rgb(255, 152, 0); // orange Stage 1
gaugeValue = 0.65f;
} else {
color = android.graphics.Color.rgb(244, 67, 54); // red Stage 2+
gaugeValue = 0.88f;
}
drawSimpleGauge(color, gaugeValue);
} else {
setText(getString(R.string.stats_empty_value));
drawSimpleGauge(android.graphics.Color.GRAY, -1);
}
}
}
@@ -0,0 +1,42 @@
/*
Copyright (C) 2026 Christian Breiteneder
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package nodomain.freeyourgadget.gadgetbridge.adapter;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.BloodPressureChartFragment;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.BloodPressurePeriodFragment;
public class BloodPressureFragmentAdapter extends NestedFragmentAdapter {
public BloodPressureFragmentAdapter(Fragment fragment) {
super(fragment);
}
@NonNull
@Override
public Fragment createFragment(int position) {
return switch (position) {
case 1 -> BloodPressurePeriodFragment.newInstance(7);
case 2 -> BloodPressurePeriodFragment.newInstance(30);
default -> new BloodPressureChartFragment();
};
}
}
@@ -697,7 +697,8 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
supportsWeightMeasurement(device) ||
supportsActiveCalories(device) ||
supportsCyclingData(device) ||
supportsRespiratoryRate(device);
supportsRespiratoryRate(device) ||
supportsBloodPressureMeasurement(device);
}
@Override
@@ -0,0 +1,105 @@
/*
Copyright (C) 2026 Christian Breiteneder
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package nodomain.freeyourgadget.gadgetbridge.devices.braun;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sbm_67.GenericBloodPressureSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericBloodPressureSampleDao;
import de.greenrobot.dao.AbstractDao;
import de.greenrobot.dao.Property;
import java.util.HashMap;
import java.util.Map;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.braun.BraunBPW4500DeviceSupport;
public class BraunBPW4500DeviceCoordinator extends AbstractBLEDeviceCoordinator {
@Nullable
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("^BPW4500$");
}
@NonNull
@Override
public Class<? extends DeviceSupport> getDeviceSupportClass(GBDevice device) {
return BraunBPW4500DeviceSupport.class;
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_braun_bpw4500;
}
@Override
public DeviceKind getDeviceKind(@NonNull GBDevice device) {
return DeviceKind.BLOOD_PRESSURE_METER;
}
@Override
public int getBondingStyle() {
return BONDING_STYLE_NONE;
}
@Override
public boolean suggestUnbindBeforePair() {
return false;
}
@Override
public boolean supportsBloodPressureMeasurement(@NonNull final GBDevice device) {
return true;
}
@Override
public GenericBloodPressureSampleProvider getBloodPressureSampleProvider(
final GBDevice device, final DaoSession session) {
return new GenericBloodPressureSampleProvider(device, session);
}
@Override
public String getManufacturer() {
return "Braun";
}
@Override
public Map<AbstractDao<?, ?>, Property> getAllDeviceDao(@NonNull final DaoSession session) {
final Map<AbstractDao<?, ?>, Property> daoMap = new HashMap<>();
daoMap.put(session.getGenericBloodPressureSampleDao(), GenericBloodPressureSampleDao.Properties.DeviceId);
return daoMap;
}
@Override
public GBDevice createDevice(final GBDeviceCandidate candidate, final DeviceType deviceType) {
final GBDevice device = super.createDevice(candidate, deviceType);
device.setAlias(GBApplication.getContext().getString(R.string.devicetype_braun_bpw4500));
return device;
}
}
@@ -35,6 +35,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.atcbleoepl.ATCBLEOEPLCoordin
import nodomain.freeyourgadget.gadgetbridge.devices.bandwpseries.BandWPSeriesDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.binary_sensor.coordinator.BinarySensorCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.braun.BraunBPW4500DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.casio.ecbs100.CasioECBS100DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.casio.gb6900.CasioGB6900DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.casio.gbx100.CasioGBX100DeviceCoordinator;
@@ -911,6 +912,7 @@ public enum DeviceType {
SOLARFLOW(SolarFlowDeviceCoordinator.class),
SANITAS_SBM_67(SanitasSBM67Coordinator.class),
SILVERCREST_SBM_67(SilverCrestSBM67Coordinator.class),
BRAUN_BPW4500(BraunBPW4500DeviceCoordinator.class),
TEST(TestDeviceCoordinator.class);
private DeviceCoordinator coordinator;
@@ -0,0 +1,291 @@
/*
Copyright (C) 2026 Christian Breiteneder
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package nodomain.freeyourgadget.gadgetbridge.service.devices.braun;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.widget.Toast;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo;
import nodomain.freeyourgadget.gadgetbridge.devices.sbm_67.GenericBloodPressureSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericBloodPressureSample;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class BraunBPW4500DeviceSupport extends AbstractBTLESingleDeviceSupport {
private static final Logger LOG = LoggerFactory.getLogger(BraunBPW4500DeviceSupport.class);
// -------------------------------------------------------------------------
// BLE Services
// -------------------------------------------------------------------------
private static final UUID SERVICE_MEASUREMENT =
UUID.fromString("56484aae-a8eb-4a97-ac19-a8ea6373e05a");
private static final UUID SERVICE_CONTROL =
UUID.fromString("bb647f01-d352-48de-9015-d055b1355d7b");
// -------------------------------------------------------------------------
// Characteristics
// -------------------------------------------------------------------------
/**
* Live measurement notification (19 bytes, little-endian):
* [0] 0x1E flags
* [1..2] systolic (uint16 LE, mmHg)
* [3..4] diastolic (uint16 LE, mmHg)
* [5..6] 0x5A constant (protocol ID)
* [7..10] constant (unknown)
* [11] hour
* [12] minute
* [13] second
* [14..15] pulse rate (uint16 LE, bpm)
* [16..18] 0x00 padding
*/
private static final UUID CHAR_MEASUREMENT =
UUID.fromString("2db34480-bce5-4bb7-9f56-55bd202317c5");
/**
* Sync command: 0x01 = start sync | 0x02 = power off device (!)
*/
private static final UUID CHAR_COMMAND =
UUID.fromString("53ebc2ce-344c-4c5d-9d65-4740aa4660cd");
/**
* RTC register: [year_lo, year_hi, month(1-12), day, hour, minute, second, 0x00]
*/
private static final UUID CHAR_RTC =
UUID.fromString("6114ac81-2a71-4acc-ada5-555b63e8c5e1");
// -------------------------------------------------------------------------
private final List<GenericBloodPressureSample> pendingSamples = new ArrayList<>();
private final BroadcastReceiver disconnectReceiver = new BroadcastReceiver() {
@Override
public void onReceive(final Context context, final Intent intent) {
if (GBDevice.ACTION_DEVICE_CHANGED.equals(intent.getAction())) {
final GBDevice.DeviceUpdateSubject subject =
(GBDevice.DeviceUpdateSubject) intent.getSerializableExtra(GBDevice.EXTRA_UPDATE_SUBJECT);
if (GBDevice.DeviceUpdateSubject.CONNECTION_STATE.equals(subject) &&
GBDevice.State.NOT_CONNECTED.equals(gbDevice.getState()) &&
!pendingSamples.isEmpty()) {
persistSamples();
pendingSamples.clear();
}
}
}
};
public BraunBPW4500DeviceSupport() {
super(LOG);
addSupportedService(SERVICE_MEASUREMENT);
addSupportedService(SERVICE_CONTROL);
final IntentFilter filter = new IntentFilter(GBDevice.ACTION_DEVICE_CHANGED);
//noinspection deprecation
LocalBroadcastManager.getInstance(GBApplication.getContext())
.registerReceiver(disconnectReceiver, filter);
}
@Override
public boolean useAutoConnect() {
return false;
}
@Override
protected TransactionBuilder initializeDevice(final TransactionBuilder builder) {
LOG.info("Initializing Braun BPW4500");
pendingSamples.clear();
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext()));
// Enable notifications for live measurements
final BluetoothGattCharacteristic measureChar = getCharacteristic(CHAR_MEASUREMENT);
if (measureChar != null) {
builder.notify(measureChar, true);
} else {
LOG.warn("Measurement characteristic not found!");
}
// Sync device clock
writeRTC(builder);
// Report firmware version
final GBDeviceEventVersionInfo versionCmd = new GBDeviceEventVersionInfo();
versionCmd.fwVersion = "N/A";
versionCmd.hwVersion = "N/A";
handleGBDeviceEvent(versionCmd);
// Send sync command to trigger data transfer
final BluetoothGattCharacteristic cmdChar = getCharacteristic(CHAR_COMMAND);
if (cmdChar != null) {
builder.write(cmdChar, new byte[]{0x01});
}
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext()));
return builder;
}
/**
* Writes the current system time to the device RTC register.
*/
private void writeRTC(final TransactionBuilder builder) {
final BluetoothGattCharacteristic rtcChar = getCharacteristic(CHAR_RTC);
if (rtcChar == null) {
LOG.warn("RTC characteristic not found");
return;
}
final Calendar now = Calendar.getInstance();
final int year = now.get(Calendar.YEAR);
final byte[] rtc = new byte[]{
(byte) (year & 0xFF),
(byte) ((year >> 8) & 0xFF),
(byte) (now.get(Calendar.MONTH) + 1),
(byte) now.get(Calendar.DAY_OF_MONTH),
(byte) now.get(Calendar.HOUR_OF_DAY),
(byte) now.get(Calendar.MINUTE),
(byte) now.get(Calendar.SECOND),
0x00
};
builder.write(rtcChar, rtc);
LOG.info("RTC written: {}-{}-{} {}:{}:{}",
year,
now.get(Calendar.MONTH) + 1,
now.get(Calendar.DAY_OF_MONTH),
now.get(Calendar.HOUR_OF_DAY),
now.get(Calendar.MINUTE),
now.get(Calendar.SECOND));
}
@Override
public boolean onCharacteristicChanged(final BluetoothGatt gatt,
final BluetoothGattCharacteristic characteristic,
final byte[] value) {
if (super.onCharacteristicChanged(gatt, characteristic, value)) {
return true;
}
if (CHAR_MEASUREMENT.equals(characteristic.getUuid())) {
handleMeasurement(value);
return true;
}
LOG.debug("Unhandled characteristic: {} = {}", characteristic.getUuid(), GB.hexdump(value));
return false;
}
/**
* Parses a 19-byte measurement packet from the BPW4500.
*/
private void handleMeasurement(final byte[] data) {
if (data == null || data.length < 19) {
LOG.warn("Invalid measurement packet length: {}", data == null ? 0 : data.length);
return;
}
if ((data[0] & 0xFF) != 0x1E) {
LOG.warn("Unknown packet type: 0x{}", Integer.toHexString(data[0] & 0xFF));
return;
}
final int systolic = (data[1] & 0xFF) | ((data[2] & 0xFF) << 8);
final int diastolic = (data[3] & 0xFF) | ((data[4] & 0xFF) << 8);
final int hour = data[11] & 0xFF;
final int minute = data[12] & 0xFF;
final int second = data[13] & 0xFF;
final int pulse = (data[14] & 0xFF) | ((data[15] & 0xFF) << 8);
LOG.info("Measurement: {}/{} mmHg, pulse: {} bpm, time: {}:{}:{}",
systolic, diastolic, pulse, hour, minute, second);
// Use system date with time from device
final Calendar ts = Calendar.getInstance();
ts.set(Calendar.HOUR_OF_DAY, hour);
ts.set(Calendar.MINUTE, minute);
ts.set(Calendar.SECOND, second);
ts.set(Calendar.MILLISECOND, 0);
final GenericBloodPressureSample sample = new GenericBloodPressureSample();
sample.setTimestamp(ts.getTimeInMillis());
sample.setBpSystolic(systolic);
sample.setBpDiastolic(diastolic);
sample.setPulseRate(pulse);
sample.setMeanArterialPressure(0);
sample.setUserIndex(0);
sample.setMeasurementStatus(0);
pendingSamples.add(sample);
// Persist immediately as the device disconnects after transmission
persistSamples();
pendingSamples.clear();
GB.signalActivityDataFinish(getDevice());
}
private void persistSamples() {
if (pendingSamples.isEmpty()) return;
try (DBHandler handler = GBApplication.acquireDB()) {
final DaoSession session = handler.getDaoSession();
final GenericBloodPressureSampleProvider provider =
new GenericBloodPressureSampleProvider(getDevice(), session);
provider.persistForDevice(getContext(), getDevice(), pendingSamples);
LOG.info("Persisted {} blood pressure samples", pendingSamples.size());
} catch (final Exception e) {
GB.toast(getContext(), "Error saving blood pressure data", Toast.LENGTH_LONG, GB.ERROR, e);
}
}
@Override
public void dispose() {
try {
//noinspection deprecation
LocalBroadcastManager.getInstance(GBApplication.getContext())
.unregisterReceiver(disconnectReceiver);
} catch (final Exception e) {
LOG.error("Failed to unregister receiver", e);
}
super.dispose();
}
}
@@ -0,0 +1,356 @@
/*
Copyright (C) 2026 Christian Breiteneder
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package nodomain.freeyourgadget.gadgetbridge.util;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.pdf.PdfDocument;
import android.net.Uri;
import androidx.core.content.FileProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
import nodomain.freeyourgadget.gadgetbridge.model.BloodPressureSample;
public class BloodPressureExportHelper {
private static final Logger LOG = LoggerFactory.getLogger(BloodPressureExportHelper.class);
private static final String DATE_PATTERN = "yyyy-MM-dd HH:mm:ss";
private static final String FILE_DATE_PATTERN = "yyyy-MM-dd";
private static SimpleDateFormat getDateFormat() {
return new SimpleDateFormat(DATE_PATTERN, Locale.getDefault());
}
private static SimpleDateFormat getFileDateFormat() {
return new SimpleDateFormat(FILE_DATE_PATTERN, Locale.getDefault());
}
public static void exportCsv(Context context, List<? extends BloodPressureSample> samples) {
try {
File exportDir = new File(context.getCacheDir(), "csv");
if (!exportDir.exists() && !exportDir.mkdirs()) {
LOG.error("Failed to create export directory for csv file");
return;
}
Date lastSampleDate = new Date(samples.get(samples.size() - 1).getTimestamp());
String fileName = "blood_pressure_" + getFileDateFormat().format(lastSampleDate) + ".csv";
File file = new File(exportDir, fileName);
String colDate = context.getString(R.string.blood_pressure_export_date);
String colTime = context.getString(R.string.pref_header_time);
String colSystolic = context.getString(R.string.blood_pressure_systolic) + " (mmHg)";
String colDiastolic = context.getString(R.string.blood_pressure_diastolic) + " (mmHg)";
SimpleDateFormat dateFormat = getDateFormat();
FileWriter writer = new FileWriter(file);
writer.append(colDate).append(",")
.append(colTime).append(",")
.append(colSystolic).append(",")
.append(colDiastolic).append("\n");
for (BloodPressureSample sample : samples) {
String dateTime = dateFormat.format(new Date(sample.getTimestamp()));
String[] parts = dateTime.split(" ");
writer.append(parts[0])
.append(",")
.append(parts[1])
.append(",")
.append(String.valueOf(sample.getBpSystolic()))
.append(",")
.append(String.valueOf(sample.getBpDiastolic()))
.append("\n");
}
writer.flush();
writer.close();
shareFile(context, file, "text/csv");
} catch (IOException e) {
LOG.error("Error exporting CSV", e);
}
}
public static void exportPdf(Context context, List<? extends BloodPressureSample> samples, String dateLabel, Bitmap chartBitmap) {
try {
File exportDir = new File(context.getCacheDir(), "pdf");
if (!exportDir.exists() && !exportDir.mkdirs()) {
LOG.error("Failed to create export directory for pdf file");
return;
}
Date lastSampleDate = new Date(samples.get(samples.size() - 1).getTimestamp());
String fileName = "blood_pressure_" + getFileDateFormat().format(lastSampleDate) + ".pdf";
File file = new File(exportDir, fileName);
// Localized strings
String reportTitle = context.getString(R.string.blood_pressure);
String colDate = context.getString(R.string.blood_pressure_export_date);
String colTime = context.getString(R.string.pref_header_time);
String colSystolic = context.getString(R.string.blood_pressure_systolic) + " (mmHg)";
String colDiastolic = context.getString(R.string.blood_pressure_diastolic) + " (mmHg)";
// User info
ActivityUser user = new ActivityUser();
String userName = user.getName();
String userDob = user.getDateOfBirth() != null ? user.getDateOfBirth().toString() : "";
SimpleDateFormat dateFormat = getDateFormat();
PdfDocument document = new PdfDocument();
int pageWidth = 595; // A4
int pageHeight = 842;
int margin = 40;
int rowHeight = 18;
Paint titlePaint = new Paint();
titlePaint.setTextSize(18);
titlePaint.setFakeBoldText(true);
titlePaint.setAntiAlias(true);
Paint subtitlePaint = new Paint();
subtitlePaint.setTextSize(11);
subtitlePaint.setAntiAlias(true);
subtitlePaint.setColor(0xFF666666);
Paint userPaint = new Paint();
userPaint.setTextSize(11);
userPaint.setAntiAlias(true);
Paint headerPaint = new Paint();
headerPaint.setTextSize(10);
headerPaint.setFakeBoldText(true);
headerPaint.setAntiAlias(true);
Paint cellPaint = new Paint();
cellPaint.setTextSize(9);
cellPaint.setAntiAlias(true);
Paint linePaint = new Paint();
linePaint.setColor(0xFFCCCCCC);
linePaint.setStrokeWidth(0.5f);
// === PAGE 1: Header + Chart + beginning of table ===
// Calculate chart height
int chartHeight = 0;
int chartTop;
if (chartBitmap != null) {
float aspectRatio = (float) chartBitmap.getHeight() / chartBitmap.getWidth();
int chartWidth = pageWidth - 2 * margin;
chartHeight = (int) (chartWidth * aspectRatio);
chartHeight = Math.min(chartHeight, 250);
}
// Layout positions
float yPos = margin;
// Title
yPos += 20;
float titleY = yPos;
yPos += 8;
// Date label
yPos += 14;
float dateLabelY = yPos;
yPos += 6;
// User info
if (userName != null && !userName.isEmpty()) {
yPos += 16;
}
if (!userDob.isEmpty()) {
yPos += 14;
}
// Chart
yPos += 10;
chartTop = (int) yPos;
if (chartBitmap != null) {
yPos += chartHeight + 10;
}
// Table starts here
float tableStartY = yPos + 10;
float usableForTable = pageHeight - tableStartY - margin;
int rowsOnFirstPage = Math.max(0, (int) (usableForTable / rowHeight) - 1);
int totalRows = samples.size();
int remainingRows = totalRows - rowsOnFirstPage;
int rowsPerFullPage = (pageHeight - margin * 2 - 30) / rowHeight;
int fullTablePages = remainingRows > 0 ? (int) Math.ceil((double) remainingRows / rowsPerFullPage) : 0;
int totalPages = 1 + fullTablePages;
float col2 = margin + 130;
float col3 = margin + 260;
float col4 = margin + 390;
// --- Draw page 1 ---
PdfDocument.PageInfo pageInfo = new PdfDocument.PageInfo.Builder(pageWidth, pageHeight, 1).create();
PdfDocument.Page pdfPage = document.startPage(pageInfo);
Canvas canvas = pdfPage.getCanvas();
// Title
canvas.drawText(reportTitle, margin, titleY, titlePaint);
// Date label
canvas.drawText(dateLabel, margin, dateLabelY, subtitlePaint);
// User info (right-aligned)
float rightX = pageWidth - margin;
if (userName != null && !userName.isEmpty()) {
float nameWidth = userPaint.measureText(userName);
canvas.drawText(userName, rightX - nameWidth, titleY, userPaint);
}
if (!userDob.isEmpty()) {
String dobText = context.getString(R.string.blood_pressure_export_dob, userDob);
float dobWidth = subtitlePaint.measureText(dobText);
canvas.drawText(dobText, rightX - dobWidth, dateLabelY, subtitlePaint);
}
// Chart bitmap
if (chartBitmap != null) {
int chartWidth = pageWidth - 2 * margin;
Rect destRect = new Rect(margin, chartTop, margin + chartWidth, chartTop + chartHeight);
// White background (chart may use dark theme)
Bitmap whiteBgBitmap = Bitmap.createBitmap(chartBitmap.getWidth(), chartBitmap.getHeight(), Bitmap.Config.ARGB_8888);
Canvas bmpCanvas = new Canvas(whiteBgBitmap);
bmpCanvas.drawColor(0xFFFFFFFF);
bmpCanvas.drawBitmap(chartBitmap, 0, 0, null);
canvas.drawBitmap(whiteBgBitmap, null, destRect, null);
whiteBgBitmap.recycle();
}
// Table header
float tableHeaderY = tableStartY + 12;
canvas.drawText(colDate, margin, tableHeaderY, headerPaint);
canvas.drawText(colTime, col2, tableHeaderY, headerPaint);
canvas.drawText(colSystolic, col3, tableHeaderY, headerPaint);
canvas.drawText(colDiastolic, col4, tableHeaderY, headerPaint);
canvas.drawLine(margin, tableHeaderY + 4, pageWidth - margin, tableHeaderY + 4, linePaint);
// Table rows on page 1
int sampleIndex = 0;
int rowsToDraw = Math.min(rowsOnFirstPage, totalRows);
for (int row = 0; row < rowsToDraw; row++) {
BloodPressureSample sample = samples.get(sampleIndex);
float y = tableHeaderY + 20 + (row * rowHeight);
String dateTime = dateFormat.format(new Date(sample.getTimestamp()));
String[] parts = dateTime.split(" ");
canvas.drawText(parts[0], margin, y, cellPaint);
canvas.drawText(parts[1], col2, y, cellPaint);
canvas.drawText(String.valueOf(sample.getBpSystolic()), col3, y, cellPaint);
canvas.drawText(String.valueOf(sample.getBpDiastolic()), col4, y, cellPaint);
canvas.drawLine(margin, y + 4, pageWidth - margin, y + 4, linePaint);
sampleIndex++;
}
drawPageNumber(canvas, subtitlePaint, 1, totalPages, pageWidth, pageHeight, margin);
document.finishPage(pdfPage);
// === ADDITIONAL PAGES ===
while (sampleIndex < totalRows) {
int currentPage = (sampleIndex - rowsOnFirstPage) / rowsPerFullPage + 2;
PdfDocument.PageInfo nextPageInfo = new PdfDocument.PageInfo.Builder(pageWidth, pageHeight, currentPage).create();
PdfDocument.Page nextPage = document.startPage(nextPageInfo);
Canvas nextCanvas = nextPage.getCanvas();
float nextHeaderY = margin + 16;
nextCanvas.drawText(colDate, margin, nextHeaderY, headerPaint);
nextCanvas.drawText(colTime, col2, nextHeaderY, headerPaint);
nextCanvas.drawText(colSystolic, col3, nextHeaderY, headerPaint);
nextCanvas.drawText(colDiastolic, col4, nextHeaderY, headerPaint);
nextCanvas.drawLine(margin, nextHeaderY + 4, pageWidth - margin, nextHeaderY + 4, linePaint);
int rowsThisPage = Math.min(rowsPerFullPage, totalRows - sampleIndex);
for (int row = 0; row < rowsThisPage; row++) {
BloodPressureSample sample = samples.get(sampleIndex);
float y = nextHeaderY + 20 + (row * rowHeight);
String dateTime = dateFormat.format(new Date(sample.getTimestamp()));
String[] parts = dateTime.split(" ");
nextCanvas.drawText(parts[0], margin, y, cellPaint);
nextCanvas.drawText(parts[1], col2, y, cellPaint);
nextCanvas.drawText(String.valueOf(sample.getBpSystolic()), col3, y, cellPaint);
nextCanvas.drawText(String.valueOf(sample.getBpDiastolic()), col4, y, cellPaint);
nextCanvas.drawLine(margin, y + 4, pageWidth - margin, y + 4, linePaint);
sampleIndex++;
}
drawPageNumber(nextCanvas, subtitlePaint, currentPage, totalPages, pageWidth, pageHeight, margin);
document.finishPage(nextPage);
}
FileOutputStream fos = new FileOutputStream(file);
document.writeTo(fos);
document.close();
fos.close();
shareFile(context, file, "application/pdf");
} catch (IOException e) {
LOG.error("Error exporting PDF", e);
}
}
private static void drawPageNumber(Canvas canvas, Paint paint, int current, int total, int pageWidth, int pageHeight, int margin) {
String pageNum = current + " / " + total;
float pageNumWidth = paint.measureText(pageNum);
canvas.drawText(pageNum, pageWidth - margin - pageNumWidth, pageHeight - margin + 10, paint);
}
private static void shareFile(Context context, File file, String mimeType) {
Uri uri = FileProvider.getUriForFile(context,
context.getApplicationContext().getPackageName() + ".screenshot_provider",
file);
Intent shareIntent = new Intent(Intent.ACTION_SEND);
shareIntent.setType(mimeType);
shareIntent.putExtra(Intent.EXTRA_STREAM, uri);
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
context.startActivity(Intent.createChooser(shareIntent, null));
}
}
@@ -0,0 +1,161 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/date_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:layout_marginBottom="20dp"
android:gravity="center"
android:textColor="?attr/textColorPrimary"
android:textSize="20sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="300sp">
<com.github.mikephil.charting.charts.LineChart
android:id="@+id/blood_pressure_line_chart"
android:layout_width="0dp"
android:layout_height="fill_parent"
android:layout_weight="2" />
</LinearLayout>
<GridLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:background="@color/gauge_line_color"
android:columnCount="2">
<LinearLayout
style="@style/GridTile"
android:layout_marginEnd="1dp"
android:layout_marginTop="2dp">
<TextView
android:id="@+id/bp_systolic_last"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stats_empty_value"
android:textColor="?attr/textColorPrimary"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/blood_pressure_systolic"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
style="@style/GridTile"
android:layout_marginStart="1dp"
android:layout_marginTop="2dp">
<TextView
android:id="@+id/bp_diastolic_last"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stats_empty_value"
android:textColor="?attr/textColorPrimary"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/blood_pressure_diastolic"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
style="@style/GridTile"
android:layout_marginEnd="1dp">
<TextView
android:id="@+id/bp_average"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stats_empty_value"
android:textColor="?attr/textColorPrimary"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/hr_average"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
style="@style/GridTile"
android:layout_marginStart="1dp">
<TextView
android:id="@+id/bp_measurement_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textColor="?attr/textColorPrimary"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/blood_pressure_measurement_count"
android:textSize="12sp" />
</LinearLayout>
</GridLayout>
<LinearLayout
android:id="@+id/manualMeasurements"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/titleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:text="@string/manual_measurements"
android:textSize="20sp" />
<LinearLayout
android:id="@+id/manualMeasurementsList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</LinearLayout>
</LinearLayout>
</ScrollView>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/bp_export_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:contentDescription="@string/appmanager_app_share"
app:srcCompat="@drawable/ic_share" />
</RelativeLayout>
@@ -0,0 +1,136 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/date_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:layout_marginBottom="20dp"
android:gravity="center"
android:textColor="?attr/textColorPrimary"
android:textSize="20sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="300sp">
<com.github.mikephil.charting.charts.CombinedChart
android:id="@+id/blood_pressure_chart"
android:layout_width="0dp"
android:layout_height="fill_parent"
android:layout_weight="2" />
</LinearLayout>
<GridLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:background="@color/gauge_line_color"
android:columnCount="2">
<LinearLayout
style="@style/GridTile"
android:layout_marginEnd="1dp"
android:layout_marginTop="2dp">
<TextView
android:id="@+id/bp_systolic_last"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stats_empty_value"
android:textColor="?attr/textColorPrimary"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/blood_pressure_systolic"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
style="@style/GridTile"
android:layout_marginStart="1dp"
android:layout_marginTop="2dp">
<TextView
android:id="@+id/bp_diastolic_last"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stats_empty_value"
android:textColor="?attr/textColorPrimary"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/blood_pressure_diastolic"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
style="@style/GridTile"
android:layout_marginEnd="1dp">
<TextView
android:id="@+id/bp_average"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stats_empty_value"
android:textColor="?attr/textColorPrimary"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/hr_average"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
style="@style/GridTile"
android:layout_marginStart="1dp">
<TextView
android:id="@+id/bp_measurement_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textColor="?attr/textColorPrimary"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/blood_pressure_measurement_count"
android:textSize="12sp" />
</LinearLayout>
</GridLayout>
</LinearLayout>
</ScrollView>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/bp_export_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:contentDescription="@string/appmanager_app_share"
app:srcCompat="@drawable/ic_share" />
</RelativeLayout>
@@ -0,0 +1,49 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp">
<TextView
android:id="@+id/date_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#4CAF50"
android:textSize="14sp"
android:layout_gravity="center_horizontal" />
<TextView
android:id="@+id/bp_item_date"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1.5"
android:textSize="13sp" />
<TextView
android:id="@+id/bp_item_systolic"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAlignment="center"
android:textColor="#F44336"
android:textSize="13sp" />
<TextView
android:id="@+id/bp_item_diastolic"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAlignment="center"
android:textColor="#2196F3"
android:textSize="13sp" />
<TextView
android:id="@+id/bp_item_pulse"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAlignment="center"
android:textColor="#4CAF50"
android:textSize="13sp" />
</LinearLayout>
+6
View File
@@ -3441,6 +3441,7 @@
<item>@string/menuitem_weight</item>
<item>@string/watchface_widget_type_calories</item>
<item>@string/respiratoryrate</item>
<item>@string/blood_pressure</item>
</string-array>
<string-array name="pref_charts_tabs_values">
@@ -3462,6 +3463,7 @@
<item>@string/p_weight</item>
<item>@string/p_calories</item>
<item>@string/p_respiratory_rate</item>
<item>@string/p_bloodpressure</item>
</string-array>
<string-array name="pref_charts_tabs_items_default">
@@ -3484,6 +3486,7 @@
<item>@string/p_weight</item>
<item>@string/p_calories</item>
<item>@string/p_respiratory_rate</item>
<item>@string/p_bloodpressure</item>
</string-array>
@@ -5078,6 +5081,7 @@
<item>@string/menuitem_stress_segmented</item>
<item>@string/menuitem_stress_breakdown</item>
<item>@string/hrv</item>
<item>@string/blood_pressure</item>
<item>@string/menuitem_pai</item>
<item>@string/menuitem_vo2_max</item>
<item>@string/vo2max_running</item>
@@ -5099,6 +5103,7 @@
<item>stress_segmented</item>
<item>stress_breakdown</item>
<item>hrv</item>
<item>bloodpressure</item>
<item>pai</item>
<item>vo2max</item>
<item>vo2max_running</item>
@@ -5117,6 +5122,7 @@
<item>bodyenergy</item>
<item>stress_segmented</item>
<item>hrv</item>
<item>bloodpressure</item>
<item>vo2max</item>
<item>calories_active</item>
<item>calories_segmented</item>
+2
View File
@@ -79,6 +79,8 @@
<color name="spo2_color" type="color">#aad93832</color>
<color name="spo2_avg_color_light" type="color">#630b0d</color>
<color name="spo2_avg_color_dark" type="color">#ff9393</color>
<color name="blood_pressure_systolic_color" type="color">#d93832</color>
<color name="blood_pressure_diastolic_color" type="color">#3F84E5</color>
<color name="value_line_color" type="color">#858585</color>
<color name="row_separator_light" type="color">#ffe2e2e5</color>
+8
View File
@@ -1054,6 +1054,13 @@
<string name="active_calories_goal">Active goal</string>
<string name="total_calories_burnt">Total burnt</string>
<string name="blood_pressure">Blood pressure</string>
<string name="blood_pressure_systolic">Systolic</string>
<string name="blood_pressure_diastolic">Diastolic</string>
<string name="blood_pressure_measurement_count">Measurements</string>
<string name="blood_pressure_export_date">Date</string>
<string name="blood_pressure_export_dob">DOB: %s</string>
<string name="blood_pressure_avg_format">%1$d/%2$d</string>
<string name="p_bloodpressure" translatable="false">bloodpressure</string>
<string name="getting_heart_rate">Measuring</string>
<string name="heart_rate_result">Measurement results</string>
<string name="movement_intensity">Movement intensity</string>
@@ -2230,6 +2237,7 @@
<string name="devicetype_garmin_hrm_200" translatable="false">Garmin HRM 200</string>
<string name="devicetype_coospo_h9z" translatable="false">Coospo H9Z</string>
<string name="devicetype_solarflow">SolarFlow</string>
<string name="devicetype_braun_bpw4500" translatable="false">Braun iCheck 7 BPW4500</string>
<!-- Menus on the smart device -->
<string name="menuitem_nothing">Nothing</string>
<string name="menuitem_status">Status</string>
+1
View File
@@ -38,6 +38,7 @@
<item name="p_menuitem_nfc" type="string">nfc</item>
<item name="p_menuitem_hr" type="string">hr</item>
<item name="p_menuitem_spo2" type="string">spo2</item>
<item name="p_menuitem_bloodpressure" type="string">bloodpressure</item>
<item name="p_menuitem_pai" type="string">pai</item>
<item name="p_menuitem_stress" type="string">stress</item>
<item name="p_menuitem_cycles" type="string">period</item>
+1
View File
@@ -8,4 +8,5 @@
<cache-path name="csv" path="csv/" />
<cache-path name="audio" path="audio/" />
<cache-path name="images" path="images/" />
<cache-path name="pdf" path="pdf/" />
</paths>
@@ -187,6 +187,7 @@ public class AbstractDeviceCoordinatorTest extends TestBase {
put("C20", DeviceType.C20); // #4070
put("C 20", DeviceType.C20); // #5495
put("OV-Touch2.6_LE", DeviceType.OVTOUCH26); // #5628
put("BPW4500", DeviceType.BRAUN_BPW4500); // #5886
}};
for (Map.Entry<String, DeviceType> e : bluetoothNameToExpectedType.entrySet()) {