❕ Bluetooth Gatt
Android에서 BLE를 다루는 프레임워크
BLE(Bluetooth Low Energy)
블루투스 통신의 단점이었던 전력소비를 보완한 저전력 블루투스
본문은 BLE 기기의 스캔과 연결에 대한 구현 방법을 담고 있다. 다음 글에서 앱 -> BLE 기기 데이터 전송 및 블루투스 연결 끊김 감지, BLE 기기 -> 앱 데이터 전송을 담을 예정이다.
🟨 구현
🟠 권한 설정
권한은 아래 링크에서 각자 필요한 것을 찾아 선언해주면 됨
블루투스 권한 | Connectivity | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 블루투스 권한 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 앱에서 블루투스 기능을 사용하려면 여
developer.android.com
나는 아래와 같이 권한 설정을 해주었으나 안드로이드 기기의 버전에 따라 위치 권한이 필요할 수 있다. 이 글에서는 12 이상만을 다루고 있다. 12 미만으로는 권한 설정이 조금 달라지니 주의해서 작성!
🔸 manifest.xml
<?xml version="1.0" encoding="utf-8"?>
<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.BLUETOOTH_CONNECT" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" /> <!-- 안드로이드 최신폰들은 이걸 넣어줘야함 -->
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.GattTest"
tools:targetApi="31">
<activity
android:name=".ConnectActivity"
android:exported="false" />
<activity
android:name=".BluetoothActivity"
android:exported="false" />
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
🔸 MainActivity.java
안드로이드 버전이 12 이상이면 블루투스 권한을 체크하는 함수를 실행한다.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
checkBluetoothPermissions();
}
필요한 권한을 리스트로 선언해준 뒤, 권한이 허용되어있는지 체크한다. 하나라도 체크되어 있지 않다면 권한을 요청하고
모든 권한이 허용되어 있다면 원하는 작업을 실행하면 된다.
@RequiresApi(api = Build.VERSION_CODES.S)
private void checkBluetoothPermissions() {
String[] bluetoothPermissions = {
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_ADVERTISE
};
// 권한 체크
boolean allPermissionsGranted = true;
for (String permission : bluetoothPermissions) {
if (ActivityCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, permission);
allPermissionsGranted = false;
break;
}
}
// 권한 요청
if (!allPermissionsGranted) {
requestPermissions(bluetoothPermissions, REQUEST_BLUETOOTH_PERMISSIONS);
} else {
// 권한이 이미 허용된 경우 블루투스 액티비티로 이동
startBluetoothActivity();
}
}
// 권한 요청의 결과가 반환되면 아래 함수가 실행된다
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == REQUEST_BLUETOOTH_PERMISSIONS) {
boolean allGranted = true;
for (int result : grantResults) {
if (result != PackageManager.PERMISSION_GRANTED) {
allGranted = false;
break;
}
}
// 권한 허용 여부에 따라 블루투스 액티비티로 이동
if (allGranted) {
startBluetoothActivity();
} else {
Toast.makeText(this, "Bluetooth permissions are required for this feature.", Toast.LENGTH_SHORT).show();
}
}
}
전체코드
public class MainActivity extends AppCompatActivity {
private static final int REQUEST_BLUETOOTH_PERMISSIONS = 1;
private static String TAG = "MainActivity";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
checkBluetoothPermissions();
}
}
@RequiresApi(api = Build.VERSION_CODES.S)
private void checkBluetoothPermissions() {
String[] bluetoothPermissions = {
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_ADVERTISE
};
// 권한 체크
boolean allPermissionsGranted = true;
for (String permission : bluetoothPermissions) {
if (ActivityCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, permission);
allPermissionsGranted = false;
break;
}
}
// 권한 요청
if (!allPermissionsGranted) {
requestPermissions(bluetoothPermissions, REQUEST_BLUETOOTH_PERMISSIONS);
} else {
// 권한이 이미 허용된 경우 블루투스 액티비티로 이동
startBluetoothActivity();
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == REQUEST_BLUETOOTH_PERMISSIONS) {
boolean allGranted = true;
for (int result : grantResults) {
if (result != PackageManager.PERMISSION_GRANTED) {
allGranted = false;
break;
}
}
// 권한 허용 여부에 따라 블루투스 액티비티로 이동
if (allGranted) {
startBluetoothActivity();
} else {
Toast.makeText(this, "Bluetooth permissions are required for this feature.", Toast.LENGTH_SHORT).show();
}
}
}
private void startBluetoothActivity() {
Intent intent = new Intent(this, BluetoothActivity.class);
startActivity(intent);
}
}
🟠 스캔
스캔 버튼을 클릭하면 블루투스 기기들을 스캔하고 리사이클러뷰에 스캔된 기기를 띄운다.
🔸 activity_bluetooth.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".BluetoothActivity"
android:orientation="vertical">
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<Button
android:id="@+id/btn_scan"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Scan" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
</androidx.appcompat.widget.LinearLayoutCompat>
<FrameLayout
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
🔸 BluetoothActivity.java
BLE 기기를 스캔하기 위해 BluetoothLeScanner를 사용한다.
스캔 버튼을 클릭하면 스캔이 시작된다. 스캔은 10초간 진행된 후, 멈춘다. 스캔은 자원을 아주 많이 사용하는 작업이므로 꼭 멈춰주어야 한다. 앱을 종료하면 자동으로 꺼지긴 하지만 혹시 모르니 꼭 멈춰주기!
아래는 스캔에 필요한 코드만 모아놓은 것으로 전체 코드는 글의 최하단에 있다.
@SuppressWarnings("ALL")
public class BluetoothActivity extends AppCompatActivity {
private static final String TAG = "BluetoothActivity";
private ActivityBluetoothBinding binding;
private BluetoothManager bluetoothManager;
private BluetoothAdapter bluetoothAdapter;
private BluetoothLeScanner bluetoothLeScanner;
private ArrayList<BluetoothDevice> discoveredDevicesList = new ArrayList<>();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityBluetoothBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
...
initBluetooth();
}
private void initBluetooth() {
bluetoothManager = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
bluetoothAdapter = bluetoothManager.getAdapter();
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) {
Toast.makeText(this, "Bluetooth가 비활성화되어 있습니다.", Toast.LENGTH_SHORT).show();
return;
}
bluetoothLeScanner = bluetoothAdapter.getBluetoothLeScanner();
...
// 버튼을 클릭하면 스캔
binding.btnScan.setOnClickListener(v -> {
...
scanDevice();
});
}
private void scanDevice() {
long SCAN_PERIOD = 10000; // 스캔 시간
// 스캔 시작
bluetoothLeScanner.startScan(scanCallback);
Log.d(TAG, "scan start");
new Handler().postDelayed(() -> {
// 스캔 종료
bluetoothLeScanner.stopScan(scanCallback);
Toast.makeText(this, "스캔 종료", Toast.LENGTH_SHORT).show();
}, SCAN_PERIOD);
}
private final ScanCallback scanCallback = new ScanCallback() {
@Override
public void onScanResult(int callbackType, ScanResult result) {
super.onScanResult(callbackType, result);
BluetoothDevice device = result.getDevice();
// 스캔된 기기가 null이 아니고 이미 리스트에 포함되지 않았다면 새로 추가
if (device != null && !discoveredDevicesList.contains(device)) {
if (device.getName() != null) {
...
discoveredDevicesList.add(device);
Log.d(TAG, "Discovered device: " + device.getName());
}
}
}
@Override
public void onScanFailed(int errorCode) {
super.onScanFailed(errorCode);
Log.e(TAG, "Scan failed with error code: " + errorCode);
}
};
}
🟠 연결
리사이클러뷰에 뜬 블루투스 기기의 이름을 클릭하면 ble 연결을 시도한다. 연결에 성공하면 ConnectFragment로 이동한다.
BluetoothGatt 객체와 BluetoothGattCallback을 선언하여 사용한다.
🔸 BluetoothActivity.java
@SuppressWarnings("ALL")
public class BluetoothActivity extends AppCompatActivity {
private static final String TAG = "BluetoothActivity";
private ActivityBluetoothBinding binding;
private BluetoothManager bluetoothManager;
private BluetoothAdapter bluetoothAdapter;
private BluetoothLeScanner bluetoothLeScanner;
private ArrayList<BluetoothDevice> discoveredDevicesList = new ArrayList<>();
private DeviceAdapter deviceAdapter;
private BluetoothGatt bluetoothGatt;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityBluetoothBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
// RecyclerView 설정
RecyclerView recyclerView = binding.recyclerView;
// RecyclerView에 리스너로 onDeviceClick을 전달하여 아이템을 클릭했을 때 블루투스 연결을 시도할 수 있게 한다
deviceAdapter = new DeviceAdapter(discoveredDevicesList, this::onDeviceClick);
recyclerView.setLayoutManager(new LinearLayoutManager(this));
recyclerView.setAdapter(deviceAdapter);
...
}
private void onDeviceClick(BluetoothDevice device) {
connectToDevice(device);
}
private void connectToDevice(BluetoothDevice device) {
bluetoothGatt = device.connectGatt(this, false, gattCallback);
Log.d(TAG, "Connecting to device: " + device.getName());
}
private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() {
@Override
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
Log.i(TAG, "Connected to GATT server.");
// 연결 성공
gatt.discoverServices(); // 서비스 발견
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Log.i(TAG, "Disconnected from GATT server.");
bluetoothGatt.close(); // 연결 종료 시 GATT 객체 닫기
}
}
@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
if (status == BluetoothGatt.GATT_SUCCESS) {
Log.i(TAG, "Services discovered.");
// 서비스 발견 후 추가 작업 수행
// 연결 프래그먼트로 이동
ConnectFragment fragment = new ConnectFragment();
getSupportFragmentManager().beginTransaction()
.replace(R.id.fragment_container, fragment)
.commit();
} else {
Log.w(TAG, "onServicesDiscovered received: " + status);
}
}
};
}
🔸 recyclerViewAdapter
BluetoothActivity에서 recyclerView를 초기화할 때, 전달받은 리스너를 아이템의 클릭리스너로 설정한다. 이렇게 하면 리사이클러뷰의 아이템을 클릭했을 때, 블루투스 연결을 시도한다.
@SuppressWarnings("ALL")
public class DeviceAdapter extends RecyclerView.Adapter<DeviceAdapter.DeviceViewHolder> {
private List<BluetoothDevice> deviceList;
private OnDeviceClickListener listener;
public interface OnDeviceClickListener {
void onDeviceClick(BluetoothDevice device);
}
public DeviceAdapter(List<BluetoothDevice> deviceList, OnDeviceClickListener listener) {
this.deviceList = deviceList;
this.listener = listener;
}
@NonNull
@Override
public DeviceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(android.R.layout.simple_list_item_1, parent, false);
return new DeviceViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull DeviceViewHolder holder, int position) {
BluetoothDevice device = deviceList.get(position);
holder.deviceName.setText(device.getName() != null ? device.getName() : "Unknown Device");
holder.itemView.setOnClickListener(v -> listener.onDeviceClick(device));
}
@Override
public int getItemCount() {
return deviceList.size();
}
static class DeviceViewHolder extends RecyclerView.ViewHolder {
TextView deviceName;
public DeviceViewHolder(@NonNull View itemView) {
super(itemView);
deviceName = itemView.findViewById(android.R.id.text1);
}
}
}
전체코드
package com.example.gatttest;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothProfile;
import android.bluetooth.le.BluetoothLeScanner;
import android.bluetooth.le.ScanCallback;
import android.bluetooth.le.ScanResult;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.util.Log;
import android.widget.Button;
import android.widget.Toast;
import com.example.gatttest.adapter.DeviceAdapter;
import com.example.gatttest.databinding.ActivityBluetoothBinding;
import java.util.ArrayList;
import java.util.UUID;
@SuppressWarnings("ALL")
public class BluetoothActivity extends AppCompatActivity {
private static final String TAG = "BluetoothActivity";
private ActivityBluetoothBinding binding;
private BluetoothManager bluetoothManager;
private BluetoothAdapter bluetoothAdapter;
private BluetoothLeScanner bluetoothLeScanner;
private ArrayList<BluetoothDevice> discoveredDevicesList = new ArrayList<>();
private DeviceAdapter deviceAdapter;
private BluetoothGatt bluetoothGatt;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityBluetoothBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
// RecyclerView 설정
RecyclerView recyclerView = binding.recyclerView;
deviceAdapter = new DeviceAdapter(discoveredDevicesList, this::onDeviceClick);
recyclerView.setLayoutManager(new LinearLayoutManager(this));
recyclerView.setAdapter(deviceAdapter);
initBluetooth();
}
private void initBluetooth() {
bluetoothManager = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
bluetoothAdapter = bluetoothManager.getAdapter();
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) {
Toast.makeText(this, "Bluetooth가 비활성화되어 있습니다.", Toast.LENGTH_SHORT).show();
return;
}
bluetoothLeScanner = bluetoothAdapter.getBluetoothLeScanner();
binding.btnScan.setOnClickListener(v -> {
discoveredDevicesList.clear(); // 리스트 초기화
deviceAdapter.notifyDataSetChanged(); // 어댑터에 변경 사항 알림
scanDevice();
});
}
private void scanDevice() {
long SCAN_PERIOD = 10000; // 스캔 시간
bluetoothLeScanner.startScan(scanCallback);
Log.d(TAG, "scan start");
new Handler().postDelayed(() -> {
bluetoothLeScanner.stopScan(scanCallback);
Toast.makeText(this, "스캔 종료", Toast.LENGTH_SHORT).show();
}, SCAN_PERIOD);
}
private final ScanCallback scanCallback = new ScanCallback() {
@Override
public void onScanResult(int callbackType, ScanResult result) {
super.onScanResult(callbackType, result);
BluetoothDevice device = result.getDevice();
if (device != null && !discoveredDevicesList.contains(device)) {
if (device.getName() != null) {
discoveredDevicesList.add(device);
deviceAdapter.notifyItemInserted(discoveredDevicesList.size() - 1); // 새 장치 추가 시 알림
Log.d(TAG, "Discovered device: " + device.getName());
}
}
}
@Override
public void onScanFailed(int errorCode) {
super.onScanFailed(errorCode);
Log.e(TAG, "Scan failed with error code: " + errorCode);
}
};
private void onDeviceClick(BluetoothDevice device) {
connectToDevice(device);
}
private void connectToDevice(BluetoothDevice device) {
bluetoothGatt = device.connectGatt(this, false, gattCallback);
Log.d(TAG, "Connecting to device: " + device.getName());
}
private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() {
@Override
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
Log.i(TAG, "Connected to GATT server.");
// 연결 성공
gatt.discoverServices(); // 서비스 발견
ConnectFragment fragment = new ConnectFragment();
getSupportFragmentManager().beginTransaction()
.replace(R.id.fragment_container, fragment)
.commit();
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Log.i(TAG, "Disconnected from GATT server.");
bluetoothGatt.close(); // 연결 종료 시 GATT 객체 닫기
}
}
@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
if (status == BluetoothGatt.GATT_SUCCESS) {
Log.i(TAG, "Services discovered.");
// 서비스 발견 후 추가 작업 수행
} else {
Log.w(TAG, "onServicesDiscovered received: " + status);
}
}
};
}
🟨 @SuppressWarnings("ALL")
class 상단부에 @SuppressWarnings("ALL")를 추가해줘야 한다. 그렇지 않으면 블루투스 관련 코드를 작성할 때마다 아래와 같은 오류 발생... 계속 permission check 오류가 뜬다.
Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with checkPermission) or explicitly handle a potential SecurityException
'안드로이드' 카테고리의 다른 글
[JAVA] BluetoothGatt로 BLE 사용하기(2) - 데이터 전송 (0) | 2025.02.04 |
---|---|
[안드로이드] Unknown Kotlin JVM target: 21 오류 해결법 (0) | 2025.01.04 |
2023 Droid knights 운영진 후기 (2) | 2023.12.31 |
[Android studio] unexpected end of stream 에러 해결 (0) | 2023.12.18 |
Android 4대 컴포넌트(앱 구성 요소)와 Intent (0) | 2022.09.28 |