본문 바로가기
  • 원하는 게 있으면 주문을 말해봐~ 디딩 보딩 디보디보딩🎶
안드로이드

[JAVA] BluetoothGatt로 BLE 사용하기(1) - 스캔 및 연결

by 딩보 2025. 1. 14.

❕ Bluetooth Gatt

Android에서 BLE를 다루는 프레임워크

BLE(Bluetooth Low Energy)

블루투스 통신의 단점이었던 전력소비를 보완한 저전력 블루투스

 

 

본문은 BLE 기기의 스캔과 연결에 대한 구현 방법을 담고 있다. 다음 글에서 앱 -> BLE 기기 데이터 전송 및 블루투스 연결 끊김 감지, BLE 기기 -> 앱 데이터 전송을 담을 예정이다.

 

 

🟨 구현

🟠 권한 설정

권한은 아래 링크에서 각자 필요한 것을 찾아 선언해주면 됨

https://developer.android.com/develop/connectivity/bluetooth/bt-permissions?hl=ko&_gl=1*hi7g9s*_up*MQ

 

블루투스 권한  |  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