Alexander Pruss
Alexander Pruss

Reputation: 434

How to reconnect Android BluetoothLE HID peripheral to central?

I want to make an Android phone work as a BLE HID peripheral (an absolute mouse, to be precise) using BluetoothGattServer and handling the gatt characteristics directly, rather than using the BluetoothHIDDevice service, so I have better control over how it all works.

Modifying the code here, I can advertise a HID peripheral and connect from my Win11 laptop to my Android 14 Pixel 7 Pro, and use HID reports (e.g., relative or absolute mouse) to control the laptop.

But I want to know how to handle reconnections when the app restarts. Currently, the only reliable way I have to reconnect is to forget the laptop on the Android side and remove the phone on the Windows side and re-pair.

I have tried simply re-advertising when the app restarts. I don't get a successful reconnection showing up in my gatt server callback, and in the Windows bluetooth panel (Bluetooth & Devices > Devices) the device jumps from Other devices to Input, but still shows "Not connected". I have occasionally had a bit more luck when I connect from the Devices and Printers panel in Windows rather than the bluetooth panel--I've had occasions (but not reliably reproducible) where it would reconnect after exiting the app.

I have tried saving the laptop's address in the app, and then reconnecting by getting a BluetoothDevice for it either by iterating through BluetoothManager.getAdapter().getBondedDevices() or calling BluetoothManager.getAdapter().getRemoteDevice() instead of advertising (I have tried with and without advertising). I can then call getBondedState() and verify the state is BOND_BONDED. I then try to connect to it with gattServer.connect(device,false) as well as with gattServer.connect(device,true), but no luck.

Specifically, after a longish wait, the gatt server's BluetoothGattServerCallback.onConnectionStateChange() gives me the newState as zero, and no characteristics get queried by the Windows side. Interestingly, on the Windows side, the Bluetooth dialog shows my phone as connected.

And, yes, I made sure to call gattServer.connect() from the main thread. I even tried forcing the gatt server's transport to TRANSPORT_LE using a hidden API, but it didn't solve the problem.

I also tried not advertising at all, but just initiating a pairing request from the app itself to the laptop for the first-time connect. But then the first-time connect doesn't work. The pairing dialogs show up on both Android and Windows, but Windows doesn't register mouse movement.

I feel like I've tried all the combinations.

Upvotes: 1

Views: 118

Answers (2)

Endor 8
Endor 8

Reputation: 86

I have been trying something very similar lately, adding BLE support to an app that already emulates a HID peripheral using the (Classic) Bluetooth HID Profile, to easily switch between different peripherals by simply advertising multiple of them.

After a week of trial, error, Bluetooth sniffing, and digging through specifications, I have concluded that what we're trying to achieve is, as of today, most likely impossible, due to privacy limitations imposed by Android.

The problem is actually very simple: Android lets your app open and advertise a GATT server, but it chooses a random MAC address for your server every time you create it. The reason Windows never attempts to reconnect to your server is because it never sees it again, as the MAC address changes every time you restart the app. HID peripherals need to be paired to communicate, and were never meant to rotate MAC addresses.

This behavior is somewhat described in this question. You can see for yourself if you open the properties of your mouse from Devices and Printers >Your Device> Properties > Connected Device > Unique Identifier (in Spanish below).

Properties of one of my mouses in Windows

