diff --git a/open_wearable/docs/app-setup.md b/open_wearable/docs/app-setup.md index 67576794..ce3cba5d 100644 --- a/open_wearable/docs/app-setup.md +++ b/open_wearable/docs/app-setup.md @@ -84,7 +84,7 @@ Two layers are used: - `WearableConnector` (`lib/models/wearable_connector.dart`) - Direct connection API and event stream for connect/disconnect events. -- `BluetoothAutoConnector` (`lib/models/bluetooth_auto_connector.dart`) +- `BluetoothAutoConnector` (`lib/models/auto_connector/bluetooth_auto_connector.dart`) - Reconnect workflow based on remembered device names and user preference. `MyApp` subscribes to connector/provider event streams to: diff --git a/open_wearable/lib/main.dart b/open_wearable/lib/main.dart index d0a34cce..9b90ed92 100644 --- a/open_wearable/lib/main.dart +++ b/open_wearable/lib/main.dart @@ -11,6 +11,7 @@ import 'package:open_wearable/models/app_shutdown_settings.dart'; import 'package:open_wearable/models/app_upgrade_coordinator.dart'; import 'package:open_wearable/models/app_upgrade_highlight.dart'; import 'package:open_wearable/models/auto_connect_preferences.dart'; +import 'package:open_wearable/models/connect_devices_scan_session.dart'; import 'package:open_wearable/models/log_file_manager.dart'; import 'package:open_wearable/models/fota_post_update_verification.dart'; import 'package:open_wearable/models/wearable_connector.dart' @@ -26,8 +27,10 @@ import 'package:open_wearable/widgets/updates/app_upgrade_page.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'models/bluetooth_auto_connector.dart'; +import 'models/auto_connector/bluetooth_auto_connector.dart'; +import 'models/auto_connector/system_auto_connector.dart'; import 'models/logger.dart'; +import 'models/permissions_handler.dart'; import 'view_models/app_banner_controller.dart'; import 'view_models/wearables_provider.dart'; @@ -56,10 +59,19 @@ void main() async { return provider; }, ), - Provider.value(value: WearableConnector()), ChangeNotifierProvider( create: (context) => AppBannerController(), ), + Provider( + create: (context) => PermissionsHandler( + navigatorGetter: () => rootNavigatorKey.currentState, + ), + ), + Provider( + create: (context) => WearableConnector( + permissionsHandler: context.read(), + ), + ), ChangeNotifierProvider.value(value: logFileManager), ], child: const MyApp(), @@ -80,6 +92,7 @@ class _MyAppState extends State with WidgetsBindingObserver { late final StreamSubscription _wearableEventSub; late final StreamSubscription _bleAvailabilitySub; late final BluetoothAutoConnector _autoConnector; + late final SystemAutoConnector _systemAutoConnector; late final WearableConnector _wearableConnector; late final Future _prefsFuture; late final StreamSubscription _wearableProvEventSub; @@ -232,12 +245,21 @@ class _MyAppState extends State with WidgetsBindingObserver { }); _wearableConnector = context.read(); + final permissionsHandler = context.read(); + ConnectDevicesScanSession.configure( + permissionsHandler: permissionsHandler, + ); _autoConnector = BluetoothAutoConnector( - navStateGetter: () => rootNavigatorKey.currentState, + connector: _wearableConnector, wearableManager: WearableManager(), + permissionsHandler: permissionsHandler, prefsFuture: _prefsFuture, - onWearableConnected: _handleWearableConnected, + ); + _systemAutoConnector = SystemAutoConnector( + connector: _wearableConnector, + wearableManager: WearableManager(), + permissionsHandler: permissionsHandler, ); AutoConnectPreferences.autoConnectEnabledListenable.addListener( _syncAutoConnectorWithSetting, @@ -298,9 +320,11 @@ class _MyAppState extends State with WidgetsBindingObserver { void _syncAutoConnectorWithSetting() { if (AutoConnectPreferences.autoConnectEnabled && _isBluetoothPoweredOn) { _autoConnector.start(); + _systemAutoConnector.start(); return; } _autoConnector.stop(); + _systemAutoConnector.stop(); } Future _syncInitialBluetoothAvailability() async { @@ -736,6 +760,7 @@ class _MyAppState extends State with WidgetsBindingObserver { _setBackgroundExecutionForShutdown(false); _setBackgroundExecutionForRecording(false); _autoConnector.stop(); + _systemAutoConnector.stop(); super.dispose(); } diff --git a/open_wearable/lib/models/auto_connect_preferences.dart b/open_wearable/lib/models/auto_connect_preferences.dart index 420932ac..ffe4e120 100644 --- a/open_wearable/lib/models/auto_connect_preferences.dart +++ b/open_wearable/lib/models/auto_connect_preferences.dart @@ -22,16 +22,35 @@ class AutoConnectPreferences { StreamController.broadcast(); static final ValueNotifier _autoConnectEnabledNotifier = ValueNotifier(true); + static final ValueNotifier> _rememberedDeviceNamesNotifier = + ValueNotifier>(const []); + /// Broadcasts any persisted auto-connect preference change. static Stream get changes => _changesController.stream; + + /// Exposes the stored Bluetooth auto-connect toggle state. static ValueListenable get autoConnectEnabledListenable => _autoConnectEnabledNotifier; + + /// Returns the currently cached Bluetooth auto-connect toggle state. static bool get autoConnectEnabled => _autoConnectEnabledNotifier.value; + /// Exposes the normalized remembered device names used for auto-connect. + static ValueListenable> get rememberedDeviceNamesListenable => + _rememberedDeviceNamesNotifier; + + /// Returns the currently cached remembered device names used for + /// auto-connect. + static List get rememberedDeviceNames => + List.unmodifiable(_rememberedDeviceNamesNotifier.value); + + /// Loads the persisted auto-connect settings into the in-memory notifiers. static Future initialize() async { await loadAutoConnectEnabled(); + await loadRememberedDeviceNames(); } + /// Loads the persisted auto-connect enabled flag from storage. static Future loadAutoConnectEnabled() async { final prefs = await SharedPreferences.getInstance(); final enabled = prefs.getBool(autoConnectEnabledKey) ?? true; @@ -39,6 +58,7 @@ class AutoConnectPreferences { return enabled; } + /// Persists the auto-connect enabled flag and updates listeners on success. static Future saveAutoConnectEnabled(bool enabled) async { final prefs = await SharedPreferences.getInstance(); final success = await prefs.setBool(autoConnectEnabledKey, enabled); @@ -49,6 +69,15 @@ class AutoConnectPreferences { return enabled; } + /// Loads the remembered auto-connect device names from storage. + static Future> loadRememberedDeviceNames() async { + final prefs = await SharedPreferences.getInstance(); + final rememberedNames = readRememberedDeviceNames(prefs); + _setRememberedDeviceNames(rememberedNames); + return rememberedNames; + } + + /// Reads normalized remembered device names from the provided preferences. static List readRememberedDeviceNames(SharedPreferences prefs) { final names = prefs.getStringList(connectedDeviceNamesKey) ?? const []; @@ -65,6 +94,7 @@ class AutoConnectPreferences { return normalizedNames; } + /// Counts how often a device name appears in the remembered device list. static int countRememberedDeviceName( SharedPreferences prefs, String deviceName, @@ -77,6 +107,7 @@ class AutoConnectPreferences { return names.where((name) => name == normalizedName).length; } + /// Persists a remembered device name for future background auto-connect. static Future rememberDeviceName( SharedPreferences prefs, String deviceName, @@ -93,10 +124,15 @@ class AutoConnectPreferences { normalizedName, ]); if (success) { + _setRememberedDeviceNames([ + ...names, + normalizedName, + ]); _changesController.add(null); } } + /// Removes one remembered device-name entry from the auto-connect targets. static Future forgetDeviceName( SharedPreferences prefs, String deviceName, @@ -118,6 +154,38 @@ class AutoConnectPreferences { updatedNames, ); if (success) { + _setRememberedDeviceNames(updatedNames); + _changesController.add(null); + } + } + + /// Removes every remembered entry for the provided device name. + /// + /// This is used when a device must no longer be managed by the Bluetooth + /// auto-connector at all, for example after it becomes a system-paired + /// device that should only reconnect through the system connector. + static Future forgetAllDeviceNameOccurrences( + SharedPreferences prefs, + String deviceName, + ) async { + final normalizedName = deviceName.trim(); + if (normalizedName.isEmpty) { + return; + } + + final names = readRememberedDeviceNames(prefs); + final updatedNames = + names.where((name) => name != normalizedName).toList(growable: false); + if (updatedNames.length == names.length) { + return; + } + + final success = await prefs.setStringList( + connectedDeviceNamesKey, + updatedNames, + ); + if (success) { + _setRememberedDeviceNames(updatedNames); _changesController.add(null); } } @@ -128,4 +196,14 @@ class AutoConnectPreferences { } _autoConnectEnabledNotifier.value = enabled; } + + /// Updates the cached remembered device names for listening widgets. + static void _setRememberedDeviceNames(List deviceNames) { + if (listEquals(_rememberedDeviceNamesNotifier.value, deviceNames)) { + return; + } + _rememberedDeviceNamesNotifier.value = List.unmodifiable( + List.from(deviceNames), + ); + } } diff --git a/open_wearable/lib/models/auto_connector/auto_connector.dart b/open_wearable/lib/models/auto_connector/auto_connector.dart new file mode 100644 index 00000000..3d6a08ac --- /dev/null +++ b/open_wearable/lib/models/auto_connector/auto_connector.dart @@ -0,0 +1,33 @@ +import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; +import 'package:open_wearable/models/logger.dart'; +import 'package:open_wearable/models/wearable_connector.dart'; + +/// Base lifecycle contract for background connection orchestrators. +abstract class AutoConnector { + /// Shared wearable connection facade used by subclasses. + final WearableConnector _connector; + + AutoConnector(WearableConnector connector) : _connector = connector; + + /// Broadcast connection lifecycle events emitted by the shared connector. + Stream get events => _connector.events; + + /// Starts the connector lifecycle. + void start(); + + /// Stops the connector lifecycle. + void stop(); + + /// Returns whether the provided device id is already paired at the OS level. + Future isSystemDeviceId(String deviceId) { + return _connector.isSystemDeviceId(deviceId); + } + + Future connect(DiscoveredDevice device) { + // log which auto-connector is connecting to which device + logger.i( + 'AutoConnector $runtimeType connecting to device ${device.name} (${device.id})', + ); + return _connector.connect(device); + } +} diff --git a/open_wearable/lib/models/bluetooth_auto_connector.dart b/open_wearable/lib/models/auto_connector/bluetooth_auto_connector.dart similarity index 81% rename from open_wearable/lib/models/bluetooth_auto_connector.dart rename to open_wearable/lib/models/auto_connector/bluetooth_auto_connector.dart index 594a52c8..dc1bb5eb 100644 --- a/open_wearable/lib/models/bluetooth_auto_connector.dart +++ b/open_wearable/lib/models/auto_connector/bluetooth_auto_connector.dart @@ -1,19 +1,20 @@ import 'dart:async'; import 'dart:io'; -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:open_wearable/models/auto_connector/auto_connector.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'auto_connect_preferences.dart'; -import 'logger.dart'; +import '../auto_connect_preferences.dart'; +import '../logger.dart'; +import '../permissions_handler.dart'; +import '../wearable_connector.dart'; /// Background reconnect orchestrator for remembered Bluetooth wearables. /// /// Needs: /// - `WearableManager` scanning/connection APIs. /// - `AutoConnectPreferences` values and change stream. -/// - Navigation access for permission dialogs. +/// - A shared [PermissionsHandler] for BLE access coordination. /// /// Does: /// - Tracks target wearable names from preferences. @@ -22,14 +23,13 @@ import 'logger.dart'; /// /// Provides: /// - `start()` / `stop()` lifecycle control and `onWearableConnected` callback. -class BluetoothAutoConnector { +class BluetoothAutoConnector extends AutoConnector { static const Duration _scanRetryInterval = Duration(seconds: 3); static const Duration _iosScanRestartDelay = Duration(seconds: 1); - final NavigatorState? Function() navStateGetter; final WearableManager wearableManager; + final PermissionsHandler permissionsHandler; final Future prefsFuture; - final void Function(Wearable wearable) onWearableConnected; StreamSubscription? _connectSubscription; StreamSubscription? _scanSubscription; @@ -38,7 +38,6 @@ class BluetoothAutoConnector { bool _isConnecting = false; bool _isAttemptingConnection = false; - bool _askedPermissionsThisSession = false; int _sessionToken = 0; // Names to look for during scanning @@ -53,12 +52,13 @@ class BluetoothAutoConnector { bool _isStartingScan = false; BluetoothAutoConnector({ - required this.navStateGetter, + required WearableConnector connector, required this.wearableManager, + required this.permissionsHandler, required this.prefsFuture, - required this.onWearableConnected, - }); + }) : super(connector); + @override void start() async { final token = ++_sessionToken; _stopInternal(); @@ -85,6 +85,7 @@ class BluetoothAutoConnector { _attemptConnection(token: token); } + @override void stop() { _sessionToken++; _stopInternal(); @@ -234,12 +235,22 @@ class BluetoothAutoConnector { if (token != _sessionToken) { return; } + final isSystemManagedDevice = await isSystemDeviceId(wearable.deviceId); + if (token != _sessionToken) { + return; + } + if (isSystemManagedDevice) { + await AutoConnectPreferences.forgetAllDeviceNameOccurrences( + prefs, + wearable.name, + ); + } final rememberedCount = AutoConnectPreferences.countRememberedDeviceName( prefs, wearable.name, ); final connectedCount = _connectedNameCounts[wearable.name] ?? 0; - if (connectedCount > rememberedCount) { + if (!isSystemManagedDevice && connectedCount > rememberedCount) { await AutoConnectPreferences.rememberDeviceName(prefs, wearable.name); } @@ -297,18 +308,14 @@ class BluetoothAutoConnector { _isAttemptingConnection = true; if (!Platform.isIOS) { - final hasPerm = await wearableManager.hasPermissions(); + final permissionsGranted = + await permissionsHandler.ensureBluetoothPermissions(); if (activeToken != _sessionToken) { _isAttemptingConnection = false; return; } - if (!hasPerm) { - logger.w('Bluetooth permissions not granted. Showing permissions dialog.'); - if (!_askedPermissionsThisSession) { - _askedPermissionsThisSession = true; - _showPermissionsDialog(); - } - logger.w('Skipping auto-connect: no permissions granted yet.'); + if (!permissionsGranted) { + logger.w('Skipping auto-connect: Bluetooth permissions are unavailable.'); _isAttemptingConnection = false; return; } @@ -321,16 +328,7 @@ class BluetoothAutoConnector { } if (_targetNames.isNotEmpty && _hasUnconnectedTargets()) { - _setupScanListener(); - if (!_isStartingScan) { - _isStartingScan = true; - try { - await _applyIosScanCooldownIfNeeded(); - await wearableManager.startScan(); - } finally { - _isStartingScan = false; - } - } + await _startAutoConnectScan(); } } catch (error, stackTrace) { logger.w('Auto-connect attempt failed: $error\n$stackTrace'); @@ -342,7 +340,7 @@ class BluetoothAutoConnector { void _setupScanListener() { if (_scanSubscription != null) return; - _scanSubscription = wearableManager.scanStream.listen((device) { + _scanSubscription = wearableManager.scanStream.listen((device) async { if (_isConnecting) return; final normalizedId = _normalizeDeviceId(device.id); @@ -350,6 +348,12 @@ class BluetoothAutoConnector { _connectedDeviceIds.contains(normalizedId)) { return; } + if (await isSystemDeviceId(device.id)) { + logger.i( + 'Skipping Bluetooth auto-connect for system-paired device ${device.name} (${device.id}).', + ); + return; + } final requiredConnections = _requiredConnectionsForName(device.name); if (requiredConnections == 0) { return; @@ -366,12 +370,11 @@ class BluetoothAutoConnector { "Match found for ${device.name}. Connecting using rotating ID: ${device.id}", ); - wearableManager.connectToDevice(device).then((wearable) { + connect(device).then((wearable) { _markConnected( deviceId: wearable.deviceId, deviceName: wearable.name, ); - onWearableConnected(wearable); }).catchError((error, stackTrace) { final message = _deviceErrorMessageSafe(error, device); if (_isAlreadyConnectedMessage(message)) { @@ -403,52 +406,34 @@ class BluetoothAutoConnector { return; } try { - _setupScanListener(); - _isStartingScan = true; - try { - await _applyIosScanCooldownIfNeeded(); - await wearableManager.startScan(); - } finally { - _isStartingScan = false; - } + await _startAutoConnectScan(); } catch (error, stackTrace) { logger.w('Failed to restart auto-connect scan: $error\n$stackTrace'); _stopScanning(); } } + /// Starts the BLE scan used by the auto-connect flow. + Future _startAutoConnectScan() async { + logger.d("Starting auto-connect scan..."); + _setupScanListener(); + if (_isStartingScan) { + logger.i("Scan start already in progress. Skipping redundant start call."); + return; + } + + _isStartingScan = true; + try { + await _applyIosScanCooldownIfNeeded(); + await wearableManager.startScan(checkAndRequestPermissions: false); + } finally { + _isStartingScan = false; + } + } + void _stopScanning() { _lastScanStoppedAt = DateTime.now(); _scanSubscription?.cancel(); _scanSubscription = null; } - - void _showPermissionsDialog() { - final nav = navStateGetter(); - final navCtx = nav?.context; - if (nav == null || navCtx == null) return; - - // Fire-and-forget; no async/await needed here - nav.push( - DialogRoute( - context: navCtx, - barrierDismissible: true, - builder: (_) => PlatformAlertDialog( - title: PlatformText("Permissions Required"), - content: PlatformText( - "This app requires Bluetooth and Location permissions to function properly.\n" - "Location access is needed for Bluetooth scanning to work. Please enable both " - "Bluetooth and Location services and grant the necessary permissions.\n" - "No data will be collected or sent to any server and will remain only on your device.", - ), - actions: [ - PlatformDialogAction( - onPressed: nav.pop, - child: PlatformText("OK"), - ), - ], - ), - ), - ); - } } diff --git a/open_wearable/lib/models/auto_connector/system_auto_connector.dart b/open_wearable/lib/models/auto_connector/system_auto_connector.dart new file mode 100644 index 00000000..d6c31587 --- /dev/null +++ b/open_wearable/lib/models/auto_connector/system_auto_connector.dart @@ -0,0 +1,198 @@ +import 'dart:async'; + +import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; + +import '../logger.dart'; +import '../permissions_handler.dart'; +import '../wearable_connector.dart'; +import 'auto_connector.dart'; + +/// Reconnects any devices that are already paired at the OS level. +/// +/// Needs: +/// - The shared [WearableConnector] used by the rest of the app. +/// - A [WearableManager] to enumerate system-known Bluetooth devices. +/// - A [PermissionsHandler] to centralize runtime permission requests. +/// +/// Does: +/// - Connects visible system devices without starting a BLE discovery scan. +/// - Retries after disconnects until paired devices are restored. +/// +/// Provides: +/// - `start()` / `stop()` lifecycle hooks for app-level auto-connect orchestration. +class SystemAutoConnector extends AutoConnector { + static const Duration _retryInterval = Duration(seconds: 4); + + final WearableManager wearableManager; + final PermissionsHandler permissionsHandler; + + StreamSubscription? _eventsSubscription; + Timer? _retryTimer; + + bool _isAttemptingConnections = false; + int _sessionToken = 0; + + final Set _connectedDeviceIds = {}; + final Set _pendingDeviceIds = {}; + + SystemAutoConnector({ + required WearableConnector connector, + required this.wearableManager, + required this.permissionsHandler, + }) : super(connector); + + @override + void start() async { + final token = ++_sessionToken; + _stopInternal(); + _connectedDeviceIds.clear(); + _pendingDeviceIds.clear(); + + _eventsSubscription = events.listen(_handleConnectorEvent); + _ensureRetryLoop(token: token); + + unawaited(_attemptConnections(token: token)); + } + + @override + void stop() { + _sessionToken++; + _stopInternal(); + } + + void _stopInternal() { + _eventsSubscription?.cancel(); + _eventsSubscription = null; + _retryTimer?.cancel(); + _retryTimer = null; + _isAttemptingConnections = false; + _pendingDeviceIds.clear(); + } + + void _handleConnectorEvent(WearableEvent event) { + if (event is WearableConnectEvent) { + _markConnected( + deviceId: event.wearable.deviceId, + ); + return; + } + + if (event is WearableDisconnectedEvent) { + _markDisconnected( + deviceId: event.wearable.deviceId, + ); + unawaited(_attemptConnections()); + } + } + + void _ensureRetryLoop({required int token}) { + _retryTimer?.cancel(); + _retryTimer = Timer.periodic(_retryInterval, (timer) { + if (token != _sessionToken) { + timer.cancel(); + return; + } + if (_isAttemptingConnections) { + return; + } + unawaited(_attemptConnections(token: token)); + }); + } + + Future _attemptConnections({int? token}) async { + final activeToken = token ?? _sessionToken; + if (activeToken != _sessionToken || _isAttemptingConnections) { + return; + } + + _isAttemptingConnections = true; + try { + if (activeToken != _sessionToken) { + return; + } + + final permissionsGranted = + await permissionsHandler.ensureBluetoothPermissions(); + if (!permissionsGranted || activeToken != _sessionToken) { + return; + } + + await connectToSystemDevices(); + } catch (error, stackTrace) { + logger.w('System auto-connect attempt failed: $error\n$stackTrace'); + } finally { + _isAttemptingConnections = false; + } + } + + Future connectToSystemDevices() async { + final systemDevices = await wearableManager.getSystemDevices( + checkAndRequestPermissions: false, + ); + if (_sessionToken == 0) { + return; + } + + for (final device in systemDevices) { + if (!_shouldConnect(device)) { + continue; + } + + final normalizedId = _normalizeDeviceId(device.id); + _pendingDeviceIds.add(normalizedId); + try { + await connect(device); + _markConnected(deviceId: device.id); + } catch (error, stackTrace) { + final message = _deviceErrorMessageSafe(error, device); + if (_isAlreadyConnectedMessage(message)) { + _markConnected(deviceId: device.id); + } else { + logger.w( + 'Failed to connect system device ${device.id}: $message\n$stackTrace', + ); + } + } finally { + _pendingDeviceIds.remove(normalizedId); + } + } + } + + bool _shouldConnect(DiscoveredDevice device) { + final normalizedId = _normalizeDeviceId(device.id); + return !_connectedDeviceIds.contains(normalizedId) && + !_pendingDeviceIds.contains(normalizedId); + } + + String _normalizeDeviceId(String id) => id.trim().toUpperCase(); + + void _markConnected({ + required String deviceId, + }) { + final normalizedId = _normalizeDeviceId(deviceId); + _connectedDeviceIds.add(normalizedId); + } + + void _markDisconnected({ + required String deviceId, + }) { + final normalizedId = _normalizeDeviceId(deviceId); + _connectedDeviceIds.remove(normalizedId); + } + + String _deviceErrorMessageSafe(Object error, DiscoveredDevice device) { + try { + return wearableManager.deviceErrorMessage(error, device.name); + } catch (_) { + final fallback = error.toString().trim(); + if (fallback.isEmpty) { + return 'Unknown connection error.'; + } + return fallback; + } + } + + bool _isAlreadyConnectedMessage(String message) { + return message.toLowerCase().contains('already connected'); + } +} diff --git a/open_wearable/lib/models/connect_devices_scan_session.dart b/open_wearable/lib/models/connect_devices_scan_session.dart index 7e0b09e1..7d1463d7 100644 --- a/open_wearable/lib/models/connect_devices_scan_session.dart +++ b/open_wearable/lib/models/connect_devices_scan_session.dart @@ -2,8 +2,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; + import 'logger.dart'; +import 'permissions_handler.dart'; +/// Immutable scan state consumed by the connect-devices UI. class ConnectDevicesScanSnapshot { final bool isScanning; final DateTime? lastScanStartedAt; @@ -35,10 +38,15 @@ class ConnectDevicesScanSnapshot { } } +/// Shared scan coordinator for the connect-devices flow. +/// +/// The session owns the discovery scan lifecycle and ensures permissions are +/// granted before any BLE scan begins. class ConnectDevicesScanSession { static const Duration _scanIndicatorDuration = Duration(seconds: 8); static final WearableManager _wearableManager = WearableManager(); + static PermissionsHandler? _permissionsHandler; static final ValueNotifier notifier = ValueNotifier( ConnectDevicesScanSnapshot.initial(), @@ -51,7 +59,20 @@ class ConnectDevicesScanSession { static ConnectDevicesScanSnapshot get snapshot => notifier.value; + /// Injects the shared permissions handler used by the scan session. + static void configure({required PermissionsHandler permissionsHandler}) { + _permissionsHandler = permissionsHandler; + } + + /// Starts a BLE discovery scan after ensuring runtime permissions exist. static Future startScanning({bool clearPrevious = false}) async { + final permissionsHandler = _permissionsHandler; + if (permissionsHandler == null) { + throw StateError( + 'ConnectDevicesScanSession.configure must be called before scanning.', + ); + } + final scanToken = ++_scanToken; await _cancelScanResources(); @@ -101,7 +122,14 @@ class ConnectDevicesScanSession { if (scanToken != _scanToken) { return; } - await _wearableManager.startScan(); + final permissionsGranted = + await permissionsHandler.ensureBluetoothPermissions(); + if (!permissionsGranted || scanToken != _scanToken) { + await stopScanning(); + return; + } + + await _wearableManager.startScan(checkAndRequestPermissions: false); } catch (error, stackTrace) { logger.w('Failed to start scan: $error\n$stackTrace'); await stopScanning(); diff --git a/open_wearable/lib/models/permissions_handler.dart b/open_wearable/lib/models/permissions_handler.dart new file mode 100644 index 00000000..50bc1b9f --- /dev/null +++ b/open_wearable/lib/models/permissions_handler.dart @@ -0,0 +1,122 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +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 'logger.dart'; + +/// Coordinates runtime permission checks and requests for Bluetooth features. +/// +/// Needs: +/// - Access to the shared [WearableManager] permission APIs. +/// - Optional navigation access for platform permission rationale dialogs. +/// +/// Does: +/// - Checks whether Bluetooth-related runtime permissions are already granted. +/// - Shows a single in-app rationale dialog before requesting missing access. +/// - Serializes concurrent permission requests so multiple callers reuse one flow. +/// +/// Provides: +/// - [ensureBluetoothPermissions] for feature code that needs BLE access. +class PermissionsHandler { + final NavigatorState? Function() _navigatorGetter; + final WearableManager _wearableManager; + + Future? _activeBluetoothPermissionRequest; + bool _hasShownBluetoothRationaleThisSession = false; + + PermissionsHandler({ + required NavigatorState? Function() navigatorGetter, + WearableManager? wearableManager, + }) : _navigatorGetter = navigatorGetter, + _wearableManager = wearableManager ?? WearableManager(); + + /// Returns whether BLE-related runtime permissions are currently granted. + Future hasBluetoothPermissions() async { + if (!_requiresRuntimeBluetoothPermissions) { + return true; + } + return _wearableManager.hasPermissions(); + } + + /// Ensures BLE-related runtime permissions are available before continuing. + /// + /// If permissions are missing, this method presents a rationale dialog once + /// per app session and then delegates the actual OS permission request to the + /// shared wearable manager. + Future ensureBluetoothPermissions() { + final inFlight = _activeBluetoothPermissionRequest; + if (inFlight != null) { + return inFlight; + } + + final request = _performBluetoothPermissionRequest(); + _activeBluetoothPermissionRequest = request; + request.whenComplete(() { + if (identical(_activeBluetoothPermissionRequest, request)) { + _activeBluetoothPermissionRequest = null; + } + }); + return request; + } + + /// Whether the current platform requires explicit BLE runtime permissions. + bool get _requiresRuntimeBluetoothPermissions { + if (kIsWeb) { + return false; + } + return Platform.isAndroid; + } + + Future _performBluetoothPermissionRequest() async { + if (!_requiresRuntimeBluetoothPermissions) { + return true; + } + + if (await hasBluetoothPermissions()) { + return true; + } + + if (!_hasShownBluetoothRationaleThisSession) { + _hasShownBluetoothRationaleThisSession = true; + await _showBluetoothPermissionRationale(); + } + + final granted = await WearableManager.checkAndRequestPermissions(); + if (!granted) { + logger.w('Bluetooth permissions remain unavailable after request.'); + } + return granted; + } + + Future _showBluetoothPermissionRationale() async { + final navigator = _navigatorGetter(); + final context = navigator?.context; + if (navigator == null || context == null) { + return; + } + + await navigator.push( + DialogRoute( + context: context, + barrierDismissible: true, + builder: (_) => PlatformAlertDialog( + title: PlatformText('Permissions Required'), + content: PlatformText( + 'Bluetooth scanning requires Bluetooth and Location permissions. ' + 'Please grant the requested access so the app can discover and reconnect devices.', + ), + actions: [ + PlatformDialogAction( + onPressed: navigator.pop, + child: PlatformText('Continue'), + ), + ], + ), + ), + ); + } +} diff --git a/open_wearable/lib/models/wearable_connector.dart b/open_wearable/lib/models/wearable_connector.dart index ca75985d..edd6e4c1 100644 --- a/open_wearable/lib/models/wearable_connector.dart +++ b/open_wearable/lib/models/wearable_connector.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'permissions_handler.dart'; + /// Base event type emitted by [WearableConnector]. abstract class WearableEvent { final Wearable wearable; @@ -52,12 +54,20 @@ class WearableConnector { // final Map _connectedDevices = {}; final WearableManager _wm; + final PermissionsHandler _permissionsHandler; final Set _trackedWearableIds = {}; final _events = StreamController.broadcast(); Stream get events => _events.stream; - WearableConnector([WearableManager? wm]) : _wm = wm ?? WearableManager(); + WearableConnector({ + WearableManager? wearableManager, + required PermissionsHandler permissionsHandler, + }) : _wm = wearableManager ?? WearableManager(), + _permissionsHandler = permissionsHandler; + + /// Normalizes a device id for stable set membership and comparisons. + String _normalizeDeviceId(String deviceId) => deviceId.trim().toUpperCase(); Future connect(DiscoveredDevice device) async { final wearable = await _wm.connectToDevice(device); @@ -65,7 +75,74 @@ class WearableConnector { return wearable; } + /// Returns the normalized ids of devices currently paired at the OS level. + Future> getSystemDeviceIds({ + bool checkAndRequestPermissions = false, + }) async { + final systemDevices = await _wm.getSystemDevices( + checkAndRequestPermissions: checkAndRequestPermissions, + ); + return systemDevices + .map((device) => _normalizeDeviceId(device.id)) + .toSet(); + } + + /// Returns whether the provided discovered device is already OS-paired. + Future isSystemDevice( + DiscoveredDevice device, { + bool checkAndRequestPermissions = false, + }) { + return isSystemDeviceId( + device.id, + checkAndRequestPermissions: checkAndRequestPermissions, + ); + } + + /// Returns whether the provided device id is already OS-paired. + Future isSystemDeviceId( + String deviceId, { + bool checkAndRequestPermissions = false, + }) async { + final normalizedId = _normalizeDeviceId(deviceId); + final systemDeviceIds = await getSystemDeviceIds( + checkAndRequestPermissions: checkAndRequestPermissions, + ); + return systemDeviceIds.contains(normalizedId); + } + + /// Connects all currently available system devices and reports whether the + /// provided device id was among the connected system wearables. + /// + /// The underlying library exposes system-device connection as a bulk action, + /// so this helper keeps the "connect a paired device through the system path" + /// behavior centralized in this facade. + Future connectSystemDevice(DiscoveredDevice device) async { + final permissionsGranted = + await _permissionsHandler.ensureBluetoothPermissions(); + if (!permissionsGranted) { + return false; + } + + final normalizedId = _normalizeDeviceId(device.id); + final connectedWearables = await _wm.connectToSystemDevices(); + var connectedRequestedDevice = false; + for (final wearable in connectedWearables) { + if (_normalizeDeviceId(wearable.deviceId) == normalizedId) { + connectedRequestedDevice = true; + } + _handleConnection(wearable); + } + return connectedRequestedDevice; + } + + /// Connects to already paired system devices after permissions are ensured. Future connectToSystemDevices() async { + final permissionsGranted = + await _permissionsHandler.ensureBluetoothPermissions(); + if (!permissionsGranted) { + return; + } + List connectedWearables = await _wm.connectToSystemDevices(); connectedWearables.forEach(_handleConnection); } diff --git a/open_wearable/lib/router.dart b/open_wearable/lib/router.dart index 331c2a1b..351bde62 100644 --- a/open_wearable/lib/router.dart +++ b/open_wearable/lib/router.dart @@ -10,6 +10,7 @@ import 'package:open_wearable/widgets/fota/fota_warning_page.dart'; import 'package:open_wearable/widgets/home_page.dart'; import 'package:open_wearable/widgets/logging/log_files_screen.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_all_recordings_page.dart'; +import 'package:open_wearable/widgets/settings/auto_connector_settings_page.dart'; import 'package:open_wearable/widgets/settings/general_settings_page.dart'; import 'package:open_wearable/widgets/updates/app_upgrade_history_page.dart'; import 'dart:io' show Platform; @@ -138,6 +139,11 @@ final GoRouter router = GoRouter( name: 'settings/general', builder: (context, state) => const GeneralSettingsPage(), ), + GoRoute( + path: '/settings/auto-connector', + name: 'settings/auto-connector', + builder: (context, state) => const AutoConnectorSettingsPage(), + ), GoRoute( path: '/whats-new', name: 'whats-new', diff --git a/open_wearable/lib/widgets/devices/connect_devices_page.dart b/open_wearable/lib/widgets/devices/connect_devices_page.dart index f9ffd08d..29af3d69 100644 --- a/open_wearable/lib/widgets/devices/connect_devices_page.dart +++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart @@ -341,6 +341,18 @@ class _ConnectDevicesPageState extends State { final connector = context.read(); try { + final isSystemDevice = await connector.isSystemDevice(device); + if (isSystemDevice) { + final connected = await connector.connectSystemDevice(device); + if (!connected) { + throw StateError( + 'The device is paired in the system Bluetooth settings but could not be connected through the system connection path.', + ); + } + ConnectDevicesScanSession.removeDiscoveredDevice(device.id); + return; + } + await connector.connect(device); ConnectDevicesScanSession.removeDiscoveredDevice(device.id); } catch (e, stackTrace) { diff --git a/open_wearable/lib/widgets/home_page.dart b/open_wearable/lib/widgets/home_page.dart index 59b4390c..2111476b 100644 --- a/open_wearable/lib/widgets/home_page.dart +++ b/open_wearable/lib/widgets/home_page.dart @@ -89,6 +89,7 @@ class _HomePageState extends State { onLogsRequested: _openLogFiles, onConnectRequested: _openConnectDevices, onGeneralSettingsRequested: _openGeneralSettings, + onAutoConnectorSettingsRequested: _openAutoConnectorSettings, ), ]; } @@ -238,6 +239,11 @@ class _HomePageState extends State { if (!mounted) return; context.push('/settings/general'); } + + void _openAutoConnectorSettings() { + if (!mounted) return; + context.push('/settings/auto-connector'); + } } class _HomeDestination { diff --git a/open_wearable/lib/widgets/settings/auto_connector_settings_page.dart b/open_wearable/lib/widgets/settings/auto_connector_settings_page.dart new file mode 100644 index 00000000..cb888bd2 --- /dev/null +++ b/open_wearable/lib/widgets/settings/auto_connector_settings_page.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:open_wearable/models/auto_connect_preferences.dart'; +import 'package:open_wearable/widgets/app_toast.dart'; +import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Settings page for Bluetooth auto-connect behavior and remembered devices. +class AutoConnectorSettingsPage extends StatefulWidget { + /// Creates the auto-connector settings page. + const AutoConnectorSettingsPage({super.key}); + + @override + State createState() => + _AutoConnectorSettingsPageState(); +} + +class _AutoConnectorSettingsPageState extends State { + bool _isSaving = false; + + /// Persists the auto-connect enabled state. + Future _setAutoConnectEnabled(bool enabled) async { + if (_isSaving) { + return; + } + + setState(() { + _isSaving = true; + }); + + try { + await AutoConnectPreferences.saveAutoConnectEnabled(enabled); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + /// Removes one remembered device entry from auto-connect storage. + Future _removeRememberedDeviceName(String deviceName) async { + if (_isSaving) { + return; + } + + setState(() { + _isSaving = true; + }); + + try { + final prefs = await SharedPreferences.getInstance(); + await AutoConnectPreferences.forgetDeviceName(prefs, deviceName); + } catch (_) { + if (mounted) { + AppToast.show( + context, + message: 'Could not remove saved auto-connect device.', + type: AppToastType.error, + icon: Icons.delete_outline_rounded, + ); + } + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return PlatformScaffold( + appBar: PlatformAppBar( + title: const Text('Auto connector'), + ), + body: ValueListenableBuilder( + valueListenable: AutoConnectPreferences.autoConnectEnabledListenable, + builder: (context, autoConnectEnabled, _) { + return ValueListenableBuilder>( + valueListenable: + AutoConnectPreferences.rememberedDeviceNamesListenable, + builder: (context, rememberedDeviceNames, __) { + return ListView( + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), + children: [ + _buildSectionHeader( + context, + title: 'Bluetooth auto-connect', + description: + 'Control whether remembered devices reconnect in the background', + ), + _buildSettingGroup( + [ + SwitchListTile.adaptive( + value: autoConnectEnabled, + onChanged: _isSaving ? null : _setAutoConnectEnabled, + secondary: const Icon( + Icons.bluetooth_searching_rounded, + size: 18, + ), + title: const Text('Enable Bluetooth auto-connect'), + subtitle: const Text( + 'Automatically reconnect remembered devices in the background', + ), + ), + ], + ), + _buildSectionHeader( + context, + title: 'Saved devices', + description: + 'Review and remove remembered devices used as reconnect targets', + ), + _buildSettingGroup( + _buildRememberedDeviceTiles(rememberedDeviceNames), + ), + ], + ); + }, + ); + }, + ), + ); + } + + /// Builds the saved auto-connect device rows shown in settings. + List _buildRememberedDeviceTiles(List rememberedDeviceNames) { + if (rememberedDeviceNames.isEmpty) { + return [ + ListTile( + leading: const Icon( + Icons.bluetooth_disabled_rounded, + size: 18, + ), + title: const Text('No saved auto-connect devices'), + subtitle: const Text( + 'Devices appear here after they have been remembered for background reconnects', + ), + ), + ]; + } + + return rememberedDeviceNames.map((deviceName) { + return ListTile( + leading: const Icon( + Icons.bluetooth_connected_rounded, + size: 18, + ), + title: Text(deviceName), + subtitle: const Text('Used as a Bluetooth auto-connect target'), + trailing: IconButton( + tooltip: 'Remove saved device', + onPressed: + _isSaving ? null : () => _removeRememberedDeviceName(deviceName), + icon: const Icon(Icons.delete_outline_rounded), + ), + ); + }).toList(growable: false); + } + + /// Renders a labeled settings section heading. + Widget _buildSectionHeader( + BuildContext context, { + required String title, + required String description, + }) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 4, 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 2), + Text( + description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + /// Groups related settings rows into a single card. + Widget _buildSettingGroup(List tiles) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < tiles.length; index++) ...[ + tiles[index], + if (index < tiles.length - 1) const Divider(height: 1), + ], + ], + ), + ); + } +} diff --git a/open_wearable/lib/widgets/settings/general_settings_page.dart b/open_wearable/lib/widgets/settings/general_settings_page.dart index f2b9dc2f..1c0b7bf2 100644 --- a/open_wearable/lib/widgets/settings/general_settings_page.dart +++ b/open_wearable/lib/widgets/settings/general_settings_page.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:open_wearable/models/auto_connect_preferences.dart'; import 'package:open_wearable/models/app_shutdown_settings.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; @@ -14,6 +13,7 @@ class GeneralSettingsPage extends StatefulWidget { class _GeneralSettingsPageState extends State { bool _isSaving = false; + /// Persists whether sensors should be disabled after app close. Future _setShutOffSensorsOnClose(bool enabled) async { if (_isSaving) { return; @@ -74,26 +74,6 @@ class _GeneralSettingsPageState extends State { } } - Future _setAutoConnectEnabled(bool enabled) async { - if (_isSaving) { - return; - } - - setState(() { - _isSaving = true; - }); - - try { - await AutoConnectPreferences.saveAutoConnectEnabled(enabled); - } finally { - if (mounted) { - setState(() { - _isSaving = false; - }); - } - } - } - Future _setKeepAppInForeground(bool enabled) async { if (_isSaving) { return; @@ -134,140 +114,107 @@ class _GeneralSettingsPageState extends State { builder: (context, hideLiveGraphsWithoutDataEnabled, ___) { return ValueListenableBuilder( valueListenable: - AutoConnectPreferences.autoConnectEnabledListenable, - builder: (context, autoConnectEnabled, ____) { - return ValueListenableBuilder( - valueListenable: - AppShutdownSettings.keepAppInForegroundListenable, - builder: (context, keepAppInForeground, _____) { - return ListView( - padding: - SensorPageSpacing.pagePaddingWithBottomInset( - context, - ), - children: [ - _buildSectionHeader( - context, - title: 'Connectivity', - description: - 'Manage how devices reconnect in the background', + AppShutdownSettings.keepAppInForegroundListenable, + builder: (context, keepAppInForeground, ____) { + return ListView( + padding: SensorPageSpacing.pagePaddingWithBottomInset( + context, + ), + children: [ + _buildSectionHeader( + context, + title: 'Display', + description: + 'Keep the screen active while the app is open', + ), + _buildSettingGroup( + [ + SwitchListTile.adaptive( + value: keepAppInForeground, + onChanged: + _isSaving ? null : _setKeepAppInForeground, + secondary: const Icon( + Icons.screen_lock_portrait_rounded, + size: 18, + ), + title: const Text( + 'Keep screen awake', + ), + subtitle: const Text( + 'Prevents the device screen from sleeping while this app is active', + ), ), - _buildSettingGroup( - [ - SwitchListTile.adaptive( - value: autoConnectEnabled, - onChanged: _isSaving - ? null - : _setAutoConnectEnabled, - secondary: const Icon( - Icons.bluetooth_searching_rounded, - size: 18, - ), - title: const Text( - 'Enable Bluetooth auto-connect', - ), - subtitle: const Text( - 'Automatically reconnect remembered devices in the background', - ), - ), - ], - ), - _buildSectionHeader( - context, - title: 'Display', - description: - 'Keep the screen active while the app is open', - ), - _buildSettingGroup( - [ - SwitchListTile.adaptive( - value: keepAppInForeground, - onChanged: _isSaving - ? null - : _setKeepAppInForeground, - secondary: const Icon( - Icons.screen_lock_portrait_rounded, - size: 18, - ), - title: const Text( - 'Keep screen awake', - ), - subtitle: const Text( - 'Prevents the device screen from sleeping while this app is active', - ), - ), - ], - ), - _buildSectionHeader( - context, - title: 'App lifecycle', - description: - 'Control what happens to sensors when the app goes to the background', - ), - _buildSettingGroup( - [ - SwitchListTile.adaptive( - value: shutOffOnCloseEnabled, - onChanged: _isSaving - ? null - : _setShutOffSensorsOnClose, - secondary: const Icon( - Icons.power_settings_new_rounded, - size: 18, - ), - title: const Text( - 'Disable all sensors on app close', - ), - subtitle: const Text( - 'Turns configurable sensors off after 10s in background when possible', - ), - ), - ], + ], + ), + _buildSectionHeader( + context, + title: 'App lifecycle', + description: + 'Control what happens to sensors when the app goes to the background', + ), + _buildSettingGroup( + [ + SwitchListTile.adaptive( + value: shutOffOnCloseEnabled, + onChanged: _isSaving + ? null + : _setShutOffSensorsOnClose, + secondary: const Icon( + Icons.power_settings_new_rounded, + size: 18, + ), + title: const Text( + 'Disable all sensors on app close', + ), + subtitle: const Text( + 'Turns configurable sensors off after 10s in background when possible', + ), ), - _buildSectionHeader( - context, - title: 'Live data', - description: - 'Adjust graph visibility and update behavior in Sensors › Live Data', + ], + ), + _buildSectionHeader( + context, + title: 'Live data', + description: + 'Adjust graph visibility and update behavior in Sensors › Live Data', + ), + _buildSettingGroup( + [ + SwitchListTile.adaptive( + value: disableLiveGraphsEnabled, + onChanged: _isSaving + ? null + : _setDisableLiveDataGraphs, + secondary: const Icon( + Icons.area_chart_rounded, + size: 18, + ), + title: const Text( + 'Disable live data graphs', + ), + subtitle: const Text( + 'Stop live chart updates in the Sensors › Live Data views', + ), ), - _buildSettingGroup( - [ - SwitchListTile.adaptive( - value: disableLiveGraphsEnabled, - onChanged: _isSaving - ? null - : _setDisableLiveDataGraphs, - secondary: const Icon( - Icons.area_chart_rounded, - size: 18, - ), - title: - const Text('Disable live data graphs'), - subtitle: const Text( - 'Stop live chart updates in the Sensors › Live Data views', - ), - ), - SwitchListTile.adaptive( - value: hideLiveGraphsWithoutDataEnabled, - onChanged: _isSaving - ? null - : _setHideLiveDataGraphsWithoutData, - secondary: const Icon( - Icons.sensors_off_rounded, - size: 18, - ), - title: const Text( - 'Hide live data graphs without data', - ), - subtitle: const Text( - 'Hides live data graphs in Sensors › Live Data until samples arrive', - ), - ), - ], + SwitchListTile.adaptive( + value: hideLiveGraphsWithoutDataEnabled, + onChanged: _isSaving + ? null + : _setHideLiveDataGraphsWithoutData, + secondary: const Icon( + Icons.sensors_off_rounded, + size: 18, + ), + title: const Text( + 'Hide live data graphs without data', + ), + subtitle: const Text( + 'Hides live data graphs in Sensors › Live Data until samples arrive', + ), ), ], - ); - }, + ), + ], ); }, ); @@ -280,6 +227,7 @@ class _GeneralSettingsPageState extends State { ); } + /// Renders a labeled settings section heading. Widget _buildSectionHeader( BuildContext context, { required String title, @@ -309,6 +257,7 @@ class _GeneralSettingsPageState extends State { ); } + /// Groups related settings rows into a single card. Widget _buildSettingGroup(List tiles) { return Card( margin: const EdgeInsets.only(bottom: 12), diff --git a/open_wearable/lib/widgets/settings/settings_page.dart b/open_wearable/lib/widgets/settings/settings_page.dart index 22156776..7eaa9d5f 100644 --- a/open_wearable/lib/widgets/settings/settings_page.dart +++ b/open_wearable/lib/widgets/settings/settings_page.dart @@ -15,12 +15,14 @@ class SettingsPage extends StatelessWidget { final VoidCallback onLogsRequested; final VoidCallback onConnectRequested; final VoidCallback onGeneralSettingsRequested; + final VoidCallback onAutoConnectorSettingsRequested; const SettingsPage({ super.key, required this.onLogsRequested, required this.onConnectRequested, required this.onGeneralSettingsRequested, + required this.onAutoConnectorSettingsRequested, }); @override @@ -45,6 +47,12 @@ class SettingsPage extends StatelessWidget { subtitle: 'Manage app-wide behavior', onTap: onGeneralSettingsRequested, ), + _QuickActionTile( + icon: Icons.bluetooth_searching_rounded, + title: 'Auto connector', + subtitle: 'Manage Bluetooth auto-connect and saved devices', + onTap: onAutoConnectorSettingsRequested, + ), _QuickActionTile( icon: Icons.receipt_long, title: 'Log files', diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 7031a8b8..370cb598 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -564,10 +564,10 @@ packages: dependency: "direct main" description: name: open_earable_flutter - sha256: "078c8a64ad05265b5b7afae991830549e08729fecacfd255dc4a8e038f8ad12b" + sha256: b55a2e70ab5ee7ce7d46cebd65f1463a0a83684aa5849e9e3e2526471c0a4b02 url: "https://pub.dev" source: hosted - version: "2.3.5" + version: "2.3.6" open_file: dependency: "direct main" description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 6147e6aa..2b6c3b3d 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 open_file: ^3.3.2 - open_earable_flutter: ^2.3.5 + open_earable_flutter: ^2.3.6 universal_ble: ^0.21.1 flutter_platform_widgets: ^9.0.0 provider: ^6.1.2 diff --git a/open_wearable/test/models/auto_connect_preferences_test.dart b/open_wearable/test/models/auto_connect_preferences_test.dart index 703f36c8..b2a7c772 100644 --- a/open_wearable/test/models/auto_connect_preferences_test.dart +++ b/open_wearable/test/models/auto_connect_preferences_test.dart @@ -9,6 +9,7 @@ void main() { setUp(() async { SharedPreferences.setMockInitialValues({}); await AutoConnectPreferences.loadAutoConnectEnabled(); + await AutoConnectPreferences.loadRememberedDeviceNames(); }); test('rememberDeviceName stores normalized names and keeps duplicates', @@ -69,6 +70,35 @@ void main() { ); }); + test( + 'forgetAllDeviceNameOccurrences removes every matching remembered name', + () async { + SharedPreferences.setMockInitialValues({ + AutoConnectPreferences.connectedDeviceNamesKey: [ + 'OpenEarable 2', + 'OpenEarable 3', + 'OpenEarable 2', + 'OpenEarable 4', + ], + }); + + final prefs = await SharedPreferences.getInstance(); + + await AutoConnectPreferences.forgetAllDeviceNameOccurrences( + prefs, + ' OpenEarable 2 ', + ); + + expect( + prefs.getStringList(AutoConnectPreferences.connectedDeviceNamesKey), + ['OpenEarable 3', 'OpenEarable 4'], + ); + expect( + AutoConnectPreferences.rememberedDeviceNames, + ['OpenEarable 3', 'OpenEarable 4'], + ); + }); + test('countRememberedDeviceName returns normalized occurrence counts', () async { SharedPreferences.setMockInitialValues({ @@ -113,6 +143,42 @@ void main() { await expectLater(forgetChange, completes); }); + test('loadRememberedDeviceNames normalizes values into the cache', + () async { + SharedPreferences.setMockInitialValues({ + AutoConnectPreferences.connectedDeviceNamesKey: [ + ' OpenEarable 2 ', + '', + 'OpenEarable 3', + ], + }); + + final rememberedNames = + await AutoConnectPreferences.loadRememberedDeviceNames(); + + expect(rememberedNames, ['OpenEarable 2', 'OpenEarable 3']); + expect( + AutoConnectPreferences.rememberedDeviceNames, + ['OpenEarable 2', 'OpenEarable 3'], + ); + }); + + test('remember and forget keep remembered-device notifier in sync', + () async { + final prefs = await SharedPreferences.getInstance(); + + expect(AutoConnectPreferences.rememberedDeviceNames, isEmpty); + + await AutoConnectPreferences.rememberDeviceName(prefs, 'OpenEarable 9'); + expect( + AutoConnectPreferences.rememberedDeviceNames, + ['OpenEarable 9'], + ); + + await AutoConnectPreferences.forgetDeviceName(prefs, 'OpenEarable 9'); + expect(AutoConnectPreferences.rememberedDeviceNames, isEmpty); + }); + test('auto-connect enabled defaults to true when no value is stored', () async { final loaded = await AutoConnectPreferences.loadAutoConnectEnabled();