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
68 changes: 65 additions & 3 deletions open_wearable/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger;
import 'package:universal_ble/universal_ble.dart';
import 'package:open_wearable/models/app_background_execution_bridge.dart';
import 'package:open_wearable/models/app_launch_session.dart';
import 'package:open_wearable/models/app_shutdown_settings.dart';
Expand Down Expand Up @@ -67,7 +68,9 @@ class MyApp extends StatefulWidget {
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
late final StreamSubscription _unsupportedFirmwareSub;
late final StreamSubscription _wearableEventSub;
late final StreamSubscription<AvailabilityState> _bleAvailabilitySub;
late final BluetoothAutoConnector _autoConnector;
late final WearableConnector _wearableConnector;
late final Future<SharedPreferences> _prefsFuture;
late final StreamSubscription _wearableProvEventSub;
late final WearablesProvider _wearablesProvider;
Expand All @@ -79,6 +82,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
bool _backgroundExecutionRequestedForShutdown = false;
bool _backgroundExecutionRequestedForRecording = false;
bool _isBackgroundExecutionActive = false;
bool _isBluetoothPoweredOn = true;

static const Duration _closeShutdownGracePeriod = Duration(
seconds: 10,
Expand Down Expand Up @@ -216,7 +220,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
);
});

final WearableConnector connector = context.read<WearableConnector>();
_wearableConnector = context.read<WearableConnector>();

