Skip to content

tjokinen/blease

Repository files navigation

blease

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.

Why it exists

Most Kotlin BLE libraries give you clean coroutine primitives but leave resilience and testing to you. blease makes both the default:

  1. GATT 133 mitigation by default: one GATT operation at a time, explicit close() on every non-connected transition, never reuse a BluetoothGatt, capped jittered exponential backoff before reconnect.
  2. Testability without hardware, as a product. A scriptable fake transport (:blease-test) that your app depends on to unit-test your BLE logic with runTest and Turbine. No phone, no flake, milliseconds on CI.
  3. 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.

Modules

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.

Quick start

// 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()

Error model: two channels, on purpose

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).

Testing your app without hardware

: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.

Install (JitPack)

// 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")
}

API docs

./gradlew dokkaGenerate

Generates HTML API docs per module under blease-core/build/dokka/html and blease-test/build/dokka/html.

Permissions

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.

Status and limitations

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).

License

Apache 2.0.

About

Resilience-first Android BLE library: coroutines/Flow API, GATT 133 mitigation by default, hardware-free testing via a published fake transport

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages