Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
- Decode compact BLE PPG for firmware 2.3.x per device while preserving legacy 2.2.x and SD sample decoding.
- Accept firmware 2.3.x in the OpenEarable V2 support range.

## 2.4.2

* fixed firmware update version comparisons for development firmware labels, prereleases, build metadata, and release tag prefixes.
* fixed malformed firmware version labels causing errors or suggesting incorrect updates.

## 2.4.1

* fixed microphone gain capability detection so it is only registered when the device exposes the required characteristic.
Expand Down
31 changes: 22 additions & 9 deletions lib/src/fota/repository/firmware_image_repository.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:pub_semver/pub_semver.dart';

import '../model/firmware_update_request.dart';

Expand Down Expand Up @@ -87,17 +88,29 @@ class FirmwareImageRepository {
return (latestRelease['tag_name'] as String).replaceFirst('v', '');
}

/// Compares semantic version strings and returns `true` when [latest] is
/// newer than [current].
/// Returns whether [latest] is newer, ignoring build metadata.
/// Unrecognized version labels do not trigger an update recommendation.
bool isNewerVersion(String latest, String current) {
List<int> parse(String v) => v.split('.').map(int.parse).toList();
final latestParts = parse(latest);
final currentParts = parse(current);
Version parse(String label) {
// Older firmware included the C string terminator in its GATT value.
final normalized = label
.trim()
.replaceFirst(RegExp(r'\x00+$'), '')
.trim()
.replaceFirst(RegExp(r'^[vV]'), '');
final version = Version.parse(normalized);
return Version(
version.major,
version.minor,
version.patch,
pre: version.preRelease.join('.'),
);
}

for (int i = 0; i < latestParts.length; i++) {
if (latestParts[i] > currentParts[i]) return true;
if (latestParts[i] < currentParts[i]) return false;
try {
return parse(latest) > parse(current);
} on FormatException {
return false;
}
return false;
}
}
57 changes: 46 additions & 11 deletions lib/src/managers/ble_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class BleManager extends BleGattManager {
final Map<String, VoidCallback> _disconnectCallbacks = {};

final List<String> _connectedDevicesIds = [];
StreamSubscription<AvailabilityState>? _availabilitySubscription;
final Map<String, Future<void>> _adapterShutdowns = {};

bool _firstScan = true;

Expand Down Expand Up @@ -61,6 +63,25 @@ class BleManager extends BleGattManager {
void _init() {
_scanStreamController = StreamController<DiscoveredDevice>.broadcast();

_availabilitySubscription = UniversalBle.availabilityStream.listen((state) {
if (state == AvailabilityState.poweredOff) {
// Adapter shutdown does not always emit a disconnect for each device.
for (final deviceId in {
..._connectedDevicesIds,
..._disconnectCallbacks.keys,
}) {
_handleDisconnect(deviceId);
// Android may retain a GATT handle tied to the stopped BT service.
_adapterShutdowns[deviceId] ??=
UniversalBle.disconnect(deviceId).catchError((Object error) {
logger.w('Bluetooth shutdown cleanup failed for $deviceId: $error');
}).whenComplete(() {
_adapterShutdowns.remove(deviceId);
});
}
}
});

UniversalBle.onConnectionChange = (
String deviceId,
bool isConnected,
Expand All @@ -72,10 +93,7 @@ class BleManager extends BleGattManager {
_connectCallbacks[deviceId]?.call();
_connectCallbacks.remove(deviceId);
} else {
_connectedDevicesIds.remove(deviceId);
_closeAndRemoveStreamsForDevice(deviceId);
_disconnectCallbacks[deviceId]?.call();
_disconnectCallbacks.remove(deviceId);
_handleDisconnect(deviceId);
}
};

Expand All @@ -98,6 +116,12 @@ class BleManager extends BleGattManager {
};
}

void _handleDisconnect(String deviceId) {
_connectedDevicesIds.removeWhere((id) => id == deviceId);
_closeAndRemoveStreamsForDevice(deviceId);
_disconnectCallbacks.remove(deviceId)?.call();
}

static Future<bool> checkAndRequestPermissions() async {
bool permGranted = false;

Expand Down Expand Up @@ -219,7 +243,8 @@ class BleManager extends BleGattManager {
Future<(bool, List<BleService>)> connectToDevice(
DiscoveredDevice device,
VoidCallback onDisconnect,
) {
) async {
await _adapterShutdowns[device.id];
final pendingConnection = _connectionFutures[device.id];
if (pendingConnection != null) {
logger.d("Reusing pending connection for ${device.id}");
Expand All @@ -232,8 +257,11 @@ class BleManager extends BleGattManager {

final completer = Completer<(bool, List<BleService>)>();
_connectionCompleters[device.id] = completer;
final connectionFuture = completer.future.whenComplete(() {
_connectionFutures.remove(device.id);
late final Future<(bool, List<BleService>)> connectionFuture;
connectionFuture = completer.future.whenComplete(() {
if (identical(_connectionFutures[device.id], connectionFuture)) {
_connectionFutures.remove(device.id);
}
});
_connectionFutures[device.id] = connectionFuture;

Expand All @@ -245,12 +273,18 @@ class BleManager extends BleGattManager {

final services = await UniversalBle.discoverServices(device.id);

_connectionCompleters[device.id]?.complete((true, services));
if (!completer.isCompleted) {
completer.complete((true, services));
}
} catch (error, stack) {
_connectionCompleters[device.id]?.completeError(error, stack);
if (!completer.isCompleted) {
completer.completeError(error, stack);
}
} finally {
_connectionCompleters.remove(device.id);
_connectCallbacks.remove(device.id);
if (identical(_connectionCompleters[device.id], completer)) {
_connectionCompleters.remove(device.id);
_connectCallbacks.remove(device.id);
}
}
};

Expand Down Expand Up @@ -458,6 +492,7 @@ class BleManager extends BleGattManager {

/// Cancel connection state subscription
void dispose() {
_availabilitySubscription?.cancel();
UniversalBle.onConnectionChange = (
String deviceId,
bool isConnected,
Expand Down
223 changes: 223 additions & 0 deletions test/ble_manager_power_cycle_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import 'dart:async';
import 'dart:typed_data';

import 'package:flutter_test/flutter_test.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart';
import 'package:open_earable_flutter/src/managers/ble_manager.dart';
import 'package:universal_ble/universal_ble.dart';

DiscoveredDevice device(String id) => DiscoveredDevice(
id: id,
name: 'Test wearable',
manufacturerData: Uint8List(0),
rssi: -40,
serviceUuids: const [],
);

Future<void> flushEvents() => Future<void>.delayed(Duration.zero);

void main() {
TestWidgetsFlutterBinding.ensureInitialized();

test('power off disconnects both peers even without native callbacks',
() async {
final platform = _Platform();
UniversalBle.setInstance(platform);
final manager = BleManager();
addTearDown(manager.dispose);
final disconnected = <String>[];
for (final id in ['left', 'right']) {
await manager.connectToDevice(device(id), () => disconnected.add(id));
}
final stream = await manager.subscribe(
deviceId: 'left',
serviceId: '180f',
characteristicId: '2a19',
);
final closed = Completer<void>();
final sub = stream.listen((_) {}, onDone: closed.complete);
addTearDown(sub.cancel);

platform.updateAvailability(AvailabilityState.poweredOff);
await flushEvents();
expect(manager.isConnected('left'), isFalse);
expect(manager.isConnected('right'), isFalse);
expect(disconnected, ['left', 'right']);
expect(platform.disconnects, ['left', 'right']);
await closed.future.timeout(const Duration(seconds: 1));

// Some platforms also deliver per-device events, possibly after power-off.
platform.updateConnection('left', false);
platform.updateConnection('right', false);
platform.updateAvailability(AvailabilityState.poweredOff);
await flushEvents();
expect(disconnected, ['left', 'right']);

platform.updateAvailability(AvailabilityState.poweredOn);
await manager.connectToDevice(
device('left'),
() => disconnected.add('left'),
);
expect(manager.isConnected('left'), isTrue);
platform.updateConnection('left', false);
expect(disconnected, ['left', 'right', 'left']);
});

test('reconnect waits until the old native handle is released', () async {
final platform = _Platform();
UniversalBle.setInstance(platform);
final manager = BleManager();
addTearDown(manager.dispose);
await manager.connectToDevice(device('left'), () {});
final cleanup = Completer<void>();
platform.disconnectGate = cleanup.future;
platform.updateAvailability(AvailabilityState.poweredOff);
await flushEvents();
platform.updateAvailability(AvailabilityState.poweredOn);
final reconnected = manager.connectToDevice(device('left'), () {});
await flushEvents();
expect(platform.connectCalls, 1);
cleanup.complete();
expect((await reconnected).$1, isTrue);
expect(platform.connectCalls, 2);
});

test(
'an old service discovery cannot finish a new connection after power off',
() async {
UniversalBle.queueType = QueueType.none;
addTearDown(() => UniversalBle.queueType = QueueType.global);
final oldServices = Completer<List<BleService>>();
final newServices = Completer<List<BleService>>();
final platform = _Platform()..discoveryReplies = [oldServices, newServices];
UniversalBle.setInstance(platform);
final manager = BleManager();
addTearDown(manager.dispose);
final first = manager.connectToDevice(device('left'), () {});
await flushEvents();
expect(platform.discoveryCalls, 1);
platform.updateAvailability(AvailabilityState.poweredOff);
await flushEvents();
expect((await first).$1, isFalse);

platform.updateAvailability(AvailabilityState.poweredOn);
var completed = false;
final second =
manager.connectToDevice(device('left'), () {}).then((result) {
completed = true;
return result;
});
await flushEvents();
expect(platform.discoveryCalls, 2);
oldServices.complete([]);
await flushEvents();
expect(completed, isFalse);
final expected = [BleService('180f', [])];
newServices.complete(expected);
expect((await second).$2, expected);
});

test('wearable manager permits reconnect after a missing disconnect event',
() async {
final platform = _Platform();
UniversalBle.setInstance(platform);
final manager = WearableManager();
manager.clearWearableFactories();
manager.addWearableFactory(_Factory());
addTearDown(manager.dispose);
final left = await manager.connectToDevice(device('left'));
final right = await manager.connectToDevice(device('right'));
var disconnects = 0;
left.addDisconnectListener(() => disconnects++);
right.addDisconnectListener(() => disconnects++);

platform.updateAvailability(AvailabilityState.poweredOff);
await flushEvents();
expect(disconnects, 2);
platform.updateAvailability(AvailabilityState.poweredOn);
final newLeft = await manager.connectToDevice(device('left'));
final newRight = await manager.connectToDevice(device('right'));
expect(identical(newLeft, left), isFalse);
expect(identical(newRight, right), isFalse);
});
}

class _Platform extends UniversalBlePlatform {
List<Completer<List<BleService>>>? discoveryReplies;
int discoveryCalls = 0;
int connectCalls = 0;
final disconnects = <String>[];
Future<void>? disconnectGate;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
@override
Future<AvailabilityState> getBluetoothAvailabilityState() async =>
AvailabilityState.poweredOn;
@override
Future<void> connect(
String deviceId, {
Duration? connectionTimeout,
bool autoConnect = false,
Object? platformConfig,
}) async {
connectCalls++;
updateConnection(deviceId, true);
}

@override
Future<BleConnectionState> getConnectionState(String deviceId) async =>
BleConnectionState.disconnected;
@override
Future<void> disconnect(String deviceId) async {
disconnects.add(deviceId);
await disconnectGate;
updateConnection(deviceId, false);
}

@override
Future<int> requestMtu(String deviceId, int expectedMtu) async => expectedMtu;
@override
Future<List<BleService>> discoverServices(
String deviceId,
bool withDescriptors,
) async {
final index = discoveryCalls++;
return discoveryReplies == null
? []
: await discoveryReplies![index].future;
}

@override
Future<void> setNotifiable(
String deviceId,
String service,
String characteristic,
BleInputProperty property,
) async {}
@override
Future<void> stopScan() async {}
}

class _Factory extends WearableFactory {
@override
Future<bool> matches(
DiscoveredDevice device,
List<BleService> services,
) async =>
true;
@override
Future<Wearable> createFromDevice(
DiscoveredDevice device, {
Set<ConnectionOption> options = const {},
}) async =>
_Wearable(device.id, disconnectNotifier!);
}

class _Wearable extends Wearable {
_Wearable(this.deviceId, WearableDisconnectNotifier notifier)
: super(name: 'Test wearable', disconnectNotifier: notifier);
@override
final String deviceId;
@override
Future<void> disconnect() async {}
}
Loading
Loading