androidandroid-jetpack-composeandroid-bluetoothandroid-11android-companion-device

Android Companion Device Manager/Pairing and devices that require bonds


We recently rewrote a significant part of our app, but are now running in to some behaviour around device pairing bonding in particular that we would like to improve. We connect to a number of different devices, some that require a bond and others that do not. In all cases, we now have them associate via the Companion Device Manager first, and then bond the device second.

While our app targets Android 12, our minimum supported android version is 10. We're seeing some very different behaviour between the version. Our frontend is written using Jetpack Compose

In addition, There now seems to be a two-step process when the user has to approve a bond: A system notification is received first, and the user must respond to the system notification first before the consent/input dialog is seen. previously when we created the bond without first associating the device, the consent dialog just appeared directly.

So, here's the question(s)

  1. Why is the behaviour different between Android 12 and earlier versions? What has changed about how bonding is accomplished that we no longer need express consent every time?
  2. Why is there now a two step process? Is this because the request to bond is somehow tied to the companion device manager, or is something else going on?
  3. Can I shortcut/remove the system notification step from the process? Not only does it add additional steps to the overall flow, it also is making it complicated when an EMM/MDM is applied to the phones (a significant use case for us is within a kiosk-mode implementation, where the only visible app is our application and system notifications are suppressed)

Here's our code for associating the device:

fun CompanionDeviceManager.associateSingleDevice(
    associationRequest:AssociationRequest,
    activityResultLauncher: ManagedActivityResultLauncher<IntentSenderRequest, ActivityResult>
) {
    this.associate(
        associationRequest,
        object : CompanionDeviceManager.Callback() {

            @Deprecated("Required to implement for API versions 32 and below")
            override fun onDeviceFound(intentSender: IntentSender) {
                handleAssociationResponse(intentSender, activityResultLauncher)
            }

            override fun onAssociationPending(intentSender: IntentSender) {
                handleAssociationResponse(intentSender, activityResultLauncher)
            }

            override fun onFailure(error: CharSequence?) {
                //TODO: handle association failure
            }
        },
        null
    )
}


private fun handleAssociationResponse(
    intentSender: IntentSender,
    activityResultLauncher: ManagedActivityResultLauncher<IntentSenderRequest, ActivityResult>
) {
    val senderRequest = IntentSenderRequest.Builder(intentSender).build()
    activityResultLauncher.launch(senderRequest)
}

The association dialog is shown, here's the relevant activityResultLauncher used when the device requires a bond be established. There is a callback provided that allows the UI to be updated on the pairing state.

    @SuppressLint("MissingPermission")
    private fun bleRequiresBondActivityResultCallback(activityResult: ActivityResult) =
        when (activityResult.resultCode) {
            Activity.RESULT_OK -> activityResult.data
                ?.getParcelableExtra<ScanResult>(CompanionDeviceManager.EXTRA_DEVICE)
                ?.device!!.run {
                    callback.updatePairingState(PairingState.BONDING)
                    if(this.bondState!= BluetoothDevice.BOND_BONDED) {
                        val createBondResult = createBond()
                        logger.debug("Device bonding initiated: createBond=$createBondResult")
                        if(!createBondResult){
                            callback.updatePairingState(PairingState.PAIRING_FAILED)
                        }
                    } else {
                        logger.debug("Device already bonded, no need to create bond.  Move straight to disconnecting")
                        callback.updatePairingState(PairingState.PAIRING_SUCCEEDED)
                    }
                }
            else -> callback.updatePairingState(PairingState.PAIRING_FAILED)
        }

In Jetpack compose, we compose a component that provides some UI/UX, registers the launcher and then starts the pairing process (i.e.calls the companion device manager as above) from within a disposableEffect

val associationLauncher = rememberLauncherForActivityResult(
            contract = ActivityResultContracts.StartIntentSenderForResult(),
            onResult = pairingManager.getActivityResultHandler() //returns the handler above
        )

    DisposableEffect("") {

        pairingManager.initializePairing()  //Does some prework
        pairingManager.startPairing(associationLauncher) //launches the association

        onDispose {
            Log.d("PairingOngoingContent", "PairingOngoingContent: dispose was called")
            pairingManager.finalizePairing() //closes out the operations
        }
    }

Solution

  • Further investigation showed that my mistake was not doing the bonding in the context of discovery. If the createBond process takes place in the context of a discovery session, then the intermediary system notification is not issued and you just get the dialog directly. This is true in android 10-13 (haven't tested beyond).

    For Android 11 and below, the experience is Link(associate) dialog -> Pair Dialog -> completed. This happens regardless of whether the bond requires a passkey, pin, or otherwise

    For Android 12 up, if no passkey or other use input is necessary, the flow is Link(Associate) dialog -> completed.