kotlinbluetooth-lowenergy

Unable to establish connection with BLE device in Android 14


I can connect with the BLE device smoothly until Android 13 and data transmission is also very smooth, but the issue starts from Android 14. In Android 14, initially the logs shows connection with the device see here in the logs:

onClientRegistered() - status=0 clientIf=6
onFocusEvent true
onClientConnectionState() - status=0 clientIf=6 connected=true device=XX:XX:XX:XX:XX:C8
onConnectionStateChange: status: 0, newState: 2
gatt connected
configureMTU() - device: XX:XX:XX:XX:XX:C8 mtu: 517
discoverServices() - device: XX:XX:XX:XX:XX:C8

But, after some time the connection closes, see here in the logs:

Connection timeout hit — still not connected
closing connection
cancelOpen() - device: XX:XX:XX:XX:XX:C8
close()
unregisterApp() - mClientIf=6

My full AndroidManifest.xml

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

    <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.ACCESS_FINE_LOCATION"
        android:maxSdkVersion="30"/>
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
        android:maxSdkVersion="30"/>
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
    <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />

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


    <uses-permission
        android:name="android.permission.BLUETOOTH_SCAN"
        android:usesPermissionFlags="neverForLocation"
        tools:targetApi="s" />
    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
        android:maxSdkVersion="32"
        tools:ignore="ScopedStorage" />

    <application
        android:name=".MyApplication"
        android:allowBackup="false"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:requestLegacyExternalStorage="true"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.App"
        tools:targetApi="31">
        <activity
            android:name=".ui.main.MainActivity"
            android:exported="true"
            android:screenOrientation="portrait"
            android:windowSoftInputMode="adjustResize"
            android:configChanges="orientation|screenSize|keyboardHidden|uiMode"
            tools:ignore="LockedOrientationActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <service android:name=".service.BluetoothLeService" />
        <service android:name=".service.TimerService"/>
    </application>

</manifest>

This is how I am establishing the connection from the BleService:

fun connect(address: String, activity: Activity): Boolean {

        // Step 1: Check the adapter
        val adapter = bluetoothAdapter ?: return false

        // Step 2: Get device
        val device = try {
            adapter.getRemoteDevice(address)
        } catch (e: IllegalArgumentException) {
            Log.e("BLE", "Invalid address", e)
            return false
        }

        // Step 3: Handle permissions for Android 12+
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
            ActivityCompat.checkSelfPermission(
                activity,
                Manifest.permission.BLUETOOTH_CONNECT
            ) != PackageManager.PERMISSION_GRANTED
        ) {

            ActivityCompat.requestPermissions(
                activity,
                arrayOf(Manifest.permission.BLUETOOTH_CONNECT), 1
            )
            return false

        }

        // Step 4: Clean up any existing connection
        bluetoothGatt?.apply {
            disconnect()
            close()
        }
        bluetoothGatt = null

        // Step 5: Connect
        bluetoothGatt = device.connectGatt(activity, false, bluetoothGattCallback)
        return bluetoothGatt != null
    }

Connecting Code from Fragment:

private fun connecting() {
        println("I am in the connecting: $address")
        progressDialog.show()

        binding.header.text = requireActivity().getString(R.string.connecting_text)
        binding.listView.isClickable = false
        handler.removeCallbacksAndMessages(null)
        val gattServiceIntent = Intent(requireActivity(), BluetoothLeService::class.java)
        isBound = requireActivity().bindService(
            gattServiceIntent, serviceConnection, Context.BIND_AUTO_CREATE
        )
        connectHandler = Handler(Looper.getMainLooper())
        connectHandler.postDelayed({
            println("I am in the connecting2")
            if (!connected && activity != null && isAdded) {
                println("I am in the connecting3 $connected-$activity-$isAdded")
                Log.d("asdfasdf", "Connection timeout hit — still not connected")
                bluetoothService?.close()
                bluetoothService = null
                Toast.makeText(
                    requireActivity(),
                    "Can't connect to the device, try again.",
                    Toast.LENGTH_SHORT
                ).show()
                binding.listView.isClickable = true
                if (progressDialog.isShowing) progressDialog.dismiss()
                address = null
                devicesList.clear()
                arrayAdapter.notifyDataSetChanged()
                if (isBound) {
                    requireActivity().unbindService(serviceConnection)
                    isBound = false
                }
                startConnection()
            } 
        }, 15000)
    }

This is how setting MTU:

