전에 크몽에 서비스를 올려두고 간단한 앱 개발 의뢰가 들어오길 기다리던 중, 한 분께서 BLE 통신을 사용하는 앱을 의뢰하셨다.
BLE 통신을 한 번도 접해본 적이 없었기에 당연히 상황을 설명드린 후, 앱 개발을 거절했는데 이대로는 할 줄 아는 게 정말 UI 개발밖에 없는 사람이 될 것 같아 차근차근 범위를 넓혀 보기로 했다.
시작은 처음이니만큼 블루투스 스캔 기능을 구현하기로 했다.
우선 블루투스 기능을 사용하기 위해서는 AndroidManifest.xml 파일에 퍼미션을 추가해야 한다.
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
다른 분들은 이렇게까지 많이 안 하시는 것 같던데, 나는 자꾸 오류가 나서 그냥 관련된 건 다 추가해줬다.
위의 코드는 Manifest 태그 안, Application 태그 밖에 넣어주면 된다.
퍼미션을 추가했으니 이제 유저가 해당 정보 사용을 허락했는지 안 했는지 확인해야 한다.
이 작업은 MainActivity.kt 파일에 추가해주면 된다.
class MainActivity : ComponentActivity() {
val scanViewModel by viewModels<ScanViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
BLE_PracticeTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "ScanScreen") {
composable(route = "ScanScreen") { ScanScreen(
scanViewModel
) }
// composable(route = "ConnectScreen") { ConnectScreen() }
}
}
}
}
if(Build.VERSION.SDK_INT >= 31){
if(permissionArray.all{ ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED}){
Toast.makeText(this, "권한 확인", Toast.LENGTH_SHORT).show()
}
else{
requestPermissionLauncher.launch(permissionArray)
}
}
}
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
permissions.entries.forEach {
Log.d("DEBUG", "${it.key} = ${it.value}")
}
}
}
위의 코드를 복사해서 MainActivity에 넣으면 아마 오류가 날 거다.
permissionArray가 없어서 그런데, 그건 Util에 따로 빼뒀다.
PermissionArray.kt
val permissionArray = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
)
} else {
arrayOf(
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
)
}
이전 코드에서 여기저기 많이 쓰였어서 유틸리티로 빼뒀는데, 굳이 그러고 싶지 않다면 메인 액티비티에 추가하면 된다.
이제 뷰를 보자.
ScanScreen.kt
@Composable
fun ScanScreen(scanViewModel: ScanViewModel = viewModel()) {
val devices by scanViewModel.devices.observeAsState(initial = listOf())
val isScanning by scanViewModel.isScanning.observeAsState(initial = false)
Column {
Button(onClick = {
if (isScanning) {
scanViewModel.stopScan()
} else {
scanViewModel.startScan()
}
}) {
Text(if (isScanning) "Stop Scan" else "Start Scan")
}
LazyColumn {
items(devices) { device ->
ScanItem(device)
}
}
}
}
@SuppressLint("MissingPermission")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScanItem(
deviceData: BluetoothDevice
) {
var expanded by remember { mutableStateOf(false) }
val deviceName = deviceData.name ?: "Unknown Device"
Card(
colors = CardDefaults.cardColors(
containerColor = Color(0xFF569097)
),
modifier = Modifier.padding(vertical = 4.dp),
onClick = {
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(3.dp)
.padding(start = 2.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = deviceName)
if (expanded) {
Spacer(modifier = Modifier.height(4.dp))
Text(text = "UUID\n>> ${deviceData.uuids}")
Spacer(modifier = Modifier.height(2.dp))
Text(text = "Address\n>> ${deviceData.address}")
}
}
IconButton(onClick = { expanded = !expanded }) {
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = ""
)
}
}
}
}
뷰는 딱히 설명할 만한 부분이 없는 것 같은데, 우선 ScanScreen은 두 가지 컴포넌트로 구성된다.
스캔을 시작하고 멈출 Button과 블루투스 디바이스들을 보여줄 LazyColumn.
LazyColumn 안에는 스캔된 디바이스의 개수만큼 ScanItem이 생성된다.
ScanItem은 디바이스의 이름이 뜨고, 카드를 확장하면 그 안에 블루투스 기기의 UUID와 address가 보이게 된다.
이제 뷰모델 코드만 작성하면 코드 작성이 완료된다.
ScanViewModel.kt
@Suppress("DEPRECATION")
class ScanViewModel(application: Application) : AndroidViewModel(application) {
// 블루투스 기능을 위한 블루투스 어댑터
private val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter()
// 블루투스 기기 목록
private val _devices = MutableLiveData<List<BluetoothDevice>>()
val devices: LiveData<List<BluetoothDevice>> = _devices
// 스캔 여부를 저장할 변수
private val _isScanning = MutableLiveData<Boolean>()
val isScanning: LiveData<Boolean> = _isScanning
// 기기가 스캔이 되면 addDevice() 실행
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult?) {
super.onScanResult(callbackType, result)
result?.device?.let { device ->
addDevice(device)
}
}
}
// 스캔 시작 함수
@SuppressLint("MissingPermission")
fun startScan() {
_isScanning.value = true
bluetoothAdapter?.bluetoothLeScanner?.startScan(scanCallback)
}
// 스캔 정지 함수
@SuppressLint("MissingPermission")
fun stopScan() {
_isScanning.value = false
bluetoothAdapter?.bluetoothLeScanner?.stopScan(scanCallback)
}
// 기기를 _devices 변수에 추가
private fun addDevice(device: BluetoothDevice) {
val newList = _devices.value?.toMutableList() ?: mutableListOf()
if (newList.none { it.address == device.address }) {
newList.add(device)
_devices.value = newList
}
}
// 블루투스 스캔 중일 때 뷰모델 소멸 시 스캔 정지
override fun onCleared() {
super.onCleared()
if (_isScanning.value == true) {
stopScan()
}
}
}
이렇게 작성하고 나면
사진처럼 디바이스 목록이 뜨게 된다.
왜인지 모르게 이름이 null인 디바이스가 참 많이 발견됐더라..
내 문제인지 모르겠어서 우선은 좀 더 공부해 봐야 할 것 같다.
아직은 BLE 통신의 초입에 들어온 거라 어떻게 활용할 수 있을지 감이 잘 안 온다.
다음은 블루투스 connect와 관련하여 포스팅을 해보겠다.