From 74ee19c1c355391189c7ec996630e7ca65bcea88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Tue, 25 Jul 2023 15:10:09 +0200 Subject: [PATCH 01/11] Add new CompanionDeviceManagerSample This sample showcases how to use CDM to associate a BLE device and connect to it Change-Id: I6b94db41c1fb8b789ea8443d958748e85ad61d8d --- samples/README.md | 2 + .../bluetooth/companion/README.md | 21 ++ .../bluetooth/companion/build.gradle.kts | 28 ++ .../companion/src/main/AndroidManifest.xml | 25 ++ .../ble/CompanionDeviceManagerSample.kt | 304 ++++++++++++++++++ 5 files changed, 380 insertions(+) create mode 100644 samples/connectivity/bluetooth/companion/README.md create mode 100644 samples/connectivity/bluetooth/companion/build.gradle.kts create mode 100644 samples/connectivity/bluetooth/companion/src/main/AndroidManifest.xml create mode 100644 samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt diff --git a/samples/README.md b/samples/README.md index 506b51e9..38b0c68b 100644 --- a/samples/README.md +++ b/samples/README.md @@ -10,6 +10,8 @@ Sample demonstrating how to make incoming call notifications and in call notific Demonstrates displaying processed pixel data directly from the camera sensor - [Color Contrast](accessibility/src/main/java/com/example/platform/accessibility/ColorContrast.kt): This sample demonstrates the importance of proper color contrast and how to +- [Companion Device Manager Sample](connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt): +This samples shows how to use the CDM to pair and connect with BLE devices - [Connect to a GATT server](connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/ConnectGATTSample.kt): Shows how to connect to a GATT server hosted by the BLE device and perform simple operations - [ConstraintLayout - 1. Centering Views](user-interface/constraintlayout/src/main/java/com/example/platform/ui/constraintlayout/ConstraintLayout.kt): diff --git a/samples/connectivity/bluetooth/companion/README.md b/samples/connectivity/bluetooth/companion/README.md new file mode 100644 index 00000000..81556fff --- /dev/null +++ b/samples/connectivity/bluetooth/companion/README.md @@ -0,0 +1,21 @@ +# Companion Device Manager Sample + +// TODO + +## License + +``` +Copyright 2022 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` diff --git a/samples/connectivity/bluetooth/companion/build.gradle.kts b/samples/connectivity/bluetooth/companion/build.gradle.kts new file mode 100644 index 00000000..0f575217 --- /dev/null +++ b/samples/connectivity/bluetooth/companion/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("com.example.platform.sample") +} + + +android { + namespace = "com.example.platform.connectivity.bluetooth.companion" +} + +dependencies { + implementation(project(":samples:connectivity:bluetooth:ble")) +} diff --git a/samples/connectivity/bluetooth/companion/src/main/AndroidManifest.xml b/samples/connectivity/bluetooth/companion/src/main/AndroidManifest.xml new file mode 100644 index 00000000..29057767 --- /dev/null +++ b/samples/connectivity/bluetooth/companion/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + diff --git a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt new file mode 100644 index 00000000..5d927d56 --- /dev/null +++ b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt @@ -0,0 +1,304 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.bluetooth.ble + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.companion.AssociationInfo +import android.companion.AssociationRequest +import android.companion.BluetoothLeDeviceFilter +import android.companion.CompanionDeviceManager +import android.content.Intent +import android.content.IntentSender +import android.os.Build +import android.os.ParcelUuid +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.getSystemService +import com.example.platform.base.PermissionBox +import com.google.android.catalog.framework.annotations.Sample +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import java.util.concurrent.Executor + +@Sample( + name = "Companion Device Manager Sample", + description = "This samples shows how to use the CDM to pair and connect with BLE devices", + documentation = "https://developer.android.com/guide/topics/connectivity/companion-device-pairing", + tags = ["bluetooth"], +) +@SuppressLint("InlinedApi", "MissingPermission") +@RequiresApi(Build.VERSION_CODES.O) +@Composable +fun CompanionDeviceManagerSample() { + val context = LocalContext.current + val deviceManager = context.getSystemService() + val adapter = context.getSystemService()?.adapter + var selectedDevice by remember { + mutableStateOf(null) + } + if (deviceManager == null || adapter == null) { + Text(text = "No Companion device manager found. The device does not support it.") + } else { + if (selectedDevice == null) { + CDMScreen(deviceManager) { + selectedDevice = it.device ?: adapter.getRemoteDevice(it.name) + } + } else { + PermissionBox(permission = Manifest.permission.BLUETOOTH_CONNECT) { + ConnectDeviceScreen(device = selectedDevice!!) { + selectedDevice = null + } + } + } + } +} + +data class AssociatedDevice( + val id: Int, + val address: String, + val name: String, + val device: BluetoothDevice?, +) + +@OptIn(ExperimentalAnimationApi::class) +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun CDMScreen( + deviceManager: CompanionDeviceManager, + onConnect: (AssociatedDevice) -> Unit, +) { + val scope = rememberCoroutineScope() + var associatedDevice by remember { + // If we already associated the device no need to do it again. + mutableStateOf(getAssociatedDevice(deviceManager)) + } + var errorMessage by remember(associatedDevice) { + mutableStateOf("") + } + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartIntentSenderForResult(), + ) { + when (it.resultCode) { + CompanionDeviceManager.RESULT_OK -> { + associatedDevice = it.data?.getAssociationResult() + } + + CompanionDeviceManager.RESULT_CANCELED -> { + errorMessage = "The request was canceled" + } + + CompanionDeviceManager.RESULT_INTERNAL_ERROR -> { + errorMessage = "Internal error happened" + } + + CompanionDeviceManager.RESULT_DISCOVERY_TIMEOUT -> { + errorMessage = "No device matching the given filter were found" + } + + CompanionDeviceManager.RESULT_USER_REJECTED -> { + errorMessage = "The user explicitly declined the request" + } + + else -> { + errorMessage = "Unknown error" + } + } + } + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + AnimatedContent(targetState = associatedDevice, label = "") { target -> + if (target != null) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = "ID: ${target.id}") + Text(text = "MAC: ${target.address}") + Text(text = "Name: ${target.name}") + Button( + onClick = { + onConnect(target) + }, + ) { + Text(text = "Connect") + } + Button( + onClick = { + scope.launch { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + deviceManager.disassociate(target.id) + } else { + @Suppress("DEPRECATION") + deviceManager.disassociate(target.address) + } + associatedDevice = null + } + }, + ) { + Text(text = "Disassociate") + } + } + } else { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Button( + onClick = { + scope.launch { + val intentSender = requestDeviceAssociation(deviceManager) + launcher.launch(IntentSenderRequest.Builder(intentSender).build()) + } + }, + ) { + Text(text = "Find & Associate device") + } + if (errorMessage.isNotBlank()) { + Text(text = errorMessage, color = MaterialTheme.colorScheme.error) + } + } + } + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +private fun getAssociatedDevice(deviceManager: CompanionDeviceManager): AssociatedDevice? { + val associatedDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + deviceManager.myAssociations.lastOrNull()?.toAssociatedDevice() + } else { + // Before Android 34 we can only get the MAC. We could use the BT adapter to find the + // device, but to use CDM we only need the MAC. + @Suppress("DEPRECATION") + deviceManager.associations.lastOrNull()?.run { + AssociatedDevice( + id = -1, + address = this, + name = "", + device = null, + ) + } + } + return associatedDevice +} + +@RequiresApi(Build.VERSION_CODES.O) +private fun Intent.getAssociationResult(): AssociatedDevice? { + var result: AssociatedDevice? = null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + result = getParcelableExtra( + CompanionDeviceManager.EXTRA_ASSOCIATION, + AssociationInfo::class.java, + )?.toAssociatedDevice() + } else { + // Below Android 33 the result returns either a BLE ScanResult, a + // Classic BluetoothDevice or a Wifi ScanResult + // In our case we are looking for our BLE GATT server so we can cast directly + // to the BLE ScanResult + @Suppress("DEPRECATION") + val scanResult = getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE) + if (scanResult != null) { + result = AssociatedDevice( + id = scanResult.advertisingSid, + address = scanResult.device.address ?: "N/A", + name = scanResult.scanRecord?.deviceName ?: "N/A", + device = scanResult.device, + ) + } + } + return result +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +private fun AssociationInfo.toAssociatedDevice() = AssociatedDevice( + id = id, + address = deviceMacAddress?.toOuiString() ?: "N/A", + name = displayName?.ifBlank { "N/A" }?.toString() ?: "N/A", + device = if (Build.VERSION.SDK_INT >= 34) { + associatedDevice?.bleDevice?.device + } else { + null + }, +) + +@RequiresApi(Build.VERSION_CODES.O) +suspend fun requestDeviceAssociation(deviceManager: CompanionDeviceManager): IntentSender { + // Match only Bluetooth devices whose service UUID matches this pattern. + // For this demo we will match our GATTServerSample + val scanFilter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build() + val deviceFilter = BluetoothLeDeviceFilter.Builder() + .setScanFilter(scanFilter) + .build() + + val pairingRequest: AssociationRequest = AssociationRequest.Builder() + // Find only devices that match this request filter. + .addDeviceFilter(deviceFilter) + // Stop scanning as soon as one device matching the filter is found. + .setSingleDevice(true) + .build() + + val result = CompletableDeferred() + + val callback = object : CompanionDeviceManager.Callback() { + override fun onAssociationPending(intentSender: IntentSender) { + result.complete(intentSender) + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun onDeviceFound(intentSender: IntentSender) { + result.complete(intentSender) + } + + override fun onAssociationCreated(associationInfo: AssociationInfo) { + // This callback was added in API 33 but the result is also send in the activity result. + // For handling backwards compatibility we can just have all the logic there instead + } + + override fun onFailure(errorMessage: CharSequence?) { + result.completeExceptionally(IllegalStateException(errorMessage?.toString().orEmpty())) + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val executor = Executor { it.run() } + deviceManager.associate(pairingRequest, executor, callback) + } else { + deviceManager.associate(pairingRequest, callback, null) + } + return result.await() +} From 3790f04e6bd045d1108081ab547a6a2f3e057f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Thu, 27 Jul 2023 11:25:10 +0200 Subject: [PATCH 02/11] Make SERVICE_UUID accessible Change-Id: Ic378e13eafe9c015e41b2d2ab8fc5c8be76434ea --- .../platform/connectivity/bluetooth/ble/GATTServerSample.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt b/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt index 9d718e6f..2c944134 100644 --- a/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt +++ b/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt @@ -156,7 +156,7 @@ internal fun GATTServerScreen() { } // Random UUID for our service known between the client and server to allow communication -internal val SERVICE_UUID = UUID.fromString("9f42ba3a-75ea-4ca1-92d0-aef57f0479e6") +val SERVICE_UUID = UUID.fromString("9f42ba3a-75ea-4ca1-92d0-aef57f0479e6") // Same as the service but for the characteristic internal val CHARACTERISTIC_UUID = UUID.fromString("5aade5a7-14ea-43f7-a136-16cb92cddf35") From 8b652ffc69c0a10f42942a3ffbf434f0e8b8f116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Thu, 27 Jul 2023 13:26:09 +0200 Subject: [PATCH 03/11] Fix UUID for GATT server Change-Id: I163322c687d6a5c1ec2114046111b5b666369518 --- .../platform/connectivity/bluetooth/ble/GATTServerSample.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt b/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt index 2c944134..bee2156f 100644 --- a/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt +++ b/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt @@ -29,6 +29,7 @@ import android.bluetooth.le.AdvertiseCallback import android.bluetooth.le.AdvertiseData import android.bluetooth.le.AdvertiseSettings import android.os.Build +import android.os.ParcelUuid import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -156,9 +157,9 @@ internal fun GATTServerScreen() { } // Random UUID for our service known between the client and server to allow communication -val SERVICE_UUID = UUID.fromString("9f42ba3a-75ea-4ca1-92d0-aef57f0479e6") +val SERVICE_UUID: UUID = UUID.fromString("00002222-0000-1000-8000-00805f9b34fb") // Same as the service but for the characteristic -internal val CHARACTERISTIC_UUID = UUID.fromString("5aade5a7-14ea-43f7-a136-16cb92cddf35") +internal val CHARACTERISTIC_UUID = UUID.fromString("00001111-0000-1000-8000-00805f9b34fb") @SuppressLint("MissingPermission") @Composable @@ -210,6 +211,7 @@ private fun GATTServerEffect( val data = AdvertiseData.Builder() .setIncludeDeviceName(true) + .addServiceUuid(ParcelUuid(SERVICE_UUID)) .build() bluetoothLeAdvertiser.startAdvertising( From 2b201ac40c0e8eebf4bde3d55a74c9c101ace500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Thu, 27 Jul 2023 13:26:53 +0200 Subject: [PATCH 04/11] Use the latest GATT client received in callback There are fields that might change between the first created instance and the ones returned in the callback. Change-Id: Icd7dc5d1ad6aaff46e6e8eea1a6415ef1161a9bc --- .../bluetooth/ble/ConnectGATTSample.kt | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/ConnectGATTSample.kt b/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/ConnectGATTSample.kt index 81f35b05..5d19a1c2 100644 --- a/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/ConnectGATTSample.kt +++ b/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/ConnectGATTSample.kt @@ -25,6 +25,7 @@ import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattService import android.bluetooth.BluetoothProfile import android.os.Build +import android.util.Log import androidx.annotation.RequiresApi import androidx.annotation.RequiresPermission import androidx.compose.animation.AnimatedContent @@ -207,7 +208,11 @@ private data class DeviceConnectionState( val mtu: Int, val services: List = emptyList(), val messageSent: Boolean = false, -) +) { + companion object { + val None = DeviceConnectionState(null, -1, -1) + } +} @SuppressLint("InlinedApi") @RequiresPermission( @@ -222,13 +227,9 @@ private fun BLEConnectEffect( val context = LocalContext.current val currentOnStateChange by rememberUpdatedState(onStateChange) - // Keep the current GATT connection - var gatt by remember(device) { - mutableStateOf(null) - } // Keep the current connection state - var state by remember(gatt) { - mutableStateOf(DeviceConnectionState(gatt, -1, -1)) + var state by remember { + mutableStateOf(DeviceConnectionState.None) } DisposableEffect(lifecycleOwner, device) { @@ -243,6 +244,15 @@ private fun BLEConnectEffect( super.onConnectionStateChange(gatt, status, newState) state = state.copy(gatt = gatt, connectionState = newState) currentOnStateChange(state) + + if (status != BluetoothGatt.GATT_SUCCESS) { + // Here you should handle the error returned in status based on the constants + // https://developer.android.com/reference/android/bluetooth/BluetoothGatt#summary + // For example for GATT_INSUFFICIENT_ENCRYPTION or + // GATT_INSUFFICIENT_AUTHENTICATION you should create a bond. + // https://developer.android.com/reference/android/bluetooth/BluetoothDevice#createBond() + Log.e("BLEConnectEffect", "An error happened: $status") + } } override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { @@ -270,16 +280,16 @@ private fun BLEConnectEffect( val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_START) { - if (gatt != null) { + if (state.gatt != null) { // If we previously had a GATT connection let's reestablish it - gatt!!.connect() + state.gatt?.connect() } else { // Otherwise create a new GATT connection - gatt = device.connectGatt(context, false, callback) + state = state.copy(gatt = device.connectGatt(context, false, callback)) } } else if (event == Lifecycle.Event.ON_STOP) { // Unless you have a reason to keep connected while in the bg you should disconnect - gatt?.disconnect() + state.gatt?.disconnect() } } @@ -289,7 +299,8 @@ private fun BLEConnectEffect( // When the effect leaves the Composition, remove the observer and close the connection onDispose { lifecycleOwner.lifecycle.removeObserver(observer) - gatt?.close() + state.gatt?.close() + state = DeviceConnectionState.None } } } From 61bf2dbb724f93daf9613a720145cbe7cc99a558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Thu, 27 Jul 2023 13:27:13 +0200 Subject: [PATCH 05/11] Get the full MAC address from the info Change-Id: I3b9a69b47ad78e16b1fd4d999f0f544dbe53413b --- .../connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt index 5d927d56..1df74faf 100644 --- a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt +++ b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt @@ -248,7 +248,7 @@ private fun Intent.getAssociationResult(): AssociatedDevice? { @RequiresApi(Build.VERSION_CODES.TIRAMISU) private fun AssociationInfo.toAssociatedDevice() = AssociatedDevice( id = id, - address = deviceMacAddress?.toOuiString() ?: "N/A", + address = deviceMacAddress?.toString() ?: "N/A", name = displayName?.ifBlank { "N/A" }?.toString() ?: "N/A", device = if (Build.VERSION.SDK_INT >= 34) { associatedDevice?.bleDevice?.device From 00588e045d5126268162dddb9af077fe277fa66f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Thu, 27 Jul 2023 14:06:32 +0200 Subject: [PATCH 06/11] Polish CDM UI Change-Id: I65d3d3a772cfecdf0d6c0662be2a331a23cfb631 --- .../ble/CompanionDeviceManagerSample.kt | 185 +++++++++++++----- 1 file changed, 132 insertions(+), 53 deletions(-) diff --git a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt index 1df74faf..33ffeb8a 100644 --- a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt +++ b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt @@ -34,14 +34,20 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.IntentSenderRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -51,6 +57,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.getSystemService @@ -100,7 +107,6 @@ data class AssociatedDevice( val device: BluetoothDevice?, ) -@OptIn(ExperimentalAnimationApi::class) @RequiresApi(Build.VERSION_CODES.O) @Composable private fun CDMScreen( @@ -108,11 +114,40 @@ private fun CDMScreen( onConnect: (AssociatedDevice) -> Unit, ) { val scope = rememberCoroutineScope() - var associatedDevice by remember { + var associatedDevices by remember { // If we already associated the device no need to do it again. - mutableStateOf(getAssociatedDevice(deviceManager)) + mutableStateOf(getAssociatedDevices(deviceManager)) } - var errorMessage by remember(associatedDevice) { + Column(modifier = Modifier.fillMaxSize()) { + ScanForDevicesMenu(deviceManager) { + associatedDevices = associatedDevices + it + } + AssociatedDevicesList( + associatedDevices = associatedDevices, + onConnect = onConnect, + onDisassociate = { + scope.launch { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + deviceManager.disassociate(it.id) + } else { + @Suppress("DEPRECATION") + deviceManager.disassociate(it.address) + } + associatedDevices = getAssociatedDevices(deviceManager) + } + }, + ) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun ScanForDevicesMenu( + deviceManager: CompanionDeviceManager, + onDeviceAssociated: (AssociatedDevice) -> Unit, +) { + val scope = rememberCoroutineScope() + var errorMessage by remember { mutableStateOf("") } val launcher = rememberLauncherForActivityResult( @@ -120,7 +155,9 @@ private fun CDMScreen( ) { when (it.resultCode) { CompanionDeviceManager.RESULT_OK -> { - associatedDevice = it.data?.getAssociationResult() + it.data?.getAssociationResult()?.run { + onDeviceAssociated(this) + } } CompanionDeviceManager.RESULT_CANCELED -> { @@ -144,53 +181,95 @@ private fun CDMScreen( } } } - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - AnimatedContent(targetState = associatedDevice, label = "") { target -> - if (target != null) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(text = "ID: ${target.id}") - Text(text = "MAC: ${target.address}") - Text(text = "Name: ${target.name}") - Button( - onClick = { - onConnect(target) - }, - ) { - Text(text = "Connect") - } - Button( - onClick = { - scope.launch { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - deviceManager.disassociate(target.id) - } else { - @Suppress("DEPRECATION") - deviceManager.disassociate(target.address) - } - associatedDevice = null - } - }, - ) { - Text(text = "Disassociate") + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row { + Text( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + text = "Find & associate another device running the GATTServerSample", + ) + Button( + modifier = Modifier.weight(0.3f), + onClick = { + scope.launch { + val intentSender = requestDeviceAssociation(deviceManager) + launcher.launch(IntentSenderRequest.Builder(intentSender).build()) } + }, + ) { + Text(text = "Start") + } + } + if (errorMessage.isNotBlank()) { + Text(text = errorMessage, color = MaterialTheme.colorScheme.error) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun AssociatedDevicesList( + associatedDevices: List, + onConnect: (AssociatedDevice) -> Unit, + onDisassociate: (AssociatedDevice) -> Unit, +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + stickyHeader { + Text( + text = "Associated Devices:", + modifier = Modifier.padding(vertical = 8.dp), + style = MaterialTheme.typography.titleMedium, + ) + } + items(associatedDevices) { device -> + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + Modifier + .fillMaxWidth() + .weight(1f), + ) { + Text(text = "ID: ${device.id}") + Text(text = "MAC: ${device.address}") + Text(text = "Name: ${device.name}") } - } else { Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, + Modifier + .fillMaxWidth() + .weight(0.6f), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.Center, ) { - Button( - onClick = { - scope.launch { - val intentSender = requestDeviceAssociation(deviceManager) - launcher.launch(IntentSenderRequest.Builder(intentSender).build()) - } - }, + OutlinedButton( + onClick = { onConnect(device) }, + modifier = Modifier.fillMaxWidth(), ) { - Text(text = "Find & Associate device") + Text(text = "Connect") } - if (errorMessage.isNotBlank()) { - Text(text = errorMessage, color = MaterialTheme.colorScheme.error) + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = { onDisassociate(device) }, + border = ButtonDefaults.outlinedButtonBorder.copy( + brush = SolidColor(MaterialTheme.colorScheme.error), + ), + ) { + Text(text = "Disassociate", color = MaterialTheme.colorScheme.error) } } } @@ -199,17 +278,17 @@ private fun CDMScreen( } @RequiresApi(Build.VERSION_CODES.O) -private fun getAssociatedDevice(deviceManager: CompanionDeviceManager): AssociatedDevice? { +private fun getAssociatedDevices(deviceManager: CompanionDeviceManager): List { val associatedDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - deviceManager.myAssociations.lastOrNull()?.toAssociatedDevice() + deviceManager.myAssociations.map { it.toAssociatedDevice() } } else { // Before Android 34 we can only get the MAC. We could use the BT adapter to find the // device, but to use CDM we only need the MAC. @Suppress("DEPRECATION") - deviceManager.associations.lastOrNull()?.run { + deviceManager.associations.map { AssociatedDevice( id = -1, - address = this, + address = it, name = "", device = null, ) From b3163996f51c1d202e58b535cdb2dbb25b86cd32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Thu, 27 Jul 2023 14:24:59 +0200 Subject: [PATCH 07/11] Clean up CDM code Change-Id: Ia9cfb907e5c4e8d478459ca22a2a4ce6e44b68c1 --- .../bluetooth/ble/AssociatedDeviceCompat.kt | 65 +++++++++++++++++++ .../ble/CompanionDeviceManagerSample.kt | 65 ++++--------------- 2 files changed, 78 insertions(+), 52 deletions(-) create mode 100644 samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/AssociatedDeviceCompat.kt diff --git a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/AssociatedDeviceCompat.kt b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/AssociatedDeviceCompat.kt new file mode 100644 index 00000000..2a9b3d0c --- /dev/null +++ b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/AssociatedDeviceCompat.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.bluetooth.ble + +import android.bluetooth.BluetoothDevice +import android.companion.AssociationInfo +import android.companion.CompanionDeviceManager +import android.os.Build +import androidx.annotation.RequiresApi + +/** + * Wrapper for the different type of classes the CDM returns + */ +data class AssociatedDeviceCompat( + val id: Int, + val address: String, + val name: String, + val device: BluetoothDevice?, +) + +@RequiresApi(Build.VERSION_CODES.O) +internal fun CompanionDeviceManager.getAssociatedDevices(): List { + val associatedDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + myAssociations.map { it.toAssociatedDevice() } + } else { + // Before Android 34 we can only get the MAC. We could use the BT adapter to find the + // device, but to use CDM we only need the MAC. + @Suppress("DEPRECATION") + associations.map { + AssociatedDeviceCompat( + id = -1, + address = it, + name = "", + device = null, + ) + } + } + return associatedDevice +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +internal fun AssociationInfo.toAssociatedDevice() = AssociatedDeviceCompat( + id = id, + address = deviceMacAddress?.toString() ?: "N/A", + name = displayName?.ifBlank { "N/A" }?.toString() ?: "N/A", + device = if (Build.VERSION.SDK_INT >= 34) { + associatedDevice?.bleDevice?.device + } else { + null + }, +) diff --git a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt index 33ffeb8a..8c5656d0 100644 --- a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt +++ b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt @@ -87,7 +87,7 @@ fun CompanionDeviceManagerSample() { Text(text = "No Companion device manager found. The device does not support it.") } else { if (selectedDevice == null) { - CDMScreen(deviceManager) { + DevicesScreen(deviceManager) { selectedDevice = it.device ?: adapter.getRemoteDevice(it.name) } } else { @@ -100,23 +100,16 @@ fun CompanionDeviceManagerSample() { } } -data class AssociatedDevice( - val id: Int, - val address: String, - val name: String, - val device: BluetoothDevice?, -) - @RequiresApi(Build.VERSION_CODES.O) @Composable -private fun CDMScreen( +private fun DevicesScreen( deviceManager: CompanionDeviceManager, - onConnect: (AssociatedDevice) -> Unit, + onConnect: (AssociatedDeviceCompat) -> Unit, ) { val scope = rememberCoroutineScope() var associatedDevices by remember { // If we already associated the device no need to do it again. - mutableStateOf(getAssociatedDevices(deviceManager)) + mutableStateOf(deviceManager.getAssociatedDevices()) } Column(modifier = Modifier.fillMaxSize()) { ScanForDevicesMenu(deviceManager) { @@ -133,7 +126,7 @@ private fun CDMScreen( @Suppress("DEPRECATION") deviceManager.disassociate(it.address) } - associatedDevices = getAssociatedDevices(deviceManager) + associatedDevices = deviceManager.getAssociatedDevices() } }, ) @@ -144,7 +137,7 @@ private fun CDMScreen( @Composable private fun ScanForDevicesMenu( deviceManager: CompanionDeviceManager, - onDeviceAssociated: (AssociatedDevice) -> Unit, + onDeviceAssociated: (AssociatedDeviceCompat) -> Unit, ) { val scope = rememberCoroutineScope() var errorMessage by remember { @@ -217,9 +210,9 @@ private fun ScanForDevicesMenu( @OptIn(ExperimentalFoundationApi::class) @Composable private fun AssociatedDevicesList( - associatedDevices: List, - onConnect: (AssociatedDevice) -> Unit, - onDisassociate: (AssociatedDevice) -> Unit, + associatedDevices: List, + onConnect: (AssociatedDeviceCompat) -> Unit, + onDisassociate: (AssociatedDeviceCompat) -> Unit, ) { LazyColumn( modifier = Modifier @@ -278,28 +271,8 @@ private fun AssociatedDevicesList( } @RequiresApi(Build.VERSION_CODES.O) -private fun getAssociatedDevices(deviceManager: CompanionDeviceManager): List { - val associatedDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - deviceManager.myAssociations.map { it.toAssociatedDevice() } - } else { - // Before Android 34 we can only get the MAC. We could use the BT adapter to find the - // device, but to use CDM we only need the MAC. - @Suppress("DEPRECATION") - deviceManager.associations.map { - AssociatedDevice( - id = -1, - address = it, - name = "", - device = null, - ) - } - } - return associatedDevice -} - -@RequiresApi(Build.VERSION_CODES.O) -private fun Intent.getAssociationResult(): AssociatedDevice? { - var result: AssociatedDevice? = null +private fun Intent.getAssociationResult(): AssociatedDeviceCompat? { + var result: AssociatedDeviceCompat? = null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { result = getParcelableExtra( CompanionDeviceManager.EXTRA_ASSOCIATION, @@ -313,7 +286,7 @@ private fun Intent.getAssociationResult(): AssociatedDevice? { @Suppress("DEPRECATION") val scanResult = getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE) if (scanResult != null) { - result = AssociatedDevice( + result = AssociatedDeviceCompat( id = scanResult.advertisingSid, address = scanResult.device.address ?: "N/A", name = scanResult.scanRecord?.deviceName ?: "N/A", @@ -324,20 +297,8 @@ private fun Intent.getAssociationResult(): AssociatedDevice? { return result } -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -private fun AssociationInfo.toAssociatedDevice() = AssociatedDevice( - id = id, - address = deviceMacAddress?.toString() ?: "N/A", - name = displayName?.ifBlank { "N/A" }?.toString() ?: "N/A", - device = if (Build.VERSION.SDK_INT >= 34) { - associatedDevice?.bleDevice?.device - } else { - null - }, -) - @RequiresApi(Build.VERSION_CODES.O) -suspend fun requestDeviceAssociation(deviceManager: CompanionDeviceManager): IntentSender { +private suspend fun requestDeviceAssociation(deviceManager: CompanionDeviceManager): IntentSender { // Match only Bluetooth devices whose service UUID matches this pattern. // For this demo we will match our GATTServerSample val scanFilter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build() From 655963fa9871fe5adacb1ad20f8ab7be1148357b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Thu, 27 Jul 2023 14:49:52 +0200 Subject: [PATCH 08/11] Use correct package Change-Id: I6de21301605d44ec410eedd7edb83821d5416fb7 --- .../bluetooth/{ble => cdm}/AssociatedDeviceCompat.kt | 2 +- .../bluetooth/{ble => cdm}/CompanionDeviceManagerSample.kt | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) rename samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/{ble => cdm}/AssociatedDeviceCompat.kt (97%) rename samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/{ble => cdm}/CompanionDeviceManagerSample.kt (98%) diff --git a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/AssociatedDeviceCompat.kt b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/AssociatedDeviceCompat.kt similarity index 97% rename from samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/AssociatedDeviceCompat.kt rename to samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/AssociatedDeviceCompat.kt index 2a9b3d0c..aa4d6bed 100644 --- a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/AssociatedDeviceCompat.kt +++ b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/AssociatedDeviceCompat.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.platform.connectivity.bluetooth.ble +package com.example.platform.connectivity.bluetooth.cdm import android.bluetooth.BluetoothDevice import android.companion.AssociationInfo diff --git a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt similarity index 98% rename from samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt rename to samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt index 8c5656d0..9436cc9c 100644 --- a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt +++ b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.platform.connectivity.bluetooth.ble +package com.example.platform.connectivity.bluetooth.cdm import android.Manifest import android.annotation.SuppressLint @@ -62,6 +62,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.getSystemService import com.example.platform.base.PermissionBox +import com.example.platform.connectivity.bluetooth.ble.ConnectDeviceScreen +import com.example.platform.connectivity.bluetooth.ble.SERVICE_UUID import com.google.android.catalog.framework.annotations.Sample import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.launch From ed8d9086b53042d5fd235f108d095cadb83158b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Fri, 28 Jul 2023 11:03:32 +0200 Subject: [PATCH 09/11] Add CDM Service showcase The service shows how to use CDM to detect when devices are in or out of range. Change-Id: I8431f11a3aaad5016ec73ec6712dd0e8fa55d8fb --- samples/README.md | 2 +- .../bluetooth/companion/README.md | 19 +- .../companion/src/main/AndroidManifest.xml | 20 ++- .../cdm/CompanionDeviceManagerSample.kt | 14 +- .../cdm/CompanionDeviceSampleService.kt | 168 ++++++++++++++++++ 5 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceSampleService.kt diff --git a/samples/README.md b/samples/README.md index f8c60488..33b287ef 100644 --- a/samples/README.md +++ b/samples/README.md @@ -10,7 +10,7 @@ Sample demonstrating how to make incoming call notifications and in call notific Demonstrates displaying processed pixel data directly from the camera sensor - [Color Contrast](accessibility/src/main/java/com/example/platform/accessibility/ColorContrast.kt): This sample demonstrates the importance of proper color contrast and how to -- [Companion Device Manager Sample](connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt): +- [Companion Device Manager Sample](connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt): This samples shows how to use the CDM to pair and connect with BLE devices - [Connect to a GATT server](connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/ConnectGATTSample.kt): Shows how to connect to a GATT server hosted by the BLE device and perform simple operations diff --git a/samples/connectivity/bluetooth/companion/README.md b/samples/connectivity/bluetooth/companion/README.md index 81556fff..6b650feb 100644 --- a/samples/connectivity/bluetooth/companion/README.md +++ b/samples/connectivity/bluetooth/companion/README.md @@ -1,6 +1,23 @@ # Companion Device Manager Sample -// TODO +This sample showcases the use of the +[Companion Device Manager](https://developer.android.com/reference/android/companion/CompanionDeviceManager#startSystemDataTransfer(int,%20java.util.concurrent.Executor,%20android.os.OutcomeReceiver%3Cjava.lang.Void,android.companion.CompanionException%3E)) +(CDM) to find and associate devices. + +## How to use the sample: + +1. Use two devices running the sample +2. In one device start the [GATTServerSample](../ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt) +3. In the other open the [CompanionDeviceManagerSample](/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt)) +4. Click start button in the CDM sample + +It should directly find the server and a system request will appear. Once accepted both devices will +be associated. You can then connect (see [ConnectGATTSample](../ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/ConnectGATTSample.kt) +and receive (see [CompanionDeviceSampleService](/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceSampleService.kt)) +appear and disappear events (if running A12+). + +> Note: You can associate multiple devices. When the server closes and opens again a new mac address +> will be used, thus you need to associate them again. ## License diff --git a/samples/connectivity/bluetooth/companion/src/main/AndroidManifest.xml b/samples/connectivity/bluetooth/companion/src/main/AndroidManifest.xml index 29057767..3cde873e 100644 --- a/samples/connectivity/bluetooth/companion/src/main/AndroidManifest.xml +++ b/samples/connectivity/bluetooth/companion/src/main/AndroidManifest.xml @@ -17,9 +17,27 @@ - + + + + + + + + + + + + + + + + diff --git a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt index 9436cc9c..0a14d284 100644 --- a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt +++ b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt @@ -50,6 +50,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -89,8 +90,8 @@ fun CompanionDeviceManagerSample() { Text(text = "No Companion device manager found. The device does not support it.") } else { if (selectedDevice == null) { - DevicesScreen(deviceManager) { - selectedDevice = it.device ?: adapter.getRemoteDevice(it.name) + DevicesScreen(deviceManager) { device -> + selectedDevice = (device.device ?: adapter.getRemoteDevice(device.address)) } } else { PermissionBox(permission = Manifest.permission.BLUETOOTH_CONNECT) { @@ -113,6 +114,15 @@ private fun DevicesScreen( // If we already associated the device no need to do it again. mutableStateOf(deviceManager.getAssociatedDevices()) } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + LaunchedEffect(associatedDevices) { + associatedDevices.forEach { + deviceManager.startObservingDevicePresence(it.address) + } + } + } + Column(modifier = Modifier.fillMaxSize()) { ScanForDevicesMenu(deviceManager) { associatedDevices = associatedDevices + it diff --git a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceSampleService.kt b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceSampleService.kt new file mode 100644 index 00000000..fd3bca41 --- /dev/null +++ b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceSampleService.kt @@ -0,0 +1,168 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.bluetooth.cdm + +import android.Manifest +import android.annotation.SuppressLint +import android.app.NotificationManager +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.companion.AssociationInfo +import android.companion.CompanionDeviceService +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.getSystemService +import androidx.core.graphics.drawable.IconCompat + +@RequiresApi(Build.VERSION_CODES.S) +class CompanionDeviceSampleService : CompanionDeviceService() { + + private val notificationManager: DeviceNotificationManager by lazy { + DeviceNotificationManager(applicationContext) + } + + private val bluetoothManager: BluetoothManager by lazy { + applicationContext.getSystemService()!! + } + + @SuppressLint("MissingPermission") + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + override fun onDeviceAppeared(associationInfo: AssociationInfo) { + super.onDeviceAppeared(associationInfo) + if (missingPermissions()) { + return + } + + val address = associationInfo.deviceMacAddress?.toString() ?: return + var device: BluetoothDevice? = null + if (Build.VERSION.SDK_INT >= 34) { + device = associationInfo.associatedDevice?.bleDevice?.device + } + if (device == null) { + device = bluetoothManager.adapter.getRemoteDevice(address) + } + val status = bluetoothManager.getConnectionState(device, BluetoothProfile.GATT) + + notificationManager.onDeviceAppeared( + address = address, + status = "$status", + ) + } + + @SuppressLint("MissingPermission") + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + override fun onDeviceDisappeared(associationInfo: AssociationInfo) { + super.onDeviceDisappeared(associationInfo) + if (missingPermissions()) { + return + } + + notificationManager.onDeviceDisappeared( + address = associationInfo.deviceMacAddress?.toString() ?: return, + ) + } + + @SuppressLint("MissingPermission") + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") + override fun onDeviceAppeared(address: String) { + super.onDeviceAppeared(address) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU || missingPermissions()) { + return + } + + val device = bluetoothManager.adapter.getRemoteDevice(address) + val status = bluetoothManager.getConnectionState(device, BluetoothProfile.GATT) + notificationManager.onDeviceAppeared(address, status.toString()) + } + + @SuppressLint("MissingPermission") + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") + override fun onDeviceDisappeared(address: String) { + super.onDeviceDisappeared(address) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU || missingPermissions()) { + return + } + + notificationManager.onDeviceDisappeared(address) + } + + /** + * Check BLUETOOTH_CONNECT is granted and POST_NOTIFICATIONS is granted for devices running + * Android 13 and above. + */ + private fun missingPermissions(): Boolean = ActivityCompat.checkSelfPermission( + this, + Manifest.permission.BLUETOOTH_CONNECT, + ) != PackageManager.PERMISSION_GRANTED || + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ActivityCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED) + + /** + * Utility class to post notification when CDM notifies that a device appears or disappears + */ + private class DeviceNotificationManager(context: Context) { + + companion object { + private const val CDM_CHANNEL = "cdm_channel" + } + + private val manager = NotificationManagerCompat.from(context) + + private val notificationBuilder = NotificationCompat.Builder(context, CDM_CHANNEL) + .setSmallIcon(IconCompat.createWithResource(context, context.applicationInfo.icon)) + .setContentTitle("Companion Device Manager Sample") + + init { + createNotificationChannel() + } + + @SuppressLint("InlinedApi") + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + fun onDeviceAppeared(address: String, status: String) { + val notification = + notificationBuilder.setContentText("Device: $address appeared.\nStatus: $status") + manager.notify(address.hashCode(), notification.build()) + } + + @SuppressLint("InlinedApi") + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + fun onDeviceDisappeared(address: String) { + val notification = notificationBuilder.setContentText("Device: $address disappeared") + manager.notify(address.hashCode(), notification.build()) + } + + private fun createNotificationChannel() { + val channel = + NotificationChannelCompat.Builder(CDM_CHANNEL, NotificationManager.IMPORTANCE_HIGH) + .setName("CDM Sample") + .setDescription("Channel for the CDM sample") + .build() + manager.createNotificationChannel(channel) + } + } +} From c5743a71209707e82457935ea8761a00002d6bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Fri, 28 Jul 2023 11:03:47 +0200 Subject: [PATCH 10/11] Update BLE README.md Change-Id: I852ec3f0d1c5cd464a4553d2913568028034dde4 --- samples/connectivity/bluetooth/ble/README.md | 5 ++++- .../platform/connectivity/bluetooth/ble/GATTServerSample.kt | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/samples/connectivity/bluetooth/ble/README.md b/samples/connectivity/bluetooth/ble/README.md index 2f7b1276..78d06a3a 100644 --- a/samples/connectivity/bluetooth/ble/README.md +++ b/samples/connectivity/bluetooth/ble/README.md @@ -2,7 +2,10 @@ This module contains samples related to Bluetooth BLE APIs -- [Find devices](src/main/java/com/example/platform/connectivity/bluetooth/ble/FindDevicesSample.kt) +- [Find BLE devices](src/main/java/com/example/platform/connectivity/bluetooth/ble/FindBLEDevicesSample.kt) +- [Find BLE devices using a PendingIntent](src/main/java/com/example/platform/connectivity/bluetooth/ble/BLEScanIntentSample.kt) +- [Connect to scanned BLE devices](src/main/java/com/example/platform/connectivity/bluetooth/ble/ConnectGATTSample.kt) +- [Create a GATT server, advertise and handle connections](src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt) More info at https://developer.android.com/guide/topics/connectivity/bluetooth/ble-overview diff --git a/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt b/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt index dc9bc68f..54cf2500 100644 --- a/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt +++ b/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt @@ -189,6 +189,7 @@ internal fun GATTServerScreen(adapter: BluetoothAdapter) { // Random UUID for our service known between the client and server to allow communication val SERVICE_UUID: UUID = UUID.fromString("00002222-0000-1000-8000-00805f9b34fb") + // Same as the service but for the characteristic val CHARACTERISTIC_UUID: UUID = UUID.fromString("00001111-0000-1000-8000-00805f9b34fb") @@ -263,10 +264,10 @@ private fun GATTServerEffect( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) bluetoothLeAdvertiser.stopAdvertising(advertiseCallback) - gattServer?.close() manager.getConnectedDevices(BluetoothProfile.GATT_SERVER)?.forEach { gattServer?.cancelConnection(it) } + gattServer?.close() } } } From d2da14a62613b55d5ba465d4a7f4c61072aa0845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Fri, 28 Jul 2023 11:11:38 +0200 Subject: [PATCH 11/11] Stop observing device presence when disassociated Change-Id: I3b5194ac2844045d84115b4816de04eaa62eaac3 --- .../connectivity/bluetooth/cdm/AssociatedDeviceCompat.kt | 2 +- .../connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/AssociatedDeviceCompat.kt b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/AssociatedDeviceCompat.kt index aa4d6bed..2134ec1e 100644 --- a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/AssociatedDeviceCompat.kt +++ b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/AssociatedDeviceCompat.kt @@ -57,7 +57,7 @@ internal fun AssociationInfo.toAssociatedDevice() = AssociatedDeviceCompat( id = id, address = deviceMacAddress?.toString() ?: "N/A", name = displayName?.ifBlank { "N/A" }?.toString() ?: "N/A", - device = if (Build.VERSION.SDK_INT >= 34) { + device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { associatedDevice?.bleDevice?.device } else { null diff --git a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt index 0a14d284..5df76fb0 100644 --- a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt +++ b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt @@ -138,6 +138,9 @@ private fun DevicesScreen( @Suppress("DEPRECATION") deviceManager.disassociate(it.address) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + deviceManager.stopObservingDevicePresence(it.address) + } associatedDevices = deviceManager.getAssociatedDevices() } },