- 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 파싱
453 lines
14 KiB
Dart
453 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
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';
|
|
|
|
/// 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 _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;
|
|
|
|
/// 현재 광고 중인 공유 코드
|
|
String? get currentShareCode => _currentShareCode;
|
|
|
|
/// BLE 지원 여부 확인
|
|
Future<bool> get isSupported async {
|
|
if (!Platform.isAndroid && !Platform.isIOS) {
|
|
return false;
|
|
}
|
|
try {
|
|
return await FlutterBluePlus.isSupported;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Bluetooth 어댑터 상태 확인
|
|
Future<bool> get isBluetoothOn async {
|
|
try {
|
|
final state = await FlutterBluePlus.adapterState.first;
|
|
return state == BluetoothAdapterState.on;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// 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 {
|
|
// 대상 디바이스 찾기
|
|
final targetDevice = _discoveredDevices.firstWhere(
|
|
(d) => d.code == targetCode,
|
|
orElse: () => throw Exception('대상 기기를 찾을 수 없습니다: $targetCode'),
|
|
);
|
|
|
|
final bleDevice = targetDevice.bleDevice;
|
|
if (bleDevice == null) {
|
|
throw Exception('BLE 디바이스 정보가 없습니다.');
|
|
}
|
|
|
|
debugPrint('[BLE] 연결 시작: $targetCode');
|
|
|
|
// 연결
|
|
await bleDevice.connect(
|
|
timeout: Duration(seconds: BleConstants.connectionTimeoutSeconds),
|
|
);
|
|
_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] 연결 해제');
|
|
}
|
|
}
|
|
|
|
/// 문자열을 청크로 분할
|
|
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();
|
|
_scanSubscription?.cancel();
|
|
_connectedDevice?.disconnect();
|
|
_cleanupPeripheral();
|
|
debugPrint('[BLE] BluetoothService disposed');
|
|
}
|
|
}
|