feat(bluetooth): 실제 BLE 기반 맛집 리스트 공유 구현

- ble_peripheral 패키지 추가 (Peripheral/GATT Server 역할)
- flutter_blue_plus 패키지 활용 (Central/스캔/연결 역할)

변경 사항:
- BleConstants: BLE UUID 및 설정 상수 정의
- BluetoothService: Mock에서 실제 BLE 통신으로 전면 재작성
  - Receiver: GATT Server로 광고, Write callback으로 데이터 수신
  - Sender: 서비스 UUID로 스캔, 연결 후 Characteristic에 데이터 전송
  - 청크 기반 대용량 데이터 전송 프로토콜 구현
- ShareDevice: BLE 디바이스 정보 필드 추가 (remoteId, rssi, bleDevice)
- Android/iOS: BLE 권한 및 기능 선언 추가

데이터 전송 프로토콜:
- 'CHUNK:index:total:data' 형식으로 500바이트 단위 분할 전송
- 수신 측에서 청크 조립 후 JSON 파싱
This commit is contained in:
JiWoong Sul
2026-01-30 00:09:13 +09:00
parent 24b074ff4c
commit c608b6c7df
11 changed files with 534 additions and 60 deletions

View File

@@ -1,89 +1,452 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:io';
import 'package:ble_peripheral/ble_peripheral.dart' as ble_peripheral;
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:lunchpick/core/constants/ble_constants.dart';
import 'package:lunchpick/domain/entities/restaurant.dart';
import 'package:lunchpick/domain/entities/share_device.dart';
/// 실제 Bluetooth 통신을 대체하는 간단한 모의(Mock) 서비스.
/// BLE 기반 맛집 리스트 공유 서비스
///
/// - Receiver (공유받기): ble_peripheral 패키지로 GATT Server 역할
/// - Sender (공유하기): flutter_blue_plus 패키지로 Central 역할
class BluetoothService {
// === Peripheral (Receiver) 관련 ===
bool _isPeripheralInitialized = false;
String? _currentShareCode;
final _incomingDataController = StreamController<String>.broadcast();
final Map<String, ShareDevice> _listeningDevices = {};
final Random _random = Random();
final _receivedChunks = <int, String>{};
// ignore: unused_field - 디버깅 및 진행률 표시용
int _expectedTotalChunks = 0;
// === Central (Sender) 관련 ===
final List<ShareDevice> _discoveredDevices = [];
StreamSubscription<List<ScanResult>>? _scanSubscription;
BluetoothDevice? _connectedDevice;
// === 진행 상태 콜백 ===
Function(int current, int total)? onSendProgress;
Function(int current, int total)? onReceiveProgress;
/// 수신된 데이터 스트림
Stream<String> get onDataReceived => _incomingDataController.stream;
/// 특정 코드로 수신 대기를 시작한다.
Future<void> startListening(String code) async {
await Future<void>.delayed(const Duration(milliseconds: 300));
stopListening();
final shareDevice = ShareDevice(
code: code,
deviceId: 'LP-${_random.nextInt(900000) + 100000}',
discoveredAt: DateTime.now(),
);
_listeningDevices[code] = shareDevice;
}
/// 현재 광고 중인 공유 코드
String? get currentShareCode => _currentShareCode;
/// 더 이상 수신 대기하지 않는다.
void stopListening() {
if (_listeningDevices.isEmpty) return;
final codes = List<String>.from(_listeningDevices.keys);
for (final code in codes) {
_listeningDevices.remove(code);
/// BLE 지원 여부 확인
Future<bool> get isSupported async {
if (!Platform.isAndroid && !Platform.isIOS) {
return false;
}
try {
return await FlutterBluePlus.isSupported;
} catch (_) {
return false;
}
}
/// 현재 주변에서 수신 대기 중인 기기 목록을 반환한다.
Future<List<ShareDevice>> scanNearbyDevices() async {
await Future<void>.delayed(const Duration(seconds: 1));
return _listeningDevices.values.toList();
/// Bluetooth 어댑터 상태 확인
Future<bool> get isBluetoothOn async {
try {
final state = await FlutterBluePlus.adapterState.first;
return state == BluetoothAdapterState.on;
} catch (_) {
return false;
}
}
/// 대상 코드로 맛집 리스트를 전송한다. 실제 BT 대신 JSON 문자열을 브로드캐스트한다.
// =========================================================================
// Receiver (Peripheral) 메서드 - ble_peripheral 사용
// =========================================================================
/// Peripheral 초기화
Future<void> initializePeripheral() async {
if (_isPeripheralInitialized) return;
if (!Platform.isAndroid && !Platform.isIOS) {
throw UnsupportedError('BLE Peripheral은 Android/iOS에서만 지원됩니다.');
}
try {
await ble_peripheral.BlePeripheral.initialize();
_setupWriteCallback();
_isPeripheralInitialized = true;
debugPrint('[BLE] Peripheral 초기화 완료');
} catch (e) {
debugPrint('[BLE] Peripheral 초기화 실패: $e');
rethrow;
}
}
/// 공유 코드로 수신 대기 시작
Future<void> startListening(String code) async {
await initializePeripheral();
// 기존 서비스 정리
await _cleanupPeripheral();
_currentShareCode = code;
_receivedChunks.clear();
_expectedTotalChunks = 0;
try {
// GATT 서비스 추가
await ble_peripheral.BlePeripheral.addService(
ble_peripheral.BleService(
uuid: BleConstants.serviceUuid,
primary: true,
characteristics: [
// 데이터 수신용 Characteristic (writable)
ble_peripheral.BleCharacteristic(
uuid: BleConstants.dataCharacteristicUuid,
properties: [
ble_peripheral.CharacteristicProperties.write.index,
ble_peripheral.CharacteristicProperties.writeWithoutResponse.index,
],
permissions: [ble_peripheral.AttributePermissions.writeable.index],
value: null,
),
// 공유 코드 확인용 Characteristic (readable)
ble_peripheral.BleCharacteristic(
uuid: BleConstants.codeCharacteristicUuid,
properties: [ble_peripheral.CharacteristicProperties.read.index],
permissions: [ble_peripheral.AttributePermissions.readable.index],
value: Uint8List.fromList(utf8.encode(code)),
),
],
),
);
// 광고 시작
await ble_peripheral.BlePeripheral.startAdvertising(
services: [BleConstants.serviceUuid],
localName: '${BleConstants.advertiseNamePrefix}$code',
);
debugPrint('[BLE] 광고 시작: ${BleConstants.advertiseNamePrefix}$code');
} catch (e) {
debugPrint('[BLE] 광고 시작 실패: $e');
_currentShareCode = null;
rethrow;
}
}
/// Write 콜백 설정
void _setupWriteCallback() {
ble_peripheral.BlePeripheral.setWriteRequestCallback((
deviceId,
characteristicId,
offset,
value,
) {
if (characteristicId.toLowerCase() ==
BleConstants.dataCharacteristicUuid.toLowerCase()) {
_handleReceivedChunk(value ?? Uint8List(0));
}
// null 반환 = 성공 (ble_peripheral 패키지 규약)
return null;
});
debugPrint('[BLE] Write 콜백 설정 완료');
}
/// 수신된 청크 처리
void _handleReceivedChunk(Uint8List data) {
try {
final decoded = utf8.decode(data);
debugPrint('[BLE] 청크 수신: ${decoded.substring(0, decoded.length.clamp(0, 50))}...');
// 프로토콜: "CHUNK:index:total:data"
if (decoded.startsWith('CHUNK:')) {
final colonIndex1 = decoded.indexOf(':', 6);
final colonIndex2 = decoded.indexOf(':', colonIndex1 + 1);
if (colonIndex1 == -1 || colonIndex2 == -1) {
debugPrint('[BLE] 잘못된 청크 형식');
return;
}
final index = int.tryParse(decoded.substring(6, colonIndex1));
final total = int.tryParse(decoded.substring(colonIndex1 + 1, colonIndex2));
final chunkData = decoded.substring(colonIndex2 + 1);
if (index == null || total == null) {
debugPrint('[BLE] 청크 인덱스/전체 파싱 실패');
return;
}
_expectedTotalChunks = total;
_receivedChunks[index] = chunkData;
// 진행 상태 콜백
onReceiveProgress?.call(_receivedChunks.length, total);
debugPrint('[BLE] 청크 $index/$total 수신 완료');
// 모든 청크 수신 완료
if (_receivedChunks.length == total) {
_assembleAndEmit();
}
}
} catch (e) {
debugPrint('[BLE] 청크 처리 오류: $e');
}
}
/// 청크 조립 후 스트림으로 전송
void _assembleAndEmit() {
final sorted = _receivedChunks.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
final fullData = sorted.map((e) => e.value).join();
debugPrint('[BLE] 데이터 조립 완료: ${fullData.length} bytes');
_incomingDataController.add(fullData);
_receivedChunks.clear();
_expectedTotalChunks = 0;
}
/// Peripheral 정리
Future<void> _cleanupPeripheral() async {
try {
await ble_peripheral.BlePeripheral.stopAdvertising();
await ble_peripheral.BlePeripheral.clearServices();
} catch (e) {
debugPrint('[BLE] Peripheral 정리 중 오류 (무시됨): $e');
}
}
/// 수신 대기 중지
Future<void> stopListening() async {
await _cleanupPeripheral();
_currentShareCode = null;
_receivedChunks.clear();
_expectedTotalChunks = 0;
debugPrint('[BLE] 광고 중지');
}
// =========================================================================
// Sender (Central) 메서드 - flutter_blue_plus 사용
// =========================================================================
/// 주변 LunchPick 기기 스캔
Future<List<ShareDevice>> scanNearbyDevices() async {
_discoveredDevices.clear();
// Bluetooth 상태 확인
if (!await isBluetoothOn) {
throw Exception('블루투스가 꺼져 있습니다. 블루투스를 켜주세요.');
}
debugPrint('[BLE] 스캔 시작...');
// 스캔 결과 리스닝
_scanSubscription = FlutterBluePlus.scanResults.listen(
(results) {
for (final result in results) {
_processScannedDevice(result);
}
},
onError: (e) {
debugPrint('[BLE] 스캔 오류: $e');
},
);
try {
// 서비스 UUID로 필터링하여 스캔
await FlutterBluePlus.startScan(
withServices: [Guid(BleConstants.serviceUuid)],
timeout: Duration(seconds: BleConstants.scanTimeoutSeconds),
);
// 스캔 완료 대기
await Future.delayed(
Duration(seconds: BleConstants.scanTimeoutSeconds + 1),
);
} catch (e) {
debugPrint('[BLE] 스캔 시작 오류: $e');
} finally {
await _scanSubscription?.cancel();
_scanSubscription = null;
}
debugPrint('[BLE] 스캔 완료: ${_discoveredDevices.length}개 기기 발견');
return List.from(_discoveredDevices);
}
/// 스캔된 디바이스 처리
void _processScannedDevice(ScanResult result) {
// 서비스 UUID 확인
final hasService = result.advertisementData.serviceUuids.any(
(uuid) =>
uuid.toString().toLowerCase() ==
BleConstants.serviceUuid.toLowerCase(),
);
if (!hasService) return;
// 광고 이름에서 공유 코드 추출
final name = result.advertisementData.advName;
if (name.isEmpty) return;
final code = name.startsWith(BleConstants.advertiseNamePrefix)
? name.substring(BleConstants.advertiseNamePrefix.length)
: name;
// 중복 방지
if (_discoveredDevices.any((d) => d.deviceId == result.device.remoteId.str)) {
return;
}
final device = ShareDevice(
code: code,
deviceId: result.device.remoteId.str,
remoteId: result.device.remoteId.str,
rssi: result.rssi,
discoveredAt: DateTime.now(),
bleDevice: result.device,
);
_discoveredDevices.add(device);
debugPrint('[BLE] 기기 발견: $code (RSSI: ${result.rssi})');
}
/// 맛집 리스트 전송
Future<void> sendRestaurantList(
String targetCode,
List<Restaurant> restaurants,
) async {
await Future<void>.delayed(const Duration(seconds: 1));
if (!_listeningDevices.containsKey(targetCode)) {
throw Exception('해당 코드를 찾을 수 없습니다.');
// 대상 디바이스 찾기
final targetDevice = _discoveredDevices.firstWhere(
(d) => d.code == targetCode,
orElse: () => throw Exception('대상 기기를 찾을 수 없습니다: $targetCode'),
);
final bleDevice = targetDevice.bleDevice;
if (bleDevice == null) {
throw Exception('BLE 디바이스 정보가 없습니다.');
}
final payload = jsonEncode(
restaurants
.map((restaurant) => _serializeRestaurant(restaurant))
.toList(),
debugPrint('[BLE] 연결 시작: $targetCode');
// 연결
await bleDevice.connect(
timeout: Duration(seconds: BleConstants.connectionTimeoutSeconds),
);
_incomingDataController.add(payload);
_connectedDevice = bleDevice;
try {
// 서비스 탐색
debugPrint('[BLE] 서비스 탐색 중...');
final services = await bleDevice.discoverServices();
final targetService = services.firstWhere(
(s) =>
s.uuid.toString().toLowerCase() ==
BleConstants.serviceUuid.toLowerCase(),
orElse: () => throw Exception('LunchPick 서비스를 찾을 수 없습니다.'),
);
final dataCharacteristic = targetService.characteristics.firstWhere(
(c) =>
c.uuid.toString().toLowerCase() ==
BleConstants.dataCharacteristicUuid.toLowerCase(),
orElse: () => throw Exception('데이터 Characteristic을 찾을 수 없습니다.'),
);
// JSON 직렬화
final payload = jsonEncode(
restaurants.map(_serializeRestaurant).toList(),
);
debugPrint('[BLE] 전송 데이터 크기: ${payload.length} bytes');
// 청킹 및 전송
final chunks = _splitIntoChunks(payload, BleConstants.chunkSize);
debugPrint('[BLE] 청크 수: ${chunks.length}');
for (var i = 0; i < chunks.length; i++) {
final chunkMessage = 'CHUNK:$i:${chunks.length}:${chunks[i]}';
final data = utf8.encode(chunkMessage);
await dataCharacteristic.write(
data,
withoutResponse: false,
);
// 진행 상태 콜백
onSendProgress?.call(i + 1, chunks.length);
debugPrint('[BLE] 청크 ${i + 1}/${chunks.length} 전송 완료');
// 청크 간 딜레이
if (i < chunks.length - 1) {
await Future.delayed(
Duration(milliseconds: BleConstants.chunkDelayMs),
);
}
}
debugPrint('[BLE] 전송 완료!');
} finally {
// 연결 해제
await bleDevice.disconnect();
_connectedDevice = null;
debugPrint('[BLE] 연결 해제');
}
}
Map<String, dynamic> _serializeRestaurant(Restaurant restaurant) {
return {
'id': restaurant.id,
'name': restaurant.name,
'category': restaurant.category,
'subCategory': restaurant.subCategory,
'description': restaurant.description,
'phoneNumber': restaurant.phoneNumber,
'roadAddress': restaurant.roadAddress,
'jibunAddress': restaurant.jibunAddress,
'latitude': restaurant.latitude,
'longitude': restaurant.longitude,
'lastVisitDate': restaurant.lastVisitDate?.toIso8601String(),
'source': restaurant.source.name,
'createdAt': restaurant.createdAt.toIso8601String(),
'updatedAt': restaurant.updatedAt.toIso8601String(),
'naverPlaceId': restaurant.naverPlaceId,
'naverUrl': restaurant.naverUrl,
'businessHours': restaurant.businessHours,
'lastVisited': restaurant.lastVisited?.toIso8601String(),
'visitCount': restaurant.visitCount,
};
/// 문자열을 청크로 분할
List<String> _splitIntoChunks(String data, int chunkSize) {
final chunks = <String>[];
for (var i = 0; i < data.length; i += chunkSize) {
final end = (i + chunkSize > data.length) ? data.length : i + chunkSize;
chunks.add(data.substring(i, end));
}
return chunks;
}
/// Restaurant를 JSON Map으로 변환
Map<String, dynamic> _serializeRestaurant(Restaurant r) => {
'id': r.id,
'name': r.name,
'category': r.category,
'subCategory': r.subCategory,
'description': r.description,
'phoneNumber': r.phoneNumber,
'roadAddress': r.roadAddress,
'jibunAddress': r.jibunAddress,
'latitude': r.latitude,
'longitude': r.longitude,
'lastVisitDate': r.lastVisitDate?.toIso8601String(),
'source': r.source.name,
'createdAt': r.createdAt.toIso8601String(),
'updatedAt': r.updatedAt.toIso8601String(),
'naverPlaceId': r.naverPlaceId,
'naverUrl': r.naverUrl,
'businessHours': r.businessHours,
'lastVisited': r.lastVisited?.toIso8601String(),
'visitCount': r.visitCount,
};
/// 스캔 중지
Future<void> stopScan() async {
await FlutterBluePlus.stopScan();
await _scanSubscription?.cancel();
_scanSubscription = null;
_discoveredDevices.clear();
debugPrint('[BLE] 스캔 중지');
}
/// 리소스 정리
void dispose() {
_incomingDataController.close();
_listeningDevices.clear();
_scanSubscription?.cancel();
_connectedDevice?.disconnect();
_cleanupPeripheral();
debugPrint('[BLE] BluetoothService disposed');
}
}