3

UPDATE: Added Main Activity code which contains Bluetooth permissions logic

I'm trying to utilize Android's CompanionDeviceManager API to find nearby bluetooth (non LE) devices on my Pixel 5 running Android 13, but it only ever seems to find nearby WiFi networks. I'm suspicious that the deviceFilter isn't working properly.

Initially, my code to configure the BluetoothDeviceFilter looked like this:

private val deviceFilter: BluetoothDeviceFilter = BluetoothDeviceFilter.Builder()
    // Match only Bluetooth devices whose name matches the pattern
    .setNamePattern(Pattern.compile("(?i)\\b(Certain Device Name)\\b"))
    .build()

private val pairingRequest: AssociationRequest = AssociationRequest.Builder()
    // Find only devices that match our request filter
    .addDeviceFilter(deviceFilter)
    // Don't stop scanning as soon as one device matching the filter is found.
    .setSingleDevice(false)
    .build()

With this code, however, no devices ever appear within the system generated Companion Device Pairing screen. The spinner spins until timeout

enter image description here

Thinking maybe my regex was unintentionally too restrictive, I changed the filter to use a regexp that allows everything, like so:

.setNamePattern(Pattern.compile(".*"))

But even this filter fails to allow any nearby bluetooth devices to appear in the Pairing screen.

When I intentionally don't add any filter all I see are WiFi networks, so the Companion Device Manager can work, it's just seemingly misconfigured for Bluetooth results.

    private val pairingRequest: AssociationRequest = AssociationRequest.Builder()
    // No filter, let's see it all!
    .setSingleDevice(false)
    .build()

enter image description here

Using the Android OS's system Bluetooth menu I clearly see there are Bluetooth devices within range of my device, and I can even connect to them, but the same devices never appear within my app.

What am I doing wrong that's causing no nearby Bluetooth devices to appear in my CompanionDeviceManager Pairing Screen?

Code below:

HomeFragment.kt class HomeFragment : Fragment() {

//Filter visible Bluetooth devices so only Mozis within range are displayed
private val deviceFilter: BluetoothDeviceFilter = BluetoothDeviceFilter.Builder()
    // Match only Bluetooth devices whose name matches the pattern.
    .setNamePattern(Pattern.compile(BLUETOOTH_DEVICE_NAME_REGEX_TO_FILTER_FOR))
    .build()

private val pairingRequest: AssociationRequest = AssociationRequest.Builder()
    // Find only devices that match this request filter.
    .addDeviceFilter(deviceFilter)
    // Don't stop scanning as soon as one device matching the filter is found.
    .setSingleDevice(false)
    .build()

private val deviceManager: CompanionDeviceManager by lazy {
    requireContext().getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager
}

private val executor: Executor = Executor { it.run() }

override fun onCreateView(
    inflater: LayoutInflater, container: ViewGroup?,
    savedInstanceState: Bundle?
): View {

    setupPairingButton()

}

/**
 * This callback listens for the result of connection attempts to our Mozi Bluetooth devices
 */
@Deprecated("Deprecated in Java")
@SuppressLint("MissingPermission")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    when (requestCode) {
        SELECT_DEVICE_REQUEST_CODE -> when (resultCode) {
            Activity.RESULT_OK -> {
                // The user chose to pair the app with a Bluetooth device.
                val deviceToPair: BluetoothDevice? =
                    data?.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE)
                deviceToPair?.createBond()
            }
        }
        else -> super.onActivityResult(requestCode, resultCode, data)
    }
}

private fun setupPairingButton() {
    binding.buttonPair.setOnClickListener {

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            /**
             * This is the approach to show a pairing dialog for Android 33+
             */
            deviceManager.associate(pairingRequest, executor,
                object : CompanionDeviceManager.Callback() {
                    // Called when a device is found. Launch the IntentSender so the user
                    // can select the device they want to pair with
                    override fun onAssociationPending(intentSender: IntentSender) {
                        intentSender.let { sender ->
                            activity?.let { fragmentActivity ->
                                startIntentSenderForResult(
                                    fragmentActivity,
                                    sender,
                                    SELECT_DEVICE_REQUEST_CODE,
                                    null,
                                    0,
                                    0,
                                    0,
                                    null
                                )
                            }
                        }
                    }

                    override fun onAssociationCreated(associationInfo: AssociationInfo) {
                        // Association created.

                        // AssociationInfo object is created and get association id and the
                        // macAddress.
                        var associationId = associationInfo.id
                        var macAddress: MacAddress? = associationInfo.deviceMacAddress
                    }

                    override fun onFailure(errorMessage: CharSequence?) {
                        // Handle the failure.
                        showBluetoothErrorMessage(errorMessage)
                    }
                })
        } else {
            /**
             * This is the approach to show a pairing dialog for Android 32 and below
             */

            // When the app tries to pair with a Bluetooth device, show the
            // corresponding dialog box to the user.
            deviceManager.associate(
                pairingRequest,
                object : CompanionDeviceManager.Callback() {

                    override fun onDeviceFound(chooserLauncher: IntentSender) {
                        startIntentSenderForResult(
                            chooserLauncher,
                            SELECT_DEVICE_REQUEST_CODE,
                            null,
                            0,
                            0,
                            0,
                            null
                        )
                    }

                    override fun onFailure(error: CharSequence?) {
                        // Handle the failure.
                       showBluetoothErrorMessage(error)
                    }
                }, null
            )
        }
    }
}


