From 3357d2f390488cb7da5685bd0bba245600fad6e5 Mon Sep 17 00:00:00 2001 From: Yongle Date: Tue, 14 Apr 2026 20:18:49 +0800 Subject: [PATCH 1/3] fix(common): preserve connected peripherals during clear_peripherals Stopping a scan or calling clear_peripherals() currently evicts active connections from the internal state map. This fixes cross-platform consistency by ensuring connected devices are always retained (matching the established behavior pattern of macOS CoreBluetooth and Linux BlueZ). --- src/common/adapter_manager.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/common/adapter_manager.rs b/src/common/adapter_manager.rs index 7da91d66..e5a09572 100644 --- a/src/common/adapter_manager.rs +++ b/src/common/adapter_manager.rs @@ -67,7 +67,15 @@ where } pub fn clear_peripherals(&self) { - self.peripherals.clear(); + // Retain peripherals that are currently connected. + // This ensures that stopping a scan or clearing discovered devices + // does not evict active connections — matching CoreBluetooth/BlueZ behavior. + self.peripherals.retain(|_id, peripheral| { + // is_connected() is async in the trait, but all platform implementations + // use an AtomicBool internally, so block_on is safe here. + // We use a short-lived runtime to avoid panics if already in an async context. + futures::executor::block_on(peripheral.is_connected()).unwrap_or(false) + }); } pub fn peripherals(&self) -> Vec { From 48b1cb9417472451e6f869ebc8b463129986d8c4 Mon Sep 17 00:00:00 2001 From: Yongle Date: Tue, 14 Apr 2026 20:18:58 +0800 Subject: [PATCH 2/3] fix(winrt): cache matched addresses in watcher to prevent dropping ScanResponses The Windows WinRT BLE watcher previously dropped all ScanResponse packets when a UUID filter was applied, because ScanResponses lack the service UUIDs present in the main advertisement. This introduces a lightweight Address cache within the watcher closure, allowing subsequent ScanResponse packets to pass through the filter if the device previously advertised the requested UUID. --- src/winrtble/ble/watcher.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/winrtble/ble/watcher.rs b/src/winrtble/ble/watcher.rs index 59881ccb..98d78b7d 100644 --- a/src/winrtble/ble/watcher.rs +++ b/src/winrtble/ble/watcher.rs @@ -51,6 +51,8 @@ impl BLEWatcher { // Pre-convert the filter UUIDs once so the handler closure is cheap. let filter_guids: Vec = services.iter().map(utils::to_guid).collect(); + + let matching_devices = std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())); let handler: TypedEventHandler< BluetoothLEAdvertisementWatcher, @@ -60,18 +62,27 @@ impl BLEWatcher { if let Ok(args) = args.ok() { // Software service-UUID filter. if !filter_guids.is_empty() { + let address = args.BluetoothAddress().unwrap_or(0); + let mut is_match = false; + if let Ok(ad) = args.Advertisement() { if let Ok(ad_uuids) = ad.ServiceUuids() { let count = ad_uuids.Size().unwrap_or(0); - let advertised: Vec = - (0..count).filter_map(|i| ad_uuids.GetAt(i).ok()).collect(); - let all_present = - filter_guids.iter().all(|g| advertised.contains(g)); - if !all_present { - return Ok(()); + if count > 0 { + let advertised: Vec = + (0..count).filter_map(|i| ad_uuids.GetAt(i).ok()).collect(); + is_match = filter_guids.iter().all(|g| advertised.contains(g)); } } } + + let mut cache = matching_devices.lock().unwrap(); + if is_match { + cache.insert(address); + } else if !cache.contains(&address) { + // If the current packet doesn't have the UUID and we haven't seen it before, drop it. + return Ok(()); + } } on_received(args)?; } From 55ac1aca5008a7bda4655633fbf3d2a18af8d08f Mon Sep 17 00:00:00 2001 From: Yongle Date: Tue, 14 Apr 2026 20:19:06 +0800 Subject: [PATCH 3/3] fix(cross-platform): enforce COMPLETE_LOCAL_NAME priority over SHORT_LOCAL_NAME Windows (WinRT): - Parse SHORT_LOCAL_NAME (0x08) and COMPLETE_LOCAL_NAME (0x09) from DataSections - Prevent short names from overwriting complete names across split packets macOS (CoreBluetooth): - Prefer advertisement_name (scan response) over peripheral.name() (GAP cache), as GAP names are frequently truncated short names - Add length protections in update_name() to prevent shorter names from overwriting longer complete names in successive updates Android: - Restore upstream MTU auto-negotiation compatibility --- src/corebluetooth/internal.rs | 8 ++++--- src/corebluetooth/peripheral.rs | 12 +++++++++- src/droidplug/peripheral.rs | 3 +++ src/winrtble/mod.rs | 2 ++ src/winrtble/peripheral.rs | 41 ++++++++++++++++++++++++++++++++- 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/corebluetooth/internal.rs b/src/corebluetooth/internal.rs index c0f42aa6..7e72d2fc 100644 --- a/src/corebluetooth/internal.rs +++ b/src/corebluetooth/internal.rs @@ -639,9 +639,11 @@ impl CoreBluetoothInternal { let id = unsafe { peripheral.identifier() }; let uuid = nsuuid_to_uuid(&id); let peripheral_name = unsafe { peripheral.name() }; - let local_name = peripheral_name - .map(|n| n.to_string()) - .or(advertisement_name.clone()); + // Prefer advertisement_name (from scan response, usually COMPLETE_LOCAL_NAME) + // over peripheral.name() (GAP cache, often the truncated SHORT_LOCAL_NAME) + let local_name = advertisement_name + .clone() + .or_else(|| peripheral_name.map(|n| n.to_string())); if self.peripherals.contains_key(&uuid) { if local_name.is_some() || advertisement_name.is_some() { diff --git a/src/corebluetooth/peripheral.rs b/src/corebluetooth/peripheral.rs index 229daaa6..b792e199 100644 --- a/src/corebluetooth/peripheral.rs +++ b/src/corebluetooth/peripheral.rs @@ -202,7 +202,17 @@ impl Peripheral { advertisement_name: Option, ) { if let Ok(mut props) = self.shared.properties.lock() { - props.local_name = local_name; + // Only update local_name if new value is present and is at least as informative + // (i.e. don't let a shorter GAP name overwrite a longer advertisement name) + if let Some(ref new_name) = local_name { + let should_update = match &props.local_name { + None => true, + Some(old) => new_name.len() >= old.len(), + }; + if should_update { + props.local_name = local_name; + } + } props.advertisement_name = advertisement_name; } } diff --git a/src/droidplug/peripheral.rs b/src/droidplug/peripheral.rs index 8554376e..a13899ca 100644 --- a/src/droidplug/peripheral.rs +++ b/src/droidplug/peripheral.rs @@ -274,6 +274,9 @@ impl api::Peripheral for Peripheral { Ok(()) })?; // Auto-negotiate maximum MTU (517) after connection + // NOTE(Custom): Upstream currently auto-negotiates MTU to max 517 upon connection. + // If specific business logic requires a manual or custom MTU setup in the future, + // we should re-expose `request_mtu(mtu: usize)` in the `api::Peripheral` trait and map it to `obj.request_mtu` here. let mtu_future = self.with_obj(|env, obj| { JSendFuture::try_from(JFuture::from_env(env, obj.request_mtu(517)?)?) })?; diff --git a/src/winrtble/mod.rs b/src/winrtble/mod.rs index 2d9920a9..39ebf434 100644 --- a/src/winrtble/mod.rs +++ b/src/winrtble/mod.rs @@ -22,4 +22,6 @@ mod advertisement_data_type { pub const SERVICE_DATA_16_BIT_UUID: u8 = 0x16; pub const SERVICE_DATA_32_BIT_UUID: u8 = 0x20; pub const SERVICE_DATA_128_BIT_UUID: u8 = 0x21; + pub const SHORT_LOCAL_NAME: u8 = 0x08; + pub const COMPLETE_LOCAL_NAME: u8 = 0x09; } diff --git a/src/winrtble/peripheral.rs b/src/winrtble/peripheral.rs index 7924d678..fb2c4d8a 100644 --- a/src/winrtble/peripheral.rs +++ b/src/winrtble/peripheral.rs @@ -185,17 +185,56 @@ impl Peripheral { if let Ok(data_sections) = advertisement.DataSections() { // See if we have any advertised service data before taking a lock to update... let mut found_service_data = false; + let mut manual_local_name: Option = None; + let mut has_complete_name = false; for section in &data_sections { match section.DataType().unwrap() { advertisement_data_type::SERVICE_DATA_16_BIT_UUID | advertisement_data_type::SERVICE_DATA_32_BIT_UUID | advertisement_data_type::SERVICE_DATA_128_BIT_UUID => { found_service_data = true; - break; + } + advertisement_data_type::COMPLETE_LOCAL_NAME => { + let data = utils::to_vec(§ion.Data().unwrap()); + if let Ok(name) = String::from_utf8(data) { + let name = name.trim_end_matches('\0').trim().to_string(); + if !name.is_empty() { + manual_local_name = Some(name); + has_complete_name = true; + } + } + } + advertisement_data_type::SHORT_LOCAL_NAME => { + // Only use SHORT_LOCAL_NAME if we haven't already found a COMPLETE_LOCAL_NAME + if !has_complete_name { + let data = utils::to_vec(§ion.Data().unwrap()); + if let Ok(name) = String::from_utf8(data) { + let name = name.trim_end_matches('\0').trim().to_string(); + if !name.is_empty() { + manual_local_name = Some(name); + } + } + } } _ => {} } } + + if let Some(name) = manual_local_name { + if !name.is_empty() { + let existing = self.shared.local_name.read().unwrap().clone(); + // Only update if: (1) no existing name, or (2) this is a COMPLETE_LOCAL_NAME, + // or (3) new name is longer (prevents SHORT from overwriting COMPLETE across packets) + let should_update = match &existing { + None => true, + Some(old) => has_complete_name || name.len() > old.len(), + }; + if should_update { + *self.shared.local_name.write().unwrap() = Some(name.clone()); + self.emit_event(CentralEvent::DeviceUpdated(self.shared.address.into())); + } + } + } if found_service_data { let mut service_data_guard = self.shared.latest_service_data.write().unwrap();