Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
.kotlin/
build/
local.properties
repo
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ plugins {
alias(libs.plugins.atomicfu) apply false
alias(libs.plugins.dokka)
alias(libs.plugins.api)
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.android.application) apply false
alias(libs.plugins.compose.compiler) apply false
}

tasks.dokkaHtmlMultiModule.configure {
Expand Down
30 changes: 29 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ coroutines = "1.8.1"
jvm-toolchain = "11"
kotlin = "2.0.20"
tuulbox = "8.0.0"
kotlin-version = "1.9.0"
junit = "4.13.2"
junit-version = "1.2.1"
espresso-core = "3.6.1"
appcompat = "1.7.0"
material = "1.12.0"
agp = "8.5.1"
lifecycle-runtime-ktx = "2.8.6"
activity-compose = "1.9.2"
compose-bom = "2024.04.01"

[libraries]
androidx-core = { module = "androidx.core:core-ktx", version = "1.13.1" }
Expand All @@ -22,12 +32,30 @@ tuulbox-coroutines = { module = "com.juul.tuulbox:coroutines", version.ref = "tu
uuid = { module = "com.benasher44:uuid", version = "0.8.4" }
wrappers-bom = { module = "org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom", version = "1.0.0-pre.814" }
wrappers-web = { module = "org.jetbrains.kotlin-wrappers:kotlin-web" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit-version" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }

[plugins]
android-library = { id = "com.android.library", version = "8.6.1" }
android-library = { id = "com.android.library", version = "8.5.1" }
api = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.16.3" }
atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" }
dokka = { id = "org.jetbrains.dokka", version = "1.9.20" }
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinter = { id = "org.jmailen.kotlinter", version = "4.4.1" }
maven-publish = { id = "com.vanniktech.maven.publish", version = "0.29.0" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin-version" }
android-application = { id = "com.android.application", version.ref = "agp" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
9 changes: 9 additions & 0 deletions kable-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ plugins {
id("com.vanniktech.maven.publish")
}

publishing {
repositories {
maven {
name = "LocalRepo"
url = uri(project.rootDir.resolve("repo").absolutePath)
}
}
}

kotlin {
explicitApi()
jvmToolchain(libs.versions.jvm.toolchain.get().toInt())
Expand Down
8 changes: 8 additions & 0 deletions kable-core/src/androidMain/kotlin/AndroidPeripheral.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothStatusCodes
import android.os.Build
import androidx.annotation.RequiresPermission
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow

@Deprecated(
Expand Down Expand Up @@ -160,4 +161,11 @@ public interface AndroidPeripheral : Peripheral {
* is negotiated.
*/
public val mtu: StateFlow<Int?>



/**
* [Flow] of the most recent [BondState] of the [AndroidPeripheral].
*/
public val bondState: Flow<PlatformAdvertisement.BondState>
}
134 changes: 122 additions & 12 deletions kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package com.juul.kable
import android.bluetooth.BluetoothAdapter.STATE_OFF
import android.bluetooth.BluetoothAdapter.STATE_TURNING_OFF
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED
import android.bluetooth.BluetoothDevice.DEVICE_TYPE_CLASSIC
import android.bluetooth.BluetoothDevice.DEVICE_TYPE_DUAL
import android.bluetooth.BluetoothDevice.DEVICE_TYPE_LE
import android.bluetooth.BluetoothDevice.DEVICE_TYPE_UNKNOWN
import android.bluetooth.BluetoothDevice.EXTRA_BOND_STATE
import android.bluetooth.BluetoothDevice.EXTRA_DEVICE
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic.PROPERTY_INDICATE
import android.bluetooth.BluetoothGattCharacteristic.PROPERTY_NOTIFY
Expand All @@ -15,6 +18,8 @@ import android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
import android.bluetooth.BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE
import android.bluetooth.BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
import android.bluetooth.BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
import android.content.IntentFilter
import androidx.core.content.IntentCompat
import com.juul.kable.AndroidPeripheral.Priority
import com.juul.kable.AndroidPeripheral.Type
import com.juul.kable.State.Disconnected
Expand All @@ -37,9 +42,22 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration
import android.bluetooth.BluetoothDevice.ERROR
import android.bluetooth.BluetoothDevice.BOND_BONDED
import android.bluetooth.BluetoothDevice.BOND_BONDING
import android.bluetooth.BluetoothDevice.BOND_NONE
import androidx.core.content.ContextCompat
import com.juul.tuulbox.coroutines.flow.broadcastReceiverFlow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch

// Number of service discovery attempts to make if no services are discovered.
// https://github.com/JuulLabs/kable/issues/295
Expand All @@ -57,6 +75,15 @@ internal class BluetoothDeviceAndroidPeripheral(
private val disconnectTimeout: Duration,
) : BasePeripheral(bluetoothDevice.toString()), AndroidPeripheral {

private fun mapBondState(state: Int): PlatformAdvertisement.BondState {
return when (state) {
BOND_NONE -> PlatformAdvertisement.BondState.None
BOND_BONDING -> PlatformAdvertisement.BondState.Bonding
BOND_BONDED -> PlatformAdvertisement.BondState.Bonded
else -> error("Unsupported bond state: $state")
}
}

init {
onBluetoothDisabled { state ->
logger.debug {
Expand All @@ -65,9 +92,36 @@ internal class BluetoothDeviceAndroidPeripheral(
}
disconnect()
}

this.launch(Dispatchers.Main) {
broadcastReceiverFlow(
IntentFilter(ACTION_BOND_STATE_CHANGED),
flags = ContextCompat.RECEIVER_EXPORTED
)
.onEach {
logger.debug { message = "Bond state changed ${it.data}" }
}
.filter { intent ->
bluetoothDevice == IntentCompat.getParcelableExtra(
intent,
EXTRA_DEVICE,
BluetoothDevice::class.java,
)
}
.map { intent -> intent.getIntExtra(EXTRA_BOND_STATE, ERROR) }
.map {
mapBondState(it)
}.onEach {
logger.debug { message = "Bond state changed" }
}
.collect {
logger.debug { message = "Bond state collected" }
_bondState.tryEmit(it)
}

}
}

private val connectAction = sharedRepeatableAction(::establishConnection)

override val identifier: String = bluetoothDevice.address
private val logger = Logger(logging, "Kable/Peripheral", bluetoothDevice.toString())
Expand Down Expand Up @@ -98,9 +152,27 @@ internal class BluetoothDeviceAndroidPeripheral(
override val name: String?
get() = bluetoothDevice.name

private suspend fun establishConnection(scope: CoroutineScope): CoroutineScope {
checkBluetoothIsOn()

val _bondState = MutableSharedFlow<PlatformAdvertisement.BondState>(
replay = 1,
extraBufferCapacity = 2,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)

//Init with the initial state
override val bondState: Flow<PlatformAdvertisement.BondState> =
_bondState.asSharedFlow()


/**
* While connection exists, we will listen for bond state changes
* That way we can update the bond state and avoid race conditions
*/
private suspend fun establishConnection(
scope: CoroutineScope,
waitBonding: Boolean,
): CoroutineScope {
checkBluetoothIsOn()
logger.info { message = "Connecting" }
_state.value = State.Connecting.Bluetooth

Expand All @@ -120,6 +192,12 @@ internal class BluetoothDeviceAndroidPeripheral(
disconnectTimeout,
) ?: throw ConnectionRejectedException()

if (waitBonding) {
logger.debug { message = "Awaiting bond state" }
val bond = bondState.first { it != PlatformAdvertisement.BondState.Bonded }
logger.debug { message = "Bond state: $bond" }
}

suspendUntil<State.Connecting.Services>()
discoverServices()
configureCharacteristicObservations()
Expand All @@ -141,11 +219,13 @@ internal class BluetoothDeviceAndroidPeripheral(
observers.onConnected()
}

override suspend fun connect(): CoroutineScope =
connectAction.await()
override suspend fun connect(waitBonding: Boolean): CoroutineScope =
sharedRepeatableAction { scope ->
establishConnection(scope, waitBonding)
}.await()

override suspend fun disconnect() {
connectAction.cancelAndJoin(
sharedRepeatableAction {}.cancelAndJoin(
CancellationException(NotConnectedException("Disconnect requested")),
)
}
Expand Down Expand Up @@ -194,8 +274,20 @@ internal class BluetoothDeviceAndroidPeripheral(
}

val platformCharacteristic = servicesOrThrow().obtain(characteristic, writeType.properties)
connectionOrThrow().execute<OnCharacteristicWrite> {
writeCharacteristicOrThrow(platformCharacteristic, data, writeType.intValue)
try {
connectionOrThrow().execute<OnCharacteristicWrite> {
writeCharacteristicOrThrow(platformCharacteristic, data, writeType.intValue)
}
} catch (_: BondRequiredException) {
awaitBond()
logger.debug {
message = "Retrying write"
detail(platformCharacteristic)
detail(data, Operation.Write)
}
connectionOrThrow().execute<OnCharacteristicWrite> {
writeCharacteristicOrThrow(platformCharacteristic, data, writeType.intValue)
}
}
}

Expand All @@ -206,11 +298,21 @@ internal class BluetoothDeviceAndroidPeripheral(
message = "read"
detail(characteristic)
}

val platformCharacteristic = servicesOrThrow().obtain(characteristic, Read)
return connectionOrThrow().execute<OnCharacteristicRead> {
readCharacteristicOrThrow(platformCharacteristic)
}.value!!
return try {
return connectionOrThrow().execute<OnCharacteristicRead> {
readCharacteristicOrThrow(platformCharacteristic)
}.value!!
} catch (_: BondRequiredException) {
awaitBond()
logger.debug {
message = "Retrying read"
detail(platformCharacteristic)
}
connectionOrThrow().execute<OnCharacteristicRead> {
readCharacteristicOrThrow(platformCharacteristic)
}.value!!
}
}

override suspend fun write(
Expand Down Expand Up @@ -249,6 +351,12 @@ internal class BluetoothDeviceAndroidPeripheral(
}.value!!
}

private suspend fun awaitBond() {
logger.warn { message = "Insufficient authentication, awaiting bond" }
bondState.first { it == PlatformAdvertisement.BondState.Bonded }
logger.debug { message = "Bond established" }
}

override fun observe(
characteristic: Characteristic,
onSubscription: OnSubscriptionAction,
Expand Down Expand Up @@ -307,13 +415,15 @@ internal class BluetoothDeviceAndroidPeripheral(
}
write(configDescriptor, ENABLE_NOTIFICATION_VALUE)
}

characteristic.supportsIndicate -> {
logger.verbose {
message = "Writing ENABLE_INDICATION_VALUE to CCCD"
detail(configDescriptor)
}
write(configDescriptor, ENABLE_INDICATION_VALUE)
}

else -> logger.warn {
message = "Characteristic supports neither notification nor indication"
detail(characteristic)
Expand Down
Loading