companion object {
    private const val SELECT_DEVICE_REQUEST_CODE = 0
    private const val BLUETOOTH_DEVICE_NAME_REGEX_TO_FILTER_FOR = "(?i)\\bCertain Device Name\\b"
}}

MainActivity.kt

class MainActivity : AppCompatActivity() {
    private val enableBluetoothIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)

private var bluetoothEnableResultLauncher =
    registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
        binding.loadingSpinner.hide()

        when (result.resultCode) {
            Activity.RESULT_OK -> {
                Snackbar.make(
                    binding.root,
                    resources.getString(R.string.bluetooth_enabled_lets_pair_with_your_mozi),
                    Snackbar.LENGTH_SHORT
                ).show()
            }
            Activity.RESULT_CANCELED -> {
                Snackbar.make(
                    binding.root,
                    getString(R.string.without_bluetooth_you_cant_pair_with_your_mozi),
                    Snackbar.LENGTH_INDEFINITE
                )
                    .setAction(resources.getString(R.string._retry)) {
                        ensureBluetoothIsEnabled()
                    }
                    .show()
            }
        }
    }

private val requestBluetoothPermissionLauncher =
    registerForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { isGranted: Boolean ->
        if (isGranted) {
            bluetoothEnableResultLauncher.launch(enableBluetoothIntent)
        } else {
            // Explain to the user that the feature is unavailable because the
            // feature requires a permission that the user has denied. At the
            // same time, respect the user's decision. Don't link to system
            // settings in an effort to convince the user to change their
            // decision.
            Snackbar.make(
                binding.root,
                getString(R.string.without_bluetooth_you_cant_pair_with_your_mozi),
                Snackbar.LENGTH_INDEFINITE
            )
                .setAction(resources.getString(R.string._retry)) {
                    ensureBluetoothIsEnabled()
                }
                .show()
        }
    }

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setupViews()
    ensureBluetoothIsEnabled()
}

private fun setupViews() {
    //Here we setup the behavior of the button in our rationale dialog: basically we need to
    //  rerun the permissions check logic if it was already denied
    binding.bluetoothPermissionsRationaleDialogButton.setOnClickListener {
        binding.permissionsRationaleDialog.animateShow(false)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            requestBluetoothPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
        } else {
            requestBluetoothPermissionLauncher.launch(Manifest.permission.BLUETOOTH)
        }
    }
}

private fun ensureBluetoothIsEnabled() {
    binding.loadingSpinner.show()

    val bluetoothManager: BluetoothManager = getSystemService(BluetoothManager::class.java)
    val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
    if (bluetoothAdapter == null) {
        // Device doesn't support Bluetooth
        binding.loadingSpinner.hide()
        Snackbar.make(
            binding.root,
            resources.getString(R.string.you_need_a_bluetooth_enabled_device),
            Snackbar.LENGTH_INDEFINITE
        ).show()
    }

    if (bluetoothAdapter?.isEnabled == false) {
        // Check if Bluetooth permissions have been granted before we try to enable the
        //  device
        if (ActivityCompat.checkSelfPermission(
                this,
                Manifest.permission.BLUETOOTH_CONNECT //TODO: test if this needs variant for legacy devices
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            /**
             * We DON'T have Bluetooth permissions. We have to get them before we can ask the
             *  user to enable Bluetooth
             */
            binding.loadingSpinner.hide()

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                if (shouldShowRequestPermissionRationale(Manifest.permission.BLUETOOTH_CONNECT)) {
                    binding.permissionsRationaleDialog.animateShow(true)
                } else {
                    requestBluetoothPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
                }
            } else {
                if (shouldShowRequestPermissionRationale(Manifest.permission.BLUETOOTH)) {
                    binding.permissionsRationaleDialog.animateShow(true)
                } else {
                    requestBluetoothPermissionLauncher.launch(Manifest.permission.BLUETOOTH)
                }
            }

            return
        } else {
            /**
             * We DO have Bluetooth permissions. Now let's prompt the user to enable their
             *  Bluetooth radio
             */
            binding.loadingSpinner.hide()
            bluetoothEnableResultLauncher.launch(enableBluetoothIntent)
        }
    } else {
        /**
         * Bluetooth is enabled, we're good to continue with normal app flow
         */
        binding.loadingSpinner.hide()
    }
}

}