_autoConnector = BluetoothAutoConnector(
navStateGetter: () => rootNavigatorKey.currentState,
Expand All @@ -227,8 +231,17 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
AutoConnectPreferences.autoConnectEnabledListenable.addListener(
_syncAutoConnectorWithSetting,
);
_bleAvailabilitySub = UniversalBle.availabilityStream.listen(
_handleBleAvailabilityChanged,
onError: (error, stackTrace) {
logger.w(
'Failed to observe Bluetooth availability updates: $error\n$stackTrace',
);
},
);
unawaited(_syncInitialBluetoothAvailability());

_wearableEventSub = connector.events.listen((event) {
_wearableEventSub = _wearableConnector.events.listen((event) {
if (event is WearableConnectEvent) {
_handleWearableConnected(event.wearable);
}
Expand All @@ -238,13 +251,61 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
}

void _syncAutoConnectorWithSetting() {
if (AutoConnectPreferences.autoConnectEnabled) {
if (AutoConnectPreferences.autoConnectEnabled && _isBluetoothPoweredOn) {
_autoConnector.start();
return;
}
_autoConnector.stop();
}

Future<void> _syncInitialBluetoothAvailability() async {
try {
final state = await UniversalBle.getBluetoothAvailabilityState();
if (!mounted) {
return;
}
_handleBleAvailabilityChanged(state);
} catch (error, stackTrace) {
logger.w(
'Failed to read initial Bluetooth availability state: $error\n$stackTrace',
);
}
}

void _handleBleAvailabilityChanged(AvailabilityState state) {
final wasPoweredOn = _isBluetoothPoweredOn;
_isBluetoothPoweredOn = state == AvailabilityState.poweredOn;

if (_isBluetoothPoweredOn) {
if (!wasPoweredOn) {
logger.i('Bluetooth powered on. Resuming connection flows.');
}
_syncAutoConnectorWithSetting();
return;
}

_autoConnector.stop();

if (state == AvailabilityState.poweredOff) {
logger.i('Bluetooth powered off. Clearing connected wearable UI state.');
_wearableConnector.clearTrackedConnections();
_removeAllConnectedWearablesFromUiState();
}
}

void _removeAllConnectedWearablesFromUiState() {
final connectedWearables =
List<Wearable>.from(_wearablesProvider.wearables);
if (connectedWearables.isEmpty) {
return;
}

for (final wearable in connectedWearables) {
_wearablesProvider.removeWearable(wearable);
_sensorRecorderProvider.removeWearable(wearable);
}
}

void _handleWearableConnected(Wearable wearable) {
_wearablesProvider.addWearable(wearable);
_sensorRecorderProvider.addWearable(wearable);
Expand Down Expand Up @@ -620,6 +681,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
void dispose() {
_unsupportedFirmwareSub.cancel();
_wearableEventSub.cancel();
_bleAvailabilitySub.cancel();
_wearableProvEventSub.cancel();
AutoConnectPreferences.autoConnectEnabledListenable.removeListener(
_syncAutoConnectorWithSetting,
Expand Down
14 changes: 14 additions & 0 deletions open_wearable/lib/models/wearable_connector.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ class WearableConnector {
connectedWearables.forEach(_handleConnection);
}

/// Clears local connection bookkeeping.
///
/// Useful when the platform Bluetooth adapter is powered off and the
/// platform stack does not emit per-device disconnect callbacks.
void clearTrackedConnections({Iterable<String>? deviceIds}) {
if (deviceIds == null) {
_trackedWearableIds.clear();
return;
}
for (final id in deviceIds) {
_trackedWearableIds.remove(id);
}
}

void _handleConnection(Wearable wearable) {
if (_trackedWearableIds.contains(wearable.deviceId)) {
return;
Expand Down
71 changes: 69 additions & 2 deletions open_wearable/lib/widgets/devices/connect_devices_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger;
import 'package:universal_ble/universal_ble.dart';
import 'package:open_wearable/models/connect_devices_scan_session.dart';
import 'package:open_wearable/models/device_name_formatter.dart';
import 'package:open_wearable/models/wearable_display_group.dart';
Expand Down Expand Up @@ -337,15 +338,27 @@ class _ConnectDevicesPageState extends State<ConnectDevicesPage> {
setState(() {
_connectingDevices[device.id] = true;
});
final connector = context.read<WearableConnector>();

try {
final connector = context.read<WearableConnector>();
await connector.connect(device);
ConnectDevicesScanSession.removeDiscoveredDevice(device.id);
} catch (e, stackTrace) {
if (_isAlreadyConnectedError(e, device)) {
logger.i(
'Device ${device.id} already connected. Refreshing connected devices.',
'Device ${device.id} already connected. Attempting stale-connection recovery.',
);
final recovered = await _recoverFromStaleConnectionState(
device: device,
connector: connector,
);
if (recovered) {
ConnectDevicesScanSession.removeDiscoveredDevice(device.id);
return;
}

logger.i(
'Stale-connection recovery failed for ${device.id}. Refreshing connected system devices.',
);
await _pullConnectedSystemDevices();
ConnectDevicesScanSession.removeDiscoveredDevice(device.id);
Expand Down Expand Up @@ -400,6 +413,60 @@ class _ConnectDevicesPageState extends State<ConnectDevicesPage> {
}
}

Future<bool> _recoverFromStaleConnectionState({
required DiscoveredDevice device,
required WearableConnector connector,
}) async {
try {
await UniversalBle.disconnect(device.id);
} catch (error, stackTrace) {
logger.d(
'Low-level disconnect attempt for ${device.id} failed during stale recovery: $error\n$stackTrace',
);
}

if (await _retryConnectorConnect(device: device, connector: connector)) {
return true;
}

try {
await UniversalBle.connect(device.id);
await UniversalBle.connectionStream(device.id)
.firstWhere((isConnected) => isConnected)
.timeout(Duration(seconds: 2));
} catch (error, stackTrace) {
logger.d(
'Low-level connect probe for ${device.id} did not complete during stale recovery: $error\n$stackTrace',
);
} finally {
try {
await UniversalBle.disconnect(device.id);
} catch (error, stackTrace) {
logger.d(
'Low-level disconnect probe for ${device.id} failed during stale recovery: $error\n$stackTrace',
);
}
}

await Future<void>.delayed(Duration(milliseconds: 250));
return _retryConnectorConnect(device: device, connector: connector);
}

Future<bool> _retryConnectorConnect({
required DiscoveredDevice device,
required WearableConnector connector,
}) async {
try {
await connector.connect(device);
return true;
} catch (error, stackTrace) {
logger.d(
'Connector retry for ${device.id} failed during stale recovery: $error\n$stackTrace',
);
return false;
}
}

@override
void dispose() {
ConnectDevicesScanSession.notifier.removeListener(_scanSnapshotListener);
Expand Down
2 changes: 1 addition & 1 deletion open_wearable/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -966,7 +966,7 @@ packages:
source: hosted
version: "1.4.0"
universal_ble:
dependency: transitive
dependency: "direct main"
description:
name: universal_ble
sha256: "6a5c6c1fb295015934a5aef3dc751ae7e00721535275f8478bfe74db77b238c5"
Expand Down
1 change: 1 addition & 0 deletions open_wearable/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies:
cupertino_icons: ^1.0.8
open_file: ^3.3.2
open_earable_flutter: ^2.3.4
universal_ble: ^0.21.1
flutter_platform_widgets: ^9.0.0
provider: ^6.1.2
logger: ^2.5.0
Expand Down
Loading