A resilience-first, test-first, Android-only coroutines/Flow BLE library: it mitigates GATT 133 correctly by default, and it ships a fake transport so you can unit-test your app's BLE logic without hardware. The library itself is tested against the same fakes.
Error 133 is a non-deterministic catch-all symptom (stack resource exhaustion, too-fast reconnect, signal, OEM-stack quirks). blease mitigates it correctly; it does not claim to "solve" it. Naive retry-on-133 makes things worse, so retries are capped, backed off, and jittered.
Most Kotlin BLE libraries give you clean coroutine primitives but leave resilience and testing to you. blease makes both the default:
- GATT 133 mitigation by default: one GATT operation at a time, explicit
close()on every non-connected transition, never reuse aBluetoothGatt, capped jittered exponential backoff before reconnect. - Testability without hardware, as a product. A scriptable fake transport (
:blease-test) that your app depends on to unit-test your BLE logic withrunTestand Turbine. No phone, no flake, milliseconds on CI. - At-most-once writes by default. Writes are never auto-retried, because after a mid-write disconnect you cannot know whether the write landed. Connect, read and discovery retry freely; writes opt into retry explicitly.
| Module | What it is |
|---|---|
:blease-core |
Public API, real Android transport, resilience layer. |
:blease-test |
FakeBleTransport / FakeGattSession, published for consumer tests. |
:sample |
Compose demo: device discovery, manual-vs-blease battery read, live heart rate. |
// Characteristics are addressed by service + characteristic UUID (a characteristic
// UUID alone is ambiguous in GATT).
val batteryLevel = BleCharacteristic(
service = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb"),
characteristic = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb"),
)
val client = Blease.client(context, scope) // one-line wiring
val connection = client.connect("99:26:A7:C5:A8:92") // connect + discover + retry on 133
when (val battery = connection.read(batteryLevel)) {
is BleResult.Success -> println("Battery: ${battery.value.first()}%")
is BleResult.Failure -> println("Failed: ${battery.error}")
}
connection.disconnect()Per-operation failures are values: read, write, requestMtu and the rest return a sealed
BleResult<T> (Success or Failure(BleError)), so you get an exhaustive when instead of
try/catch. Connection death is state: BleConnection.state: StateFlow<ConnectionState>, and
it cancels in-flight operation coroutines (structured concurrency).
:blease-test is a real published artifact, not an internal fixture:
@Test
fun `low battery warning shows`() = runTest {
val session = FakeGattSession(reads = mapOf(batteryLevel to FakeResponse.of(byteArrayOf(12))))
val transport = FakeBleTransport(onConnect = { BleResult.Success(session) })
val client = DefaultBleClient(transport, backgroundScope)
val battery = (client.connect("AA:BB:CC:DD:EE:FF").read(batteryLevel)
as BleResult.Success).value.first()
assertEquals(12, battery)
}You can script transient failures (for example a GATT 133 N times, then success) to test
your own retry handling and UX without owning a device. See blease-core/src/test for worked
examples.
// settings.gradle.kts
dependencyResolutionManagement {
repositories { maven("https://jitpack.io") }
}
// app build.gradle.kts
dependencies {
implementation("com.github.tjokinen.blease:blease-core:0.1.0")
testImplementation("com.github.tjokinen.blease:blease-test:0.1.0")
}./gradlew dokkaGenerate
Generates HTML API docs per module under blease-core/build/dokka/html and
blease-test/build/dokka/html.
Android 12+ (API 31+) needs runtime BLUETOOTH_CONNECT (and BLUETOOTH_SCAN for scanning).
A missing permission can silently surface as a 133, so blease maps it to
BleError.PermissionMissing instead. Declare and request them in your app.
blease is experimental (0.x): the public API can still change between minor versions until 1.0 (it is tracked with committed ABI dumps, so changes are always explicit). It is built test-first against the fake harness and verified against real hardware (a smartwatch's Battery and Heart Rate services), so far on a single phone.
- 133 behaviour is OEM-stack specific. Full validation needs two or three phones (for
example Pixel plus Samsung); a single phone cannot fully validate resilience. See
docs/resilience.md. - Toggling the adapter off is handled at connect time and mid-connection: the session
listens for the adapter state broadcast, tears down deterministically, and surfaces
Disconnected(cause = BluetoothDisabled)rather than a generic disconnect. - CCCD notification teardown is best-effort. Enabling notifications goes through the serialized op gate, but the disable write when a stream is cancelled is fire-and-forget (the link is usually being torn down at that point anyway).