Commit 51c9841

derdilla <82763757+derdilla@users.noreply.github.com>
2025-01-16 10:07:16
Make new BLE implementation choosable (#504)
* re-add old bluetooth input code * allow choosing the bluetooth implementation
1 parent 9d5ea56
app/lib/features/input/add_measurement_dialoge.dart
@@ -7,8 +7,10 @@ import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.
 import 'package:blood_pressure_app/features/bluetooth/bluetooth_input.dart';
 import 'package:blood_pressure_app/features/input/add_bodyweight_dialoge.dart';
 import 'package:blood_pressure_app/features/input/forms/date_time_form.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/bluetooth_input.dart';
 import 'package:blood_pressure_app/features/settings/tiles/color_picker_list_tile.dart';
 import 'package:blood_pressure_app/model/blood_pressure/pressure_unit.dart';
+import 'package:blood_pressure_app/model/storage/bluetooth_input_mode.dart';
 import 'package:blood_pressure_app/model/storage/storage.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
@@ -191,6 +193,11 @@ class _AddEntryDialogeState extends State<AddEntryDialoge> {
     ),
   );
 
+  void _onExternalMeasurement(BloodPressureRecord record) => setState(() {
+    final note = Note(time: record.time, note: noteController.text, color: color?.value);
+    _loadFields((record, note, []));
+  });
+
   @override
   Widget build(BuildContext context) {
     final localizations = AppLocalizations.of(context)!;
@@ -253,19 +260,20 @@ class _AddEntryDialogeState extends State<AddEntryDialoge> {
         child: ListView(
           padding: const EdgeInsets.symmetric(horizontal: 8),
           children: [
-            if (settings.bleInput)
-              BluetoothInput(
-                manager: BluetoothManager.create(
-                  isTestingEnvironment
-                    ? BluetoothBackend.mock
-                    : Platform.isAndroid
-                    ? BluetoothBackend.flutterBluePlus
-                    : BluetoothBackend.bluetoothLowEnergy
-                ),
-                onMeasurement: (record) => setState(
-                  () => _loadFields((record, Note(time: record.time, note: noteController.text, color: color?.value), [])),
-                ),
+            (() => switch (settings.bleInput) {
+              BluetoothInputMode.disabled => SizedBox.shrink(),
+              BluetoothInputMode.oldBluetoothInput => OldBluetoothInput(
+                onMeasurement: _onExternalMeasurement,
+              ),
+              BluetoothInputMode.newBluetoothInputOldLib => BluetoothInput(
+                manager: BluetoothManager.create(isTestingEnvironment ? BluetoothBackend.mock : BluetoothBackend.flutterBluePlus),
+                onMeasurement: _onExternalMeasurement,
+              ),
+              BluetoothInputMode.newBluetoothInputCrossPlatform => BluetoothInput(
+                manager: BluetoothManager.create(isTestingEnvironment ? BluetoothBackend.mock : BluetoothBackend.bluetoothLowEnergy),
+                onMeasurement: _onExternalMeasurement,
               ),
+            })(),
             if (settings.allowManualTimeInput)
               DateTimeForm(
                 validate: settings.validateInputs,
app/lib/features/old_bluetooth/logic/characteristics/ble_date_time.dart
@@ -0,0 +1,32 @@
+import 'package:blood_pressure_app/features/old_bluetooth/logic/characteristics/decoding_util.dart';
+
+extension BleDateTimeParser on DateTime {
+  static DateTime? parseBle(List<int> bytes, int offset) {
+    if (bytes.length < offset + 7) return null;
+
+    final int? year = readUInt16Le(bytes, offset);
+    offset += 2;
+    final int? month = readUInt8(bytes, offset);
+    offset += 1;
+    final int? day = readUInt8(bytes, offset);
+    offset += 1;
+    final int? hourOfDay = readUInt8(bytes, offset);
+    offset += 1;
+    final int? minute = readUInt8(bytes, offset);
+    offset += 1;
+    final int? second = readUInt8(bytes, offset);
+
+    if (year == null
+      || month == null
+      || day == null
+      || hourOfDay == null
+      || minute == null
+      || second == null) return null;
+
+    if (year <= 0
+      || month <= 0
+      || day <= 0) return null;
+
+    return DateTime(year, month, day, hourOfDay, minute, second);
+  }
+}
app/lib/features/old_bluetooth/logic/characteristics/ble_measurement_data.dart
@@ -0,0 +1,110 @@
+import 'package:blood_pressure_app/logging.dart';
+
+import 'ble_date_time.dart';
+import 'ble_measurement_status.dart';
+import 'decoding_util.dart';
+
+/// https://developer.nordicsemi.com/nRF51_SDK/nRF51_SDK_v4.x.x/doc/html/structble__bps__meas__s.html
+/// https://github.com/NordicSemiconductor/Kotlin-BLE-Library/blob/6b565e59de21dfa53ef80ff8351ac4a4550e8d58/profile/src/main/java/no/nordicsemi/android/kotlin/ble/profile/bps/BloodPressureMeasurementParser.kt
+class BleMeasurementData with TypeLogger {
+  BleMeasurementData({
+    required this.systolic,
+    required this.diastolic,
+    required this.meanArterialPressure,
+    required this.isMMHG,
+    required this.pulse,
+    required this.userID,
+    required this.status,
+    required this.timestamp,
+  });
+
+  static BleMeasurementData? decode(List<int> data, int offset) {
+    // https://github.com/NordicSemiconductor/Kotlin-BLE-Library/blob/6b565e59de21dfa53ef80ff8351ac4a4550e8d58/profile/src/main/java/no/nordicsemi/android/kotlin/ble/profile/bps/BloodPressureMeasurementParser.kt
+
+    // Reading specific bits: `(byte & (1 << bitIdx))`
+
+    if (data.length < 7) {
+      log.finest('BleMeasurementData decodeMeasurement: Not enough data, $data has less than 7 bytes.');
+      return null;
+    }
+
+    int offset = 0;
+
+    final int flagsByte = data[offset];
+    offset += 1;
+
+    final bool isMMHG = !isBitIntByteSet(flagsByte, 0); // 0 => mmHg 1 =>kPA
+    final bool timestampPresent = isBitIntByteSet(flagsByte, 1);
+    final bool pulseRatePresent = isBitIntByteSet(flagsByte, 2);
+    final bool userIdPresent = isBitIntByteSet(flagsByte, 3);
+    final bool measurementStatusPresent = isBitIntByteSet(flagsByte, 4);
+
+    if (data.length < (7
+      + (timestampPresent ? 7 : 0)
+      + (pulseRatePresent ? 2 : 0)
+      + (userIdPresent ? 1 : 0)
+      + (measurementStatusPresent ? 2 : 0)
+    )) {
+      log.finest("BleMeasurementData decodeMeasurement: Flags don't match, $data has less bytes than expected.");
+      return null;
+    }
+
+    final double? systolic = readSFloat(data, offset);
+    offset += 2;
+    final double? diastolic = readSFloat(data, offset);
+    offset += 2;
+    final double? meanArterialPressure = readSFloat(data, offset);
+    offset += 2;
+
+    if (systolic == null || diastolic == null || meanArterialPressure == null) {
+      log.finest('BleMeasurementData decodeMeasurement: Unable to decode required values sys, dia, and meanArterialPressure, $data.');
+      return null;
+    }
+
+    DateTime? timestamp;
+    if (timestampPresent) {
+      timestamp = BleDateTimeParser.parseBle(data, offset);
+      offset += 7;
+    }
+
+    double? pulse;
+    if (pulseRatePresent) {
+      pulse = readSFloat(data, offset);
+      offset += 2;
+    }
+
+    int? userId;
+    if (userIdPresent) {
+      userId = data[offset];
+      offset += 1;
+    }
+
+    BleMeasurementStatus? status;
+    if (measurementStatusPresent) {
+      status = BleMeasurementStatus.decode(data[offset]);
+    }
+
+    return BleMeasurementData(
+      systolic: systolic,
+      diastolic: diastolic,
+      meanArterialPressure: meanArterialPressure,
+      isMMHG: isMMHG,
+      pulse: pulse,
+      userID: userId,
+      status: status,
+      timestamp: timestamp,
+    );
+  }
+
+  final double systolic;
+  final double diastolic;
+  final double meanArterialPressure;
+  final bool isMMHG; // mmhg or kpa
+  final double? pulse;
+  final int? userID;
+  final BleMeasurementStatus? status;
+  final DateTime? timestamp;
+
+  @override
+  String toString() => 'BleMeasurementData{systolic: $systolic, diastolic: $diastolic, meanArterialPressure: $meanArterialPressure, isMMHG: $isMMHG, pulse: $pulse, userID: $userID, status: $status, timestamp: $timestamp}';
+}
app/lib/features/old_bluetooth/logic/characteristics/ble_measurement_status.dart
@@ -0,0 +1,34 @@
+import 'package:blood_pressure_app/features/old_bluetooth/logic/characteristics/decoding_util.dart';
+
+class BleMeasurementStatus {
+  BleMeasurementStatus({
+    required this.bodyMovementDetected,
+    required this.cuffTooLose,
+    required this.irregularPulseDetected,
+    required this.pulseRateInRange,
+    required this.pulseRateExceedsUpperLimit,
+    required this.pulseRateIsLessThenLowerLimit,
+    required this.improperMeasurementPosition,
+  });
+
+  factory BleMeasurementStatus.decode(int byte) => BleMeasurementStatus(
+    bodyMovementDetected: isBitIntByteSet(byte, 1),
+    cuffTooLose: isBitIntByteSet(byte, 2),
+    irregularPulseDetected: isBitIntByteSet(byte, 3),
+    pulseRateInRange: (byte & (1 << 4) >> 3) == 0,
+    pulseRateExceedsUpperLimit: (byte & (1 << 4) >> 3) == 1,
+    pulseRateIsLessThenLowerLimit: (byte & (1 << 4) >> 3) == 2,
+    improperMeasurementPosition: isBitIntByteSet(byte, 5),
+  );
+
+  final bool bodyMovementDetected;
+  final bool cuffTooLose;
+  final bool irregularPulseDetected;
+  final bool pulseRateInRange;
+  final bool pulseRateExceedsUpperLimit;
+  final bool pulseRateIsLessThenLowerLimit;
+  final bool improperMeasurementPosition;
+
+  @override
+  String toString() => 'BleMeasurementStatus{bodyMovementDetected: $bodyMovementDetected, cuffTooLose: $cuffTooLose, irregularPulseDetected: $irregularPulseDetected, pulseRateInRange: $pulseRateInRange, pulseRateExceedsUpperLimit: $pulseRateExceedsUpperLimit, pulseRateIsLessThenLowerLimit: $pulseRateIsLessThenLowerLimit, improperMeasurementPosition: $improperMeasurementPosition}';
+}
app/lib/features/old_bluetooth/logic/characteristics/decoding_util.dart
@@ -0,0 +1,33 @@
+import 'dart:math';
+
+/// Whether the bit at offset (0-7) is set.
+///
+/// Masks the byte with a 1 that has [offset] to the right and moves the
+/// remaining bit to the first position and checks if it's equal to 1.
+bool isBitIntByteSet(int byte, int offset) =>
+    (((byte & (1 << offset)) >> offset) == 1);
+
+/// Attempts to read an IEEE-11073 16bit SFloat starting at data[offset].
+double? readSFloat(List<int> data, int offset) {
+  if (data.length < offset + 2) return null;
+  // TODO: special values (NaN, Infinity)
+  // If this ever stops working: https://github.com/NordicSemiconductor/Kotlin-BLE-Library/blob/6b565e59de21dfa53ef80ff8351ac4a4550e8d58/core/src/main/java/no/nordicsemi/android/kotlin/ble/core/data/util/DataByteArray.kt#L392
+  final mantissa = data[offset] + ((data[offset + 1] & 0x0F) << 8);
+  final exponent = data[offset + 1] >> 4;
+  return (mantissa * (pow(10, exponent))).toDouble();
+}
+
+int? readUInt8(List<int> data, int offset) {
+  if (data.length < offset + 1) return null;
+  return data[offset];
+}
+
+int? readUInt16Le(List<int> data, int offset) {
+  if (data.length < offset + 2) return null;
+  return data[offset] + (data[offset+1] << 8);
+}
+
+int? readUInt16Be(List<int> data, int offset) {
+  if (data.length < offset + 2) return null;
+  return (data[offset] << 8) + data[offset + 1];
+}
app/lib/features/old_bluetooth/logic/ble_read_cubit.dart
@@ -0,0 +1,186 @@
+import 'dart:async';
+
+import 'package:blood_pressure_app/features/old_bluetooth/logic/characteristics/ble_measurement_data.dart';
+import 'package:blood_pressure_app/logging.dart';
+import 'package:collection/collection.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_blue_plus/flutter_blue_plus.dart';
+
+part 'ble_read_state.dart';
+
+/// Logic for reading a characteristic from a device through a "indication".
+///
+/// For using this Cubit the flow is as follows:
+/// 1. Create a instance with the initial [BleReadInProgress] state
+/// 2. Wait for either a [BleReadFailure] or a [BleReadSuccess].
+///
+/// When a read failure is emitted, the only way to try again is to create a new
+/// cubit. This should be accompanied with reconnecting to the [_device].
+///
+/// Internally the class performs multiple steps to successfully read data, if
+/// one of them fails the entire cubit fails:
+/// 1. Discover services
+/// 2. If the searched service is found read characteristics
+/// 3. If the searched characteristic is found read its value
+/// 4. If binary data is read decode it to object
+/// 5. Emit decoded object
+class BleReadCubit extends Cubit<BleReadState> with TypeLogger {
+  /// Start reading a characteristic from a device.
+  BleReadCubit(this._device, {
+    required this.serviceUUID,
+    required this.characteristicUUID,
+  }) : super(BleReadInProgress())
+  {
+    _subscription = _device.connectionState
+      .listen(_onConnectionStateChanged);
+    // timeout
+    _timeoutTimer = Timer(const Duration(minutes: 2), () {
+      if (state is BleReadInProgress) {
+        logger.finest('BleReadCubit timeout reached and still running');
+        emit(BleReadFailure());
+      } else {
+        logger.finest('BleReadCubit timeout reached with state: $state, ${state is BleReadInProgress}');
+      }
+    });
+  }
+
+  /// UUID of the service to read.
+  final Guid serviceUUID;
+
+  /// UUID of the characteristic to read.
+  final Guid characteristicUUID;
+
+  /// Bluetooth device to connect to.
+  ///
+  /// Must have an active established connection and support the measurement
+  /// characteristic.
+  final BluetoothDevice _device;
+  
+  late final StreamSubscription<BluetoothConnectionState> _subscription;
+  late final Timer _timeoutTimer;
+  StreamSubscription<List<int>>? _indicationListener;
+
+  @override
+  Future<void> close() async {
+    logger.finest('BleReadCubit close');
+    await _subscription.cancel();
+    _timeoutTimer.cancel();
+
+    if (_device.isConnected) {
+      try {
+        logger.finest('BleReadCubit close: Attempting disconnect from ${_device.advName}');
+        await _device.disconnect();
+        assert(_device.isDisconnected);
+      } catch (e) {
+        logger.severe('unable to disconnect', [e, _device]);
+      }
+    }
+
+    await super.close();
+  }
+
+  bool _ensureConnectionInProgress = false;
+  Future<void> _ensureConnection([int attemptCount = 0]) async {
+    logger.finest('BleReadCubit _ensureConnection');
+    if (_ensureConnectionInProgress) return;
+    _ensureConnectionInProgress = true;
+    
+    if (_device.isAutoConnectEnabled) {
+      logger.finest('BleReadCubit Waiting for auto connect...');
+      _ensureConnectionInProgress = false;
+      return;
+    }
+    
+    if (_device.isDisconnected) {
+      logger.finest('BleReadCubit _ensureConnection: Attempting to connect with ${_device.advName}');
+      try {
+        await _device.connect();
+      } on FlutterBluePlusException catch (e) {
+        logger.severe('BleReadCubit _device.connect failed:', [_device, e]);
+      }
+      
+      if (_device.isDisconnected) {
+        logger.finest('BleReadCubit _ensureConnection: Device not connected');
+        _ensureConnectionInProgress = false;
+        if (attemptCount >= 5) {
+          emit(BleReadFailure());
+          return;
+        } else {
+          return _ensureConnection(attemptCount + 1);
+        }
+      } else {
+        logger.finest('BleReadCubit Connection successful');
+      }
+    }
+    assert(_device.isConnected);
+    _ensureConnectionInProgress = false;
+  }
+
+  Future<void> _onConnectionStateChanged(BluetoothConnectionState state) async {
+    logger.finest('BleReadCubit _onConnectionStateChanged: $state');
+    if (super.state is BleReadSuccess) return;
+    if (state == BluetoothConnectionState.disconnected) {
+      logger.finest('BleReadCubit _onConnectionStateChanged disconnected: '
+        '${_device.disconnectReason} Attempting reconnect');
+      await _ensureConnection();
+      return;
+    }
+    assert(state == BluetoothConnectionState.connected, 'state should be '
+      'connected as connecting and disconnecting are not streamed by android');
+    assert(_device.isConnected);
+
+    // Query actual services supported by the device. While they must be
+    // rediscovered when a disconnect happens, this object is also recreated.
+    late final List<BluetoothService> allServices;
+    try {
+      allServices = await _device.discoverServices();
+      logger.finest('BleReadCubit allServices: $allServices');
+    } catch (e) {
+      logger.severe('service discovery', [_device, e]);
+      emit(BleReadFailure());
+      return;
+    }
+
+    // [Guid.str] trims standard parts from the uuid. 0x1810 is the blood
+    // pressure uuid. https://developer.nordicsemi.com/nRF51_SDK/nRF51_SDK_v4.x.x/doc/html/group___u_u_i_d___s_e_r_v_i_c_e_s.html
+    final BluetoothService? service = allServices
+      .firstWhereOrNull((BluetoothService s) => s.uuid == serviceUUID);
+    if (service == null) {
+      logger.severe('unsupported service', [_device, allServices]);
+      emit(BleReadFailure());
+      return;
+    }
+
+    // https://developer.nordicsemi.com/nRF51_SDK/nRF51_SDK_v4.x.x/doc/html/group___u_u_i_d___c_h_a_r_a_c_t_e_r_i_s_t_i_c_s.html#ga95fc99c7a99cf9d991c81027e4866936
+    final List<BluetoothCharacteristic> allCharacteristics = service.characteristics;
+    logger.finest('BleReadCubit allCharacteristics: $allCharacteristics');
+    final BluetoothCharacteristic? characteristic = allCharacteristics
+      .firstWhereOrNull((c) => c.uuid == characteristicUUID,);
+    if (characteristic == null) {
+      logger.severe('no characteristic', [_device, allServices, allCharacteristics]);
+      emit(BleReadFailure());
+      return;
+    }
+
+    // This characteristic only supports indication so we need to listen to values.
+    await _indicationListener?.cancel();
+    _indicationListener = characteristic
+      .onValueReceived.listen((rawData) {
+        logger.finest('BleReadCubit data received: $rawData');
+        final decodedData = BleMeasurementData.decode(rawData, 0);
+        if (decodedData == null) {
+          logger.severe('BleReadCubit decoding failed', [ rawData ]);
+          emit(BleReadFailure());
+        } else {
+          logger.finest('BleReadCubit decoded: $decodedData');
+          emit(BleReadSuccess(decodedData));
+        }
+        _indicationListener?.cancel();
+        _indicationListener = null;
+      });
+
+    final bool indicationsSet = await characteristic.setNotifyValue(true);
+    logger.finest('BleReadCubit indicationsSet: $indicationsSet');
+  }
+}
app/lib/features/old_bluetooth/logic/ble_read_state.dart
@@ -0,0 +1,20 @@
+part of 'ble_read_cubit.dart';
+
+/// State of reading a characteristic from a BLE device.
+@immutable
+sealed class BleReadState {}
+
+/// The reading has been started.
+class BleReadInProgress extends BleReadState {}
+
+/// The reading failed unrecoverable for some reason.
+class BleReadFailure extends BleReadState {}
+
+/// Data has been successfully read.
+class BleReadSuccess extends BleReadState {
+  /// Indicate a successful reading of a ble characteristic.
+  BleReadSuccess(this.data);
+
+  /// Measurement decoded from the device.
+  final BleMeasurementData data;
+}
app/lib/features/old_bluetooth/logic/bluetooth_cubit.dart
@@ -0,0 +1,80 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:blood_pressure_app/features/old_bluetooth/logic/flutter_blue_plus_mockable.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_blue_plus/flutter_blue_plus.dart';
+
+part 'bluetooth_state.dart';
+
+/// Availability of the devices bluetooth adapter.
+///
+/// The only state that allows using the adapter is [BluetoothReady].
+class BluetoothCubit extends Cubit<BluetoothState> {
+  /// Create a cubit connecting to the bluetooth module for availability.
+  ///
+  /// [flutterBluePlus] may be provided for testing purposes.
+  BluetoothCubit({
+    FlutterBluePlusMockable? flutterBluePlus
+  }): _flutterBluePlus = flutterBluePlus ?? FlutterBluePlusMockable(),
+        super(BluetoothInitial()) {
+    _adapterStateStateSubscription = _flutterBluePlus.adapterState.listen(_onAdapterStateChanged);
+  }
+
+  final FlutterBluePlusMockable _flutterBluePlus;
+
+  BluetoothAdapterState _adapterState = BluetoothAdapterState.unknown;
+
+  late StreamSubscription<BluetoothAdapterState> _adapterStateStateSubscription;
+
+  @override
+  Future<void> close() async {
+    await _adapterStateStateSubscription.cancel();
+    await super.close();
+  }
+
+  void _onAdapterStateChanged(BluetoothAdapterState state) async {
+    _adapterState = state;
+    switch (_adapterState) {
+      case BluetoothAdapterState.unavailable:
+        emit(BluetoothUnfeasible());
+      case BluetoothAdapterState.unauthorized:
+        // Bluetooth permissions should always be granted on normal android
+        // devices. Users on non-standard android devices will know how to
+        // enable them. If this is not the case there will be bug reports.
+        emit(BluetoothUnauthorized());
+      case BluetoothAdapterState.on:
+        emit(BluetoothReady());
+      case BluetoothAdapterState.off:
+      case BluetoothAdapterState.turningOff:
+      case BluetoothAdapterState.turningOn:
+        emit(BluetoothDisabled());
+      case BluetoothAdapterState.unknown:
+        emit(BluetoothInitial());
+    }
+  }
+
+  /// Request to enable bluetooth on the device
+  Future<bool> enableBluetooth() async {
+    assert(state is BluetoothDisabled, 'No need to enable bluetooth when '
+        'already enabled or not known to be disabled.');
+    try {
+      if (!Platform.isAndroid) return false;
+      await _flutterBluePlus.turnOn();
+      return true;
+    } on FlutterBluePlusException {
+      return false;
+    }
+  }
+
+  /// Reevaluate the current state.
+  ///
+  /// When the user is in another app like the device settings, sometimes
+  /// the app won't get notified about permission changes and such. In those
+  /// instances the user should have the option to manually recheck the state to
+  /// avoid getting stuck on a unauthorized state.
+  Future<void> forceRefresh() async {
+    _onAdapterStateChanged(_flutterBluePlus.adapterStateNow);
+  }
+}
app/lib/features/old_bluetooth/logic/bluetooth_state.dart
@@ -0,0 +1,26 @@
+part of 'bluetooth_cubit.dart';
+
+/// State of the devices bluetooth module.
+@immutable
+sealed class BluetoothState {}
+
+/// No information on whether bluetooth is available.
+///
+/// Users may show a loading indication but can not assume bluetooth is
+/// available.
+class BluetoothInitial extends BluetoothState {}
+
+/// There is no way bluetooth will work (e.g. no sensor).
+///
+/// Options relating to bluetooth should not be shown.
+class BluetoothUnfeasible extends BluetoothState {}
+
+/// There is a bluetooth sensor but the app has no permission.
+class BluetoothUnauthorized extends BluetoothState {}
+
+/// The device has Bluetooth and the app has permissions, but it is disabled in
+/// the device settings.
+class BluetoothDisabled extends BluetoothState {}
+
+/// Bluetooth is ready for use by the app.
+class BluetoothReady extends BluetoothState {}
app/lib/features/old_bluetooth/logic/device_scan_cubit.dart
@@ -0,0 +1,112 @@
+import 'dart:async';
+
+import 'package:blood_pressure_app/features/old_bluetooth/logic/bluetooth_cubit.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/logic/flutter_blue_plus_mockable.dart';
+import 'package:blood_pressure_app/logging.dart';
+import 'package:blood_pressure_app/model/storage/settings_store.dart';
+import 'package:collection/collection.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_blue_plus/flutter_blue_plus.dart';
+
+part 'device_scan_state.dart';
+
+/// A component to search for bluetooth devices.
+///
+/// For this to work the app must have access to the bluetooth adapter
+/// ([BluetoothCubit]).
+/// 
+/// A device counts as recognized, when the user connected with it at least 
+/// once. Recognized devices connect automatically.
+class DeviceScanCubit extends Cubit<DeviceScanState> with TypeLogger {
+  /// Search for bluetooth devices that match the criteria or are known
+  /// ([Settings.knownBleDev]).
+  DeviceScanCubit({
+    FlutterBluePlusMockable? flutterBluePlus,
+    required this.service,
+    required this.settings,
+  }) : _flutterBluePlus = flutterBluePlus ?? FlutterBluePlusMockable(),
+        super(DeviceListLoading()) {
+    assert(!_flutterBluePlus.isScanningNow);
+    _startScanning();
+  }
+
+  /// Storage for known devices.
+  final Settings settings;
+
+  /// Service required from bluetooth devices.
+  final Guid service;
+
+  final FlutterBluePlusMockable _flutterBluePlus;
+
+  late StreamSubscription<List<ScanResult>> _scanResultsSubscription;
+
+  @override
+  Future<void> close() async {
+    await _scanResultsSubscription.cancel();
+    try {
+      await _flutterBluePlus.stopScan();
+    } catch (e) {
+      logger.severe('Failed to stop scanning', [e]);
+      return;
+    }
+    await super.close();
+  }
+
+  Future<void> _startScanning() async {
+    _scanResultsSubscription = _flutterBluePlus.scanResults
+      .listen(_onScanResult,
+        onError: _onScanError,
+    );
+    try {
+      await _flutterBluePlus.startScan(
+        // no timeout, the user knows best how long scanning is needed
+        withServices: [ service ],
+        // Not all devices are found using this configuration (https://pub.dev/packages/flutter_blue_plus#scanning-does-not-find-my-device).
+      );
+    } catch (e) {
+      _onScanError(e);
+    }
+  }
+
+  void _onScanResult(List<ScanResult> devices) {
+    logger.finest('_onScanResult devices: $devices');
+
+    assert(devices.isEmpty || _flutterBluePlus.isScanningNow);
+    // No need to check whether the devices really support the searched
+    // characteristic as users have to select their device anyways.
+    if(state is DeviceSelected) return;
+    final preferred = devices.firstWhereOrNull((dev) =>
+      settings.knownBleDev.contains(dev.device.platformName));
+    if (preferred != null) {
+      _flutterBluePlus.stopScan()
+        .then((_) => emit(DeviceSelected(preferred.device)));
+    } else if (devices.isEmpty) {
+      emit(DeviceListLoading());
+    } else if (devices.length == 1) {
+      emit(SingleDeviceAvailable(devices.first));
+    } else {
+      emit(DeviceListAvailable(devices));
+    }
+  }
+
+  void _onScanError(Object error) {
+    logger.severe('Starting device scan failed', [ error ]);
+  }
+
+  /// Mark a new device as known and switch to selected device state asap.
+  Future<void> acceptDevice(BluetoothDevice device) async {
+    assert(state is! DeviceSelected);
+    try {
+      await _flutterBluePlus.stopScan();
+    } catch (e) {
+      _onScanError(e);
+      return;
+    }
+    assert(!_flutterBluePlus.isScanningNow);
+    emit(DeviceSelected(device));
+    final List<String> list = settings.knownBleDev.toList();
+    list.add(device.platformName);
+    settings.knownBleDev = list;
+  }
+}
app/lib/features/old_bluetooth/logic/device_scan_state.dart
@@ -0,0 +1,38 @@
+part of 'device_scan_cubit.dart';
+
+/// Search of bluetooth devices that meet some criteria.
+@immutable
+sealed class DeviceScanState {}
+
+/// Searching for devices or a reason they are not available.
+class DeviceListLoading extends DeviceScanState {}
+
+/// A device has been selected, either automatically or by the user.
+class DeviceSelected extends DeviceScanState {
+  /// Indicate that a device has been selected.
+  DeviceSelected(this.device);
+
+  /// The selected device.
+  final BluetoothDevice device;
+}
+
+/// Multiple unrecognized devices.
+class DeviceListAvailable extends DeviceScanState {
+  /// Indicate that multiple unrecognized have been found.
+  DeviceListAvailable(this.devices);
+
+  /// All found devices.
+  final List<ScanResult> devices;
+}
+
+/// One unrecognized device has been found.
+///
+/// While not technically correct, this can be understood as a connection
+/// request the user has to accept.
+class SingleDeviceAvailable extends DeviceScanState {
+  /// Indicate that one unrecognized device has been found.
+  SingleDeviceAvailable(this.device);
+
+  /// The only found device.
+  final ScanResult device;
+}
app/lib/features/old_bluetooth/logic/flutter_blue_plus_mockable.dart
@@ -0,0 +1,139 @@
+import 'dart:async';
+
+import 'package:flutter_blue_plus/flutter_blue_plus.dart';
+
+/// Wrapper for FlutterBluePlus in order to easily mock it
+/// Wraps all calls for testing purposes
+class FlutterBluePlusMockable {
+  LogLevel get logLevel => FlutterBluePlus.logLevel;
+
+  /// Checks whether the hardware supports Bluetooth
+  Future<bool> get isSupported => FlutterBluePlus.isSupported;
+
+  /// The current adapter state
+  BluetoothAdapterState get adapterStateNow => FlutterBluePlus.adapterStateNow;
+
+  /// Return the friendly Bluetooth name of the local Bluetooth adapter
+  Future<String> get adapterName => FlutterBluePlus.adapterName;
+
+  /// returns whether we are scanning as a stream
+  Stream<bool> get isScanning => FlutterBluePlus.isScanning;
+
+  /// are we scanning right now?
+  bool get isScanningNow => FlutterBluePlus.isScanningNow;
+
+  /// the most recent scan results
+  List<ScanResult> get lastScanResults => FlutterBluePlus.lastScanResults;
+
+  /// a stream of scan results
+  /// - if you re-listen to the stream it re-emits the previous results
+  /// - the list contains all the results since the scan started
+  /// - the returned stream is never closed.
+  Stream<List<ScanResult>> get scanResults => FlutterBluePlus.scanResults;
+
+  /// This is the same as scanResults, except:
+  /// - it *does not* re-emit previous results after scanning stops.
+  Stream<List<ScanResult>> get onScanResults => FlutterBluePlus.onScanResults;
+
+  /// Get access to all device event streams
+  BluetoothEvents get events => FlutterBluePlus.events;
+
+  /// Gets the current state of the Bluetooth module
+  Stream<BluetoothAdapterState> get adapterState =>
+      FlutterBluePlus.adapterState;
+
+  /// Retrieve a list of devices currently connected to your app
+  List<BluetoothDevice> get connectedDevices =>
+      FlutterBluePlus.connectedDevices;
+
+  /// Retrieve a list of devices currently connected to the system
+  /// - The list includes devices connected to by *any* app
+  /// - You must still call device.connect() to connect them to *your app*
+  Future<List<BluetoothDevice>> systemDevices(List<Guid> withServices) =>
+      FlutterBluePlus.systemDevices(withServices);
+
+  /// Retrieve a list of bonded devices (Android only)
+  Future<List<BluetoothDevice>> get bondedDevices =>
+      FlutterBluePlus.bondedDevices;
+
+  /// Set configurable options
+  ///   - [showPowerAlert] Whether to show the power alert (iOS & MacOS only). i.e. CBCentralManagerOptionShowPowerAlertKey
+  ///       To set this option you must call this method before any other method in this package.
+  ///       See: https://developer.apple.com/documentation/corebluetooth/cbcentralmanageroptionshowpoweralertkey
+  ///       This option has no effect on Android.
+  Future<void> setOptions({
+    bool showPowerAlert = true,
+  }) => FlutterBluePlus.setOptions(showPowerAlert: showPowerAlert);
+  
+  /// Turn on Bluetooth (Android only),
+  Future<void> turnOn({int timeout = 60}) => 
+      FlutterBluePlus.turnOn(timeout: timeout);
+
+  /// Start a scan, and return a stream of results
+  /// Note: scan filters use an "or" behavior. i.e. if you set `withServices` & `withNames` we
+  /// return all the advertisments that match any of the specified services *or* any of the specified names.
+  ///   - [withServices] filter by advertised services
+  ///   - [withRemoteIds] filter for known remoteIds (iOS: 128-bit guid, android: 48-bit mac address)
+  ///   - [withNames] filter by advertised names (exact match)
+  ///   - [withKeywords] filter by advertised names (matches any substring)
+  ///   - [withMsd] filter by manfacture specific data
+  ///   - [withServiceData] filter by service data
+  ///   - [timeout] calls stopScan after a specified duration
+  ///   - [removeIfGone] if true, remove devices after they've stopped advertising for X duration
+  ///   - [continuousUpdates] If `true`, we continually update 'lastSeen' & 'rssi' by processing
+  ///        duplicate advertisements. This takes more power. You typically should not use this option.
+  ///   - [continuousDivisor] Useful to help performance. If divisor is 3, then two-thirds of advertisements are
+  ///        ignored, and one-third are processed. This reduces main-thread usage caused by the platform channel.
+  ///        The scan counting is per-device so you always get the 1st advertisement from each device.
+  ///        If divisor is 1, all advertisements are returned. This argument only matters for `continuousUpdates` mode.
+  ///   - [oneByOne] if `true`, we will stream every advertistment one by one, possibly including duplicates.
+  ///        If `false`, we deduplicate the advertisements, and return a list of devices.
+  ///   - [androidScanMode] choose the android scan mode to use when scanning
+  ///   - [androidUsesFineLocation] request `ACCESS_FINE_LOCATION` permission at runtime
+  Future<void> startScan({
+    List<Guid> withServices = const [],
+    List<String> withRemoteIds = const [],
+    List<String> withNames = const [],
+    List<String> withKeywords = const [],
+    List<MsdFilter> withMsd = const [],
+    List<ServiceDataFilter> withServiceData = const [],
+    Duration? timeout,
+    Duration? removeIfGone,
+    bool continuousUpdates = false,
+    int continuousDivisor = 1,
+    bool oneByOne = false,
+    AndroidScanMode androidScanMode = AndroidScanMode.lowLatency,
+    bool androidUsesFineLocation = false,
+  }) => FlutterBluePlus.startScan(
+    withServices: withServices,
+    withRemoteIds: withRemoteIds,
+    withNames: withNames,
+    withKeywords: withKeywords,
+    withMsd: withMsd,
+    withServiceData: withServiceData,
+    timeout: timeout,
+    removeIfGone: removeIfGone,
+    continuousUpdates: continuousUpdates,
+    continuousDivisor: continuousDivisor,
+    oneByOne: oneByOne,
+    androidScanMode: androidScanMode,
+    androidUsesFineLocation: androidUsesFineLocation,
+  );
+
+  /// Stops a scan for Bluetooth Low Energy devices
+  Future<void> stopScan() => FlutterBluePlus.stopScan();
+
+  /// Register a subscription to be canceled when scanning is complete.
+  /// This function simplifies cleanup, to prevent creating duplicate stream subscriptions.
+  ///   - this is an optional convenience function
+  ///   - prevents accidentally creating duplicate subscriptions before each scan
+  void cancelWhenScanComplete(StreamSubscription subscription) =>
+      FlutterBluePlus.cancelWhenScanComplete(subscription);
+
+  /// Sets the internal FlutterBlue log level
+  Future<void> setLogLevel(LogLevel level, {bool color = true}) =>
+      FlutterBluePlus.setLogLevel(level, color: color);
+
+  /// Request Bluetooth PHY support
+  Future<PhySupport> getPhySupport() => FlutterBluePlus.getPhySupport();
+}
\ No newline at end of file
app/lib/features/old_bluetooth/ui/closed_bluetooth_input.dart
@@ -0,0 +1,75 @@
+import 'package:app_settings/app_settings.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/logic/bluetooth_cubit.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A closed ble input that shows the adapter state and allows to start the input.
+class ClosedBluetoothInput extends StatelessWidget {
+  /// Show adapter state and allow starting inputs
+  const ClosedBluetoothInput({super.key,
+    required this.bluetoothCubit,
+    required this.onStarted,
+    this.inputInfo,
+  });
+
+  /// State update provider and interaction with the device.
+  final BluetoothCubit bluetoothCubit;
+
+  /// Called when the user taps on an active start button.
+  final void Function() onStarted;
+
+  /// Callback called when the user wants to know more about this input.
+  ///
+  /// The info icon is not shown when this is null.
+  final void Function()? inputInfo;
+
+  Widget _buildTile({
+    required String text,
+    required IconData icon,
+    required void Function() onTap,
+  }) => ListTile(
+    title: Text(text),
+    leading: Icon(icon),
+    onTap: onTap,
+    trailing: inputInfo == null ? null : IconButton(
+      icon: const Icon(Icons.info_outline),
+      onPressed: inputInfo!,
+    ),
+  );
+  
+  @override
+  Widget build(BuildContext context) {
+    final localizations = AppLocalizations.of(context)!;
+    return BlocBuilder<BluetoothCubit, BluetoothState>(
+      bloc: bluetoothCubit,
+      builder: (context, BluetoothState state) => switch(state) {
+        BluetoothInitial() => const SizedBox.shrink(),
+        BluetoothUnfeasible() => const SizedBox.shrink(),
+        BluetoothUnauthorized() => _buildTile(
+          text: localizations.errBleNoPerms,
+          icon: Icons.bluetooth_disabled,
+          onTap: () async {
+            await AppSettings.openAppSettings();
+            await bluetoothCubit.forceRefresh();
+          },
+        ),
+        BluetoothDisabled() => _buildTile(
+          text: localizations.bluetoothDisabled,
+          icon: Icons.bluetooth_disabled,
+          onTap: () async {
+            final bluetoothOn = await bluetoothCubit.enableBluetooth();
+            if (!bluetoothOn) await AppSettings.openAppSettings(type: AppSettingsType.bluetooth);
+            await bluetoothCubit.forceRefresh();
+          },
+        ),
+        BluetoothReady() => _buildTile(
+          text: localizations.bluetoothInput,
+          icon: Icons.bluetooth,
+          onTap: onStarted,
+        ),
+      },
+    );
+  }
+  
+}
app/lib/features/old_bluetooth/ui/device_selection.dart
@@ -0,0 +1,44 @@
+import 'package:blood_pressure_app/features/old_bluetooth/ui/input_card.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_blue_plus/flutter_blue_plus.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A pairing dialoge with a single bluetooth device.
+class DeviceSelection extends StatelessWidget {
+  /// Create a pairing dialoge with a single bluetooth device.
+  const DeviceSelection({super.key,
+    required this.scanResults,
+    required this.onAccepted,
+  });
+
+  /// The name of the device trying to connect.
+  final List<ScanResult> scanResults;
+
+  /// Called when the user accepts the device.
+  final void Function(BluetoothDevice) onAccepted;
+
+  Widget _buildDeviceTile(BuildContext context, ScanResult dev) => ListTile(
+    title: Text(dev.device.platformName),
+    trailing: FilledButton(
+      onPressed: () => onAccepted(dev.device),
+      child: Text(AppLocalizations.of(context)!.connect),
+    ),
+    onTap: () => onAccepted(dev.device),
+  );
+
+  @override
+  Widget build(BuildContext context) {
+    assert(scanResults.isNotEmpty);
+    return InputCard(
+      title: Text(AppLocalizations.of(context)!.availableDevices),
+      child: ListView(
+        shrinkWrap: true,
+        children: [
+          for (final dev in scanResults)
+            _buildDeviceTile(context, dev),
+        ]
+      ),
+    );
+  }
+
+}
app/lib/features/old_bluetooth/ui/input_card.dart
@@ -0,0 +1,70 @@
+import 'package:flutter/material.dart';
+
+/// Card to place a complex opened input on.
+class InputCard extends StatelessWidget {
+  /// Create a card to host a complex input.
+  const InputCard({super.key,
+    required this.child,
+    this.title,
+    this.onClosed
+  });
+
+  /// Main content of the card
+  final Widget child;
+
+  /// Description of the card or the state of the card.
+  final Widget? title;
+
+  /// When provided a close icon at the top left corner is shown.
+  final void Function()? onClosed;
+
+  Widget _buildCloseIcon() => Align(
+    alignment: Alignment.topRight,
+    child: IconButton(
+      icon: const Icon(Icons.close),
+      onPressed: onClosed!,
+    ),
+  );
+
+  Widget _buildTitle(BuildContext context) => Align(
+    alignment: Alignment.topLeft,
+    child: Padding(
+      padding: const EdgeInsets.only(
+        top: 8.0,
+        left: 16.0,
+      ),
+      child: DefaultTextStyle(
+        style: Theme.of(context).textTheme.titleMedium ?? const TextStyle(),
+        child: title!,
+      ),
+    ),
+  );
+
+  Widget _buildBody() => Padding( // content
+    padding: EdgeInsets.only(
+      top: (title == null) ? 12.0 : 42.0,
+      bottom: 8.0,
+      left: 8.0,
+      right: 8.0,
+    ),
+    child: Center(
+      child: child,
+    ),
+  );
+
+  @override
+  Widget build(BuildContext context) => Card(
+    color: Theme.of(context).cardColor,
+    margin: const EdgeInsets.only(top: 8.0, bottom: 16.0),
+    child: Stack(
+      children: [
+        _buildBody(),
+        if (title != null)
+          _buildTitle(context),
+        if (onClosed != null)
+          _buildCloseIcon(),
+      ],
+    ),
+  );
+
+}
app/lib/features/old_bluetooth/ui/measurement_failure.dart
@@ -0,0 +1,34 @@
+import 'package:blood_pressure_app/features/old_bluetooth/ui/input_card.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// Indication of a failure while taking a bluetooth measurement.
+class MeasurementFailure extends StatelessWidget {
+  /// Indicate a failure while taking a bluetooth measurement.
+  const MeasurementFailure({super.key, required this.onTap});
+
+  /// Called when the user requests closing.
+  final void Function() onTap;
+  
+  @override
+  Widget build(BuildContext context) => GestureDetector(
+    onTap: onTap,
+    child: InputCard(
+      onClosed: onTap,
+      child: Center(
+        child: Column(
+          mainAxisAlignment: MainAxisAlignment.center,
+          children: [
+            const Icon(Icons.error_outline, color: Colors.red),
+            const SizedBox(height: 8,),
+            Text(AppLocalizations.of(context)!.errMeasurementRead),
+            const SizedBox(height: 4,),
+            Text(AppLocalizations.of(context)!.tapToClose),
+            const SizedBox(height: 8,),
+          ],
+        ),
+      ),
+    ),
+  );
+  
+}
app/lib/features/old_bluetooth/ui/measurement_success.dart
@@ -0,0 +1,84 @@
+import 'package:blood_pressure_app/features/old_bluetooth/logic/characteristics/ble_measurement_data.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/ui/input_card.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// Indication of a successful bluetooth measurement.
+class MeasurementSuccess extends StatelessWidget {
+  /// Indicate a successful while taking a bluetooth measurement.
+  const MeasurementSuccess({super.key,
+    required this.onTap,
+    required this.data,
+  });
+
+  /// Data decoded from bluetooth.
+  final BleMeasurementData data;
+
+  /// Called when the user requests closing.
+  final void Function() onTap;
+  
+  @override
+  Widget build(BuildContext context) => GestureDetector(
+    onTap: onTap,
+    child: InputCard(
+      onClosed: onTap,
+      child: Center(
+        child: ListTileTheme(
+          data: ListTileThemeData(
+            iconColor: Colors.orange,
+          ),
+          child: Column(
+            mainAxisAlignment: MainAxisAlignment.center,
+            children: [
+              const Icon(Icons.done, color: Colors.green),
+              const SizedBox(height: 8,),
+              Text(AppLocalizations.of(context)!.measurementSuccess,
+                style: Theme.of(context).textTheme.titleMedium,),
+              const SizedBox(height: 8,),
+              ListTile(
+                title: Text(AppLocalizations.of(context)!.meanArterialPressure),
+                subtitle: Text(data.meanArterialPressure.round().toString()),
+              ),
+              if (data.userID != null)
+                ListTile(
+                  title: Text(AppLocalizations.of(context)!.userID),
+                  subtitle: Text(data.userID!.toString()),
+                ),
+              if (data.status?.bodyMovementDetected ?? false)
+                ListTile(
+                  title: Text(AppLocalizations.of(context)!.bodyMovementDetected),
+                  leading: Icon(Icons.directions_walk),
+                ),
+              if (data.status?.cuffTooLose ?? false)
+                ListTile(
+                  title: Text(AppLocalizations.of(context)!.cuffTooLoose),
+                  leading: Icon(Icons.space_bar),
+                ),
+              if (data.status?.improperMeasurementPosition ?? false)
+                ListTile(
+                  title: Text(AppLocalizations.of(context)!.improperMeasurementPosition),
+                  leading: Icon(Icons.emoji_people),
+                ),
+              if (data.status?.irregularPulseDetected ?? false)
+                ListTile(
+                  title: Text(AppLocalizations.of(context)!.irregularPulseDetected),
+                  leading: Icon(Icons.heart_broken),
+                ),
+              if (data.status?.pulseRateExceedsUpperLimit ?? false)
+                ListTile(
+                  title: Text(AppLocalizations.of(context)!.pulseRateExceedsUpperLimit),
+                  leading: Icon(Icons.monitor_heart),
+                ),
+              if (data.status?.pulseRateIsLessThenLowerLimit ?? false)
+                ListTile(
+                  title: Text(AppLocalizations.of(context)!.pulseRateLessThanLowerLimit),
+                  leading: Icon(Icons.monitor_heart),
+                ),
+            ],
+          ),
+        ),
+      ),
+    ),
+  );
+  
+}
app/lib/features/old_bluetooth/bluetooth_input.dart
@@ -0,0 +1,221 @@
+import 'dart:async';
+
+import 'package:blood_pressure_app/features/bluetooth/bluetooth_input.dart' show BluetoothInput;
+import 'package:blood_pressure_app/features/old_bluetooth/logic/ble_read_cubit.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/logic/bluetooth_cubit.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/logic/characteristics/ble_measurement_data.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/logic/device_scan_cubit.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/ui/closed_bluetooth_input.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/ui/device_selection.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/ui/input_card.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/ui/measurement_failure.dart';
+import 'package:blood_pressure_app/features/old_bluetooth/ui/measurement_success.dart';
+import 'package:blood_pressure_app/logging.dart';
+import 'package:blood_pressure_app/model/storage/storage.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_blue_plus/flutter_blue_plus.dart' show BluetoothDevice, Guid;
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+import 'package:health_data_store/health_data_store.dart';
+
+/// Class for inputting measurement through bluetooth.
+/// 
+/// This widget is superseded by [BluetoothInput].
+class OldBluetoothInput extends StatefulWidget {
+  /// Create a measurement input through bluetooth.
+  const OldBluetoothInput({super.key,
+    required this.onMeasurement,
+    this.bluetoothCubit,
+    this.deviceScanCubit,
+    this.bleReadCubit,
+  });
+
+  /// Called when a measurement was received through bluetooth.
+  final void Function(BloodPressureRecord data) onMeasurement;
+
+  /// Function to customize [BluetoothCubit] creation.
+  final BluetoothCubit Function()? bluetoothCubit;
+
+  /// Function to customize [DeviceScanCubit] creation.
+  final DeviceScanCubit Function()? deviceScanCubit;
+
+  /// Function to customize [BleReadCubit] creation.
+  final BleReadCubit Function(BluetoothDevice dev)? bleReadCubit;
+
+  @override
+  State<OldBluetoothInput> createState() => _OldBluetoothInputState();
+}
+
+class _OldBluetoothInputState extends State<OldBluetoothInput> with TypeLogger {
+  /// Whether the user expanded bluetooth input
+  bool _isActive = false;
+
+  late final BluetoothCubit _bluetoothCubit;
+  DeviceScanCubit? _deviceScanCubit;
+  BleReadCubit? _deviceReadCubit;
+
+  StreamSubscription<BluetoothState>? _bluetoothSubscription;
+
+  /// Data received from reading bluetooth values.
+  ///
+  /// Its presence indicates that this input is done.
+  BleMeasurementData? _finishedData;
+
+  @override
+  void initState() {
+    super.initState();
+    _bluetoothCubit = widget.bluetoothCubit?.call() ?? BluetoothCubit();
+  }
+
+  @override
+  void dispose() {
+    unawaited(_bluetoothSubscription?.cancel());
+    unawaited(_bluetoothCubit.close());
+    unawaited(_deviceScanCubit?.close());
+    unawaited(_deviceReadCubit?.close());
+    super.dispose();
+  }
+
+  void _returnToIdle() async {
+    // No need to show wait in the UI.
+    if (_isActive) {
+      setState(() {
+        _isActive = false;
+        _finishedData = null;
+      });
+    }
+
+    await _deviceReadCubit?.close();
+    _deviceReadCubit = null;
+    await _deviceScanCubit?.close();
+    _deviceScanCubit = null;
+    await _bluetoothSubscription?.cancel();
+    _bluetoothSubscription = null;
+  }
+
+  Widget _buildActive(BuildContext context) {
+    final Guid serviceUUID = Guid('1810');
+    final Guid characteristicUUID = Guid('2A35');
+    _bluetoothSubscription = _bluetoothCubit.stream.listen((state) {
+      if (state is! BluetoothReady) {
+        logger.finest('_OldBluetoothInputState: _bluetoothSubscription state=$state, calling _returnToIdle');
+        _returnToIdle();
+      }
+    });
+    final settings = context.watch<Settings>();
+    _deviceScanCubit ??= widget.deviceScanCubit?.call() ?? DeviceScanCubit(
+      service: serviceUUID,
+      settings: settings,
+    );
+    return BlocBuilder<DeviceScanCubit, DeviceScanState>(
+      bloc: _deviceScanCubit,
+      builder: (context, DeviceScanState state) {
+        logger.finest('OldBluetoothInput _OldBluetoothInputState _deviceScanCubit: $state');
+        const SizeChangedLayoutNotification().dispatch(context);
+        return switch(state) {
+          DeviceListLoading() => _buildMainCard(context,
+            title: Text(AppLocalizations.of(context)!.scanningForDevices),
+            child: const CircularProgressIndicator(),
+          ),
+          DeviceListAvailable() => DeviceSelection(
+            scanResults: state.devices,
+            onAccepted: (dev) => _deviceScanCubit!.acceptDevice(dev),
+          ),
+          SingleDeviceAvailable() => DeviceSelection(
+            scanResults: [ state.device ],
+            onAccepted: (dev) => _deviceScanCubit!.acceptDevice(dev),
+          ),
+            // distinction
+          DeviceSelected() => BlocConsumer<BleReadCubit, BleReadState>(
+            bloc: () {
+              _deviceReadCubit = widget.bleReadCubit?.call(state.device) ?? BleReadCubit(
+                state.device,
+                characteristicUUID: characteristicUUID,
+                serviceUUID: serviceUUID,
+              );
+              return _deviceReadCubit;
+            }(),
+            listener: (BuildContext context, BleReadState state) {
+              if (state is BleReadSuccess) {
+                final BloodPressureRecord record = BloodPressureRecord(
+                  time: state.data.timestamp ?? DateTime.now(),
+                  sys: state.data.isMMHG
+                    ? Pressure.mmHg(state.data.systolic.toInt())
+                    : Pressure.kPa(state.data.systolic),
+                  dia: state.data.isMMHG
+                    ? Pressure.mmHg(state.data.diastolic.toInt())
+                    : Pressure.kPa(state.data.diastolic),
+                  pul: state.data.pulse?.toInt(),
+                );
+                widget.onMeasurement(record);
+                setState(() {
+                  _finishedData = state.data;
+                });
+              }
+            },
+            builder: (BuildContext context, BleReadState state) {
+              logger.finest('_OldBluetoothInputState BleReadCubit: $state');
+              const SizeChangedLayoutNotification().dispatch(context);
+              return switch (state) {
+                BleReadInProgress() => _buildMainCard(context,
+                  child: const CircularProgressIndicator(),
+                ),
+                BleReadFailure() => MeasurementFailure(
+                  onTap: _returnToIdle,
+                ),
+                BleReadSuccess() => MeasurementSuccess(
+                  onTap: _returnToIdle,
+                  data: state.data,
+                ),
+              };
+            },
+          ),
+        };
+      },
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    const SizeChangedLayoutNotification().dispatch(context);
+    if (_finishedData != null) {
+      return MeasurementSuccess(
+        onTap: _returnToIdle,
+        data: _finishedData!,
+      );
+    }
+    if (_isActive) return _buildActive(context);
+    return ClosedBluetoothInput(
+      bluetoothCubit: _bluetoothCubit,
+      onStarted: () async {
+        setState(() =>_isActive = true);
+      },
+      inputInfo: () async {
+        if (context.mounted) {
+          await showDialog(
+            context: context,
+            builder: (BuildContext context) => AlertDialog(
+              title: Text(AppLocalizations.of(context)!.bluetoothInput),
+              content: Text(AppLocalizations.of(context)!.aboutBleInput),
+                actions: <Widget>[
+                  ElevatedButton(
+                    child: Text((AppLocalizations.of(context)!.btnConfirm)),
+                    onPressed: () => Navigator.of(context).pop(),
+                  ),
+                ],
+            ),
+          );
+        }
+      },
+    );
+  }
+
+  Widget _buildMainCard(BuildContext context, {
+    required Widget child,
+    Widget? title,
+  }) => InputCard(
+    onClosed: _returnToIdle,
+    title: title,
+    child: child,
+  );
+}
app/lib/l10n/app_en.arb
@@ -542,5 +542,15 @@
   "preferredWeightUnit": "Preferred weight unit",
   "@preferredWeightUnit": {
     "description": "Setting for the unit the app will use for displaying weight"
-  }
+  },
+  "disabled": "Disabled",
+  "@disabled": {},
+  "oldBluetoothInput": "Stable",
+  "@oldBluetoothInput": {},
+  "newBluetoothInputOldLib": "Beta",
+  "@newBluetoothInputOldLib": {},
+  "newBluetoothInputCrossPlatform": "Beta cross-platform",
+  "@newBluetoothInputCrossPlatform": {},
+  "bluetoothInputDesc": "The beta backend works on more devices but is less tested. The cross-platform version may work on non-android and is planned to supersede the stable implementation once mature enough.",
+  "@bluetoothInputDesc": {}
 }
\ No newline at end of file
app/lib/model/storage/bluetooth_input_mode.dart
@@ -0,0 +1,38 @@
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// Different modes for the bluetooth input field.
+enum BluetoothInputMode {
+  /// No bluetooth input.
+  disabled,
+  /// The established bluetooth input.
+  oldBluetoothInput,
+  /// The new bluetooth input with flutter_blue_plus backend.
+  newBluetoothInputOldLib,
+  /// The new bluetooth input with bluetooth_low_energy backend.
+  newBluetoothInputCrossPlatform;
+
+  /// Turn object into [deserialize]able number.
+  int serialize() => switch(this) {
+    BluetoothInputMode.disabled => 0,
+    BluetoothInputMode.oldBluetoothInput => 1,
+    BluetoothInputMode.newBluetoothInputOldLib => 2,
+    BluetoothInputMode.newBluetoothInputCrossPlatform => 3,
+  };
+
+  /// Try to create an object from [serialize]d form.
+  static BluetoothInputMode? deserialize(int? value) => switch (value) {
+    0 => BluetoothInputMode.disabled,
+    1 => BluetoothInputMode.oldBluetoothInput,
+    2 => BluetoothInputMode.newBluetoothInputOldLib,
+    3 => BluetoothInputMode.newBluetoothInputCrossPlatform,
+    _ => null,
+  };
+
+  /// Determine the matching localization.
+  String localize(AppLocalizations localizations) => switch(this) {
+    BluetoothInputMode.disabled => localizations.disabled,
+    BluetoothInputMode.oldBluetoothInput => localizations.oldBluetoothInput,
+    BluetoothInputMode.newBluetoothInputOldLib => localizations.newBluetoothInputOldLib,
+    BluetoothInputMode.newBluetoothInputCrossPlatform => localizations.newBluetoothInputCrossPlatform,
+  };
+}
app/lib/model/storage/settings_store.dart
@@ -2,9 +2,11 @@ import 'dart:collection';
 import 'dart:convert';
 
 import 'package:blood_pressure_app/config.dart';
+import 'package:blood_pressure_app/features/bluetooth/logic/device_scan_cubit.dart';
 import 'package:blood_pressure_app/model/blood_pressure/medicine/medicine.dart';
 import 'package:blood_pressure_app/model/blood_pressure/pressure_unit.dart';
 import 'package:blood_pressure_app/model/horizontal_graph_line.dart';
+import 'package:blood_pressure_app/model/storage/bluetooth_input_mode.dart';
 import 'package:blood_pressure_app/model/storage/convert_util.dart';
 import 'package:blood_pressure_app/model/weight_unit.dart';
 import 'package:flutter/material.dart';
@@ -48,7 +50,7 @@ class Settings extends ChangeNotifier {
     PressureUnit? preferredPressureUnit,
     List<String>? knownBleDev,
     int? highestMedIndex,
-    bool? bleInput,
+    BluetoothInputMode? bleInput,
     bool? weightInput,
     WeightUnit? weightUnit,
   }) {
@@ -113,7 +115,7 @@ class Settings extends ChangeNotifier {
           Medicine.fromJson(jsonDecode(e)),).toList(),
       highestMedIndex: ConvertUtil.parseInt(map['highestMedIndex']),
       knownBleDev: ConvertUtil.parseList<String>(map['knownBleDev']),
-      bleInput: ConvertUtil.parseBool(map['bleInput']),
+      bleInput: BluetoothInputMode.deserialize(ConvertUtil.parseInt(map['bleInput'])),
       weightInput: ConvertUtil.parseBool(map['weightInput']),
       preferredPressureUnit: PressureUnit.decode(ConvertUtil.parseInt(map['preferredPressureUnit'])),
       weightUnit: WeightUnit.deserialize(ConvertUtil.parseInt(map['weightUnit'])),
@@ -163,7 +165,7 @@ class Settings extends ChangeNotifier {
     'highestMedIndex': highestMedIndex,
     'preferredPressureUnit': preferredPressureUnit.encode(),
     'knownBleDev': knownBleDev,
-    'bleInput': bleInput,
+    'bleInput': bleInput.serialize(),
     'weightInput': weightInput,
     'weightUnit': weightUnit.serialized,
   };
@@ -409,11 +411,11 @@ class Settings extends ChangeNotifier {
     notifyListeners();
   }
 
-  bool _bleInput = true;
+  BluetoothInputMode _bleInput = BluetoothInputMode.oldBluetoothInput;
   /// Whether to show bluetooth input on add measurement page.
-  bool get bleInput => isPlatformSupportedBluetooth && _bleInput;
-  set bleInput(bool value) {
-    _bleInput = value;
+  BluetoothInputMode get bleInput => _bleInput;
+  set bleInput(BluetoothInputMode value) {
+    if (isPlatformSupportedBluetooth) _bleInput = value;
     notifyListeners();
   }
 
app/lib/screens/settings_screen.dart
@@ -21,6 +21,7 @@ import 'package:blood_pressure_app/logging.dart';
 import 'package:blood_pressure_app/model/blood_pressure/pressure_unit.dart';
 import 'package:blood_pressure_app/model/blood_pressure/warn_values.dart';
 import 'package:blood_pressure_app/model/iso_lang_names.dart';
+import 'package:blood_pressure_app/model/storage/bluetooth_input_mode.dart';
 import 'package:blood_pressure_app/model/storage/db/config_db.dart';
 import 'package:blood_pressure_app/model/storage/db/file_settings_loader.dart';
 import 'package:blood_pressure_app/model/storage/db/settings_loader.dart';
@@ -160,12 +161,20 @@ class SettingsPage extends StatelessWidget {
                 title: Text(localizations.medications),
                 trailing: const Icon(Icons.arrow_forward_ios),
               ),
-              SwitchListTile(
-                value: settings.bleInput,
-                onChanged: isPlatformSupportedBluetooth ? (value) { settings.bleInput = value; } : null,
-                secondary: const Icon(Icons.bluetooth),
+              DropDownListTile<BluetoothInputMode>(
                 title: Text(localizations.bluetoothInput),
-                subtitle: isPlatformSupportedBluetooth ? null : Text(localizations.errFeatureNotSupported),
+                subtitle: Text(localizations.bluetoothInputDesc),
+                leading: const Icon(Icons.bluetooth),
+                items: [
+                  for (final e in BluetoothInputMode.values)
+                    DropdownMenuItem(
+                      value: e,
+                      child: Text(e.localize(localizations)),
+                    ),
+                ],
+                value: settings.bleInput,
+                onChanged: (value) => settings.bleInput = value ?? settings.bleInput,
+
               ),
               SwitchListTile(
                 value: settings.allowManualTimeInput,
app/test/features/bluetooth/mock/fake_characteristic.dart
@@ -81,4 +81,8 @@ class FakeBleBpCharacteristic implements BluetoothCharacteristic {
     throw UnimplementedError();
   }
 
+  @override
+  // TODO: implement primaryServiceUuid
+  Guid? get primaryServiceUuid => throw UnimplementedError();
+
 }
\ No newline at end of file
app/test/features/bluetooth/mock/fake_service.dart
@@ -27,4 +27,16 @@ class FakeBleBPService implements BluetoothService {
   @override
   Guid get uuid => Guid('1810');
 
+  @override
+  // TODO: implement isSecondary
+  bool get isSecondary => throw UnimplementedError();
+
+  @override
+  // TODO: implement primaryService
+  BluetoothService? get primaryService => throw UnimplementedError();
+
+  @override
+  // TODO: implement primaryServiceUuid
+  Guid? get primaryServiceUuid => throw UnimplementedError();
+
 }
app/test/features/input/add_measurement_dialoge_test.dart
@@ -2,6 +2,7 @@ import 'package:blood_pressure_app/features/bluetooth/bluetooth_input.dart';
 import 'package:blood_pressure_app/features/input/add_bodyweight_dialoge.dart';
 import 'package:blood_pressure_app/features/input/add_measurement_dialoge.dart';
 import 'package:blood_pressure_app/features/settings/tiles/color_picker_list_tile.dart';
+import 'package:blood_pressure_app/model/storage/bluetooth_input_mode.dart';
 import 'package:blood_pressure_app/model/storage/settings_store.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
@@ -141,7 +142,7 @@ void main() {
     });
     testWidgets('respects settings about showing bluetooth input', (tester) async {
       final settings = Settings(
-        bleInput: true,
+        bleInput: BluetoothInputMode.newBluetoothInputCrossPlatform,
       );
       await tester.pumpWidget(materialApp(
         const AddEntryDialoge(
@@ -152,7 +153,7 @@ void main() {
       await tester.pumpAndSettle();
       expect(find.byType(BluetoothInput, skipOffstage: false), findsOneWidget);
 
-      settings.bleInput = false;
+      settings.bleInput = BluetoothInputMode.disabled;
       await tester.pumpAndSettle();
       expect(find.byType(BluetoothInput), findsNothing);
     });
app/test/model/json_serialization_test.dart
@@ -3,6 +3,7 @@ import 'package:blood_pressure_app/model/blood_pressure/pressure_unit.dart';
 import 'package:blood_pressure_app/model/export_import/column.dart';
 import 'package:blood_pressure_app/model/export_import/export_configuration.dart';
 import 'package:blood_pressure_app/model/horizontal_graph_line.dart';
+import 'package:blood_pressure_app/model/storage/bluetooth_input_mode.dart';
 import 'package:blood_pressure_app/model/storage/export_columns_store.dart';
 import 'package:blood_pressure_app/model/storage/export_csv_settings_store.dart';
 import 'package:blood_pressure_app/model/storage/export_pdf_settings_store.dart';
@@ -97,7 +98,7 @@ void main() {
         horizontalGraphLines: [HorizontalGraphLine(Colors.blue, 1230)],
         bottomAppBars: true,
         knownBleDev: ['a', 'b'],
-        bleInput: false,
+        bleInput: BluetoothInputMode.newBluetoothInputCrossPlatform,
         weightInput: true,
         weightUnit: WeightUnit.st,
         preferredPressureUnit: PressureUnit.kPa,