private val bluetoothGattCallback = object : BluetoothGattCallback() {
        override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
            Log.d("asdfasdf", "onConnectionStateChange: status: $status, newState: $newState")
            when (newState) {
                BluetoothProfile.STATE_CONNECTED -> {
                    // successfully connected to the GATT Server
                    connectionState = STATE_CONNECTED
                    Log.d("asdfasdf", "gatt connected")
                    broadcastUpdate(ACTION_GATT_CONNECTED)
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && ContextCompat.checkSelfPermission(
                            this@BluetoothLeService,
                            Manifest.permission.BLUETOOTH_CONNECT
                        ) != PackageManager.PERMISSION_GRANTED
                    ){
                        Log.w("BLE","Missing BLUETOOTH_CONNECT  permission, can't request MTU")
                        return
                    }

                    gatt?.requestMtu(517) // MTU here

                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && ActivityCompat.checkSelfPermission(
                            this@BluetoothLeService,
                            Manifest.permission.BLUETOOTH_CONNECT
                        ) != PackageManager.PERMISSION_GRANTED
                    ) {
                        ActivityCompat.requestPermissions(
                            Activity(),
                            arrayOf(Manifest.permission.BLUETOOTH_CONNECT), 1
                        )
                        return
                    }
                    gatt?.discoverServices()
                }

                BluetoothProfile.STATE_DISCONNECTED -> {
                    // disconnected from the GATT Server
                    Log.d("asdfasdf", "gatt disconnected service")
                    connectionState = STATE_DISCONNECTED
                    broadcastUpdate(ACTION_GATT_DISCONNECTED)
                }
            }
        }

        @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
        override fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) {
            super.onMtuChanged(gatt, mtu, status)
            if (status == BluetoothGatt.GATT_SUCCESS) {
                negotiatedMtu = mtu
                Log.d("BLE", "MTU changed successfully. Negotiated MTU = $mtu")
                gatt?.discoverServices() 
            } else {
                Log.w("BLE", "MTU change failed. Status = $status")
                gatt?.discoverServices()  
            }
        }

override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
            println("onServiceDiscovered: $gatt -- $status")
            super.onServicesDiscovered(gatt, status)
            if (status == BluetoothGatt.GATT_SUCCESS) {
                broadcastUpdate(ACTION_GATT_SERVICES_DISCOVERED)
            } else {
                //message
            }
        }

        override fun onCharacteristicWrite(
            gatt: BluetoothGatt?,
            characteristic: BluetoothGattCharacteristic?,
            status: Int
        ) {
            super.onCharacteristicWrite(gatt, characteristic, status)
            if (characteristic != null) {
                when (characteristic.uuid) {
                    COMMAND_CHAR -> {
                        broadcastUpdate(ACTION_GATT_COMMAND_WRITE)
                    }
                }
            }
        }

        @Deprecated("Deprecated in Java")
        override fun onCharacteristicChanged(
            gatt: BluetoothGatt?,
            characteristic: BluetoothGattCharacteristic?
        ) {
            super.onCharacteristicChanged(gatt, characteristic)
            println("Receiving Characteristics..")
            if (characteristic == null) return

            when (characteristic.uuid) {
                TRAINING_SES_CHAR -> {
                    val lines = characteristic.value.decodeToString().lines()
                    SessionOngoingFragment.data += lines[0] + "\n"
                    val receivedData = lines[0].split(",").toTypedArray()
                    dataBroadcastUpdate(ACTION_GATT_RESPONSE_TRAINING, receivedData)
                }

                DEVELOPMENT_DATA_CHAR -> {
                    broadcastUpdate(ACTION_GATT_RESPONSE_DEV)
                }

                else -> {
                    Log.w("BLE_RESPONSE", "Unhandled characteristic: ${characteristic.uuid}")
                }
            }
        }

        @Deprecated("Deprecated in Java")
        override fun onCharacteristicRead(
            gatt: BluetoothGatt?,
            characteristic: BluetoothGattCharacteristic?,
            status: Int
        ) {
            super.onCharacteristicRead(gatt, characteristic, status)
            if (status == BluetoothGatt.GATT_SUCCESS) {
                Log.d("asdfasdf", "onCharacteristicRead ${characteristic!!.value.decodeToString()}")
                val result = characteristic.value.decodeToString()
                when (characteristic.uuid) {
                    VERSION_CHAR -> {
                        val version = result.substring(0, 5)
                        Log.d("asdfasdf", "version $version")
                        dataBroadcastUpdate(ACTION_GATT_VERSION, arrayOf(version))
                    }

                    RESPONSE_CHAR -> {
                        val initLetter = result.trim().first()
                        if (initLetter == 'F') {
                            broadcastUpdate(ACTION_GATT_FEEDBACK)
                        } else {
                            val battery = result.lines()[0].split(',').toTypedArray()
                            dataBroadcastUpdate(ACTION_GATT_BATTERY, battery)
                        }
                    }
                }
            }
        }
}

permissions I am asking:

init {
        requestPermissionLauncher = registerForActivityResult(
            ActivityResultContracts.RequestMultiplePermissions()){ result ->
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                if (result[Manifest.permission.BLUETOOTH_SCAN] != null){
                    isScanPermissionGranted = result[Manifest.permission.BLUETOOTH_SCAN] == true
                }
                if (result[Manifest.permission.BLUETOOTH_CONNECT] != null){
                    isConnectPermissionGranted = result[Manifest.permission.BLUETOOTH_CONNECT] == true
                }
            }
            if (result[Manifest.permission.ACCESS_FINE_LOCATION] != null){
                isFinePermissionGranted = result[Manifest.permission.ACCESS_FINE_LOCATION] == true
            }

            when {
                !(isScanPermissionGranted && isConnectPermissionGranted) -> {
                    permissionRequired()
                }
                !(isFinePermissionGranted) -> {
                    locationPermissionRequired()
                }
                else -> {
                    askPermissionForBackgroundUsage()
                }
            }
        }
    }


