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.broadcast(); final _receivedChunks = {}; // ignore: unused_field - 디버깅 및 진행률 표시용 int _expectedTotalChunks = 0; // === Central (Sender) 관련 === final List _discoveredDevices = []; StreamSubscription>? _scanSubscription; BluetoothDevice? _connectedDevice; // === 진행 상태 콜백 === Function(int current, int total)? onSendProgress; Function(int current, int total)? onReceiveProgress; /// 수신된 데이터 스트림 Stream get onDataReceived => _incomingDataController.stream; /// 현재 광고 중인 공유 코드 String? get currentShareCode => _currentShareCode; /// BLE 지원 여부 확인 Future get isSupported async { if (!Platform.isAndroid && !Platform.isIOS) { return false; } try { return await FlutterBluePlus.isSupported; } catch (_) { return false; } } /// Bluetooth 어댑터 상태 확인 Future get isBluetoothOn async { try { final state = await FlutterBluePlus.adapterState.first; return state == BluetoothAdapterState.on; } catch (_) { return false; } } // ========================================================================= // Receiver (Peripheral) 메서드 - ble_peripheral 사용 // ========================================================================= /// Peripheral 초기화 Future 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 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 _cleanupPeripheral() async { try { await ble_peripheral.BlePeripheral.stopAdvertising(); await ble_peripheral.BlePeripheral.clearServices(); } catch (e) { debugPrint('[BLE] Peripheral 정리 중 오류 (무시됨): $e'); } } /// 수신 대기 중지 Future stopListening() async { await _cleanupPeripheral(); _currentShareCode = null; _receivedChunks.clear(); _expectedTotalChunks = 0; debugPrint('[BLE] 광고 중지'); } // ========================================================================= // Sender (Central) 메서드 - flutter_blue_plus 사용 // ========================================================================= /// 주변 LunchPick 기기 스캔 Future> 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 sendRestaurantList( String targetCode, List 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 _splitIntoChunks(String data, int chunkSize) { final chunks = []; 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 _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 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'); } }