[Android] Jetpack Compose BLE 통신 - 블루투스 스캔

mi-fasol·2024년 3월 17일
0

Compose

목록 보기
6/6

전에 크몽에 서비스를 올려두고 간단한 앱 개발 의뢰가 들어오길 기다리던 중, 한 분께서 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와 관련하여 포스팅을 해보겠다.

profile
정위블

0개의 댓글