private fun requestPermission(){
        if (Build.VERSION.SDK_INT<=Build.VERSION_CODES.R){
            isFinePermissionGranted = ContextCompat.checkSelfPermission(
                requireContext(),
                Manifest.permission.ACCESS_FINE_LOCATION
            ) == PackageManager.PERMISSION_GRANTED
            isScanPermissionGranted = true
            isConnectPermissionGranted = true
        }else {
            isFinePermissionGranted = true
            isScanPermissionGranted = ContextCompat.checkSelfPermission(
                requireContext(),
                Manifest.permission.BLUETOOTH_SCAN
            ) == PackageManager.PERMISSION_GRANTED
            isConnectPermissionGranted = ContextCompat.checkSelfPermission(
                requireContext(),
                Manifest.permission.BLUETOOTH_CONNECT
            ) == PackageManager.PERMISSION_GRANTED
        }
        val list : ArrayList<String> = arrayListOf()
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            if (!isScanPermissionGranted){
                list.add(Manifest.permission.BLUETOOTH_SCAN)
            }
            if (!isConnectPermissionGranted){
                list.add(Manifest.permission.BLUETOOTH_CONNECT)
            }
        }
        if (!isFinePermissionGranted)
            list.add(Manifest.permission.ACCESS_FINE_LOCATION)

        if (list.isEmpty()){
            askPermissionForBackgroundUsage()
        }else{
            requestPermissionLauncher.launch(list.toTypedArray())
        }
    }

see some more logs from finding to losing connection with the device.

onClientRegistered() - status=0 clientIf=6
onFocusEvent true
onClientConnectionState() - status=0 clientIf=6 connected=true 
onConnectionStateChange: status: 0, newState: 2
gatt connected
configureMTU() - device: XX:XX:XX:XX:E6:C8 mtu: 517
discoverServices() - device: XX:XX:XX:XX:E6:C8
onPhyUpdate() - status=0 address=XX:XX:XX:XX:E6:C8 txPhy=2 rxPhy=2
onConfigureMTU() - Device=XX:XX:XX:XX:E6:C8 mtu=256 status=0
MTU changed successfully. Negotiated MTU = 256
discoverServices() - device: XX:XX:XX:XX:E6:C8
onSearchComplete() = Device=XX:XX:XX:XX:E6:C8 Status=0
onConnectionUpdated() - Device=XX:XX:XX:XX:E6:C8 interval=24 latency=0 timeout=500 status=0
onConnectionUpdated() - Device=XX:XX:XX:XX:E6:C8 interval=144 latency=0 timeout=400 status=0

Connection timeout hit — still not connected
closing connection
cancelOpen() - device: XX:XX:XX:XX:E6:C8
close()

ui.main.MainActivity,window dying
ui.main.MainActivity,unregisterSystemUIBroadcastReceiver 
ui.main.MainActivity, unregisterSystemUIBroadcastReceiver failed java.lang.IllegalArgumentException: Receiver not registered: android.view.OplusScrollToTopManager$2@d25b5db

Can anyone help me establish a successful connection with the BLE device on Android 14? No issue till Android 13.


Solution

  • The issue was in the BroadcastReceiver While registering. Here I am targeting different SDK versions.

    Here, I needed to pass Context.RECEIVER_EXPORTED instead of Context.RECEIVER_NOT_EXPORTED while targeting TIRAMISU and above.

     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                requireActivity().registerReceiver(
                    gattUpdateReceiver,
                    makeGattUpdateIntentFilter(),
                    Context.RECEIVER_NOT_EXPORTED
                )
            } else {
                ContextCompat.registerReceiver(
                    requireActivity(),
                    gattUpdateReceiver,
                    makeGattUpdateIntentFilter(),
                    ContextCompat.RECEIVER_NOT_EXPORTED
                )
            }
    

    But passing Context.RECEIVER_EXPORTED Directly is not a good practice unless you have specific requirements.

    Then, I secured my Context.RECEIVER_EXPORTED Passing it along with a permission.

    First, I declared a permission in the AndroidManifest.xml like this:

     <!-- Signature level permission to secure RECEIVER_EXPORTED -->
        <uses-permission android:name="com.your.package.SAFE_BROADCAST" />
        <permission
            android:name="com.your.package.SAFE_BROADCAST"
            android:protectionLevel="signature" />
    

    place it above <application/> block.

    Then use it like this:

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                requireActivity().registerReceiver(
                    gattUpdateReceiver,
                    makeGattUpdateIntentFilter(),
                    "${requireActivity().packageName}.SAFE_BROADCAST",
                    null,
                    Context.RECEIVER_EXPORTED
                )
            } else {
                ContextCompat.registerReceiver(
                    requireActivity(),
                    gattUpdateReceiver,
                    makeGattUpdateIntentFilter(),
                    ContextCompat.RECEIVER_NOT_EXPORTED
                )
            }
    

    It solved my issue.