Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bluetooth Devices disconnect upon bluetooth sensor switch #405

Closed
wants to merge 8 commits into from
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import com.splendo.kaluga.bluetooth.Service
import com.splendo.kaluga.bluetooth.UUID
import com.splendo.kaluga.bluetooth.containsAnyOf
import com.splendo.kaluga.bluetooth.uuidString
import com.splendo.kaluga.logging.info
import com.splendo.kaluga.logging.warn
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
Expand Down Expand Up @@ -111,15 +112,18 @@ internal actual class DeviceConnectionManager(
}

override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
warn(TAG) { "onConnectionStateChange($status): $lastKnownState -> $newState" }
lastKnownState = newState
launch(mainDispatcher) {
when (newState) {
BluetoothProfile.STATE_DISCONNECTED -> {
info(TAG) { "onConnectionStateChange($status): disconnect" }
handleDisconnect {
closeGatt()
}
}
BluetoothProfile.STATE_CONNECTED -> {
info(TAG) { "onConnectionStateChange($status): Connect" }
handleConnect()
}
}
Expand Down
7 changes: 5 additions & 2 deletions bluetooth/src/commonMain/kotlin/Bluetooth.kt
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,12 @@ fun Flow<Device?>.services(): Flow<List<Service>> {
suspend fun Flow<Device?>.connect() {
state().transformLatest { deviceState ->
when (deviceState) {
is Disconnected -> deviceState.startConnecting()
is Connected -> emit(Unit)
is Connecting, is Reconnecting, is Disconnecting -> { }
is Disconnected.NotConnected -> deviceState.startConnecting()
is Disconnected.WaitingForBluetooth,
is Disconnecting,
is Connecting,
is Reconnecting -> { }
}
}.first()
}
Expand Down
43 changes: 43 additions & 0 deletions bluetooth/src/commonMain/kotlin/device/DeviceConnectionManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,46 @@ abstract class BaseDeviceConnectionManager(
}
}

suspend fun handleBluetoothStateChange(isOn: Boolean) {
val clean = suspend {
currentAction = null
notifyingCharacteristics.clear()
}

stateRepo.takeAndChangeState { state ->
if (!isOn) state.noBluetooth
else when (state) {
is DeviceState.Disconnected.WaitingForBluetooth -> state.onBluetoothAvailable().also {
if (it == state.didDisconnect) {
clean()
}
}
else -> state.remain()
// is DeviceState.Reconnecting -> {
// state.retry().also {
// if (it == state.didDisconnect) {
// clean()
// }
// }
// }
// is DeviceState.Connected -> when (connectionSettings.reconnectionSettings) {
// is ConnectionSettings.ReconnectionSettings.Always,
// is ConnectionSettings.ReconnectionSettings.Limited -> state.reconnect
// is ConnectionSettings.ReconnectionSettings.Never -> {
// clean()
// state.didDisconnect
// }
// }
// is DeviceState.Disconnected -> state.remain()
// is DeviceState.Connecting,
// is DeviceState.Disconnecting -> {
// clean()
// state.didDisconnect
// }
}
}
}

suspend fun handleScanCompleted(services: List<Service>) {
stateRepo.takeAndChangeState { state ->
when (state) {
Expand Down Expand Up @@ -178,6 +218,9 @@ abstract class BaseDeviceConnectionManager(
handleCurrentActionCompleted(succeeded)
}
}

private suspend fun handleNoBluetooth() {
}
}

internal expect class DeviceConnectionManager : BaseDeviceConnectionManager
71 changes: 55 additions & 16 deletions bluetooth/src/commonMain/kotlin/device/DeviceState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ sealed class DeviceState(
connectionManager.disconnect()
}
}

data class Connecting constructor(
override val deviceInfo: DeviceInfoImpl,
override val connectionManager: BaseDeviceConnectionManager
Expand Down Expand Up @@ -252,25 +253,47 @@ sealed class DeviceState(
}
}

data class Disconnected constructor(
sealed class Disconnected constructor(
override val deviceInfo: DeviceInfoImpl,
override val connectionManager: BaseDeviceConnectionManager
) : DeviceState(deviceInfo, connectionManager) {

fun startConnecting() = connectionManager.stateRepo.launchTakeAndChangeState(
coroutineContext,
remainIfStateNot = Disconnected::class
) { deviceState ->
connect(deviceState)
data class WaitingForBluetooth constructor(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is something to discuss. This state represents Device when Bluetooth is turned off by the system. On iOS it means all services/characteristic objects are invalid (we can't read/write from them anymore). Also as far as I know Identifier for Bluetooth peripheral autogenerated by system at the time of discovery (compare to Android, where it's always MAC address of the device). We have to check if keeping Identifier somewhere and doing connection/reconnection/bluetooth/on/off after next scan we can access to the same device by this Identifier.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I haven't thought of that cause I din't know about this requirement for ios.
Is true that I have been having re-connect issues on iOs for a project using this lib, at this specific branch, but I was blaming a different part of the code for it.
(we disabled automatic reconnection on that project, due to un-stable behaviour in general)
Interesting to know: I should now check this again, following on what you pointed out.

In my understanding, was enough to make sure Not to save services/characteristics the same way the original code was already doing upon other types of disconnection.
You can see in this file, at line 346, that internal val noBluetooth = suspend {} function passes a null for those.
In my understanding, that should have been enough to guarantee that re-connecting would fetch services/characteristics again.

Long story short, I will check the feasibility/impact of keeping Identifier in the state.
Is a bit unfortunate that I don't have with me the device we use on the other project, but I will check what I can do using our own example.

override val deviceInfo: DeviceInfoImpl,
override val connectionManager: BaseDeviceConnectionManager,
val services: List<Service>?
) : Disconnected(deviceInfo, connectionManager) {

fun onBluetoothAvailable(): suspend () -> DeviceState = suspend {
val shouldReconnect = connectionManager.connectionSettings.reconnectionSettings != ConnectionSettings.ReconnectionSettings.Never
if (shouldReconnect && deviceInfo.advertisementData.isConnectible) {
Reconnecting(0, services, deviceInfo, connectionManager)
} else {
NotConnected(deviceInfo, connectionManager)
}
}
}

fun connect(deviceState: DeviceState): suspend () -> DeviceState =
if (deviceInfo.advertisementData.isConnectible)
suspend {
Connecting(deviceInfo, connectionManager)
}
else
deviceState.remain()
data class NotConnected constructor(
override val deviceInfo: DeviceInfoImpl,
override val connectionManager: BaseDeviceConnectionManager
) : Disconnected(deviceInfo, connectionManager) {

fun startConnecting() = connectionManager.stateRepo.launchTakeAndChangeState(
coroutineContext,
remainIfStateNot = NotConnected::class
) { deviceState ->
connect(deviceState)
}

fun connect(deviceState: DeviceState): suspend () -> DeviceState =
if (deviceInfo.advertisementData.isConnectible)
suspend {
Connecting(deviceInfo, connectionManager)
}
else
deviceState.remain()
}
}

data class Disconnecting constructor(
Expand Down Expand Up @@ -304,13 +327,29 @@ sealed class DeviceState(
is Connecting -> copy(deviceInfo = newDeviceInfo)
is Reconnecting -> copy(deviceInfo = newDeviceInfo)
is Disconnecting -> copy(deviceInfo = newDeviceInfo)
is Disconnected -> copy(deviceInfo = newDeviceInfo)
is Disconnected.WaitingForBluetooth -> copy(deviceInfo = newDeviceInfo)
is Disconnected.NotConnected -> copy(deviceInfo = newDeviceInfo)
}
}
}

fun bluetoothSwitchedOff(): suspend () -> DeviceState =
when (this) {
is Connecting,
is Reconnecting,
is Connected -> noBluetooth
is Disconnecting -> didDisconnect
is Disconnected.NotConnected,
is Disconnected.WaitingForBluetooth -> remain()
}

internal val noBluetooth = suspend {
// All services, characteristics and descriptors become invalidated after it disconnects
Disconnected.WaitingForBluetooth(deviceInfo, connectionManager, null)
}

internal val didDisconnect = suspend {
Disconnected(deviceInfo, connectionManager)
Disconnected.NotConnected(deviceInfo, connectionManager)
}

internal val disconnecting = suspend {
Expand All @@ -327,7 +366,7 @@ class Device constructor(
coroutineContext = coroutineContext,
initialState = {
val deviceConnectionManager = connectionBuilder.create(connectionSettings, initialDeviceInfo.deviceWrapper, it)
DeviceState.Disconnected(initialDeviceInfo, deviceConnectionManager)
DeviceState.Disconnected.NotConnected(initialDeviceInfo, deviceConnectionManager)
}
) {
val identifier: Identifier
Expand Down
14 changes: 14 additions & 0 deletions bluetooth/src/commonMain/kotlin/scanner/Scanner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import com.splendo.kaluga.base.flow.filterOnlyImportant
import com.splendo.kaluga.bluetooth.BluetoothMonitor
import com.splendo.kaluga.bluetooth.UUID
import com.splendo.kaluga.bluetooth.device.AdvertisementData
import com.splendo.kaluga.bluetooth.device.BaseDeviceConnectionManager
import com.splendo.kaluga.bluetooth.device.ConnectionSettings
import com.splendo.kaluga.bluetooth.device.Device
import com.splendo.kaluga.bluetooth.device.Identifier
Expand Down Expand Up @@ -148,10 +149,14 @@ abstract class BaseScanner constructor(

fun bluetoothEnabled() = stateRepo.launchTakeAndChangeState(remainIfStateNot = Disabled::class) {
it.enable
}.also {
notifyPeripherals { handleBluetoothStateChange(isOn = true) }
}

fun bluetoothDisabled() = stateRepo.launchTakeAndChangeState(remainIfStateNot = Enabled::class) {
it.disable
}.also {
notifyPeripherals { handleBluetoothStateChange(isOn = false) }
}

internal fun handleDeviceDiscovered(identifier: Identifier, rssi: Int, advertisementData: AdvertisementData, deviceCreator: () -> Device) =
Expand All @@ -173,6 +178,15 @@ abstract class BaseScanner constructor(
else -> bluetoothDisabled()
}
}

private fun notifyPeripherals(block: suspend BaseDeviceConnectionManager.() -> Unit) = suspend {
stateRepo.launchUseState { scannerState ->
if (scannerState is ScanningState.Initialized.Enabled)
scannerState.discovered.devices.forEach { device ->
block(device.peekState().connectionManager)
}
}
}
}

expect class Scanner : BaseScanner
15 changes: 14 additions & 1 deletion bluetooth/src/iosMain/kotlin/scanner/Scanner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,28 @@ actual class Scanner internal constructor(
private class PoweredOnCBCentralManagerDelegate(private val scanner: Scanner, private val isEnabledCompleted: EmptyCompletableDeferred) : NSObject(), CBCentralManagerDelegateProtocol {

override fun centralManagerDidUpdateState(central: CBCentralManager) = mainContinuation {
if (central.state == CBCentralManagerStatePoweredOn) {
val isEnabled = central.state == CBCentralManagerStatePoweredOn
if (isEnabled) {
isEnabledCompleted.complete()
}
notifyPeripherals {
handleBluetoothStateChange(isOn = isEnabled)
}
}.invoke()

override fun centralManager(central: CBCentralManager, didDiscoverPeripheral: CBPeripheral, advertisementData: Map<Any?, *>, RSSI: NSNumber) {
scanner.discoverPeripheral(central, didDiscoverPeripheral, advertisementData.typedMap(), RSSI.intValue)
}

fun notifyPeripherals(block: suspend BaseDeviceConnectionManager.() -> Unit) = mainContinuation {
scanner.stateRepo.launchUseState { scannerState ->
if (scannerState is ScanningState.Initialized.Enabled)
scannerState.discovered.devices.forEach { device ->
block(device.peekState().connectionManager)
}
}
}.invoke()

fun handlePeripheral(didConnectPeripheral: CBPeripheral, block: suspend BaseDeviceConnectionManager.() -> Unit) = mainContinuation {
scanner.stateRepo.launchUseState { scannerState ->
if (scannerState is ScanningState.Initialized.Enabled)
Expand Down