diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index 764cb645..110052ae 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -1,4 +1,7 @@ PODS: + - audioplayers_darwin (0.0.1): + - Flutter + - FlutterMacOS - DKImagePickerController/Core (4.3.9): - DKImagePickerController/ImageDataManager - DKImagePickerController/Resource @@ -48,6 +51,8 @@ PODS: - SwiftProtobuf - open_file_ios (1.0.3): - Flutter + - package_info_plus (0.4.5): + - Flutter - permission_handler_apple (9.3.0): - Flutter - SDWebImage (5.21.5): @@ -66,20 +71,25 @@ PODS: - FlutterMacOS - url_launcher_ios (0.0.1): - Flutter + - wakelock_plus (0.0.1): + - Flutter - ZIPFoundation (0.9.19) DEPENDENCIES: + - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) - flutter_archive (from `.symlinks/plugins/flutter_archive/ios`) - mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/ios`) - open_file_ios (from `.symlinks/plugins/open_file_ios/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - universal_ble (from `.symlinks/plugins/universal_ble/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) SPEC REPOS: trunk: @@ -93,6 +103,8 @@ SPEC REPOS: - ZIPFoundation EXTERNAL SOURCES: + audioplayers_darwin: + :path: ".symlinks/plugins/audioplayers_darwin/darwin" file_picker: :path: ".symlinks/plugins/file_picker/ios" file_selector_ios: @@ -105,6 +117,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/mcumgr_flutter/ios" open_file_ios: :path: ".symlinks/plugins/open_file_ios/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" share_plus: @@ -115,8 +129,11 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/universal_ble/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" + wakelock_plus: + :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: + audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be @@ -126,6 +143,7 @@ SPEC CHECKSUMS: iOSMcuManagerLibrary: e9555825af11a61744fe369c12e1e66621061b58 mcumgr_flutter: 969e99cc15e9fe658242669ce1075bf4612aef8a open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a @@ -135,6 +153,7 @@ SPEC CHECKSUMS: SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 universal_ble: ff19787898040d721109c6324472e5dd4bc86adc url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c PODFILE CHECKSUM: 251cb053df7158f337c0712f2ab29f4e0fa474ce diff --git a/open_wearable/lib/models/bluetooth_auto_connector.dart b/open_wearable/lib/models/bluetooth_auto_connector.dart index b7534d42..1de2d1fa 100644 --- a/open_wearable/lib/models/bluetooth_auto_connector.dart +++ b/open_wearable/lib/models/bluetooth_auto_connector.dart @@ -24,6 +24,7 @@ import 'logger.dart'; /// - `start()` / `stop()` lifecycle control and `onWearableConnected` callback. class BluetoothAutoConnector { static const Duration _scanRetryInterval = Duration(seconds: 3); + static const Duration _iosScanRestartDelay = Duration(seconds: 1); final NavigatorState? Function() navStateGetter; final WearableManager wearableManager; @@ -46,6 +47,10 @@ class BluetoothAutoConnector { final Set _connectedDeviceIds = {}; final Map _connectedNameCounts = {}; final Set _pendingDeviceIds = {}; + final Set _disconnectListenerAttachedIds = {}; + + DateTime? _lastScanStoppedAt; + bool _isStartingScan = false; BluetoothAutoConnector({ required this.navStateGetter, @@ -60,6 +65,7 @@ class BluetoothAutoConnector { _connectedDeviceIds.clear(); _connectedNameCounts.clear(); _pendingDeviceIds.clear(); + _disconnectListenerAttachedIds.clear(); // Load the last connected names await _reloadTargetNames(token: token, reloadPrefs: false); @@ -91,7 +97,9 @@ class BluetoothAutoConnector { _preferencesSubscription = null; _isAttemptingConnection = false; _isConnecting = false; + _isStartingScan = false; _pendingDeviceIds.clear(); + _disconnectListenerAttachedIds.clear(); _scanRetryTimer?.cancel(); _scanRetryTimer = null; _stopScanning(); @@ -238,22 +246,44 @@ class BluetoothAutoConnector { // Stop scanning immediately when a successful connection is made _stopScanning(); - // Set up the disconnect listener to trigger a scan for the saved name. - wearable.addDisconnectListener(() async { - if (token != _sessionToken) { - return; - } - logger.i( - "Device ${wearable.name} disconnected. Initiating reconnection scan.", - ); - _markDisconnected(deviceId: wearable.deviceId, deviceName: wearable.name); + // Set up one disconnect listener per device id to avoid reconnection storms. + if (_disconnectListenerAttachedIds.add(wearable.deviceId)) { + wearable.addDisconnectListener(() async { + if (token != _sessionToken) { + return; + } + logger.i( + "Device ${wearable.name} disconnected. Initiating reconnection scan.", + ); + _disconnectListenerAttachedIds.remove(wearable.deviceId); + _markDisconnected( + deviceId: wearable.deviceId, + deviceName: wearable.name, + ); - await _syncTargetsWithPreferences(token: token); + await _syncTargetsWithPreferences(token: token); - if (_hasUnconnectedTargets()) { - _attemptConnection(); - } - }); + if (_hasUnconnectedTargets()) { + await _attemptConnection(); + } + }); + } + } + + Future _applyIosScanCooldownIfNeeded() async { + if (!Platform.isIOS) { + return; + } + final stoppedAt = _lastScanStoppedAt; + if (stoppedAt == null) { + return; + } + + final elapsed = DateTime.now().difference(stoppedAt); + if (elapsed >= _iosScanRestartDelay) { + return; + } + await Future.delayed(_iosScanRestartDelay - elapsed); } Future _attemptConnection({int? token}) async { @@ -291,7 +321,15 @@ class BluetoothAutoConnector { if (_targetNames.isNotEmpty && _hasUnconnectedTargets()) { _setupScanListener(); - await wearableManager.startScan(); + if (!_isStartingScan) { + _isStartingScan = true; + try { + await _applyIosScanCooldownIfNeeded(); + await wearableManager.startScan(); + } finally { + _isStartingScan = false; + } + } } } catch (error, stackTrace) { logger.w('Auto-connect attempt failed: $error\n$stackTrace'); @@ -354,7 +392,7 @@ class BluetoothAutoConnector { } Future _restartScanIfNeeded() async { - if (_isConnecting || _isAttemptingConnection) { + if (_isConnecting || _isAttemptingConnection || _isStartingScan) { return; } if (_scanSubscription != null) { @@ -365,7 +403,13 @@ class BluetoothAutoConnector { } try { _setupScanListener(); - await wearableManager.startScan(); + _isStartingScan = true; + try { + await _applyIosScanCooldownIfNeeded(); + await wearableManager.startScan(); + } finally { + _isStartingScan = false; + } } catch (error, stackTrace) { logger.w('Failed to restart auto-connect scan: $error\n$stackTrace'); _stopScanning(); @@ -373,6 +417,7 @@ class BluetoothAutoConnector { } void _stopScanning() { + _lastScanStoppedAt = DateTime.now(); _scanSubscription?.cancel(); _scanSubscription = null; } diff --git a/open_wearable/lib/models/wearable_connector.dart b/open_wearable/lib/models/wearable_connector.dart index 557722fb..6807b398 100644 --- a/open_wearable/lib/models/wearable_connector.dart +++ b/open_wearable/lib/models/wearable_connector.dart @@ -52,6 +52,7 @@ class WearableConnector { // final Map _connectedDevices = {}; final WearableManager _wm; + final Set _trackedWearableIds = {}; final _events = StreamController.broadcast(); Stream get events => _events.stream; @@ -70,8 +71,14 @@ class WearableConnector { } void _handleConnection(Wearable wearable) { + if (_trackedWearableIds.contains(wearable.deviceId)) { + return; + } + _trackedWearableIds.add(wearable.deviceId); + //_connectedDevices[device] = wearable; wearable.addDisconnectListener(() { + _trackedWearableIds.remove(wearable.deviceId); _events.add( WearableDisconnectedEvent( DisconnectReason.system, diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift index e8af59e3..696f95be 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,7 +11,6 @@ import file_selector_macos import flutter_archive import open_file_mac import package_info_plus -import path_provider_foundation import share_plus import shared_preferences_foundation import universal_ble @@ -25,7 +24,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin"))