Android Manifest

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<!-- Bluetooth Permissions -->
<uses-feature android:name="android.software.companion_device_setup" android:required="true"/>
<uses-feature android:name="android.hardware.bluetooth" android:required="true"/>
<!-- Request legacy Bluetooth permissions on older devices. -->
<uses-permission android:name="android.permission.BLUETOOTH"
    android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
    android:maxSdkVersion="30" />

<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Needed only if your app looks for Bluetooth devices.
     If your app doesn't use Bluetooth scan results to derive physical
     location information, you can strongly assert that your app
     doesn't derive physical location. -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags= "neverForLocation"
    tools:targetApi="s" />

<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />

...
</manifest>
Cody
  • 1,801
  • 3
  • 28
  • 53

3 Answers3

3

The documentation does not mention it, but it appears that even with the CompanionDeviceManager the location access must be enabled on the device.
The app does not need the location permission anymore, but it must be enabled.

inv3rse
  • 31
  • 2
  • Your answer could be improved with additional supporting information. Please [edit] to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers [in the help center](/help/how-to-answer). – Community Jan 03 '23 at 03:46
  • 1
    This is absolutely true. The accepted answer only works if I have location enabled on my device. – Cody Jan 08 '23 at 02:22
  • I was testing with Redmi 10i faced same scenario even if location is enabled allow all the time still unable to connect – Rohit gupta Jul 22 '23 at 15:14
1

You could try using an empty BluetoothDeviceFilter like this:

private val deviceFilter: BluetoothDeviceFilter = BluetoothDeviceFilter.Builder().build()

to signal to the API that you want Bluetooth devices, and see if at least the phone sees your device.

Then you could try again with the name filter, this time adding a service UUID filter with BluetoothDeviceFilter.Builder.addServiceUuid.

If you don't know the UUID of your device or don't want to use it as a filter, you can use an arbitrary one and set the mask to all zeros (the docs suggest that it might also work using null values).

This is a hackish solution, but it might help you move a step further

Hexwell
  • 130
  • 2
  • 11
  • 1
    I tested using both an empty device filter, a device filter with only `.addServiceUuid(null, null)`, and a device filter with `.addServiceUuid(null, null)` and `.setNamePattern(Pattern.compile(BLUETOOTH_DEVICE_NAME_REGEX_TO_FILTER_FOR))`. None of the combinations showed any bluetooth devices in the Companion Device Manager unfortunately – Cody Dec 22 '22 at 16:58
  • 1
    @Cody that's unfortunate. My guess then is that it's a permission issue. I'll post a new answer so this one that didn't work can be deleted. – Hexwell Dec 22 '22 at 20:21
1

It might be a permission issue.

In the docs, I read:

The BLUETOOTH_ADVERTISE, BLUETOOTH_CONNECT, and BLUETOOTH_SCAN permissions are runtime permissions. Therefore, you must explicitly request user approval in your app before you can look for Bluetooth devices, make a device discoverable to other devices, or communicate with already-paired Bluetooth devices.

So you could to add the following code in your HomeFragment class:

private val requestMultiplePermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
    permissions.entries.forEach {
        Log.d("Permission Request", "${it.key} = ${it.value}")
    }
}

private val requestBluetooth = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
    if (result.resultCode == RESULT_OK) {
        // granted
    } else {
        // denied
    }
}

and in the onCreateView method:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
    requestMultiplePermissions.launch(arrayOf(
        Manifest.permission.BLUETOOTH_SCAN,
        Manifest.permission.BLUETOOTH_CONNECT
    ))
} else {
    val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
    requestBluetooth.launch(enableBtIntent)
}

to request the permissions at runtime.

Hexwell
  • 130
  • 2
  • 11
  • 1
    Hm, i'm already declaring these permissions in the Android Manifest like so: `` and `````` Unless i'm mistaken, isn't this supposed to prevent you from having to request those two permissions at runtime? I also obtain Bluetooth Permission from the users explicitly in MainActivity in case we don't have it already for Bluetooth Connect. Check my updated Question – Cody Dec 26 '22 at 04:45
  • 1
    I think they must be explicitly requested at runtime despite having them in the manifest. This is because with the new Android permission model users can deny permissions granularly. You can check the permissions in the app settings to see if they are enabled. (even before adding the code I suggested). I think that declaring a permission in the manifest adds a switch for that permission in the settings, but it defaults to off. You can then ask the user for the permission and have it enabled, but it's not granted just by having it in the manifest – Hexwell Dec 26 '22 at 10:24