All notable changes to blew are documented here. Format follows
Keep a Changelog.
CentralConfig::connect_timeout: Option<Duration>— deadline applied toCentral::connect().Defaultsets it to 15s;Nonerestores pre-0.3 unbounded-wait behavior. Applied uniformly across Apple, Linux, and Android backends; emitsCentralEvent::DeviceDisconnected { cause: Timeout }when it fires.BlewError::ConnectTimedOut(DeviceId)— returned fromCentral::connectwhen the new deadline elapses. Distinct fromBlewError::Timeout, which remains reserved for adapter-readiness waits (wait_ready/wait_powered).BlewError::ConnectInFlight(DeviceId)— returned whenCentral::connectis called for a device that already has a connect in flight. Previously the second caller silently stole the first caller's completion.
- Android
Central::disconnectnow awaits theonConnectionStateChangecallback (with a 2-second fallback) instead of returningOkimmediately after dispatching the JNI call. This prevents zombieBluetoothGatthandles when the callback never arrives — the fallback path calls a new KotlinforceClose(addr)that flushes the service cache (refresh()) and closes the GATT handle synchronously, freeing the client-IF slot. - Android post-133 cleanup.
onConnectionStateChange(DISCONNECTED, status=133)now calls the hiddenBluetoothGatt.refresh()beforeclose()to flush the client-side service cache. Theconnect()stale-cleanup path also callsrefresh()on the old handle and waits ~300ms before issuing the freshconnectGatt(), matching the canonical Android BLE back-off recommendation. - Linux
Central::connectdeadline is now config-driven (viaCentralConfig::connect_timeout) and returnsBlewError::ConnectTimedOuton elapse. Previously it was hardcoded to 30s and returned the genericBlewError::Timeout.
Central::connect()could hang indefinitely on Android ifonConnectionStateChangenever fired (e.g. radio busy, adapter thrash, status-133 zombie). The newconnect_timeoutdefault now bounds this; callers that need the old behavior can opt in withCentralConfig { connect_timeout: None, .. }.- Overlapping
Central::connect()calls for the same device no longer silently orphan the first caller's completion channel. Android, Apple: the second caller receivesBlewError::ConnectInFlightimmediately. - Apple
Central::connectsilently evicted any pending completion channel when called twice concurrently for the same device, leaving the first caller hanging. Now rejected withConnectInFlight.
- Pin
kotlinx-coroutines-androidto 1.7.3 in the Android module for Tauri compatibility. 1.10.x conflicts with the coroutines version Tauri's own Android runtime pulls in.
- Android Gradle module now declares
kotlinx-coroutines-androidas animplementationdependency. The Kotlin sources (BleCentralManager,GattOperationQueue) importkotlinx.coroutines.*but the coroutine runtime was previously only pulled in transitively via the host app, which failed for consumers that didn't already depend on it.
tauri-plugin-blewno longer fails to locate the Android Kotlin sources when consumed from crates.io. Previouslybuild.rsresolved../blew/androidrelative to its own manifest, which works in the workspace but points to a non-existent sibling in the published tarball (Gradle then reported "No variants exist").blewnow emits its Android directory via thelinks = "blew"metadata channel, andtauri-plugin-blewreads it asDEP_BLEW_ANDROID_DIRat build time.
- iOS state restoration for both roles.
Central::with_configandPeripheral::with_configaccept arestore_identifier; after construction,take_restored()drains the peripherals/services preserved bywillRestoreState:. Available only on Apple targets (target_vendor = "apple"). - Single-consumer GATT request stream on
Peripheral::take_requests(), yieldingPeripheralRequestvalues with RAIIReadResponder/WriteResponderhandles — the type system enforces that exactly one consumer owns incoming requests. - In-memory mock backends (
testing::MockLink,MockCentral,MockPeripheral) behind thetestingfeature, with fault injection for BLE-semantic edge cases (post-disconnect ops, adapter off, duplicate subscribes, dropped notifications). - Two-host integration examples:
integration_central,integration_peripheral(GATT + L2CAP speedtest with live progress), andrestore.rs(iOS launch sequence). - Typed error variants:
StreamClosed,DisconnectedDuringOperation,DiscoveryFailed. - Moved the long platform notes and bare Android setup guide out of the top-level
README into
docs/platform-notes.mdanddocs/android-without-tauri.md. tauri-plugin-blew:BlewPluginConfig+init_with_config()to opt out of auto-requesting Android BLE permissions at plugin load, andrequest_ble_permissions()to trigger the runtime dialog on demand (e.g. after an in-app explanation modal). Default behavior (init()) is unchanged.blew::platform::android::request_ble_permissions()— fire-and-forget helper that invokes the Tauri plugin's static method over JNI to show the Android runtime-permissions dialog. Requires the Tauri plugin to have loaded.tauri-plugin-blew::permission_events()+BlePermissionStatus— broadcast stream emitted whenever the aggregate Android BLE-permission state flips between granted and denied. Detected inBlewPlugin.onResume, so it covers both in-app dialog responses and out-of-app toggles (e.g. the user disabling a permission in system Settings while the app is backgrounded).
- Apple L2CAP transport rewritten. Replaced per-channel worker threads with a
single
NSRunLoopreactor; all channels now share one run-loop with explicit register/write/close commands over anmpsc. Fixes prior close/shutdown bugs. - Peripheral events split. The old unified
PeripheralEventstream is gone. State-like events (adapter power, subscription changes) fan out viaPeripheral::state_events(); inbound GATT read/write requests go toPeripheral::take_requests(). - Event fan-out unified on
tokio::sync::broadcast. The bespokeEventFanoututility was removed. All three Central backends and Peripheral state streams usebroadcast::channel(256)wrapped inBroadcastEventStream(silently dropsLaggederrors). Slow subscribers now miss events but stay connected; the old behavior was to disconnect slow subscribers entirely. - L2CAP accept channels unbounded on Apple and Android. The previous
bounded(16) could block the CoreBluetooth GCD delegate queue on Apple and
silently drop incoming channels on Android. Linux keeps its bounded +
awaitdesign so pressure flows through BlueZ's kernel socket queue. - Android auto-requests MTU 512 on connect; per-device Kotlin coroutine queue serializes GATT ops so one slow peer can't block others.
- Linux
Central::connectnow times out after 30s rather than blocking indefinitely against BlueZ.
PeripheralEventenum. Usestate_events()+take_requests().Central::take_restored()/Peripheral::take_restored()from the sealed-trait surface on Android and Linux. The method exists only on Apple.util::event_fanoutmodule (EventFanout,EventFanoutTx).- The
Restoredvariant previously emitted on the event stream (iOS state restoration now flows throughtake_restored()instead of an event).
- Apple L2CAP close hook no longer fires twice when the outbound bridge task
observes EOF — the
DuplexTransportdrop impl is now the single sender. - Multiple backend races and GATT-queue bugs shaken out by the new test
harness (see
test(mock)andfix:commits in the history). - Linux clippy pedantic cleanup across the BlueZ backend.
If you were previously handling PeripheralEvent:
// Before (0.1.x)
let mut events = peripheral.events();
while let Some(ev) = events.next().await {
match ev {
PeripheralEvent::AdapterStateChanged { .. } => { /* ... */ }
PeripheralEvent::ReadRequest { .. } => { /* ... */ }
PeripheralEvent::WriteRequest { .. } => { /* ... */ }
PeripheralEvent::SubscriptionChanged { .. } => { /* ... */ }
}
}// After (0.2.0) — state + requests are separate streams
let mut state = peripheral.state_events();
let mut requests = peripheral.take_requests()
.expect("requests stream already taken");
tokio::spawn(async move {
while let Some(ev) = state.next().await {
match ev {
PeripheralStateEvent::AdapterStateChanged { .. } => { /* ... */ }
PeripheralStateEvent::SubscriptionChanged { .. } => { /* ... */ }
}
}
});
while let Some(req) = requests.next().await {
match req {
PeripheralRequest::Read { responder, .. } => responder.respond(Ok(b"...".into())),
PeripheralRequest::Write { responder, .. } => responder.respond(Ok(())),
}
}Key points:
take_requests()returnsOption— the first caller getsSome, the rest getNone. Requests are single-consumer by design (theReadResponder/WriteResponderhandles move into the consumer and respond via RAII).state_events()can be called multiple times; each caller gets an independent broadcast receiver.
If you were calling take_restored() on non-Apple platforms:
// Before — compiled everywhere, returned None on non-Apple
if let Some(devices) = central.take_restored() { /* ... */ }
// After — cfg-gate the call, or just delete it
#[cfg(target_vendor = "apple")]
if let Some(devices) = central.take_restored() { /* ... */ }If you were matching on BlewError::Internal(_):
Some call sites now return typed variants. Match them explicitly, or keep a catch-all:
match err {
BlewError::StreamClosed => /* previously Internal("...") */,
BlewError::DisconnectedDuringOperation(_) => /* previously Internal("...") */,
BlewError::DiscoveryFailed(_) => /* previously Internal("...") */,
BlewError::Internal(msg) => /* remaining JNI/NSError fallbacks */,
_ => { /* ... */ }
}If you were using EventFanout or EventFanoutTx directly:
These were pub use'd from util but are removed. If you reached into them
(unlikely — they were primarily a backend implementation detail), switch to
tokio::sync::broadcast + BroadcastEventStream from util::event_stream.
If you were constructing CentralConfig as a struct literal, it gains a
new field:
// Before
let config = CentralConfig { restore_identifier: Some("...".into()) };
// After — either spread the default or set the new field explicitly
let config = CentralConfig {
restore_identifier: Some("...".into()),
..CentralConfig::default()
};If you were matching on BlewError::Timeout for a connect failure on
Linux, the variant narrowed to BlewError::ConnectTimedOut(DeviceId):
// Before — Linux connect timeout returned the generic variant
match central.connect(&id).await {
Err(BlewError::Timeout) => /* ... */,
_ => /* ... */,
}
// After
match central.connect(&id).await {
Err(BlewError::ConnectTimedOut(_)) => /* ... */,
_ => /* ... */,
}BlewError::Timeout is still used by Central::wait_ready,
Peripheral::wait_ready, and wait_powered.
If you relied on unbounded connect waits, set
CentralConfig.connect_timeout = None when constructing the central:
let central: Central = Central::with_config(CentralConfig {
connect_timeout: None,
..CentralConfig::default()
}).await?;If overlapping connect() calls were previously "works by luck": the
second caller now receives BlewError::ConnectInFlight(DeviceId) immediately
on Apple and Android. If you need shared-completion fan-out semantics,
implement them at your layer (e.g. wrap the shared future in
futures::future::Shared). Linux still permits concurrent connects via
bluer's own state machine.
Initial release.