(I don't know why there's an empty MAC address field above it (maybe it's for Wifi), the Unique Identifier is actually the Bluetooth MAC)

Every time you pair your phone after restarting the app, the ID changes randomly, unlike the entry for the actual phone. The reason why the Bluetooth (Classic) approach works is because it goes through the phone's public MAC address, which Windows can find to reconnect.


As you describe, I've very rarely managed to see it reconnect after a single restart, but most times the connection just dies shortly after. My belief is this is probably just a(nother) quirk of Android's Bluetooth stack, since I never managed to reproduce it consistently nor for long, and I don't think I ever managed to actually send data during the brief illusory connection.

I know this is not the kind of answer you want, but I hope it helps you (and others) avoid wasting effort aimlessly trying to face this problem. This has been a very frustrating problem for me to investigate, as I was in the dark until I found out about the address thing, a week into reading thousands of pages of specifications; Bluetooth is an obscure technology for me. I've tried every way I could think to bypass Android restrictions, but even if we could use system APIs, it seems impossible.

On another note, for anyone working on this, since I'll probably forget, I never managed to get Windows to discover the optional Scan Parameters Service the HoGP specification declares. My understanding is Windows doesn't look for it at all.


I think the only possible (long term) solution to this issue would be the addition of a new permission to Android, with a spirit akin to

Allow this app to advertise and provide uniquely/personally identifiable services to nearby devices.

The permission would allow the app to request persistable tokens that could be used when opening/advertising a GATT server, which would be translated by the stack into persistently assigned MAC addresses per application. The app would be able to advertise GATT servers that require persistent hosting, without having knowledge/control of the actual address, and only after explicit user confirmation for every service. Somewhat similar to the system in place to request persistable tokens for storage URIs.

Maybe it could even allow the app to advertise the server using a different device name (user-chosen maybe) and a custom appearance (app-chosen), so the fake peripherals would be easier to manage in the host machine.

This sounds reasonable to me. However, I don't know how to even start proposing this kind of feature to Android, and it sounds like a massive amount of work, so for the time being I have decided to drop the idea.

Upvotes: 0

Renzo Sampietro
Renzo Sampietro

Reputation: 149

In my app, the functionality that uses BluetoothLE to exchange texts and images between two smartphones manages the connections when the app restarts (for example in case of screen rotation) via onResume, onPause, onDestroy methods.

override fun onResume() {
    super.onResume()
    mywindow = getWindow()
    setToFullScreen(mywindow)
    //
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        registerReceiver(gattUpdateReceiver, makeGattUpdateIntentFilter(), RECEIVER_NOT_EXPORTED)
        }
        else
        {
        registerReceiver(gattUpdateReceiver, makeGattUpdateIntentFilter())
        }
    //
    // check Bluetooth Ble Permissions
    //
    if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
        // version code TIRAMISU = version 33 = Android 13
        && (
                (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_ADVERTISE
                ) != PackageManager.PERMISSION_GRANTED)
                        ||
                        (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT
                        ) != PackageManager.PERMISSION_GRANTED)
                        ||
                        (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN
                        ) != PackageManager.PERMISSION_GRANTED)
                        ||
                        (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS
                        ) != PackageManager.PERMISSION_GRANTED)
                ))
    {
        // se siamo qui è perchè non si è mostrata alcuna spiegazione all'utente, richiesta di permission
        ActivityCompat.requestPermissions(
            this,
            arrayOf(Manifest.permission.BLUETOOTH_ADVERTISE, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN,
                Manifest.permission.POST_NOTIFICATIONS
                ),
            1
        )
    }
    else
    {
        if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
            // version code S = version 31 = Android 12
            && (
                    (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_ADVERTISE
                    ) != PackageManager.PERMISSION_GRANTED)
                            ||
                            (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT
                            ) != PackageManager.PERMISSION_GRANTED)
                            ||
                            (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN
                            ) != PackageManager.PERMISSION_GRANTED)
                    ))
        {
            // se siamo qui è perchè non si è mostrata alcuna spiegazione all'utente, richiesta di permission
            ActivityCompat.requestPermissions(
                this,
                arrayOf(Manifest.permission.BLUETOOTH_ADVERTISE, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN ),
                2
            )
        }
        else
        {
            if ((Build.VERSION.SDK_INT < Build.VERSION_CODES.S)
                // version code S = version 31 = Android 12
                && (
                        (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH
                        ) != PackageManager.PERMISSION_GRANTED)
                                ||
                                (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION
                                ) != PackageManager.PERMISSION_GRANTED)
                        ))
            {
                // se siamo qui è perchè non si è mostrata alcuna spiegazione all'utente, richiesta di permission
                ActivityCompat.requestPermissions(
                    this,
                    arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.ACCESS_FINE_LOCATION ),
                    3
                )
            }
            else
            {
                if (!btAdapter.isEnabled) {
                    val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
                    enableBtResultLauncher.launch(enableBtIntent)
                } else {
                    /*
                    step 3 -> BluetoothLeService
                    A client binds to a service by calling bindService().
                    When it does, it must provide an implementation of ServiceConnection, which monitors
                    the connection with the service.
                    The return value of bindService() indicates whether the requested service exists and
                    whether the client is permitted access to it.
                    */
                    if (bluetoothService == null) {
                        val gattServiceIntent = Intent(this, BluetoothLeService::class.java)
                        bindService(gattServiceIntent, serviceConnection, Context.BIND_AUTO_CREATE)
                        }
                        else
                        {
                        /*
                        BluetootLeService -> step 5 -> BluetootLeService
                        The activity calls this function within its ServiceConnection implementation.
                        Handling a false return value from the initialize() function depends on your application.
                        You could show an error message to the user indicating that the current device
                        does not support the Bluetooth operation or disable any features that require Bluetooth to work.
                        In the following example, finish() is called on the activity to send the user back to the previous screen.
                        */
                        if (!bluetoothService!!.initialize(this)) {
                            Log.e(TAG, "Unable to initialize Bluetooth")
                            finish()
                        }
                        if (preference_BluetoothMode == "Server")
                            {
                                /*
                                BluetootLeService -> step 7 -> BluetootLeService
                                */
                                bluetoothService!!.setupServer()
                                bluetoothService!!.startAdvertising()
                            }
                            else
                            {
                                /*
                                step 10 -> BluetootLeService
                                */
                                // perform device scanning
                                bluetoothService!!.scan()
                            }
                        bluetoothService!!.activityInActiveState("Game1BleActivity")
                    }
                }
            }
        }
    }
}

override fun onPause() {
    super.onPause()
    if (preference_BluetoothMode == "Server")
    {
        if (bluetoothService != null)
        {
            bluetoothService!!.stopAdvertising()
        }
    }
    else
    {
        if (bluetoothService != null)
        {
            bluetoothService!!.stopScanner()
        }
    }
    if (bluetoothService != null)
        {
        bluetoothService!!.activityInPausedState("Game1BleActivity")
        }
    //
    unregisterReceiver(gattUpdateReceiver)
    // other
}

override fun onDestroy() {
    super.onDestroy()
    if (preference_BluetoothMode == "Server")
    {
        bluetoothService!!.sendMessageFromServer("I'M DISCONNECTING")
    }
    else
    {
        bluetoothService!!.disconnect()
    }
    // other
}

Upvotes: 0

Related Questions