Skip to content

fix(DCMAW-21804): Advertise after opening gatt server (#522) - #1

Open
covolution wants to merge 1 commit into
mainfrom
review/8e33f039
Open

fix(DCMAW-21804): Advertise after opening gatt server (#522)#1
covolution wants to merge 1 commit into
mainfrom
review/8e33f039

Conversation

@covolution

Copy link
Copy Markdown

DCMAW-00000: title of change

Evidence of the change

Checklist

  • Check against acceptance criteria
  • Add automated tests
  • Self-review code
  • Manually test changes on a physical device
    • Android <--> Android
    • Android <--> iOS

@covolution

Copy link
Copy Markdown
Author

/gemini help

@gemini-code-assist

Copy link
Copy Markdown
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

@covolution

Copy link
Copy Markdown
Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the Bluetooth GATT server and advertising flow to defer advertising until the GATT service is successfully added, and introduces a state check to reject incoming connections if the service is not yet ready. Feedback on these changes highlights a potential race condition where the advertising coroutine is launched on a scope that outlives the transport's lifecycle, thread-safety concerns regarding the connectedDevice property which should be marked @Volatile, and a missing state update to set isServiceReady to false when advertising fails.

Comment on lines +173 to +179
is GattServerEvent.ServiceAdded -> {
coroutineScope.launch(
ioDispatcher + "$logTag.StartAdvertising".asCoroutineName()
) {
startAdvertising(event.service.uuid)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

In handleGattEvent, when GattServerEvent.ServiceAdded is received, a coroutine is launched on coroutineScope (which is the ApplicationScope). Since coroutineScope is not cancelled when stop() is called, this coroutine can continue running even after the transport has been stopped. If stop() is called while startAdvertising is starting, it can lead to a race condition where advertising is started after the GATT server has been closed.

To fix this, we should launch the coroutine using a scope that is tied to the lifecycle of monitoringJob. We can create a CoroutineScope using coroutineScope.coroutineContext + monitoringJob so that the coroutine is automatically cancelled when monitoringJob is cancelled during stop().

Suggested change
is GattServerEvent.ServiceAdded -> {
coroutineScope.launch(
ioDispatcher + "$logTag.StartAdvertising".asCoroutineName()
) {
startAdvertising(event.service.uuid)
}
}
is GattServerEvent.ServiceAdded -> {
CoroutineScope(coroutineScope.coroutineContext + monitoringJob).launch(
ioDispatcher + "$logTag.StartAdvertising".asCoroutineName()
) {
startAdvertising(event.service.uuid)
}
}

override val events: SharedFlow<GattServerEvent> = _events
private var gattServer: BluetoothGattServer? = null
private var connectedDevice: BluetoothDevice? = null
internal var connectedDevice: BluetoothDevice? = null

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The connectedDevice property is read and written across multiple threads (e.g., the Android Bluetooth binder threads in handleConnectionStateChange and the application's ioDispatcher threads in cancelCurrentConnection and sendMessage). To ensure thread safety and proper visibility of updates across threads, connectedDevice should be marked with @Volatile.

Suggested change
internal var connectedDevice: BluetoothDevice? = null
@Volatile
internal var connectedDevice: BluetoothDevice? = null

Comment on lines +144 to +149
is AdvertiserState.Failed -> {
_state.value =
PeripheralBluetoothState.Error(
PeripheralBluetoothTransportError.ADVERTISING_FAILED
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

When the advertiser state transitions to AdvertiserState.Failed, isServiceReady is not updated. If advertising fails, the service is no longer ready to accept connections, so isServiceReady should be explicitly set to false to prevent accepting any incoming connections in an invalid state.

Suggested change
is AdvertiserState.Failed -> {
_state.value =
PeripheralBluetoothState.Error(
PeripheralBluetoothTransportError.ADVERTISING_FAILED
)
}
is AdvertiserState.Failed -> {
isServiceReady = false
_state.value =
PeripheralBluetoothState.Error(
PeripheralBluetoothTransportError.ADVERTISING_FAILED
)
}

@mattsalt123

Copy link
Copy Markdown

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the BLE peripheral transport layer to ensure connections are only accepted when the GATT service is fully ready, introducing a state-tracking mechanism and the ability to cancel connections. However, the reviewer identified critical issues with the changes: changing BluetoothGattService to non-nullable is risky as the Android framework can pass null on registration failure, potentially causing runtime crashes. Additionally, the reviewer pointed out a state leak during connection rejection due to a missing early return, and a potential race condition where advertising coroutines are launched on an unsupervised scope instead of being tied to the lifecycle of the monitoring job.

}

override fun onServiceAdded(status: Int, service: BluetoothGattService?) {
override fun onServiceAdded(status: Int, service: BluetoothGattService) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Reverting BluetoothGattService? to a non-nullable BluetoothGattService in onServiceAdded is risky. In the Android BLE framework, if service registration fails (i.e., status != GATT_SUCCESS), the framework can pass null for the service parameter.

Since Kotlin enforces strict null-safety checks on non-nullable parameters for overridden platform methods, receiving a null value at runtime will trigger an immediate NullPointerException before the method body even executes, causing the application to crash.

Please revert this parameter to be nullable.

Suggested change
override fun onServiceAdded(status: Int, service: BluetoothGattService) {
override fun onServiceAdded(status: Int, service: BluetoothGattService?) {

}

data class ServiceAdded(val status: Int, val service: BluetoothGattService?) :
data class ServiceAdded(val status: Int, val service: BluetoothGattService) :

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Revert the service property to be nullable to match the nullability of the parameter in GattServerCallback.onServiceAdded and prevent potential runtime crashes when service registration fails.

Suggested change
data class ServiceAdded(val status: Int, val service: BluetoothGattService) :
data class ServiceAdded(val status: Int, val service: BluetoothGattService?) :

data class Connected(val address: String) : GattServerEvent
data class Disconnected(val address: String, val isSessionEnd: Boolean) : GattServerEvent
data class ServiceAdded(val service: BluetoothGattService?) : GattServerEvent
data class ServiceAdded(val service: BluetoothGattService) : GattServerEvent

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Revert the service property to be nullable to safely handle cases where the GATT service registration fails and the Android framework passes null.

Suggested change
data class ServiceAdded(val service: BluetoothGattService) : GattServerEvent
data class ServiceAdded(val service: BluetoothGattService?) : GattServerEvent


is GattServerEvent.ServiceAdded -> {
logger.debug(logTag, "Service Added: ${source.service?.uuid}")
logger.debug(logTag, "Service Added: ${source.service.uuid}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Revert to using a safe call (?.) since service should remain nullable to prevent potential null pointer exceptions under error conditions.

Suggested change
logger.debug(logTag, "Service Added: ${source.service.uuid}")
logger.debug(logTag, "Service Added: ${source.service?.uuid}")

Comment on lines +163 to +179
is GattServerEvent.Connected -> {
if (!isServiceReady) {
logger.debug(
logTag,
"Rejecting connection from ${event.address} - service not ready"
)
gattServerManager.cancelCurrentConnection()
}
}

is GattServerEvent.ServiceAdded -> {
coroutineScope.launch(
ioDispatcher + "$logTag.StartAdvertising".asCoroutineName()
) {
startAdvertising(event.service.uuid)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

This block contains two significant issues:

  1. State Leak on Rejected Connection: When !isServiceReady is true, we cancel the connection but do not prevent the event from being transformed and updating _state.value to PeripheralBluetoothState.Connected. This causes a brief, incorrect state transition to Connected before the subsequent disconnection event is processed. Returning early when rejecting the connection prevents this.
  2. Race Condition / Unsupervised Coroutine: Launching the startAdvertising coroutine on the application-level coroutineScope means it is not cancelled when stop() is called (which only cancels monitoringJob). If stop() is called while StartAdvertising is pending, the advertiser could be started after the transport has stopped, leaving it running indefinitely. Launching it on a scope tied to monitoringJob ensures it is automatically cancelled when stop() is called.

Additionally, we use a safe call (?.let) on event.service to handle the reverted nullable type safely.

Suggested change
is GattServerEvent.Connected -> {
if (!isServiceReady) {
logger.debug(
logTag,
"Rejecting connection from ${event.address} - service not ready"
)
gattServerManager.cancelCurrentConnection()
}
}
is GattServerEvent.ServiceAdded -> {
coroutineScope.launch(
ioDispatcher + "$logTag.StartAdvertising".asCoroutineName()
) {
startAdvertising(event.service.uuid)
}
}
is GattServerEvent.Connected -> {
if (!isServiceReady) {
logger.debug(
logTag,
"Rejecting connection from ${event.address} - service not ready"
)
gattServerManager.cancelCurrentConnection()
return
}
}
is GattServerEvent.ServiceAdded -> {
event.service?.let { service ->
CoroutineScope(ioDispatcher + monitoringJob + "$logTag.StartAdvertising".asCoroutineName()).launch {
startAdvertising(service.uuid)
}
}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants