Commit 7ed8aad
Changed files (8)
app
android
app
macos
test
bluetooth
health_data_store
app/android/app/src/main/AndroidManifest.xml
@@ -7,7 +7,23 @@
</intent>
</queries>
- <application
+ <!-- Tell Google Play Store that your app uses Bluetooth LE
+ Set android:required="true" if bluetooth is necessary -->
+ <uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
+
+ <!-- New Bluetooth permissions in Android 12
+ https://developer.android.com/about/versions/12/features/bluetooth-permissions -->
+ <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
+ <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+
+ <!-- legacy for Android 11 or lower -->
+ <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
+ <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
+ <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30"/>
+
+ <!-- legacy for Android 9 or lower -->
+ <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28" />
+ <application
android:label="blood pressure app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
app/android/app/proguard-rules.pro
@@ -0,0 +1,1 @@
+-keep class com.lib.flutter_blue_plus.* { *; }
\ No newline at end of file
app/lib/bluetooth/bluetooth_cubit.dart
@@ -0,0 +1,78 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:blood_pressure_app/bluetooth/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';
+import 'package:permission_handler/permission_handler.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);
+ }
+
+ 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) {
+ _adapterState = state;
+ switch (_adapterState) {
+ case BluetoothAdapterState.unavailable:
+ emit(BluetoothUnfeasible());
+ case BluetoothAdapterState.unauthorized:
+ emit(BluetoothUnauthorized());
+ case BluetoothAdapterState.on:
+ emit(BluetoothReady());
+ case BluetoothAdapterState.off:
+ case BluetoothAdapterState.turningOff:
+ case BluetoothAdapterState.turningOn:
+ emit(BluetoothDisabled());
+ case BluetoothAdapterState.unknown:
+ emit(BluetoothInitial());
+ }
+
+ /// Request the permission to connect to bluetooth devices.
+ Future<bool> requestPermission() async {
+ assert(_adapterState == BluetoothAdapterState.unauthorized, 'No need to '
+ 'request permission when device unavailable or already authorized.');
+ assert(await Permission.bluetoothConnect.isGranted, 'Permissions handler'
+ 'should report the same as blue_plus');
+ final permission = await Permission.bluetoothConnect.request();
+ return permission.isGranted;
+ }
+
+ /// 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.');
+ if (!Platform.isAndroid) return false;
+ try {
+ await _flutterBluePlus.turnOn();
+ return true;
+ } on FlutterBluePlusException {
+ return false;
+ }
+ }
+ }
+}
app/lib/bluetooth/bluetooth_state.dart
@@ -0,0 +1,25 @@
+part of 'bluetooth_cubit.dart';
+
+/// State of the devices bluetooth module.
+@immutable
+abstract class BluetoothState {}
+
+/// No information on whether bluetooth is available.
+///
+/// Options relating to bluetooth should only be shown where they don't disturb.
+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/bluetooth/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>> get systemDevices =>
+ FlutterBluePlus.systemDevices;
+
+ /// 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/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -5,12 +5,14 @@
import FlutterMacOS
import Foundation
+import flutter_blue_plus
import package_info_plus
import shared_preferences_foundation
import sqflite
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
+ FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
app/test/bluetooth/bluetooth_cubit_test.dart
@@ -0,0 +1,42 @@
+import 'dart:async';
+
+import 'package:blood_pressure_app/bluetooth/bluetooth_cubit.dart';
+import 'package:blood_pressure_app/bluetooth/flutter_blue_plus_mockable.dart';
+import 'package:flutter_blue_plus/flutter_blue_plus.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/annotations.dart';
+import 'package:mockito/mockito.dart';
+
+@GenerateNiceMocks([MockSpec<FlutterBluePlusMockable>()])
+import 'bluetooth_cubit_test.mocks.dart';
+
+void main() {
+ test('should translate adapter stream to state', () async {
+ final bluePlus = MockFlutterBluePlusMockable();
+ when(bluePlus.adapterState).thenAnswer((_) =>
+ Stream.fromIterable([
+ BluetoothAdapterState.unknown,
+ BluetoothAdapterState.unavailable,
+ BluetoothAdapterState.turningOff,
+ BluetoothAdapterState.off,
+ BluetoothAdapterState.unauthorized,
+ BluetoothAdapterState.turningOn,
+ BluetoothAdapterState.on,
+ ]));
+ final cubit = BluetoothCubit(flutterBluePlus: bluePlus);
+ expect(cubit.state, isA<BluetoothInitial>());
+
+ await expectLater(cubit.stream, emitsInOrder([
+ isA<BluetoothInitial>(),
+ isA<BluetoothUnfeasible>(),
+ isA<BluetoothDisabled>(),
+ isA<BluetoothDisabled>(),
+ isA<BluetoothUnauthorized>(),
+ isA<BluetoothDisabled>(),
+ isA<BluetoothReady>(),
+ ]));
+ });
+ // TODO: integration tests ?
+ test('should request permissions', () async {});
+ test('should enable bluetooth', () async {});
+}
health_data_store/pubspec.yaml
@@ -4,7 +4,7 @@ version: 0.1.0+1
publish_to: none
environment:
- sdk: "^3.3.0"
+ sdk: '>=3.0.2 <4.0.0'
dependencies:
freezed_annotation: ^2.4.1